@agent-native/core 0.133.2 → 0.133.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-action.ts +6 -0
  5. package/corpus/templates/content/actions/_batch-utils.ts +33 -0
  6. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -81
  7. package/corpus/templates/content/actions/_content-files.ts +31 -21
  8. package/corpus/templates/content/actions/_database-source-utils.ts +653 -155
  9. package/corpus/templates/content/actions/_database-utils.ts +535 -41
  10. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +291 -240
  11. package/corpus/templates/content/actions/add-database-item.ts +15 -1
  12. package/corpus/templates/content/actions/attach-content-database-source.ts +156 -58
  13. package/corpus/templates/content/actions/bind-content-database-source-field.ts +2 -2
  14. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +4 -1
  15. package/corpus/templates/content/actions/delete-database-items.ts +8 -2
  16. package/corpus/templates/content/actions/disconnect-content-database-source.ts +2 -2
  17. package/corpus/templates/content/actions/duplicate-database-item.ts +15 -1
  18. package/corpus/templates/content/actions/duplicate-database-items.ts +19 -1
  19. package/corpus/templates/content/actions/execute-builder-source-execution.ts +2 -1
  20. package/corpus/templates/content/actions/get-content-database.ts +12 -64
  21. package/corpus/templates/content/actions/move-database-item.ts +4 -1
  22. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +1 -1
  23. package/corpus/templates/content/actions/prepare-builder-source-review.ts +4 -8
  24. package/corpus/templates/content/actions/preview-content-database-source-attach.ts +92 -0
  25. package/corpus/templates/content/actions/process-builder-body-hydration.ts +2 -1
  26. package/corpus/templates/content/actions/query-content-database-items.ts +64 -0
  27. package/corpus/templates/content/actions/refresh-content-database-source.ts +7 -0
  28. package/corpus/templates/content/actions/review-content-database-source-change-set.ts +1 -1
  29. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +1 -1
  30. package/corpus/templates/content/actions/stage-builder-revision.ts +1 -1
  31. package/corpus/templates/content/actions/update-content-database-personal-view.ts +29 -3
  32. package/corpus/templates/content/actions/update-content-database-view.ts +1 -1
  33. package/corpus/templates/content/actions/validate-builder-source-execution.ts +1 -1
  34. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +3 -22
  35. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +267 -105
  36. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +82 -1
  37. package/corpus/templates/content/app/hooks/use-content-database.ts +285 -24
  38. package/corpus/templates/content/app/hooks/use-document-properties.ts +7 -6
  39. package/corpus/templates/content/app/i18n-data.ts +10 -0
  40. package/corpus/templates/content/changelog/2026-07-29-large-databases-keep-useful-rows-visible.md +6 -0
  41. package/corpus/templates/content/changelog/2026-07-30-builder-source-columns-now-appear-immediately-when-connected.md +6 -0
  42. package/corpus/templates/content/changelog/2026-07-30-large-builder-backed-tables-show-useful-rows-sooner-and-fini.md +6 -0
  43. package/corpus/templates/content/changelog/2026-08-01-large-builder-databases-now-show-rows-immediately-and-finish.md +6 -0
  44. package/corpus/templates/content/parity/matrix.md +2 -1
  45. package/corpus/templates/content/parity/matrix.ts +25 -0
  46. package/corpus/templates/content/shared/api.ts +54 -4
  47. package/corpus/templates/content/shared/database-query.ts +359 -0
  48. package/dist/client/use-action.d.ts.map +1 -1
  49. package/dist/client/use-action.js +6 -0
  50. package/dist/client/use-action.js.map +1 -1
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/observability/routes.d.ts +3 -3
  53. package/dist/progress/routes.d.ts +1 -1
  54. package/dist/resources/handlers.d.ts +1 -1
  55. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  56. package/dist/server/transcribe-voice.d.ts +1 -1
  57. package/package.json +1 -1
  58. package/src/client/use-action.ts +6 -0
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
3
  import { getDialect, type Dialect } from "@agent-native/core/db";
