From 379a1a6275ca13d5b30549d91d3bdfb7285f7828 Mon Sep 17 00:00:00 2001 From: "Sam (automated drift fix)" Date: Tue, 6 Oct 2026 15:58:32 +0300 Subject: [PATCH] feat(entities): add cursor pagination, count(), aggregate(), and upsert() Documents and tunes doc-generation for four new entity APIs: cursor-based pagination on list()/filter(), count(), aggregate(), and upsert(). Includes JSDoc for all new types and overloads, a new overload-presentation config for the pipeline, method ordering, and type-exposure config updates. Co-Authored-By: Claude Sonnet 5 --- .claude/skills/sdk-docs-writing/SKILL.md | 2 + .../appended-articles.json | 3 +- .../copy-to-local-docs.js | 7 +- .../file-processing/file-processing.js | 348 +++++++++++++- .../method-order.json | 3 + .../overload-presentation.json | 38 ++ .../typedoc-mintlify-content.js | 17 +- .../typedoc-mintlify-parameters.js | 66 ++- .../typedoc-mintlify-returns.js | 64 ++- .../types-to-delete-after-processing.json | 8 + .../types-to-expose.json | 5 +- packages/sdk/src/index.ts | 2 +- packages/sdk/src/modules/entities.types.ts | 424 +++++++++++------- 13 files changed, 790 insertions(+), 197 deletions(-) create mode 100644 packages/sdk/scripts/mintlify-post-processing/overload-presentation.json diff --git a/.claude/skills/sdk-docs-writing/SKILL.md b/.claude/skills/sdk-docs-writing/SKILL.md index 667ed210..4c90ef64 100644 --- a/.claude/skills/sdk-docs-writing/SKILL.md +++ b/.claude/skills/sdk-docs-writing/SKILL.md @@ -80,6 +80,8 @@ Every public method needs: description, `@param` tags, `@returns`, and at least - **State environment constraints** when a method is browser-only: "Requires a browser environment and can't be used in the backend." - **Document side effects** explicitly (e.g., "automatically sets the token for subsequent requests"). - **Link method references.** When mentioning another SDK method or module by name in JSDoc prose, always use `{@link}` or `{@linkcode}` to create a cross-reference. +- **Never use a colon as a sentence break.** Write "Use this when you sync data from another system. Name the field..." not "...another system: name the field...". Reserve colons for `label: description` bullet-list items, not prose sentences. +- **Never start a sentence with a lowercase inline-code term.** If the natural phrasing would open with one (e.g., "`week` is only supported on..."), restructure so a capitalized word leads instead (e.g., "Every date field supports `day`, `month` and `year`. The `created_date` and `updated_date` fields also support `week`."). ## References diff --git a/packages/sdk/scripts/mintlify-post-processing/appended-articles.json b/packages/sdk/scripts/mintlify-post-processing/appended-articles.json index 27fb69b8..2804d072 100644 --- a/packages/sdk/scripts/mintlify-post-processing/appended-articles.json +++ b/packages/sdk/scripts/mintlify-post-processing/appended-articles.json @@ -8,7 +8,8 @@ "interfaces/EntityHandler", "type-aliases/EntityRecord", "interfaces/EntityTypeRegistry", - "type-aliases/SortField" + "type-aliases/SortField", + "type-aliases/EntityFilterQuery" ], "interfaces/FunctionsModule": [ "type-aliases/FunctionName", diff --git a/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js b/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js index 9dbebdbf..4b857875 100644 --- a/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js +++ b/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js @@ -139,7 +139,12 @@ function updateDocsJson(repoDir, sdkFiles) { return Array.from(groupMap.entries()).map(([group, pages]) => ({ group, expanded: true, - pages: pages.sort(), + // Sort by basename, not full path: a plain .sort() groups all + // "interfaces/..." entries before "type-aliases/..." entries (since + // "i" < "t"), instead of interleaving both kinds alphabetically by + // module name. Keep in sync with the same fix in + // file-processing/file-processing.js's generateDocsJson. + pages: pages.sort((a, b) => a.split("/").pop().localeCompare(b.split("/").pop())), })); }; diff --git a/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js b/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js index a3fc1e38..e3104b8c 100755 --- a/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js +++ b/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js @@ -31,6 +31,11 @@ const APPENDED_ARTICLES_PATH = path.join( "../appended-articles.json" ); const METHOD_ORDER_PATH = path.join(__dirname, "..", "method-order.json"); +const OVERLOAD_PRESENTATION_PATH = path.join( + __dirname, + "..", + "overload-presentation.json" +); // Controlled via env var so we can re-enable Panel injection when needed. const PANELS_ENABLED = process.env.MINTLIFY_INCLUDE_PANELS === "true"; @@ -391,7 +396,13 @@ function generateDocsJson(docsContent) { if (existingGroup) { existingGroup.pages.push(...docsContent.typeAliases); - existingGroup.pages.sort(); // Sort combined pages alphabetically + // Sort by basename, not full path: a plain .sort() would group all + // "interfaces/..." entries before "type-aliases/..." entries (since + // "i" < "t"), instead of interleaving both kinds alphabetically by + // module name. + existingGroup.pages.sort((a, b) => + a.split("/").pop().localeCompare(b.split("/").pop()) + ); } else { groups.push({ group: groupName, @@ -700,12 +711,14 @@ function applyAppendedArticles(appendedArticles) { let hostContent = fs.readFileSync(hostPath, "utf-8"); let combinedSections = ""; const collectedHeadings = PANELS_ENABLED ? [] : null; + const mergedTypeNames = []; for (const appendKey of appendList) { // Check if appended file was renamed (derives rename automatically for *Module names) let effectiveAppendKey = appendKey; const appendParts = appendKey.split("/"); const appendName = appendParts[appendParts.length - 1]; + mergedTypeNames.push(appendName); const appendModuleRename = getModuleRename(appendName); if (appendModuleRename) { appendParts[appendParts.length - 1] = appendModuleRename; @@ -765,6 +778,25 @@ function applyAppendedArticles(appendedArticles) { hostContent = hostContent.trimEnd() + combinedSections + "\n"; hostContent = updatePanelWithHeadings(hostContent, collectedHeadings); + + // Types merged onto this page no longer have their own file, so any link + // that still points at one — a `{@link Type.member}` cross-reference + // TypeDoc resolved to `.../Type.mdx#member`, or a plain `{@link Type}` + // resolved to `.../Type` with no fragment at all — now 404s. Rewrite + // every such link to a same-page anchor: `#member` when TypeDoc kept a + // fragment, otherwise `#` (Mintlify's own heading + // slug for a single-word PascalCase heading has no inserted hyphens). + for (const typeName of mergedTypeNames) { + const linkRegex = new RegExp( + `\\]\\((?:\\.\\./(?:interfaces|type-aliases|classes|functions)/)?${typeName}(?:\\.mdx|\\.md)?(#[\\w-]*)?\\)`, + "g" + ); + hostContent = hostContent.replace( + linkRegex, + (_match, anchor) => `](${anchor || `#${typeName.toLowerCase()}`})` + ); + } + fs.writeFileSync(hostPath, hostContent, "utf-8"); } } @@ -804,6 +836,118 @@ function cleanupSignatures(content) { modified = true; } + // 7d: Expand truncated `EntityPage`\<...\[...\]\> to `EntityPage`\<`T`\[`K`\]\>. + // Same class of bug as 7b (TypeDoc truncates a generic it can't fully render), + // but for an indexed access type (T[K], from the list()/filter() overload that + // returns distinct field values) instead of Pick. + if (content.includes("EntityPage`\\<...\\[...\\]\\>")) { + // The link href can be the bare un-linked form, the original un-rewritten + // `(EntityPage)`, or the same-page `(#entitypage)` anchor the merged-link + // rewrite above produces — match any of them and leave the href alone. + content = content.replace( + /(`EntityPage`|\[`EntityPage`\]\([^)]*\))\\<\.\.\.\\\[\.\.\.\\\]\\>/g, + "$1\\<`T`\\[`K`\\]\\>" + ); + modified = true; + } + + // 7e: Expand truncated `(keyof T & string) | (keyof T & string)[]` (and the + // bare `keyof T & string` form). TypeDoc can't render this union-of-an- + // intersection-with-its-own-array-form and truncates it in both ParamField + // type attributes and inline property signatures. + if (content.includes('type="any & string | ... & ...[]"')) { + content = content.replace( + /type="any & string \| \.\.\. & \.\.\.\[\]"/g, + 'type="(keyof T & string) | (keyof T & string)[]"' + ); + modified = true; + } + if (content.includes('type="... & ..."')) { + content = content.replace(/type="\.\.\. & \.\.\."/g, 'type="keyof T & string"'); + modified = true; + } + if (content.includes("keyof ... & `string` | ... & ...[]")) { + content = content.replace( + /keyof \.\.\. & `string` \| \.\.\. & \.\.\.\[\]/g, + "(keyof `T` & `string`) | (keyof `T` & `string`)[]" + ); + modified = true; + } + if (content.includes("**field**: ... & ...")) { + content = content.replace( + /\*\*field\*\*: \.\.\. & \.\.\./g, + "**field**: keyof `T` & `string`" + ); + modified = true; + } + if (content.includes("**countDistinct**: keyof ... & `string`")) { + content = content.replace( + /\*\*countDistinct\*\*: keyof \.\.\. & `string`/g, + "**countDistinct**: keyof `T` & `string`" + ); + modified = true; + } + + // 7f: Expand truncated EntityFilterQuery declaration and its $and/$or/$nor + // fields. TypeDoc can't fully render the mapped type `{ [K in keyof T]?: + // EntityFilterValue }` or the self-referential `EntityFilterQuery[]` + // array fields, and truncates both to `(...)`. + if (content.includes("{ [K in keyof (...)]?: EntityFilterValue<(...)> }")) { + content = content.replace( + /\{ \[K in keyof \(\.\.\.\)\]\?: EntityFilterValue<\(\.\.\.\)> \}/g, + "{ [K in keyof T]?: EntityFilterValue }" + ); + modified = true; + } + if (content.includes('**$and**: ...[]')) { + // Must stay inside backticks with escaped angle brackets, matching how + // every other generic shows up in these signature lines (e.g. + // `Record`\<`string`, `any`\>[]) — raw `` outside a code span is + // unclosed JSX as far as the MDX parser is concerned and breaks the + // whole page. + content = content.replace( + /\*\*(\$and|\$or|\$nor)\*\*: \.\.\.\[\]/g, + "**$1**: `EntityFilterQuery`\\<`T`\\>[]" + ); + modified = true; + } + + // 7g: Fix nested inline property breakdowns (e.g. the Accordion under + // aggregate()'s "spec", or dateBucket's own nested properties). When an + // interface parameter's own properties get expanded inline this way, our + // parameters-parsing code re-parses the already-rendered markdown rather + // than reading the original comment, and for a `keyof T & string` field it + // drops any {@linkcode} link entirely and mis-resolves the type to + // `any & string`. Patch both symptoms directly rather than the parser. + if (content.includes("matching EntityFilterQuery. Defaults to all records.")) { + content = content.replace( + /matching EntityFilterQuery\. Defaults to all records\./g, + "matching [`EntityFilterQuery`](#entityfilterquery). Defaults to all records." + ); + modified = true; + } + if (content.includes('type="any & string"')) { + content = content.replace( + /([]>` (list()/filter()'s legacy array + // overload). The nested generic (Promise wrapping a Pick that TypeDoc + // already can't render fully) defeats extractSignatureInfo's single-level + // regex, which falls back to TypeDoc's own `Pick<..., ...>` truncation + // crammed into one code span instead of the usual per-identifier backtick + // style every other signature on the page uses. + if (content.includes("`Promise[]>`")) { + content = content.replace( + /`Promise\[\]>`/g, + "`Promise`\\<`Pick`\\<`T`, `K`\\>[]\\>" + ); + modified = true; + } + // Fix 5: Clean up broken function-return-type patterns. // When a method returns a function (e.g., () => void), TypeDoc generates a stray // function signature and an empty Accordion in the Returns section. Remove them. @@ -881,17 +1025,18 @@ function cleanupSignatures(content) { continue; } - // Fix 4: Remove method-level #### Type Parameters sections. + // Fix 4: Remove method-level Type Parameters sections. // These are redundant — the info is already in the signature and parameter docs. - // Skip from "#### Type Parameters" until the next "#### " heading. - if (line.trim() === "#### Type Parameters") { - // Skip ahead past this section until the next #### heading or ### heading + // Matches "#### Type Parameters" for a plain method, or "##### Type + // Parameters" for one overload's own nested Call Signature block, and + // stops at the next heading at the same depth or shallower. + const typeParamsMatch = line.trim().match(/^(#{4,6}) Type Parameters$/); + if (typeParamsMatch) { + const ownLevel = typeParamsMatch[1].length; let j = i + 1; while (j < lines.length) { - const upcoming = lines[j].trim(); - if (upcoming.startsWith("#### ") && upcoming !== "#### Type Parameters") break; - if (upcoming.startsWith("### ")) break; - if (upcoming.startsWith("## ")) break; + const headingMatch = lines[j].trim().match(/^(#+)\s/); + if (headingMatch && headingMatch[1].length <= ownLevel) break; j++; } // Also skip any trailing blank lines @@ -1394,10 +1539,13 @@ function applyIntroSectionGrouping(dir) { } /** - * Group type definition sections under a parent heading - * For entities: EntityRecord, EntityTypeRegistry, SortField - * For functions: FunctionName, FunctionNameRegistry - * For agents: AgentName, AgentNameRegistry + * Group type definition sections under a parent heading. + * + * Each group below must list every entry from appended-articles.json for that + * module except the one that gets absorbed into the "## ... Methods" section + * (typically named `*Module` or `*Handler`, e.g. EntityHandler) — otherwise + * the appended type keeps its original `##` heading and renders as a sibling + * of "Type Definitions" instead of nested under it. */ function groupTypeDefinitions(content) { let modified = false; @@ -1411,7 +1559,12 @@ function groupTypeDefinitions(content) { }, // Entities module { - types: ["EntityRecord", "EntityTypeRegistry", "SortField"], + types: [ + "EntityRecord", + "EntityTypeRegistry", + "SortField", + "EntityFilterQuery" + ], indicator: "EntityRecord" }, // Functions module @@ -1455,8 +1608,15 @@ function groupTypeDefinitions(content) { const regex = new RegExp(`^## ${typeName}$`, "m"); demotedSection = demotedSection.replace(regex, `### ${typeName}`); } - - const updatedContent = + + // Any "##" heading still left in this slice belongs to one of the types + // above it (e.g. "## Properties" from an appended interface with members, + // since normalizeHeadings() clamps an appended article's own headings to + // the same H2 floor as its title). Bump these so they nest under their + // now-H3 type heading instead of rendering as a sibling of it. + demotedSection = demotedSection.replace(/^## (.+)$/gm, "#### $1"); + + const updatedContent = beforeTypeDefinitions + "## Type Definitions\n\n" + demotedSection; @@ -2201,6 +2361,158 @@ function applyMethodOrdering(dir) { } } +/** + * Load overload-presentation.json config. + * Keys are MDX base filenames (e.g. "entities"), values map method name -> + * { intro, labels }. See that file for the format and when to add an entry: + * any method with more than one "#### Call Signature" block (i.e. TypeScript + * overloads) benefits from this, since TypeDoc renders overloads as bare, + * visually-identical signature blocks with no shared context between them. + */ +function loadOverloadPresentationConfig() { + if (!fs.existsSync(OVERLOAD_PRESENTATION_PATH)) return {}; + return JSON.parse(fs.readFileSync(OVERLOAD_PRESENTATION_PATH, "utf-8")); +} + +/** + * Slugify an overload label the same way Mintlify slugs a heading: lowercase, + * drop punctuation, collapse runs of non-alphanumerics to one hyphen. + */ +function slugifyOverloadLabel(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * For one method with multiple overloads, insert a lead-in sentence plus a + * bulleted summary (one bullet per overload, linking to it) right after its + * "### name()" heading, and replace each "#### Call Signature" heading with + * an anchor and a bold label from `overloads`, in source declaration order. + * + * "#### Call Signature" is demoted to an anchored bold label rather than + * relabeled in place: TypeDoc nests everything under an overload's own + * signature one heading level deeper than a plain method's ("##### Parameters" + * instead of "#### Parameters", etc., since it all sits inside that "#### + * Call Signature"). Left alone, that one extra level of nesting makes an + * overloaded method's Parameters/Returns/Examples render at a visibly + * different (smaller) heading size than every other method's. Demoting + * "Call Signature" off the heading hierarchy entirely, and promoting + * everything under it back up by one level, makes them land on the exact + * same heading depth as a non-overloaded method — "#### Parameters" either + * way — while the anchored bold label still visually separates the + * overloads and gives the intro bullets something to link to. + */ +function presentOverloads(content, methodConfig) { + const lines = content.split("\n"); + const result = []; + let modified = false; + let labelIndex = -1; // -1 = not currently inside the target method's block + let overloadsRemaining = []; + let methodName = ""; + let inFence = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (/^### \w+\(\)$/.test(line.trim())) { + result.push(line); + methodName = line.trim().slice(4, -2); + if (methodConfig[methodName]) { + labelIndex = 0; + overloadsRemaining = methodConfig[methodName].overloads; + const bullets = overloadsRemaining + .map((o) => { + const anchor = `${methodName}-${slugifyOverloadLabel(o.label)}`; + return `- [**${o.label}**](#${anchor}): ${o.description}`; + }) + .join("\n"); + result.push("", methodConfig[methodName].leadIn, bullets); + modified = true; + } else { + labelIndex = -1; + } + continue; + } + + if (labelIndex === -1) { + result.push(line); + continue; + } + + // Leaving the method's block entirely (next method, or Type Definitions). + if (/^## /.test(line.trim())) { + labelIndex = -1; + result.push(line); + continue; + } + + if (line.trim().startsWith("```")) inFence = !inFence; + + if (!inFence && line.trim() === "#### Call Signature" && labelIndex < overloadsRemaining.length) { + const label = overloadsRemaining[labelIndex].label; + const anchor = `${methodName}-${slugifyOverloadLabel(label)}`; + result.push(``, "", `**${label}**`); + labelIndex++; + modified = true; + continue; + } + + // Demote every other heading in this overload's block by one level, now + // that "Call Signature" no longer occupies a level of its own. + if (!inFence) { + const headingMatch = line.match(/^(#{3,})(\s.*)$/); + if (headingMatch && headingMatch[1].length >= 5) { + result.push(`${headingMatch[1].slice(1)}${headingMatch[2]}`); + modified = true; + continue; + } + } + + result.push(line); + } + + return { content: result.join("\n"), modified }; +} + +/** + * Apply overload presentation (intro + signature labels) to MDX files whose + * base name matches a key in overload-presentation.json. + */ +function applyOverloadPresentation(dir) { + const config = loadOverloadPresentationConfig(); + if (Object.keys(config).length === 0) return; + + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + applyOverloadPresentation(entryPath); + } else if ( + entry.isFile() && + (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) + ) { + const baseName = path.basename(entry.name, path.extname(entry.name)); + const methodConfig = config[baseName]; + if (!methodConfig) continue; + + const content = fs.readFileSync(entryPath, "utf-8"); + const { content: updated, modified } = presentOverloads( + content, + methodConfig + ); + if (modified) { + fs.writeFileSync(entryPath, updated, "utf-8"); + console.log( + `Presented overloads: ${path.relative(DOCS_DIR, entryPath)}` + ); + } + } + } +} + function main() { console.log("Processing TypeDoc MDX files for Mintlify...\n"); @@ -2253,6 +2565,10 @@ function main() { // Reorder methods according to method-order.json applyMethodOrdering(DOCS_DIR); + // Add an intro blurb and distinguishing labels to overloaded methods + // listed in overload-presentation.json + applyOverloadPresentation(DOCS_DIR); + // Link type names in Type Declarations sections to their corresponding headings applyTypeDeclarationLinking(DOCS_DIR); diff --git a/packages/sdk/scripts/mintlify-post-processing/method-order.json b/packages/sdk/scripts/mintlify-post-processing/method-order.json index 8c05055d..f1c513f8 100644 --- a/packages/sdk/scripts/mintlify-post-processing/method-order.json +++ b/packages/sdk/scripts/mintlify-post-processing/method-order.json @@ -3,9 +3,12 @@ "get", "list", "filter", + "count", + "aggregate", "create", "bulkCreate", "importEntities", + "upsert", "update", "updateMany", "bulkUpdate", diff --git a/packages/sdk/scripts/mintlify-post-processing/overload-presentation.json b/packages/sdk/scripts/mintlify-post-processing/overload-presentation.json new file mode 100644 index 00000000..9b078d4f --- /dev/null +++ b/packages/sdk/scripts/mintlify-post-processing/overload-presentation.json @@ -0,0 +1,38 @@ +{ + "entities": { + "list": { + "leadIn": "The `list()` method has three forms:", + "overloads": [ + { + "label": "Records (cursor)", + "description": "Returns one cursor page of records. Use this for loops, or any time you want pagination that's unaffected by records added or deleted mid-walk." + }, + { + "label": "Distinct values (cursor)", + "description": "Returns one cursor page of a single field's distinct values, always in ascending order. Use it to list the unique values of a field, for example to populate filter options or autocomplete." + }, + { + "label": "Records (skip)", + "description": "Returns an array of records. Kept for existing code. Prefer a cursor for pagination instead." + } + ] + }, + "filter": { + "leadIn": "The `filter()` method has three forms. The query is always the first argument.", + "overloads": [ + { + "label": "Records (cursor)", + "description": "Returns one cursor page of matching records. Use this for loops, or any time you want pagination that's unaffected by records added or deleted mid-walk." + }, + { + "label": "Distinct values (cursor)", + "description": "Returns one cursor page of a single field's distinct values among matching records, always in ascending order. Use it to list the unique values of a field among matching records, for example to populate filter options or autocomplete." + }, + { + "label": "Records (skip)", + "description": "Returns an array of matching records. Kept for existing code. Prefer a cursor for pagination instead." + } + ] + } + } +} diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js index 6bf1e0ac..0398d063 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js @@ -18,7 +18,7 @@ export function addHeadingsToCodeGroups(content) { let hasHeading = false; for (let j = Math.max(0, i - 3); j < i; j++) { const prevLine = lines[j].trim(); - if (/^#{2,4}\s+/.test(prevLine)) { + if (/^#{2,5}\s+/.test(prevLine)) { hasHeading = true; break; } @@ -41,8 +41,10 @@ export function addHeadingsToCodeGroups(content) { * Convert code examples to Mintlify CodeGroup */ export function convertExamplesToCodeGroup(content) { - // Match Example/Examples headings from level 2-4 and capture their content until next section - const exampleSectionRegex = /^(#{2,4})\s+(Example|Examples)\s*$([\s\S]*?)(?=^#{2,4}\s|\n<\/ResponseField>|\n\*\*\*|$(?!\n))/gm; + // Match Example/Examples headings from level 2-5 and capture their content until next section. + // Level 5 shows up for overloaded methods, where each "#### Call Signature" pushes its own + // "##### Examples" one level deeper than a plain method's "#### Examples". + const exampleSectionRegex = /^(#{2,5})\s+(Example|Examples)\s*$([\s\S]*?)(?=^#{2,5}\s|\n<\/ResponseField>|\n\*\*\*|$(?!\n))/gm; return content.replace(exampleSectionRegex, (match, headingLevel, exampleHeading, exampleContent) => { const codeBlockRegex = /```([\w-]*)\s*([^\n]*)\n([\s\S]*?)```/g; @@ -86,7 +88,14 @@ export function convertExamplesToCodeGroup(content) { codeGroup += '```\n\n'; } - codeGroup += '\n'; + // Two newlines, not one: the regex above uses a lookahead so whatever + // heading/content follows is preserved untouched. Most of the time that + // content is already cushioned by a blank line, but when an Examples + // section sits right before the next overload's "#### Call Signature" + // (only possible for overloaded methods, e.g. list()/filter()), there's + // nothing else providing that gap — without it, MDX glues a closing JSX + // tag directly to the next line and the heading after it breaks. + codeGroup += '\n\n'; return codeGroup; }); diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-parameters.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-parameters.js index 759eed7c..7fbf680f 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-parameters.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-parameters.js @@ -136,7 +136,7 @@ export function convertInterfaceMethodParameters( writeLinkedTypesFile = null ) { const context = app && page ? { app, page, currentPagePath: page.url } : null; - return rewriteParameterSections( + content = rewriteParameterSections( content, "#### Parameters", "#####", @@ -145,6 +145,18 @@ export function convertInterfaceMethodParameters( linkedTypeNames, writeLinkedTypesFile ); + // Overloaded methods (multiple "#### Call Signature" blocks) render their + // own "##### Parameters" one level deeper than a plain method's "#### + // Parameters" — run the same conversion again at that depth. + return rewriteParameterSections( + content, + "##### Parameters", + "######", + "#######", + context, + linkedTypeNames, + writeLinkedTypesFile + ); } /** @@ -158,7 +170,7 @@ export function convertClassMethodParameters( writeLinkedTypesFile = null ) { const context = app && page ? { app, page, currentPagePath: page.url } : null; - return rewriteParameterSections( + content = rewriteParameterSections( content, "#### Parameters", "#####", @@ -167,6 +179,15 @@ export function convertClassMethodParameters( linkedTypeNames, writeLinkedTypesFile ); + return rewriteParameterSections( + content, + "##### Parameters", + "######", + "#######", + context, + linkedTypeNames, + writeLinkedTypesFile + ); } function rewriteParameterSections( @@ -182,14 +203,21 @@ function rewriteParameterSections( const result = []; let i = 0; + // Derived from sectionHeading so this also works one level deeper, where + // an overloaded method's own "##### Parameters" sits nested under its + // "#### Call Signature" instead of a plain "#### Parameters" at the top + // of the method. Stop at the matching Returns/Example heading, "***", or + // anything shallower (a heading with fewer leading #'s) — which covers + // the next overload's own "#### Call Signature" for the nested case. + const ownLevel = (sectionHeading.match(/^(#+)/) || [, "####"])[1].length; const isTerminatorLine = (line) => { - return ( - line.startsWith("#### Returns") || - line.startsWith("#### Example") || - line === "***" || - line.startsWith("### ") || - line.startsWith("## ") - ); + const level = "#".repeat(ownLevel); + if (line.startsWith(`${level} Returns`)) return true; + if (line.startsWith(`${level} Example`)) return true; + if (line === "***") return true; + const headingMatch = line.match(/^(#+)\s/); + if (headingMatch && headingMatch[1].length < ownLevel) return true; + return false; }; while (i < lines.length) { @@ -266,15 +294,19 @@ function parseParametersWithExpansion( const isTerminator = (line) => { const trimmed = line.trim(); if (!trimmed) return false; + // Derived from paramLevel so this also works one level deeper, for an + // overloaded method's own nested "##### Parameters"/"###### Parameters". + const ownLevel = paramLevel.length; if ( - trimmed.startsWith("#### Returns") || - trimmed.startsWith("#### Example") || + trimmed.startsWith(`${"#".repeat(ownLevel)} Returns`) || + trimmed.startsWith(`${"#".repeat(ownLevel)} Example`) || trimmed === "***" ) { return true; } const nestedPrefix = nestedLevel ? nestedLevel + " " : null; - if (/^#{1,3}\s+/.test(trimmed)) { + const headingMatch = trimmed.match(/^(#+)\s+/); + if (headingMatch && headingMatch[1].length < ownLevel) { if ( !trimmed.startsWith(paramLevel + " ") && !(nestedPrefix && trimmed.startsWith(nestedPrefix)) @@ -565,15 +597,19 @@ function parseParameters( const isTerminator = (line) => { const trimmed = line.trim(); if (!trimmed) return false; + // Derived from paramLevel so this also works one level deeper, for an + // overloaded method's own nested "##### Parameters"/"###### Parameters". + const ownLevel = paramLevel.length; if ( - trimmed.startsWith("#### Returns") || - trimmed.startsWith("#### Example") || + trimmed.startsWith(`${"#".repeat(ownLevel)} Returns`) || + trimmed.startsWith(`${"#".repeat(ownLevel)} Example`) || trimmed === "***" ) { return true; } const nestedPrefix = nestedLevel ? nestedLevel + " " : null; - if (/^#{1,3}\s+/.test(trimmed)) { + const headingMatch = trimmed.match(/^(#+)\s+/); + if (headingMatch && headingMatch[1].length < ownLevel) { if ( !trimmed.startsWith(paramLevel + " ") && !(nestedPrefix && trimmed.startsWith(nestedPrefix)) diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js index 4f73aa8d..82d50e3c 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js @@ -127,11 +127,33 @@ export function extractSignatureInfo( for (let i = 0; i < lines.length; i++) { const line = lines[i]; + + // A signature can wrap across multiple lines when TypeDoc breaks a long + // parameter list onto its own lines (e.g. `list()`'s positional-args + // overload), putting the closing "): `ReturnType`" several lines below + // the opening "**name**(". Join continuation lines (also "> "-prefixed) + // until we see that closing pattern, or give up after a few lines, before + // trying to match — otherwise this signature is silently skipped here, + // and the Returns section below falls back to whatever an unrelated + // nearby signature happens to have registered. + let signatureText = line; + if (/^>\s*\*\*\w+\*\*/.test(line) && !/\):\s*`/.test(line)) { + for (let j = i + 1; j < Math.min(lines.length, i + 10); j++) { + if (!/^>/.test(lines[j])) break; + signatureText += " " + lines[j].replace(/^>\s*/, "").trim(); + if (/\):\s*`/.test(signatureText)) break; + } + } + // Match function signature: > **methodName**(...): `returnType` or `returnType`\<`generic`\> // Method-level generics appear between the bold name and arguments. // Handle both simple types and generic types like `Promise`\<`any`\> or `Promise`\<[`TypeName`](link)\> - const sigMatch = line.match( - /^>\s*\*\*(\w+)\*\*(?:\\<.*?\\>)?\([^)]*\):\s*`([^`]+)`(?:\\<(.+?)\\>)?/ + // The trailing `\<(.+)\>` is greedy, not lazy: a return type can itself + // be a generic nested inside another (e.g. `Promise[]>`), and + // a lazy match would stop at the FIRST inner `\>` instead of the line's + // last one, capturing a truncated, unbalanced fragment. + const sigMatch = signatureText.match( + /^>\s*\*\*(\w+)\*\*(?:\\<.*?\\>)?\([^)]*\):\s*`([^`]+)`(?:\\<(.+)\\>)?/ ); if (sigMatch) { const methodName = sigMatch[1]; @@ -255,7 +277,7 @@ export function convertInterfaceMethodReturns( page?.url ); - return rewriteReturnSections(content, { + content = rewriteReturnSections(content, { heading: "#### Returns", fieldHeading: "#####", nestedHeading: "######", @@ -265,6 +287,19 @@ export function convertInterfaceMethodReturns( app, page, }); + // Overloaded methods (multiple "#### Call Signature" blocks) render their + // own "##### Returns" one level deeper than a plain method's "#### Returns" + // — run the same conversion again at that depth. + return rewriteReturnSections(content, { + heading: "##### Returns", + fieldHeading: "######", + nestedHeading: "#######", + stopOnLevel3: true, + signatureMap, + linkedTypeMap, + app, + page, + }); } /** @@ -286,7 +321,7 @@ export function convertClassMethodReturns( page?.url ); - return rewriteReturnSections(content, { + content = rewriteReturnSections(content, { heading: "#### Returns", fieldHeading: "#####", nestedHeading: "######", @@ -296,6 +331,16 @@ export function convertClassMethodReturns( app, page, }); + return rewriteReturnSections(content, { + heading: "##### Returns", + fieldHeading: "######", + nestedHeading: "#######", + stopOnLevel3: true, + signatureMap, + linkedTypeMap, + app, + page, + }); } /** @@ -332,10 +377,15 @@ function rewriteReturnSections(content, options) { const result = []; let i = 0; + // Derived from `heading` so this also catches a heading shallower than our + // own level — needed for an overloaded method's nested "##### Returns", + // which must stop at the next overload's "#### Call Signature" (4 < 5), + // something none of the specific checks below were written to expect. + const ownHeadingLevel = (heading.match(/^(#+)/) || [, "##"])[1].length; const isTerminatorLine = (line) => { const trimmed = line.trim(); if (!trimmed) return false; - if (trimmed.match(/^#{2,4}\s+Examples?/i) || trimmed === "***") { + if (trimmed.match(/^#{2,7}\s+Examples?/i) || trimmed === "***") { return true; } if (heading !== "## Returns" && trimmed.startsWith("## ")) { @@ -348,6 +398,10 @@ function rewriteReturnSections(content, options) { if (stopOnLevel3 && trimmed.startsWith("### ")) { return true; } + const headingMatch = trimmed.match(/^(#+)\s/); + if (headingMatch && headingMatch[1].length < ownHeadingLevel) { + return true; + } return false; }; diff --git a/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json b/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json index 5083520f..894f1272 100644 --- a/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json +++ b/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json @@ -2,6 +2,14 @@ "AiGatewayConnection", "DeleteManyResult", "DeleteResult", + "EntityAggregateResult", + "EntityAggregateSpec", + "EntityDateBucket", + "EntityDistinctOptions", + "EntityListOptions", + "EntityPage", + "EntityUpsertOptions", + "EntityUpsertResult", "ImportResult", "SortField", "UpdateManyResult", diff --git a/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json b/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json index 2c6cc102..052a8e46 100644 --- a/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json +++ b/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json @@ -16,14 +16,16 @@ "ConnectorIntegrationType", "ConnectorIntegrationTypeRegistry", "ConnectorsModule", + "CoreIntegrations", "CustomIntegrationsModule", "DeleteManyResult", "DeleteResult", "EntitiesModule", "EntityAggregateResult", "EntityAggregateSpec", - "EntityDateBucketUnit", + "EntityDateBucket", "EntityDistinctOptions", + "EntityFilterQuery", "EntityHandler", "EntityListOptions", "EntityPage", @@ -36,7 +38,6 @@ "FunctionsModule", "ImportResult", "IntegrationsModule", - "CoreIntegrations", "SortField", "SsoModule", "UpdateManyResult" diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index acac074f..0ccfe159 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -41,7 +41,7 @@ export type { EntitiesModule, EntityAggregateResult, EntityAggregateSpec, - EntityDateBucketUnit, + EntityDateBucket, EntityDistinctOptions, EntityFilterOperators, EntityFilterQuery, diff --git a/packages/sdk/src/modules/entities.types.ts b/packages/sdk/src/modules/entities.types.ts index e96265a7..5faff31a 100644 --- a/packages/sdk/src/modules/entities.types.ts +++ b/packages/sdk/src/modules/entities.types.ts @@ -64,15 +64,15 @@ export interface UpdateManyResult { * @typeParam K - The fields to include in each record. */ export interface EntityListOptions { - /** Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. */ + /** Sort parameter, such as `'-priority'` for descending. Defaults to `'-created_date'`. */ sort?: SortField; /** Maximum number of records per page, up to 5,000. Defaults to 100. */ limit?: number; /** - * `next_cursor` from the previous page. Omit or pass `null` for the first page. + * The `next_cursor` from the previous page. Omit or pass `null` for the first page. * - * The token carries the query, sort and fields of the walk, so a later page needs only - * `cursor` and `limit`. Passing a different query, sort or fields with a cursor is an error. + * The token carries this walk's parameters, so a later page needs only + * `cursor` and `limit`. Passing different parameters with a cursor is an error. */ cursor?: string | null; /** Array of field names to include in each record. Defaults to all fields. */ @@ -88,49 +88,55 @@ export interface EntityListOptions { * @typeParam K - The field whose distinct values to read. */ export interface EntityDistinctOptions { - /** Field whose distinct values to return, in ascending order. Array fields contribute each element. */ + /** Field whose distinct values to return, in ascending order. For an array field like `tags`, this returns the distinct individual tags used across records, not the distinct arrays. */ distinct: K; /** Maximum number of values per page, up to 1,000. Defaults to 100. */ limit?: number; - /** `next_cursor` from the previous page. Omit or pass `null` for the first page. The token carries the query and field. */ + /** The `next_cursor` from the previous page. Omit or pass `null` for the first page. The token carries this lookup's parameters. */ cursor?: string | null; } /** - * One page of records, returned by {@linkcode EntityHandler.list | list()} and - * {@linkcode EntityHandler.filter | filter()} when called with an options object. + * One page of items, with a cursor to continue. * - * @typeParam T - Record type of the items. + * @typeParam T - Type of the items. */ export interface EntityPage { - /** The page's records in the requested sort order, or the distinct values in ascending order. */ + /** The page's items. Without `distinct`, these are records in the requested sort order. With `distinct`, these are that field's distinct values instead of records, in ascending order. */ items: T[]; - /** Pass as `cursor` to get the next page. `null` on the last page. */ + /** A cursor for the next page, or `null` on the last page. Pass it as `cursor` to keep paging. */ next_cursor: string | null; /** Whether records remain after this page. */ has_more: boolean; } /** - * Time unit for {@linkcode EntityAggregateSpec.dateBucket | dateBucket}. + * A time bucket to group a date field by, for {@linkcode EntityAggregateSpec.dateBucket | dateBucket}. + * + * @typeParam T - Entity record type. */ -export type EntityDateBucketUnit = "day" | "week" | "month" | "year"; +export interface EntityDateBucket { + /** The date field to bucket by: `created_date`, `updated_date`, or a date field of your schema. */ + field: keyof T & string; + /** Every date field supports `day`, `month` and `year`. The `created_date` and `updated_date` fields also support `week`. */ + unit: "day" | "week" | "month" | "year"; +} /** * Describes what {@linkcode EntityHandler.aggregate | aggregate()} computes. * - * Name the fields to group by and the measures to compute; the server does the work and + * Name the fields to group by and the measures to compute, and the server does the work and * returns one row per group. Field names are the entity's own field names. * * @typeParam T - Entity record type. */ export interface EntityAggregateSpec { - /** Filter applied before grouping, in the same form {@linkcode EntityHandler.filter | filter()} accepts. Defaults to all records. */ + /** Filter applied before grouping, matching {@linkcode EntityFilterQuery}. Defaults to all records. */ query?: EntityFilterQuery; /** Field, or up to four fields, to group by. Omit to get one total row. */ groupBy?: (keyof T & string) | (keyof T & string)[]; - /** Group by a time bucket of a date field. `created_date` and `updated_date` support every unit; date fields of your schema support `day`, `month` and `year`. */ - dateBucket?: { field: keyof T & string; unit: EntityDateBucketUnit }; + /** Group by a time bucket of a date field. */ + dateBucket?: EntityDateBucket; /** Whether to include the number of records per group as `count`. Defaults to `true`. */ count?: boolean; /** Field, or fields, to sum. Each appears in the rows as `sum_`. */ @@ -141,9 +147,9 @@ export interface EntityAggregateSpec { min?: (keyof T & string) | (keyof T & string)[]; /** Field, or fields, to take the maximum of. Each appears in the rows as `max_`. */ max?: (keyof T & string) | (keyof T & string)[]; - /** Field whose distinct values to count per group, returned as `count_distinct_`. */ + /** Field whose distinct values to count per group, returned as `count_distinct_`. Unlike `distinct` on `list()`/`filter()`, an array field here counts each whole array as one value, not its individual elements. */ countDistinct?: keyof T & string; - /** Filter on the computed fields, applied after grouping. For example `{ count: { $gt: 1 } }` keeps only duplicated groups. */ + /** Filter applied to the computed fields after grouping, not to raw or `groupBy` fields. Supports `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, and `$nin`. With more than one key, a row is kept only when every key's condition holds. For example, grouped by `external_id`, `having: { count: { $gt: 1 } }` keeps only the external ids that appear in more than one record. */ having?: Record; /** Computed or group field to sort the rows by, with a `-` prefix for descending. For example `'-count'`. */ sort?: string; @@ -155,9 +161,9 @@ export interface EntityAggregateSpec { * Rows returned by {@linkcode EntityHandler.aggregate | aggregate()}. */ export interface EntityAggregateResult { - /** One row per group: the group fields by name, then `count`, `sum_`, `avg_`, `min_`, `max_` or `count_distinct_`. */ + /** One row per group. Each row has the group fields by name, then `count`, `sum_`, `avg_`, `min_`, `max_` or `count_distinct_`. */ rows: Record[]; - /** `true` when more groups exist than `limit` allowed. */ + /** Set to `true` when more groups exist than `limit` allowed. */ truncated: boolean; } @@ -167,7 +173,7 @@ export interface EntityAggregateResult { * @typeParam T - Entity record type. */ export interface EntityUpsertOptions { - /** Field, or fields, that identify a record. A record whose key values match an existing record updates it; any other record is created. */ + /** Field, or fields, that identify a record. A record whose key values match an existing record updates it, and any other record is created. */ key: (keyof T & string) | (keyof T & string)[]; } @@ -224,20 +230,6 @@ export type SortField = | `+${keyof T & string}` | `-${keyof T & string}`; -/** - * Entity filter query type system. - * - * `EntityFilterQuery` keeps field names tied to the entity schema while - * allowing Base44's documented filtering syntax. Each field can use an exact - * value, `null`, an array shorthand for matching any listed value, or a - * field-level operator object. Root-level `$and`, `$or`, and `$nor` combine - * nested filter queries. - * - * Operator values are typed from the field they filter where possible. For - * example, numeric fields accept numeric comparison values, string fields - * accept `$regex`, and array fields accept `$all` and `$size`. - */ - /** * Value accepted when filtering an entity field. * @@ -297,12 +289,30 @@ type EntityFilterArrayOperators = [ }; /** - * Query object accepted by entity filtering methods. + * Query object accepted by {@linkcode EntityHandler.filter | filter()}, + * {@linkcode EntityHandler.count | count()}, {@linkcode EntityHandler.deleteMany | deleteMany()}, + * {@linkcode EntityHandler.updateMany | updateMany()}, and the `query` field of + * `EntityAggregateSpec`. * - * Field keys are typed from the entity schema. `$and`, `$or`, and `$nor` - * combine nested filter queries at the root level. + * Field keys are typed from the entity schema. Each field can use an exact value, `null`, + * an array shorthand for matching any of the listed values, or a field-level operator + * object. Root-level `$and`, `$or`, and `$nor` combine nested filter queries. + * + * Operator values are typed from the field they filter where possible. For example, + * numeric fields accept numeric comparison values, string fields accept `$regex`, and + * array fields accept `$all` and `$size`. * * @typeParam T - Entity record type. + * + * @example + * ```typescript + * // Exact match, a comparison operator, and a nested $or + * const query: EntityFilterQuery = { + * status: 'open', + * priority: { $gte: 3 }, + * $or: [{ assignee: 'me' }, { team: 'core' }] + * }; + * ``` */ export type EntityFilterQuery = { [K in keyof T]?: EntityFilterValue; @@ -368,23 +378,87 @@ export type EntityRecord = { */ export interface EntityHandler { /** - * Lists records with optional pagination and sorting. + * Lists one cursor page of records, sorted and optionally field-selected. + * + * Pass `cursor` from the previous page's `next_cursor` to keep paging, or omit it + * for the first page. Records added or deleted between pages never shift the boundary. + * + * @typeParam K - The fields to include in each record. Defaults to all fields. + * @param options - Paging options. + * @returns Promise resolving to a page of records. + * + * @example + * ```typescript + * // Get one page of records + * const page = await base44.entities.MyEntity.list({ limit: 10 }); + * console.log(page.items); + * ``` + * + * @example + * ```typescript + * // Sort records + * const page = await base44.entities.MyEntity.list({ sort: '-priority', limit: 10 }); + * ``` + * + * @example + * ```typescript + * // Only return specific fields + * const page = await base44.entities.MyEntity.list({ fields: ['name', 'status'], limit: 10 }); + * ``` + * + * @example + * ```typescript + * // Walk every record with a cursor + * const allItems = []; + * let page = await base44.entities.MyEntity.list({ sort: '-created_date', limit: 1000 }); + * allItems.push(...page.items); + * while (page.has_more) { + * page = await base44.entities.MyEntity.list({ cursor: page.next_cursor, limit: 1000 }); + * allItems.push(...page.items); + * } + * ``` + */ + list( + options: EntityListOptions, + ): Promise>>; + + /** + * Lists one cursor page of a single field's distinct values, instead of records. + * + * @typeParam K - The field whose distinct values to read. + * @param options - Paging options naming the field to read. + * @returns Promise resolving to a page of distinct values. + * + * @example + * ```typescript + * // Regular field + * const { items: categories } = await base44.entities.Product.list({ distinct: 'category' }); + * // categories: ['books', 'electronics', 'toys'] + * ``` * - * Retrieves all records of this type with support for sorting, - * pagination, and field selection. + * @example + * ```typescript + * // Array field + * // Each tag counts once, not each array + * const { items: tags } = await base44.entities.Product.list({ distinct: 'tags' }); + * // tags: ['bestseller', 'clearance', 'new'] + * ``` + */ + list( + options: EntityDistinctOptions, + ): Promise>; + + /** + * Lists records as an array, using `skip` for pagination. * - * **Note:** The maximum limit is 5,000 items per request. To read more than - * one page, pass an {@linkcode EntityListOptions | options object} with a - * `cursor` instead of `skip`: every page costs the same however deep you are, - * and records deleted between pages never shift the boundary. `skip` is kept - * for existing code and is deprecated for loops. + * Kept for existing code. Prefer a cursor for pagination instead. * * @typeParam K - The fields to include in the response. Defaults to all fields. - * @param sort - Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. + * @param sort - Sort parameter, such as `'-priority'` for descending. Defaults to `'-created_date'`. * @param limit - Maximum number of results to return. Defaults to `5000`. - * @param skip - Number of results to skip for pagination. Defaults to `0`. Deprecated for loops; use a cursor. + * @param skip - Number of results to skip for pagination. Defaults to `0`. Prefer a cursor for loops instead. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`; with a `distinct` option the items are the field's values. + * @returns Promise resolving to an array of records with selected fields. * * @example * ```typescript @@ -410,61 +484,115 @@ export interface EntityHandler { * // Get only specific fields * const fields = await base44.entities.MyEntity.list('-created_date', 10, 0, ['name', 'status']); * ``` + */ + list( + sort?: SortField, + limit?: number, + skip?: number, + fields?: K[], + ): Promise[]>; + + /** + * Filters and returns one cursor page of matching records, sorted and + * optionally field-selected. + * + * Pass `cursor` from the previous page's `next_cursor` to keep paging, or omit it + * for the first page. Records added or deleted between pages never shift the boundary. + * + * @typeParam K - The fields to include in each record. Defaults to all fields. + * @param query - Query matching {@linkcode EntityFilterQuery}. Field names are + * case-sensitive, and records matching every field are returned. + * @param options - Paging options. + * @returns Promise resolving to a page of matching records. * * @example * ```typescript - * // Walk every record with a cursor. Pass an options object instead of - * // positional arguments to get a page with `next_cursor` and `has_more`. - * let page = await base44.entities.MyEntity.list({ sort: '-created_date', limit: 1000 }); - * await exportRows(page.items); + * // Get one page of matching records + * const page = await base44.entities.Order.filter({ status: 'open' }, { limit: 10 }); + * console.log(page.items); + * ``` + * + * @example + * ```typescript + * // Sort matching records + * const page = await base44.entities.Order.filter({ status: 'open' }, { sort: '-priority', limit: 10 }); + * ``` + * + * @example + * ```typescript + * // Only return specific fields + * const page = await base44.entities.Order.filter({ status: 'open' }, { fields: ['status', 'region'], limit: 10 }); + * ``` + * + * @example + * ```typescript + * // Walk all matching records with a cursor + * const allOrders = []; + * let page = await base44.entities.Order.filter( + * { status: 'open' }, + * { sort: '-created_date', limit: 1000 } + * ); + * allOrders.push(...page.items); * while (page.has_more) { - * page = await base44.entities.MyEntity.list({ cursor: page.next_cursor, limit: 1000 }); - * await exportRows(page.items); + * page = await base44.entities.Order.filter({ status: 'open' }, { cursor: page.next_cursor, limit: 1000 }); + * allOrders.push(...page.items); * } * ``` + */ + filter( + query: EntityFilterQuery, + options: EntityListOptions, + ): Promise>>; + + /** + * Filters and returns one cursor page of a single field's distinct values + * among matching records. + * + * @typeParam K - The field whose distinct values to read. + * @param query - Query matching {@linkcode EntityFilterQuery}. Field names are + * case-sensitive, and records matching every field are returned. + * @param options - Paging options naming the field to read. + * @returns Promise resolving to a page of distinct values. * * @example * ```typescript - * // Distinct values of one field, instead of records - * const { items: categories } = await base44.entities.Product.list({ distinct: 'category' }); + * // Regular field + * const { items: regions } = await base44.entities.Order.filter( + * { status: 'open' }, + * { distinct: 'region' } + * ); + * // regions: ['east', 'north', 'west'] + * ``` + * + * @example + * ```typescript + * // Array field + * // Each tag counts once, not each array + * const { items: tags } = await base44.entities.Order.filter( + * { status: 'open' }, + * { distinct: 'tags' } + * ); + * // tags: ['gift', 'international', 'rush'] * ``` */ - list( - sort?: SortField, - limit?: number, - skip?: number, - fields?: K[], - ): Promise[]>; - list( + filter( + query: EntityFilterQuery, options: EntityDistinctOptions, ): Promise>; - list( - options: EntityListOptions, - ): Promise>>; /** - * Filters records based on a query. - * - * Retrieves records that match specific criteria with support for - * sorting, pagination, and field selection. + * Filters records as an array, using `skip` for pagination. * - * **Note:** The maximum limit is 5,000 items per request. To read more than - * one page, pass an {@linkcode EntityListOptions | options object} with a - * `cursor` instead of `skip`: every page costs the same however deep you are, - * and records deleted between pages never shift the boundary. `skip` is kept - * for existing code and is deprecated for loops. + * Kept for existing code. Prefer a cursor for pagination instead. * * @typeParam K - The fields to include in the response. Defaults to all fields. - * @param query - Query object with field-value pairs. Each key should be a field name - * from your entity schema, and each value is the criteria to match. Records matching all - * specified criteria are returned. Field names are case-sensitive. Use field-value pairs - * for exact matches, `null` for null values, arrays as shorthand for matching any of the - * provided values, or documented MongoDB query operators for advanced filtering. - * @param sort - Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. + * @param query - Query matching {@linkcode EntityFilterQuery}. Field names are + * case-sensitive, and records matching every field are returned. + * @param sort - Sort parameter, such as `'-priority'` for descending. Defaults to `'-created_date'`. * @param limit - Maximum number of results to return. Defaults to `5000`. - * @param skip - Number of results to skip for pagination. Defaults to `0`. Deprecated for loops; use a cursor. + * @param skip - Number of results to skip for pagination. Defaults to `0`. Prefer a cursor for loops instead. * @param fields - Array of field names to include in the response. Defaults to all fields. - * @returns Promise resolving to an array of filtered records with selected fields. When called with an options object, resolves instead to an {@linkcode EntityPage | EntityPage} with `items`, `next_cursor` and `has_more`; with a `distinct` option the items are the field's values. + * @returns Promise resolving to an array of filtered records with selected fields. * * @example * ```typescript @@ -541,30 +669,6 @@ export interface EntityHandler { * ['name', 'priority'] * ); * ``` - * - * @example - * ```typescript - * // Walk all matching records with a cursor. Pass an options object as the - * // second argument to get a page with `next_cursor` and `has_more`. - * let page = await base44.entities.Order.filter( - * { status: 'open' }, - * { sort: '-created_date', limit: 1000 } - * ); - * await exportRows(page.items); - * while (page.has_more) { - * page = await base44.entities.Order.filter({ status: 'open' }, { cursor: page.next_cursor, limit: 1000 }); - * await exportRows(page.items); - * } - * ``` - * - * @example - * ```typescript - * // Distinct values of one field among the matching records - * const { items: agents } = await base44.entities.Order.filter( - * { status: 'open' }, - * { distinct: 'agent_id' } - * ); - * ``` */ filter( query: EntityFilterQuery, @@ -573,14 +677,6 @@ export interface EntityHandler { skip?: number, fields?: K[], ): Promise[]>; - filter( - query: EntityFilterQuery, - options: EntityDistinctOptions, - ): Promise>; - filter( - query: EntityFilterQuery, - options: EntityListOptions, - ): Promise>>; /** * Gets a single record by ID. @@ -677,9 +773,7 @@ export interface EntityHandler { * * Permanently removes all records that match the provided query. * - * @param query - Query object with field-value pairs. Each key should be a field name - * from your entity schema, and each value is the criteria to match. Records matching all - * specified criteria will be deleted. Field names are case-sensitive. + * @param query - Query matching {@linkcode EntityFilterQuery}. Every matching record is deleted. * @returns Promise resolving to the deletion result. * * @example @@ -731,12 +825,7 @@ export interface EntityHandler { * To update a single record by ID, use {@linkcode update | update()} instead. To update * multiple specific records with different data each, use {@linkcode bulkUpdate | bulkUpdate()}. * - * @param query - Query object to filter which records to update. Use field-value - * pairs for exact matches, or - * [MongoDB query operators](https://www.mongodb.com/docs/manual/reference/operator/query/) - * for advanced filtering. Supported query operators include `$eq`, `$ne`, `$gt`, - * `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$and`, `$or`, `$not`, `$nor`, - * `$exists`, `$regex`, `$all`, `$elemMatch`, and `$size`. + * @param query - Query matching {@linkcode EntityFilterQuery}, selecting which records to update. * @param data - Update operation object containing one or more * [MongoDB update operators](https://www.mongodb.com/docs/manual/reference/operator/update/). * Each field may only appear in one operator per call. @@ -802,23 +891,30 @@ export interface EntityHandler { * Counts the records that match a query. * * Returns the number of records the current user can read, without fetching them. - * Use it for totals, badges and "page N of M" instead of listing records and - * measuring the array. + * Use it for totals, badges and "page N of M". * - * @param query - Filter query, in the same form {@linkcode filter | filter()} accepts. Defaults to all records. + * @param query - Query matching {@linkcode EntityFilterQuery}. Defaults to all records. * @returns Promise resolving to the number of matching records. * * @example * ```typescript - * // How many tasks are still open? + * // Count matching records * const open = await base44.entities.Task.count({ status: 'open' }); * ``` * * @example * ```typescript - * // Total records in the entity + * // Count all records * const total = await base44.entities.Task.count(); * ``` + * + * @example + * ```typescript + * // Page N of M + * const pageSize = 20; + * const total = await base44.entities.Task.count({ status: 'open' }); + * const totalPages = Math.ceil(total / pageSize); + * ``` */ count(query?: EntityFilterQuery): Promise; @@ -829,7 +925,7 @@ export interface EntityHandler { * and adding up in the browser. The server groups the records you can read and * returns one row per group, up to 1,000 rows. * - * @param spec - What to group by and what to compute. See {@linkcode EntityAggregateSpec | EntityAggregateSpec}. + * @param spec - What to group by and what to compute. * @returns Promise resolving to the rows and a `truncated` flag. * * @example @@ -841,59 +937,79 @@ export interface EntityHandler { * sum: 'amount', * sort: '-sum_amount' * }); - * // rows: [{ agent_id: 'a1', count: 42, sum_amount: 18250 }, ...] + * // rows: [ + * // { agent_id: 'a1', count: 42, sum_amount: 18250 }, + * // { agent_id: 'a2', count: 37, sum_amount: 15400 }, + * // ... + * // ] * ``` * * @example * ```typescript - * // Records created per day - * const { rows } = await base44.entities.Visit.aggregate({ + * // Tasks created per day + * const { rows } = await base44.entities.Task.aggregate({ * dateBucket: { field: 'created_date', unit: 'day' } * }); + * // rows: [ + * // { created_date: '2026-09-01', count: 8 }, + * // { created_date: '2026-09-02', count: 11 }, + * // ... + * // ] * ``` * * @example * ```typescript * // Find duplicated external ids - * const { rows } = await base44.entities.Contact.aggregate({ + * const { rows } = await base44.entities.Order.aggregate({ * groupBy: 'external_id', * having: { count: { $gt: 1 } } * }); + * // rows: [ + * // { external_id: 'ext-42', count: 3 }, + * // { external_id: 'ext-77', count: 2 } + * // ] * ``` * * @example * ```typescript - * // Unique visitors per page - * const { rows } = await base44.entities.PageView.aggregate({ - * groupBy: 'path', - * countDistinct: 'session_id' + * // Unique customers per product + * const { rows } = await base44.entities.Order.aggregate({ + * groupBy: 'product_id', + * countDistinct: 'customer_id' * }); + * // rows: [ + * // { product_id: 'p1', count_distinct_customer_id: 86 }, + * // { product_id: 'p2', count_distinct_customer_id: 34 } + * // ] * ``` */ aggregate(spec: EntityAggregateSpec): Promise; /** - * Creates or updates records by a key of your own. + * Creates or updates records by a key you define, instead of by `id`. * - * Use this when you sync data from another system: name the field, or fields, that - * identify a record, and the server updates the records whose key already exists and - * creates the rest, in one call. You no longer need to list existing records to check - * for duplicates before writing. + * Use this whenever records have a natural key, such as an ID from another system or a + * one-per-user record keyed by `user_id`, so you don't have to look up each record first + * to decide between create and update. Name the field, or fields, that identify a record, + * and the server updates the records whose key already exists and creates the rest, in + * one call. * * You can upsert up to 500 records per request. When two records in one call share a * key, the last one wins. Updates merge the given fields into the existing record, like * {@linkcode update | update()}. * - * @param records - Array of record data objects. Each must carry a value for every key field. - * @param options - The key field or fields. See {@linkcode EntityUpsertOptions | EntityUpsertOptions}. + * @param records - Array of record data objects. Each must carry a value, not an object or + * array, for every field named in `options.key`, to find or create by. Other fields are + * optional, and only the ones you include are merged into a matched record, like `update()`. + * @param options - The key field or fields. * @returns Promise resolving to the counts and the written records. * * @example * ```typescript - * // Sync contacts from a CRM by their CRM id - * const result = await base44.entities.Contact.upsert( - * crmContacts.map(c => ({ crm_id: c.id, name: c.name, email: c.email })), - * { key: 'crm_id' } + * // Sync by sku + * const result = await base44.entities.Product.upsert( + * supplierCatalog.map(p => ({ sku: p.sku, name: p.name, price: p.price })), + * { key: 'sku' } * ); * console.log(`${result.created} new, ${result.updated} updated`); * ``` @@ -901,7 +1017,11 @@ export interface EntityHandler { * @example * ```typescript * // Compound key - * await base44.entities.Inventory.upsert(rows, { key: ['sku', 'warehouse'] }); + * const result = await base44.entities.Inventory.upsert( + * warehouseFeed.map(row => ({ sku: row.sku, warehouse: row.warehouseCode, quantity: row.qty })), + * { key: ['sku', 'warehouse'] } + * ); + * console.log(`${result.created} new, ${result.updated} updated`); * ``` */ upsert(