4
- import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
4
+ import { and, asc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
7
  import type {
@@ -55,7 +55,12 @@ import {
55
55
  type DocumentPropertyOptionColor,
56
56
  } from "../shared/properties.js";
57
57
  import { sanitizeNormalizationFormula } from "../shared/properties.js";
58
- import { chunks } from "./_batch-utils.js";
58
+ import {
59
+ bulkChunkSizeForColumnCount,
60
+ chunks,
61
+ processWithConcurrency,
62
+ } from "./_batch-utils.js";
63
+ export { bulkChunkSizeForColumnCount } from "./_batch-utils.js";
59
64
  import {
60
65
  readBuilderCmsContentEntry,
61
66
  readBuilderCmsContentEntries,
@@ -123,6 +128,24 @@ function stableBuilderImportId(
123
128
  .slice(0, 24)}`;
124
129
  }
125
130
 
131
+ export function builderCmsImportIds(args: {
132
+ ownerEmail: string;
133
+ databaseId: string;
134
+ sourceTable: string;
135
+ entryId: string;
136
+ }) {
137
+ const identity = [
138
+ args.ownerEmail,
139
+ args.databaseId,
140
+ args.sourceTable,
141
+ args.entryId,
142
+ ];
143
+ return {
144
+ documentId: stableBuilderImportId("builder-doc", identity),
145
+ itemId: stableBuilderImportId("builder-item", identity),
146
+ };
147
+ }
148
+
126
149
  const DEFAULT_SOURCE_CAPABILITIES: ContentDatabaseSourceCapabilities = {
127
150
  canRefresh: true,
128
151
  canCreateChangeSets: true,
@@ -958,38 +981,22 @@ function builderBodyUsesCurrentMediaConverter(
958
981
 
959
982
  const BUILDER_BODY_HYDRATION_BACKGROUND_PRIORITY = 10;
960
983
  const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0;
961
- const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25;
962
- const BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY = 6;
984
+ const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 600;
985
+ const BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY = 72;
986
+ const BUILDER_BODY_HYDRATION_BULK_PRELOAD_MIN_JOBS = 20;
963
987
  const BUILDER_BODY_HYDRATION_MAX_ATTEMPTS = 5;
988
+ const BUILDER_BODY_HYDRATION_CLAIM_LEASE_MS = 2 * 60 * 1000;
989
+ const BUILDER_BODY_HYDRATION_POSTGRES_BULK_LIMIT = 200;
990
+ const BUILDER_BODY_HYDRATION_MAX_BOUND_PARAMS_PER_ROW = 8;
964
991
  const BUILDER_BODY_HYDRATION_CODEC_VERSION =
965
992
  "readable-native-images-authoritative-raw-baseline-v9";
966
993
  const BUILDER_CMS_REFRESH_INITIAL_PAGES = 1;
967
994
  const BUILDER_BODY_NOT_AVAILABLE_ERROR = "body not yet available from Builder";
968
995
 
969
- export function bulkChunkSizeForColumnCount(
970
- columnCount: number,
971
- dialect: Dialect = getDialect(),
972
- ) {
973
- // D1 rejects statements with more than 100 bound params, so derive every
974
- // bulk chunk from the statement's column count instead of a fixed row count.
975
- const budget = dialect === "d1" ? 90 : 900;
976
- return Math.max(1, Math.floor(budget / Math.max(1, columnCount)));
977
- }
978
-
979
996
  function idChunkSize() {
980
997
  return bulkChunkSizeForColumnCount(1);
981
998
  }
982
999
 
983
- async function processInBatches<T>(
984
- items: T[],
985
- concurrency: number,
986
- worker: (item: T) => Promise<void>,
987
- ) {
988
- for (const batch of chunks(items, concurrency)) {
989
- await Promise.all(batch.map((item) => worker(item)));
990
- }
991
- }
992
-
993
1000
  function builderBodyHydrationDelayMs() {
994
1001
  if (
995
1002
  process.env.NODE_ENV === "production" ||
@@ -1081,7 +1088,10 @@ function normalizeHydrationLimit(limit: number | null | undefined) {
1081
1088
  if (typeof limit !== "number" || !Number.isFinite(limit)) {
1082
1089
  return BUILDER_BODY_HYDRATION_BATCH_LIMIT;
1083
1090
  }
1084
- return Math.max(1, Math.min(Math.floor(limit), 50));
1091
+ return Math.max(
1092
+ 1,
1093
+ Math.min(Math.floor(limit), BUILDER_BODY_HYDRATION_BATCH_LIMIT),
1094
+ );
1085
1095
  }
1086
1096
 
1087
1097
  function builderBodyIsRawPlaceholderOnly(content: string | null | undefined) {
@@ -1531,6 +1541,7 @@ export async function enqueueBuilderBodyHydrationForItems(args: {
1531
1541
  items: ContentDatabaseItem[];
1532
1542
  builderEntriesByDocumentId: Map<string, BuilderCmsSourceEntry> | undefined;
1533
1543
  now: string;
1544
+ processInBackground?: boolean;
1534
1545
  }) {
1535
1546
  if (!args.builderEntriesByDocumentId?.size) return;
1536
1547
  const persistedStateByDocumentId = new Map<
@@ -1626,10 +1637,12 @@ export async function enqueueBuilderBodyHydrationForItems(args: {
1626
1637
  });
1627
1638
  }
1628
1639
  const queuedJobs = await enqueueBuilderBodyHydrations(requests);
1640
+ if (args.processInBackground === false) return;
1629
1641
  void processBuilderBodyHydrationQueue({
1630
1642
  sourceId: args.sourceId,
1631
1643
  limit: BUILDER_BODY_HYDRATION_BATCH_LIMIT,
1632
1644
  preloadedJobs: queuedJobs,
1645
+ preloadBodies: true,
1633
1646
  }).catch((error) => {
1634
1647
  console.error("Builder body hydration background kick failed", error);
1635
1648
  });
@@ -1732,6 +1745,7 @@ async function processBuilderBodyHydrationJob(
1732
1745
  sourceRow?: ContentDatabaseSourceRecordRowDb | null;
1733
1746
  documentContent?: string | null;
1734
1747
  bodyHydrationVersion?: string | null;
1748
+ bodyEntry?: BuilderCmsSourceEntry | null;
1735
1749
  },
1736
1750
  ) {
1737
1751
  const db = getDb();
@@ -1741,9 +1755,19 @@ async function processBuilderBodyHydrationJob(
1741
1755
  entry.sourceValues,
1742
1756
  BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
1743
1757
  );
1758
+ const bodyEntry =
1759
+ preloaded?.bodyEntry?.id === entry.id
1760
+ ? {
1761
+ ...preloaded.bodyEntry,
1762
+ sourceValues: {
1763
+ ...entry.sourceValues,
1764
+ ...preloaded.bodyEntry.sourceValues,
1765
+ },
1766
+ }
1767
+ : entry;
1744
1768
  let activeSourceEntryJson = row.sourceEntryJson;
1745
1769
  let entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless(
1746
- await withBuilderBodySourceValues(entry),
1770
+ await withBuilderBodySourceValues(bodyEntry),
1747
1771
  );
1748
1772
  const sourceRow =
1749
1773
  preloaded?.sourceRow != null
@@ -1873,6 +1897,10 @@ async function processBuilderBodyHydrationJob(
1873
1897
  schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
1874
1898
  activeSourceEntryJson,
1875
1899
  ),
1900
+ eq(
1901
+ schema.contentDatabaseBodyHydrationQueue.attempts,
1902
+ row.attempts,
1903
+ ),
1876
1904
  ),
1877
1905
  )
1878
1906
  .returning({ id: schema.contentDatabaseBodyHydrationQueue.id });
@@ -1912,6 +1940,10 @@ async function processBuilderBodyHydrationJob(
1912
1940
  schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
1913
1941
  activeSourceEntryJson,
1914
1942
  ),
1943
+ eq(
1944
+ schema.contentDatabaseBodyHydrationQueue.attempts,
1945
+ row.attempts,
1946
+ ),
1915
1947
  ),
1916
1948
  )
1917
1949
  .returning({ id: schema.contentDatabaseBodyHydrationQueue.id });
@@ -1926,12 +1958,9 @@ async function processBuilderBodyHydrationJob(
1926
1958
  if (!nextContent.trim()) {
1927
1959
  const attempts = row.attempts;
1928
1960
  await db.transaction(async (tx) => {
1929
- const queueRowCas = and(
1930
- eq(schema.contentDatabaseBodyHydrationQueue.id, row.id),
1931
- eq(
1932
- schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
1933
- activeSourceEntryJson,
1934
- ),
1961
+ const queueRowCas = builderBodyHydrationQueueOwnershipFilter(
1962
+ row,
1963
+ activeSourceEntryJson,
1935
1964
  );
1936
1965
  const markPendingIfReplaced = async () => {
1937
1966
  const [replacedByNewerJob] = await tx
@@ -1974,6 +2003,7 @@ async function processBuilderBodyHydrationJob(
1974
2003
  const [stillOwnsQueueRow] = await tx
1975
2004
  .update(schema.contentDatabaseBodyHydrationQueue)
1976
2005
  .set({
2006
+ lastAttemptedAt: null,
1977
2007
  lastError: BUILDER_BODY_NOT_AVAILABLE_ERROR,
1978
2008
  updatedAt: now,
1979
2009
  })
@@ -2021,12 +2051,9 @@ async function processBuilderBodyHydrationJob(
2021
2051
  });
2022
2052
  let wroteBody = false;
2023
2053
  await db.transaction(async (tx) => {
2024
- const queueRowCas = and(
2025
- eq(schema.contentDatabaseBodyHydrationQueue.id, row.id),
2026
- eq(
2027
- schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
2028
- activeSourceEntryJson,
2029
- ),
2054
+ const queueRowCas = builderBodyHydrationQueueOwnershipFilter(
2055
+ row,
2056
+ activeSourceEntryJson,
2030
2057
  );
2031
2058
  const markPendingIfReplaced = async () => {
2032
2059
  const [replacedByNewerJob] = await tx
@@ -2185,6 +2212,276 @@ async function processBuilderBodyHydrationJob(
2185
2212
  // repersist the empty state over the newly hydrated Builder body.
2186
2213
  }
2187
2214
 
2215
+ type PreparedPristineBuilderBodyHydration = {
2216
+ job: ContentDatabaseBodyHydrationQueueRowDb;
2217
+ sourceRow: ContentDatabaseSourceRecordRowDb;
2218
+ documentContent: string;
2219
+ content: string;
2220
+ sourceValuesJson: string;
2221
+ lastSourceUpdatedAt: string;
2222
+ bodyHydrationVersion: string;
2223
+ };
2224
+
2225
+ class PristineBuilderBodyHydrationCasMiss extends Error {}
2226
+
2227
+ function hydrationCaseSql<T>(
2228
+ idColumn: unknown,
2229
+ fallbackColumn: unknown,
2230
+ values: Array<{ id: string; value: T }>,
2231
+ ) {
2232
+ const cases = values.map(({ id, value }) => sql`WHEN ${id} THEN ${value}`);
2233
+ return sql<T>`CASE ${idColumn} ${sql.join(cases, sql` `)} ELSE ${fallbackColumn} END`;
2234
+ }
2235
+
2236
+ function builderBodyHydrationQueueOwnershipFilter(
2237
+ job: ContentDatabaseBodyHydrationQueueRowDb,
2238
+ sourceEntryJson = job.sourceEntryJson,
2239
+ ) {
2240
+ return and(
2241
+ eq(schema.contentDatabaseBodyHydrationQueue.id, job.id),
2242
+ eq(
2243
+ schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
2244
+ sourceEntryJson,
2245
+ ),
2246
+ eq(schema.contentDatabaseBodyHydrationQueue.attempts, job.attempts),
2247
+ );
2248
+ }
2249
+
2250
+ async function preparePristineBuilderBodyHydration(args: {
2251
+ job: ContentDatabaseBodyHydrationQueueRowDb;
2252
+ sourceRow: ContentDatabaseSourceRecordRowDb | null;
2253
+ documentContent: string | null | undefined;
2254
+ bodyHydrationVersion: string | null;
2255
+ bodyEntry: BuilderCmsSourceEntry | null;
2256
+ }): Promise<PreparedPristineBuilderBodyHydration | null> {
2257
+ const { job, sourceRow, documentContent, bodyHydrationVersion, bodyEntry } =
2258
+ args;
2259
+ if (
2260
+ job.attempts !== 1 ||
2261
+ !sourceRow ||
2262
+ !bodyEntry ||
2263
+ bodyHydrationVersion !== null ||
2264
+ !isEffectivelyEmptyDocumentContent(documentContent ?? "") ||
2265
+ sourceRow.sourceRowId !== job.sourceRowId ||
2266
+ bodyEntry.id !== job.sourceRowId
2267
+ ) {
2268
+ return null;
2269
+ }
2270
+ const queuedEntry = parseHydrationEntry(job);
2271
+ if (!queuedEntry || queuedEntry.id !== bodyEntry.id) return null;
2272
+ const sourceValues =
2273
+ parseObject<Record<string, DocumentPropertyValue>>(
2274
+ sourceRow.sourceValuesJson,
2275
+ ) ?? {};
2276
+ if (
2277
+ stringSourceValue(sourceValues, BUILDER_CMS_BODY_CONTENT_KEY)?.trim() ||
2278
+ stringSourceValue(sourceValues, BUILDER_CMS_BODY_BLOCKS_HASH_KEY)
2279
+ ) {
2280
+ return null;
2281
+ }
2282
+ const queuedLastUpdated =
2283
+ stringSourceValue(queuedEntry.sourceValues, "lastUpdated") ??
2284
+ queuedEntry.updatedAt;
2285
+ const bodyLastUpdated =
2286
+ stringSourceValue(bodyEntry.sourceValues, "lastUpdated") ??
2287
+ bodyEntry.updatedAt;
2288
+ if (
2289
+ queuedLastUpdated &&
2290
+ bodyLastUpdated &&
2291
+ queuedLastUpdated !== bodyLastUpdated
2292
+ ) {
2293
+ return null;
2294
+ }
2295
+ const entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless(
2296
+ await withBuilderBodySourceValues({
2297
+ ...bodyEntry,
2298
+ sourceValues: {
2299
+ ...queuedEntry.sourceValues,
2300
+ ...bodyEntry.sourceValues,
2301
+ },
2302
+ }),
2303
+ );
2304
+ const nextValues = {
2305
+ ...sourceValues,
2306
+ ...entryWithBody.sourceValues,
2307
+ };
2308
+ const content =
2309
+ stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? "";
2310
+ if (!content.trim()) return null;
2311
+ return {
2312
+ job,
2313
+ sourceRow,
2314
+ documentContent: documentContent ?? "",
2315
+ content,
2316
+ sourceValuesJson: JSON.stringify(nextValues),
2317
+ lastSourceUpdatedAt: entryWithBody.updatedAt ?? new Date().toISOString(),
2318
+ bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody),
2319
+ };
2320
+ }
2321
+
2322
+ async function persistPristineBuilderBodyHydrationsInBulk(
2323
+ prepared: PreparedPristineBuilderBodyHydration[],
2324
+ now: string,
2325
+ ) {
2326
+ const db = getDb();
2327
+ const persistedJobIds = new Set<string>();
2328
+ const chunkLimit = builderBodyHydrationBulkChunkLimit();
2329
+ for (const batch of chunks(prepared, chunkLimit)) {
2330
+ try {
2331
+ await db.transaction(async (tx) => {
2332
+ const queueOwnership = batch.map(({ job }) =>
2333
+ builderBodyHydrationQueueOwnershipFilter(job),
2334
+ );
2335
+
2336
+ const updatedDocuments = await tx
2337
+ .update(schema.documents)
2338
+ .set({
2339
+ content: hydrationCaseSql(
2340
+ schema.documents.id,
2341
+ schema.documents.content,
2342
+ batch.map((row) => ({
2343
+ id: row.job.documentId,
2344
+ value: row.content,
2345
+ })),
2346
+ ),
2347
+ updatedAt: now,
2348
+ })
2349
+ .where(
2350
+ or(
2351
+ ...batch.map((row) =>
2352
+ and(
2353
+ eq(schema.documents.id, row.job.documentId),
2354
+ eq(schema.documents.content, row.documentContent),
2355
+ ),
2356
+ ),
2357
+ ),
2358
+ )
2359
+ .returning({ id: schema.documents.id });
2360
+ if (updatedDocuments.length !== batch.length) {
2361
+ throw new PristineBuilderBodyHydrationCasMiss(
2362
+ "Builder body hydration document changed.",
2363
+ );
2364
+ }
2365
+
2366
+ const updatedSourceRows = await tx
2367
+ .update(schema.contentDatabaseSourceRows)
2368
+ .set({
2369
+ sourceValuesJson: hydrationCaseSql(
2370
+ schema.contentDatabaseSourceRows.id,
2371
+ schema.contentDatabaseSourceRows.sourceValuesJson,
2372
+ batch.map((row) => ({
2373
+ id: row.sourceRow.id,
2374
+ value: row.sourceValuesJson,
2375
+ })),
2376
+ ),
2377
+ lastSyncedAt: now,
2378
+ lastSourceUpdatedAt: hydrationCaseSql(
2379
+ schema.contentDatabaseSourceRows.id,
2380
+ schema.contentDatabaseSourceRows.lastSourceUpdatedAt,
2381
+ batch.map((row) => ({
2382
+ id: row.sourceRow.id,
2383
+ value: row.lastSourceUpdatedAt,
2384
+ })),
2385
+ ),
2386
+ updatedAt: now,
2387
+ })
2388
+ .where(
2389
+ or(
2390
+ ...batch.map((row) =>
2391
+ and(
2392
+ eq(schema.contentDatabaseSourceRows.id, row.sourceRow.id),
2393
+ eq(
2394
+ schema.contentDatabaseSourceRows.sourceId,
2395
+ row.job.sourceId,
2396
+ ),
2397
+ eq(
2398
+ schema.contentDatabaseSourceRows.databaseItemId,
2399
+ row.job.databaseItemId,
2400
+ ),
2401
+ eq(
2402
+ schema.contentDatabaseSourceRows.sourceValuesJson,
2403
+ row.sourceRow.sourceValuesJson,
2404
+ ),
2405
+ ),
2406
+ ),
2407
+ ),
2408
+ )
2409
+ .returning({ id: schema.contentDatabaseSourceRows.id });
2410
+ if (updatedSourceRows.length !== batch.length) {
2411
+ throw new PristineBuilderBodyHydrationCasMiss(
2412
+ "Builder body hydration source row changed.",
2413
+ );
2414
+ }
2415
+
2416
+ const deletedQueueRows = await tx
2417
+ .delete(schema.contentDatabaseBodyHydrationQueue)
2418
+ .where(or(...queueOwnership))
2419
+ .returning({
2420
+ id: schema.contentDatabaseBodyHydrationQueue.id,
2421
+ });
2422
+ if (deletedQueueRows.length !== batch.length) {
2423
+ // This guarded delete is the queue-ownership CAS for the whole
2424
+ // transaction; a miss rolls back the preceding document/source writes.
2425
+ throw new PristineBuilderBodyHydrationCasMiss(
2426
+ "Builder body hydration queue changed.",
2427
+ );
2428
+ }
2429
+
2430
+ const updatedItems = await tx
2431
+ .update(schema.contentDatabaseItems)
2432
+ .set({
2433
+ bodyHydrationStatus: "hydrated",
2434
+ bodyHydrationAttemptedAt: now,
2435
+ bodyHydrationError: null,
2436
+ bodyHydrationVersion: hydrationCaseSql(
2437
+ schema.contentDatabaseItems.id,
2438
+ schema.contentDatabaseItems.bodyHydrationVersion,
2439
+ batch.map((row) => ({
2440
+ id: row.job.databaseItemId,
2441
+ value: row.bodyHydrationVersion,
2442
+ })),
2443
+ ),
2444
+ updatedAt: now,
2445
+ })
2446
+ .where(
2447
+ and(
2448
+ inArray(
2449
+ schema.contentDatabaseItems.id,
2450
+ batch.map((row) => row.job.databaseItemId),
2451
+ ),
2452
+ eq(schema.contentDatabaseItems.bodyHydrationStatus, "hydrating"),
2453
+ ),
2454
+ )
2455
+ .returning({ id: schema.contentDatabaseItems.id });
2456
+ if (updatedItems.length !== batch.length) {
2457
+ throw new PristineBuilderBodyHydrationCasMiss(
2458
+ "Builder body hydration item changed.",
2459
+ );
2460
+ }
2461
+ });
2462
+ for (const row of batch) persistedJobIds.add(row.job.id);
2463
+ } catch (error) {
2464
+ if (!(error instanceof PristineBuilderBodyHydrationCasMiss)) throw error;
2465
+ // The transaction rolled the whole chunk back. The existing per-row path
2466
+ // below reloads its conflict-sensitive state and preserves the safer
2467
+ // edit, migration, retry, and unavailable-body semantics.
2468
+ }
2469
+ }
2470
+ return persistedJobIds;
2471
+ }
2472
+
2473
+ export function builderBodyHydrationBulkChunkLimit(
2474
+ dialect: Dialect = getDialect(),
2475
+ ) {
2476
+ const portableLimit = bulkChunkSizeForColumnCount(
2477
+ BUILDER_BODY_HYDRATION_MAX_BOUND_PARAMS_PER_ROW,
2478
+ dialect,
2479
+ );
2480
+ return dialect === "postgres"
2481
+ ? Math.max(portableLimit, BUILDER_BODY_HYDRATION_POSTGRES_BULK_LIMIT)
2482
+ : portableLimit;
2483
+ }
2484
+
2188
2485
  async function enqueueStaleBuilderBodyHydrationForOpenDocument(args: {
2189
2486
  sourceId: string;
2190
2487
  documentId: string;
@@ -2263,6 +2560,7 @@ export async function processBuilderBodyHydrationQueue(args: {
2263
2560
  documentId?: string | null;
2264
2561
  limit?: number | null;
2265
2562
  preloadedJobs?: ContentDatabaseBodyHydrationQueueRowDb[];
2563
+ preloadBodies?: boolean;
2266
2564
  }) {
2267
2565
  const db = getDb();
2268
2566
  const limit = normalizeHydrationLimit(args.limit);
@@ -2329,11 +2627,17 @@ export async function processBuilderBodyHydrationQueue(args: {
2329
2627
  });
2330
2628
  })()
2331
2629
  : persistedJobs(limit));
2630
+ const claimLeaseCutoff = new Date(
2631
+ Date.now() - BUILDER_BODY_HYDRATION_CLAIM_LEASE_MS,
2632
+ ).toISOString();
2332
2633
 
2333
2634
  let succeeded = 0;
2334
2635
  let failed = 0;
2335
2636
  const claimedJobs: ContentDatabaseBodyHydrationQueueRowDb[] = [];
2336
- for (const jobChunk of chunks(jobs, bulkChunkSizeForColumnCount(2))) {
2637
+ for (const jobChunk of chunks(
2638
+ jobs,
2639
+ bulkChunkSizeForColumnCount(args.preloadBodies === true ? 5 : 3),
2640
+ )) {
2337
2641
  const claimFilters = jobChunk.map((job) =>
2338
2642
  and(
2339
2643
  eq(schema.contentDatabaseBodyHydrationQueue.id, job.id),
@@ -2341,6 +2645,28 @@ export async function processBuilderBodyHydrationQueue(args: {
2341
2645
  schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
2342
2646
  job.sourceEntryJson,
2343
2647
  ),
2648
+ eq(schema.contentDatabaseBodyHydrationQueue.attempts, job.attempts),
2649
+ args.preloadBodies === true
2650
+ ? and(
2651
+ job.lastAttemptedAt
2652
+ ? eq(
2653
+ schema.contentDatabaseBodyHydrationQueue.lastAttemptedAt,
2654
+ job.lastAttemptedAt,
2655
+ )
2656
+ : isNull(
2657
+ schema.contentDatabaseBodyHydrationQueue.lastAttemptedAt,
2658
+ ),
2659
+ or(
2660
+ isNull(
2661
+ schema.contentDatabaseBodyHydrationQueue.lastAttemptedAt,
2662
+ ),
2663
+ lt(
2664
+ schema.contentDatabaseBodyHydrationQueue.lastAttemptedAt,
2665
+ claimLeaseCutoff,
2666
+ ),
2667
+ ),
2668
+ )
2669
+ : undefined,
2344
2670
  ),
2345
2671
  );
2346
2672
  if (claimFilters.length === 0) continue;
@@ -2446,8 +2772,64 @@ export async function processBuilderBodyHydrationQueue(args: {
2446
2772
  const documentContentById = new Map(
2447
2773
  documents.map((document) => [document.id, document.content]),
2448
2774
  );
2449
- await processInBatches(
2450
- claimedJobs,
2775
+ const claimedSourceRowIds = new Set(
2776
+ claimedJobs.map((job) => job.sourceRowId),
2777
+ );
2778
+ const bodyEntryById = new Map<string, BuilderCmsSourceEntry>();
2779
+ const bulkPreloadBodies =
2780
+ args.preloadBodies === true &&
2781
+ claimedJobs.length >= BUILDER_BODY_HYDRATION_BULK_PRELOAD_MIN_JOBS;
2782
+ if (!args.documentId && bulkPreloadBodies && claimedJobs.length > 0) {
2783
+ const sourceTables = Array.from(
2784
+ new Set(claimedJobs.map((job) => job.sourceTable)),
2785
+ );
2786
+ const bodyReads = await Promise.all(
2787
+ sourceTables.map((model) =>
2788
+ readBuilderCmsContentEntries({
2789
+ model,
2790
+ includeBodies: true,
2791
+ limit: 10_000,
2792
+ }),
2793
+ ),
2794
+ );
2795
+ for (const read of bodyReads) {
2796
+ if (read.state !== "live") continue;
2797
+ for (const entry of read.entries) {
2798
+ if (claimedSourceRowIds.has(entry.id)) {
2799
+ bodyEntryById.set(entry.id, entry);
2800
+ }
2801
+ }
2802
+ }
2803
+ }
2804
+ const preparedPristineHydrations: PreparedPristineBuilderBodyHydration[] = [];
2805
+ if (!args.documentId && bulkPreloadBodies) {
2806
+ await processWithConcurrency(
2807
+ claimedJobs,
2808
+ BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY,
2809
+ async (job) => {
2810
+ try {
2811
+ const prepared = await preparePristineBuilderBodyHydration({
2812
+ job,
2813
+ sourceRow: sourceRowsByItemId.get(job.databaseItemId) ?? null,
2814
+ documentContent: documentContentById.get(job.documentId),
2815
+ bodyHydrationVersion:
2816
+ bodyHydrationVersionByItemId.get(job.databaseItemId) ?? null,
2817
+ bodyEntry: bodyEntryById.get(job.sourceRowId) ?? null,
2818
+ });
2819
+ if (prepared) preparedPristineHydrations.push(prepared);
2820
+ } catch {
2821
+ // The established per-row path below owns conversion and retry errors.
2822
+ }
2823
+ },
2824
+ );
2825
+ }
2826
+ const bulkPersistedJobIds = await persistPristineBuilderBodyHydrationsInBulk(
2827
+ preparedPristineHydrations,
2828
+ now,
2829
+ );
2830
+ succeeded += bulkPersistedJobIds.size;
2831
+ await processWithConcurrency(
2832
+ claimedJobs.filter((job) => !bulkPersistedJobIds.has(job.id)),
2451
2833
  BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY,
2452
2834
  async (job) => {
2453
2835
  const attemptNow = new Date().toISOString();
@@ -2458,6 +2840,7 @@ export async function processBuilderBodyHydrationQueue(args: {
2458
2840
  sourceRow: sourceRowsByItemId.get(job.databaseItemId) ?? null,
2459
2841
  bodyHydrationVersion:
2460
2842
  bodyHydrationVersionByItemId.get(job.databaseItemId) ?? null,
2843
+ bodyEntry: bodyEntryById.get(job.sourceRowId) ?? null,
2461
2844
  documentContent: documentContentById.has(job.documentId)
2462
2845
  ? (documentContentById.get(job.documentId) ?? null)
2463
2846
  : undefined,
@@ -2467,13 +2850,7 @@ export async function processBuilderBodyHydrationQueue(args: {
2467
2850
  failed += 1;
2468
2851
  const message = error instanceof Error ? error.message : String(error);
2469
2852
  const attempts = job.attempts;
2470
- const queueRowCas = and(
2471
- eq(schema.contentDatabaseBodyHydrationQueue.id, job.id),
2472
- eq(
2473
- schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
2474
- job.sourceEntryJson,
2475
- ),
2476
- );
2853
+ const queueRowCas = builderBodyHydrationQueueOwnershipFilter(job);
2477
2854
  const markPendingIfReplaced = async () => {
2478
2855
  const [replacedByNewerJob] = await db
2479
2856
  .select({ id: schema.contentDatabaseBodyHydrationQueue.id })
@@ -2514,7 +2891,7 @@ export async function processBuilderBodyHydrationQueue(args: {
2514
2891
  .update(schema.contentDatabaseBodyHydrationQueue)
2515
2892
  .set({
2516
2893
  attempts,
2517
- lastAttemptedAt: attemptNow,
2894
+ lastAttemptedAt: null,
2518
2895
  lastError: message,
2519
2896
  priority: job.priority + 10,
2520
2897
  updatedAt: attemptNow,
@@ -3514,6 +3891,7 @@ export async function getContentDatabaseSourceSnapshotForReview(
3514
3891
  */
3515
3892
  export async function getAllContentDatabaseSourceSnapshots(
3516
3893
  database: ContentDatabaseRow | ContentDatabase,
3894
+ options: { documentIds?: string[] } = {},
3517
3895
  ): Promise<ContentDatabaseSource[]> {
3518
3896
  if ("deletedAt" in database && database.deletedAt) {
3519
3897
  throw new Error(`Database "${database.id}" not found`);
@@ -3527,15 +3905,31 @@ export async function getAllContentDatabaseSourceSnapshots(
3527
3905
  asc(schema.contentDatabaseSources.createdAt),
3528
3906
  asc(schema.contentDatabaseSources.id),
3529
3907
  );
3530
- const snapshots: ContentDatabaseSource[] = [];
3531
- for (const source of sources) {
3532
- snapshots.push(
3533
- await loadSourceSnapshot(source, database, {
3908
+ return Promise.all(
3909
+ sources.map((source) => {
3910
+ return loadSourceSnapshot(source, database, {
3534
3911
  includeHeavyBuilderBodyValues: false,
3535
- }),
3536
- );
3537
- }
3538
- return snapshots;
3912
+ documentIds: sourceSnapshotPageDocumentIds({
3913
+ sourceType: source.sourceType,
3914
+ metadataJson: source.metadataJson,
3915
+ documentIds: options.documentIds,
3916
+ }),
3917
+ });
3918
+ }),
3919
+ );
3920
+ }
3921
+
3922
+ export function sourceSnapshotPageDocumentIds(args: {
3923
+ sourceType: string;
3924
+ metadataJson: string | null;
3925
+ documentIds?: string[];
3926
+ }) {
3927
+ const metadata = parseObject<SourceMetadataRecord>(args.metadataJson);
3928
+ const federation = normalizeSourceFederation(metadata?.federation);
3929
+ return normalizeSourceType(args.sourceType) === "builder-cms" &&
3930
+ federation?.role !== "secondary"
3931
+ ? args.documentIds
3932
+ : undefined;
3539
3933
  }
3540
3934
 
3541
3935
  async function readSourceSnapshotRowsOnce(args: {
@@ -3546,9 +3940,7 @@ async function readSourceSnapshotRowsOnce(args: {
3546
3940
  documentIds?: string[];
3547
3941
  }) {
3548
3942
  const db = getDb();
3549
- const documentScope = args.documentIds?.length
3550
- ? new Set(args.documentIds)
3551
- : null;
3943
+ const documentScope = args.documentIds ? new Set(args.documentIds) : null;
3552
3944
  const rowRows = await db
3553
3945
  .select(
3554
3946
  sourceSnapshotRowSelection({
@@ -3660,9 +4052,7 @@ async function sourceSnapshotConsistencyMarker(args: {
3660
4052
  documentIds?: string[];
3661
4053
  }) {
3662
4054
  const db = getDb();
3663
- const documentScope = args.documentIds?.length
3664
- ? new Set(args.documentIds)
3665
- : null;
4055
+ const documentScope = args.documentIds ? new Set(args.documentIds) : null;
3666
4056
  const [rows] = await db
3667
4057
  .select({
3668
4058
  count: sql<number>`COUNT(*)`,
@@ -3737,9 +4127,14 @@ async function loadSourceSnapshotRowsOptimistically(args: {
3737
4127
  const before = await sourceSnapshotConsistencyMarker(args);
3738
4128
  latest = await readSourceSnapshotRowsOnce(args);
3739
4129
  const after = await sourceSnapshotConsistencyMarker(args);
3740
- if (sourceSnapshotConsistencyMarkersEqual(before, after)) return latest;
4130
+ if (sourceSnapshotConsistencyMarkersEqual(before, after)) {
4131
+ return { ...latest, consistencyAttempts: attempt + 1 };
4132
+ }
3741
4133
  }
3742
- return latest ?? (await readSourceSnapshotRowsOnce(args));
4134
+ return {
4135
+ ...(latest ?? (await readSourceSnapshotRowsOnce(args))),
4136
+ consistencyAttempts: 3,
4137
+ };
3743
4138
  }
3744
4139
 
3745
4140
  async function loadSourceSnapshot(
@@ -3748,50 +4143,76 @@ async function loadSourceSnapshot(
3748
4143
  options: { includeHeavyBuilderBodyValues: boolean; documentIds?: string[] },
3749
4144
  ): Promise<ContentDatabaseSource> {
3750
4145
  const db = getDb();
3751
- const [fieldRows, changeRows, reviewRows, executionRows, propertyDefs] =
3752
- await Promise.all([
3753
- db
3754
- .select()
3755
- .from(schema.contentDatabaseSourceFields)
3756
- .where(eq(schema.contentDatabaseSourceFields.sourceId, source.id))
3757
- .orderBy(asc(schema.contentDatabaseSourceFields.createdAt)),
3758
- db
3759
- .select()
3760
- .from(schema.contentDatabaseSourceChangeSets)
3761
- .where(
3762
- options.documentIds?.length
3763
- ? and(
3764
- eq(schema.contentDatabaseSourceChangeSets.sourceId, source.id),
3765
- inArray(
3766
- schema.contentDatabaseSourceChangeSets.documentId,
3767
- options.documentIds,
4146
+ const [fieldRows, changeRows, propertyDefs] = await Promise.all([
4147
+ db
4148
+ .select()
4149
+ .from(schema.contentDatabaseSourceFields)
4150
+ .where(eq(schema.contentDatabaseSourceFields.sourceId, source.id))
4151
+ .orderBy(asc(schema.contentDatabaseSourceFields.createdAt)),
4152
+ db
4153
+ .select()
4154
+ .from(schema.contentDatabaseSourceChangeSets)
4155
+ .where(
4156
+ options.documentIds !== undefined
4157
+ ? and(
4158
+ eq(schema.contentDatabaseSourceChangeSets.sourceId, source.id),
4159
+ inArray(
4160
+ schema.contentDatabaseSourceChangeSets.documentId,
4161
+ options.documentIds,
4162
+ ),
4163
+ )
4164
+ : eq(schema.contentDatabaseSourceChangeSets.sourceId, source.id),
4165
+ )
4166
+ .orderBy(asc(schema.contentDatabaseSourceChangeSets.createdAt)),
4167
+ db
4168
+ .select({
4169
+ id: schema.documentPropertyDefinitions.id,
4170
+ name: schema.documentPropertyDefinitions.name,
4171
+ type: schema.documentPropertyDefinitions.type,
4172
+ optionsJson: schema.documentPropertyDefinitions.optionsJson,
4173
+ })
4174
+ .from(schema.documentPropertyDefinitions)
4175
+ .where(eq(schema.documentPropertyDefinitions.databaseId, database.id)),
4176
+ ]);
4177
+ const scopedChangeSetIds = changeRows.map((changeSet) => changeSet.id);
4178
+ const [reviewRows, executionRows] =
4179
+ options.documentIds !== undefined && scopedChangeSetIds.length === 0
4180
+ ? [[], []]
4181
+ : await Promise.all([
4182
+ db
4183
+ .select()
4184
+ .from(schema.contentDatabaseSourceChangeReviews)
4185
+ .where(
4186
+ and(
4187
+ eq(
4188
+ schema.contentDatabaseSourceChangeReviews.sourceId,
4189
+ source.id,
3768
4190
  ),
3769
- )
3770
- : eq(schema.contentDatabaseSourceChangeSets.sourceId, source.id),
3771
- )
3772
- .orderBy(asc(schema.contentDatabaseSourceChangeSets.createdAt)),
3773
- db
3774
- .select()
3775
- .from(schema.contentDatabaseSourceChangeReviews)
3776
- .where(
3777
- eq(schema.contentDatabaseSourceChangeReviews.sourceId, source.id),
3778
- )
3779
- .orderBy(asc(schema.contentDatabaseSourceChangeReviews.createdAt)),
3780
- db
3781
- .select()
3782
- .from(schema.contentDatabaseSourceExecutions)
3783
- .where(eq(schema.contentDatabaseSourceExecutions.sourceId, source.id))
3784
- .orderBy(asc(schema.contentDatabaseSourceExecutions.createdAt)),
3785
- db
3786
- .select({
3787
- id: schema.documentPropertyDefinitions.id,
3788
- name: schema.documentPropertyDefinitions.name,
3789
- type: schema.documentPropertyDefinitions.type,
3790
- optionsJson: schema.documentPropertyDefinitions.optionsJson,
3791
- })
3792
- .from(schema.documentPropertyDefinitions)
3793
- .where(eq(schema.documentPropertyDefinitions.databaseId, database.id)),
3794
- ]);
4191
+ options.documentIds !== undefined
4192
+ ? inArray(
4193
+ schema.contentDatabaseSourceChangeReviews.changeSetId,
4194
+ scopedChangeSetIds,
4195
+ )
4196
+ : undefined,
4197
+ ),
4198
+ )
4199
+ .orderBy(asc(schema.contentDatabaseSourceChangeReviews.createdAt)),
4200
+ db
4201
+ .select()
4202
+ .from(schema.contentDatabaseSourceExecutions)
4203
+ .where(
4204
+ and(
4205
+ eq(schema.contentDatabaseSourceExecutions.sourceId, source.id),
4206
+ options.documentIds !== undefined
4207
+ ? inArray(
4208
+ schema.contentDatabaseSourceExecutions.changeSetId,
4209
+ scopedChangeSetIds,
4210
+ )
4211
+ : undefined,
4212
+ ),
4213
+ )
4214
+ .orderBy(asc(schema.contentDatabaseSourceExecutions.createdAt)),
4215
+ ]);
3795
4216
 
3796
4217
  const propertyNameById = new Map(
3797
4218
  propertyDefs.map((row) => [row.id, row.name]),
@@ -3844,6 +4265,7 @@ async function loadSourceSnapshot(
3844
4265
  allDocumentIds,
3845
4266
  rowDocuments,
3846
4267
  propertyValueRows,
4268
+ consistencyAttempts,
3847
4269
  } = await loadSourceSnapshotRowsOptimistically({
3848
4270
  source,
3849
4271
  database,
@@ -4224,6 +4646,10 @@ async function loadSourceSnapshot(
4224
4646
  fields,
4225
4647
  rows,
4226
4648
  changeSets,
4649
+ projection:
4650
+ options.documentIds !== undefined
4651
+ ? { rows: "page", changeSets: "page" }
4652
+ : { rows: "complete", changeSets: "complete" },
4227
4653
  bodyHydration,
4228
4654
  };
4229
4655
  }
@@ -5640,10 +6066,11 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
5640
6066
  }): Promise<{
5641
6067
  imported: number;
5642
6068
  importedEntriesByDocumentId: Map<string, BuilderCmsSourceEntry>;
6069
+ importedItems: ContentDatabaseItem[];
5643
6070
  }> {
5644
6071
  const importedEntriesByDocumentId = new Map<string, BuilderCmsSourceEntry>();
5645
6072
  if (args.entries.length === 0) {
5646
- return { imported: 0, importedEntriesByDocumentId };
6073
+ return { imported: 0, importedEntriesByDocumentId, importedItems: [] };
5647
6074
  }
5648
6075
  const db = getDb();
5649
6076
  const [databaseDocument] = await db
@@ -5732,6 +6159,9 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
5732
6159
  .filter((row) => !representedDocumentIds.has(row.document.id))
5733
6160
  .map((row) => row.document.title.trim().toLowerCase()),
5734
6161
  );
6162
+ const currentItemByDocumentId = new Map(
6163
+ currentItems.map((row) => [row.document.id, row.item]),
6164
+ );
5735
6165
 
5736
6166
  // Reads MAX(position) for both `documents` and `content_database_items`
5737
6167
  // then batch-inserts at MAX+1.. — serialize the whole read-through-write
@@ -5777,26 +6207,29 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
5777
6207
  continue;
5778
6208
  }
5779
6209
 
6210
+ const { documentId, itemId } = builderCmsImportIds({
6211
+ ownerEmail: args.database.ownerEmail,
6212
+ databaseId: args.database.id,
6213
+ sourceTable: args.sourceTable,
6214
+ entryId: entry.id,
6215
+ });
6216
+ const existingDeterministicItem =
6217
+ currentItemByDocumentId.get(documentId);
6218
+ if (existingDeterministicItem?.id === itemId) {
6219
+ // A prior attach can commit the deterministic document/item and
6220
+ // fail before linking its source row. Treat that pair as the
6221
+ // same Builder identity so refresh repairs the missing link
6222
+ // without synthesizing a duplicate response item.
6223
+ importedEntriesByDocumentId.set(documentId, entry);
6224
+ continue;
6225
+ }
6226
+
5780
6227
  const title = entry.title.trim() || entry.id;
5781
6228
  const titleKey = title.toLowerCase();
5782
6229
  if (!args.skipTitleDedup && existingUnlinkedTitles.has(titleKey)) {
5783
6230
  continue;
5784
6231
  }
5785
6232
 
5786
- const importIdentity = [
5787
- args.database.ownerEmail,
5788
- args.database.id,
5789
- args.sourceTable,
5790
- entry.id,
5791
- ];
5792
- const documentId = stableBuilderImportId(
5793
- "builder-doc",
5794
- importIdentity,
5795
- );
5796
- const itemId = stableBuilderImportId(
5797
- "builder-item",
5798
- importIdentity,
5799
- );
5800
6233
  documentRows.push({
5801
6234
  id: documentId,
5802
6235
  spaceId: databaseSpaceId,
@@ -5827,6 +6260,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
5827
6260
  });
5828
6261
  importedEntriesByDocumentId.set(documentId, entry);
5829
6262
  }
6263
+ const insertedItemIds = new Set<string>();
5830
6264
  await db.transaction(async (tx) => {
5831
6265
  for (const chunk of chunks(
5832
6266
  documentRows,
@@ -5841,10 +6275,14 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
5841
6275
  itemRows,
5842
6276
  bulkChunkSizeForColumnCount(10),
5843
6277
  )) {
5844
- await tx
6278
+ const insertedItems = await tx
5845
6279
  .insert(schema.contentDatabaseItems)
5846
6280
  .values(chunk)
5847
- .onConflictDoNothing();
6281
+ .onConflictDoNothing()
6282
+ .returning({ id: schema.contentDatabaseItems.id });
6283
+ for (const item of insertedItems) {
6284
+ insertedItemIds.add(item.id);
6285
+ }
5848
6286
  }
5849
6287
  await ensureDocumentsFilesMembership(
5850
6288
  tx,
@@ -5854,7 +6292,47 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
5854
6292
  );
5855
6293
  });
5856
6294
 
5857
- return { imported: itemRows.length, importedEntriesByDocumentId };
6295
+ const candidateDocumentById = new Map(
6296
+ documentRows.map((document) => [document.id!, document]),
6297
+ );
6298
+ const importedItems: ContentDatabaseItem[] = itemRows
6299
+ .filter((item) => insertedItemIds.has(item.id!))
6300
+ .map((item) => {
6301
+ const document = candidateDocumentById.get(item.documentId)!;
6302
+ return {
6303
+ id: item.id!,
6304
+ databaseId: args.database.id,
6305
+ document: {
6306
+ id: document.id!,
6307
+ parentId: document.parentId ?? null,
6308
+ title: document.title ?? "Untitled",
6309
+ content: "",
6310
+ icon: document.icon ?? null,
6311
+ position: document.position ?? 0,
6312
+ isFavorite: false,
6313
+ hideFromSearch: Boolean(document.hideFromSearch),
6314
+ visibility: document.visibility ?? "private",
6315
+ accessRole: "owner",
6316
+ canEdit: true,
6317
+ canManage: true,
6318
+ createdAt: document.createdAt!,
6319
+ updatedAt: document.updatedAt!,
6320
+ },
6321
+ position: item.position ?? 0,
6322
+ properties: [],
6323
+ bodyHydration: {
6324
+ status: "pending",
6325
+ attemptedAt: null,
6326
+ error: null,
6327
+ version: null,
6328
+ },
6329
+ };
6330
+ });
6331
+ return {
6332
+ imported: insertedItemIds.size,
6333
+ importedEntriesByDocumentId,
6334
+ importedItems,
6335
+ };
5858
6336
  },
5859
6337
  ),
5860
6338
  );
@@ -5865,9 +6343,10 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5865
6343
  source: ContentDatabaseSourceRowDb;
5866
6344
  now: string;
5867
6345
  runFullRefresh?: boolean;
6346
+ finishPagination?: boolean;
5868
6347
  refreshClaimId?: string;
5869
6348
  }) {
5870
- let { properties, response } = await sourceSetupPayload(args.database.id);
6349
+ const setupPromise = sourceSetupPayload(args.database.id);
5871
6350
  const db = getDb();
5872
6351
  const sourceMetadata =
5873
6352
  parseObject<SourceMetadataRecord>(args.source.metadataJson) ?? {};
@@ -5877,7 +6356,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5877
6356
  ? sourceMetadata.activeReadSourceRowIds
5878
6357
  : [];
5879
6358
  const continueOffset =
5880
- !args.runFullRefresh &&
6359
+ (!args.runFullRefresh || args.finishPagination) &&
5881
6360
  sourceMetadata.sourceFetchState === "fetching" &&
5882
6361
  sourceMetadata.lastReadHasMore === true &&
5883
6362
  activeReadSourceRowIds.length > 0 &&
@@ -5891,25 +6370,33 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5891
6370
  .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id));
5892
6371
  let builderModelFields: BuilderCmsModelFieldSummary[] | undefined;
5893
6372
  let builderModelFieldsReadFailed = false;
5894
- try {
5895
- builderModelFields = await readBuilderCmsModelFields({
5896
- model: args.source.sourceTable,
5897
- });
5898
- builderModelFields = mergeBuilderCmsModelFieldsPreservingReferenceModels({
5899
- existing: sourceMetadata.builderModelFields,
5900
- refreshed: builderModelFields,
5901
- });
5902
- } catch (error) {
5903
- builderModelFieldsReadFailed = true;
5904
- const message = error instanceof Error ? error.message : String(error);
5905
- console.warn(
5906
- `[content] Builder model field read failed for ${args.source.sourceTable}; continuing source row sync without model field metadata. ${message}`,
5907
- );
6373
+ if (continueOffset > 0 && sourceMetadata.builderModelFields?.length) {
6374
+ builderModelFields = sourceMetadata.builderModelFields;
6375
+ } else {
6376
+ try {
6377
+ builderModelFields = await readBuilderCmsModelFields({
6378
+ model: args.source.sourceTable,
6379
+ });
6380
+ builderModelFields = mergeBuilderCmsModelFieldsPreservingReferenceModels({
6381
+ existing: sourceMetadata.builderModelFields,
6382
+ refreshed: builderModelFields,
6383
+ });
6384
+ } catch (error) {
6385
+ builderModelFieldsReadFailed = true;
6386
+ const message = error instanceof Error ? error.message : String(error);
6387
+ console.warn(
6388
+ `[content] Builder model field read failed for ${args.source.sourceTable}; continuing source row sync without model field metadata. ${message}`,
6389
+ );
6390
+ }
5908
6391
  }
5909
6392
  const projectionModelFields =
5910
6393
  builderModelFields && builderModelFields.length > 0
5911
6394
  ? builderModelFields
5912
6395
  : (sourceMetadata.builderModelFields ?? []);
6396
+ const existingRowsPromise = db
6397
+ .select()
6398
+ .from(schema.contentDatabaseSourceRows)
6399
+ .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id));
5913
6400
  const builderRead = await readBuilderCmsContentEntries({
5914
6401
  model: args.source.sourceTable,
5915
6402
  fieldPaths: [
@@ -5945,10 +6432,9 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5945
6432
  "Builder source refresh claim was lost before snapshot mutation.",
5946
6433
  );
5947
6434
  }
5948
- let existingRows = await db
5949
- .select()
5950
- .from(schema.contentDatabaseSourceRows)
5951
- .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id));
6435
+ const { properties, response: initialResponse } = await setupPromise;
6436
+ let response = initialResponse;
6437
+ const existingRows = await existingRowsPromise;
5952
6438
  const readStartOffset = builderRead.progress?.startOffset ?? 0;
5953
6439
  const activeReadSourceRowIdSet = new Set(activeReadSourceRowIds);
5954
6440
  const suspiciousEmptyRead =
@@ -5995,11 +6481,14 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5995
6481
  });
5996
6482
  importedEntriesByDocumentId = importResult.importedEntriesByDocumentId;
5997
6483
  if (importResult.imported > 0) {
5998
- ({ properties, response } = await sourceSetupPayload(args.database.id));
5999
- existingRows = await db
6000
- .select()
6001
- .from(schema.contentDatabaseSourceRows)
6002
- .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id));
6484
+ const existingItemIds = new Set(response.items.map((item) => item.id));
6485
+ const importedItems = importResult.importedItems.filter(
6486
+ (item) => !existingItemIds.has(item.id),
6487
+ );
6488
+ response = {
6489
+ ...response,
6490
+ items: [...response.items, ...importedItems],
6491
+ };
6003
6492
  }
6004
6493
  }
6005
6494
  const builderEntriesByDocumentId =
@@ -6971,15 +7460,24 @@ export async function getSourceRows(sourceId: string) {
6971
7460
  .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId));
6972
7461
  }
6973
7462
 
6974
- export async function listDatabasePropertiesAndItems(databaseId: string) {
7463
+ export async function listDatabasePropertiesAndItems(
7464
+ databaseId: string,
7465
+ options: { limit?: number; offset?: number; documentIds?: string[] } = {},
7466
+ ) {
6975
7467
  const { getContentDatabaseResponse } = await import("./_database-utils.js");
6976
- return getContentDatabaseResponse(databaseId);
7468
+ return getContentDatabaseResponse(databaseId, {
7469
+ ...options,
7470
+ includeSources: false,
7471
+ });
6977
7472
  }
6978
7473
 
6979
- export async function sourceSetupPayload(databaseId: string) {
7474
+ export async function sourceSetupPayload(
7475
+ databaseId: string,
7476
+ options: { limit?: number; offset?: number; documentIds?: string[] } = {},
7477
+ ) {
6980
7478
  const [properties, response] = await Promise.all([
6981
7479
  listPropertiesForDatabase(databaseId),
6982
- listDatabasePropertiesAndItems(databaseId),
7480
+ listDatabasePropertiesAndItems(databaseId, options),
6983
7481
  ]);
6984
7482
  return { properties, response };
6985
7483
  }