@agent-native/core 0.154.2 → 0.154.4

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 (43) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
  3. package/corpus/templates/content/actions/_database-utils.ts +11 -3
  4. package/corpus/templates/content/actions/_property-utils.ts +137 -30
  5. package/corpus/templates/content/actions/configure-document-property.ts +5 -0
  6. package/corpus/templates/content/actions/delete-document-property.ts +38 -17
  7. package/corpus/templates/content/actions/delete-document.ts +3 -0
  8. package/corpus/templates/content/actions/edit-document.ts +18 -1
  9. package/corpus/templates/content/actions/export-document.ts +41 -0
  10. package/corpus/templates/content/actions/remove-database-items.ts +10 -0
  11. package/corpus/templates/content/actions/set-document-property.ts +62 -2
  12. package/corpus/templates/content/actions/update-document.ts +24 -0
  13. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
  14. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
  15. package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
  16. package/corpus/templates/content/app/i18n-data.ts +20 -0
  17. package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
  18. package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
  19. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
  20. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
  21. package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
  22. package/corpus/templates/content/server/db/schema.ts +61 -0
  23. package/corpus/templates/content/server/plugins/db.ts +55 -0
  24. package/corpus/templates/content/shared/api.ts +3 -0
  25. package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
  26. package/corpus/templates/content/shared/document-export.ts +33 -1
  27. package/dist/cli/create.js +3 -0
  28. package/dist/collab/awareness.d.ts +2 -2
  29. package/dist/extensions/url-safety.d.ts +7 -2
  30. package/dist/extensions/url-safety.js +27 -12
  31. package/dist/mcp-client/oauth-client.d.ts +16 -1
  32. package/dist/mcp-client/oauth-client.js +177 -83
  33. package/dist/mcp-client/remote-store.js +46 -20
  34. package/dist/oauth-tokens/index.d.ts +2 -1
  35. package/dist/oauth-tokens/index.js +2 -1
  36. package/dist/oauth-tokens/lifecycle.d.ts +87 -0
  37. package/dist/oauth-tokens/lifecycle.js +434 -0
  38. package/dist/oauth-tokens/migrations.js +10 -0
  39. package/dist/oauth-tokens/store.d.ts +22 -0
  40. package/dist/oauth-tokens/store.js +141 -18
  41. package/dist/observability/routes.d.ts +1 -1
  42. package/dist/resources/handlers.d.ts +1 -1
  43. package/package.json +2 -1
@@ -1,10 +1,15 @@
1
- import { accessFilter, assertAccess } from "@agent-native/core/sharing";
1
+ import {
2
+ accessFilter,
3
+ assertAccess,
4
+ resolveAccess,
5
+ } from "@agent-native/core/sharing";
2
6
  import {
3
7
  and,
4
8
  asc,
5
9
  eq,
6
10
  inArray,
7
11
  isNull,
12
+ or,
8
13
  sql,
9
14
  type InferSelectModel,
10
15
  } from "drizzle-orm";
@@ -21,6 +26,7 @@ import type {
21
26
  ContentDatabaseOpenPagesIn,
22
27
  DocumentProperty,
23
28
  } from "../shared/api.js";
29
+ import { blocksFieldId } from "../shared/blocks-field-identity.js";
24
30
  import {
25
31
  DEFAULT_BLOCKS_FIELD_NAME,
26
32
  defaultPropertyOptions,
@@ -42,6 +48,7 @@ import {
42
48
  type DocumentPropertyValue,
43
49
  } from "../shared/properties.js";
44
50
  import { chunks } from "./_batch-utils.js";
51
+ import { readBlocksFieldIdentities } from "./_blocks-field-identity.js";
45
52
  import {
46
53
  propertyDefinitionsPositionScope,
47
54
  withPositionLock,
@@ -506,6 +513,44 @@ export async function listPropertiesForDocument(
506
513
  return listPropertiesForDatabase(database.id, document);
507
514
  }
508
515
 
516
+ export async function listPropertiesForAllDocumentDatabases(
517
+ document: DocumentRow,
518
+ ) {
519
+ const db = getDb();
520
+ const memberships = await db
521
+ .select({ databaseId: schema.contentDatabaseItems.databaseId })
522
+ .from(schema.contentDatabaseItems)
523
+ .where(eq(schema.contentDatabaseItems.documentId, document.id));
524
+ const membershipIds = memberships.map((membership) => membership.databaseId);
525
+ const databases = await db
526
+ .select({
527
+ id: schema.contentDatabases.id,
528
+ documentId: schema.contentDatabases.documentId,
529
+ })
530
+ .from(schema.contentDatabases)
531
+ .where(
532
+ and(
533
+ isNull(schema.contentDatabases.deletedAt),
534
+ membershipIds.length > 0
535
+ ? or(
536
+ eq(schema.contentDatabases.documentId, document.id),
537
+ inArray(schema.contentDatabases.id, membershipIds),
538
+ )
539
+ : eq(schema.contentDatabases.documentId, document.id),
540
+ ),
541
+ )
542
+ .orderBy(asc(schema.contentDatabases.id));
543
+
544
+ const properties = [];
545
+ for (const database of databases) {
546
+ if (!(await resolveAccess("document", database.documentId))) continue;
547
+ properties.push(
548
+ ...(await listPropertiesForDatabase(database.id, document)),
549
+ );
550
+ }
551
+ return properties;
552
+ }
553
+
509
554
  export async function listPropertiesForDatabase(
510
555
  databaseId: string,
511
556
  valueDocument?: DocumentRow,
@@ -540,10 +585,46 @@ export async function listPropertiesForDatabase(
540
585
  ? await blockFieldContentsForDocument(valueDocument.id)
541
586
  : new Map<string, string>();
542
587
 
588
+ const blocksFieldIdentityById = valueDocument
589
+ ? await readBlocksFieldIdentities({
590
+ db,
591
+ fields: definitions.flatMap((definition) => {
592
+ const type = definition.type as DocumentPropertyType;
593
+ if (!isBlocksPropertyType(type)) return [];
594
+ const options = parsePropertyOptions(definition.optionsJson);
595
+ return [
596
+ {
597
+ documentId: valueDocument.id,
598
+ propertyId: definition.id,
599
+ markdown: resolveBlocksFieldValue({
600
+ options,
601
+ documentBody: valueDocument.content,
602
+ blockFieldContent: blockContentByPropertyId.get(definition.id),
603
+ }),
604
+ },
605
+ ];
606
+ }),
607
+ })
608
+ : new Map();
609
+
543
610
  const properties = definitions.map((definition) => {
544
611
  const type = definition.type as DocumentPropertyType;
545
612
  const storedValue = valueByPropertyId.get(definition.id);
546
613
  const options = parsePropertyOptions(definition.optionsJson);
614
+ const value =
615
+ valueDocument && isComputedPropertyType(type) && type !== "formula"
616
+ ? computedPropertyValue(type, valueDocument, {
617
+ databaseRowNumber: rowNumberByDocumentId.get(valueDocument.id),
618
+ })
619
+ : valueDocument && isBlocksPropertyType(type)
620
+ ? // Each Blocks field reads from exactly one place: the primary from
621
+ // the document body, additional fields from their own store.
622
+ resolveBlocksFieldValue({
623
+ options,
624
+ documentBody: valueDocument.content,
625
+ blockFieldContent: blockContentByPropertyId.get(definition.id),
626
+ })
627
+ : parsePropertyValue(storedValue?.valueJson);
547
628
  return {
548
629
  definition: {
549
630
  id: definition.id,
@@ -560,21 +641,15 @@ export async function listPropertiesForDatabase(
560
641
  createdAt: definition.createdAt,
561
642
  updatedAt: definition.updatedAt,
562
643
  },
563
- value:
564
- valueDocument && isComputedPropertyType(type) && type !== "formula"
565
- ? computedPropertyValue(type, valueDocument, {
566
- databaseRowNumber: rowNumberByDocumentId.get(valueDocument.id),
567
- })
568
- : valueDocument && isBlocksPropertyType(type)
569
- ? // Each Blocks field reads from exactly one place: the primary from
570
- // the document body, additional fields from their own store.
571
- resolveBlocksFieldValue({
572
- options,
573
- documentBody: valueDocument.content,
574
- blockFieldContent: blockContentByPropertyId.get(definition.id),
575
- })
576
- : parsePropertyValue(storedValue?.valueJson),
644
+ value,
577
645
  editable: !definition.systemRole && !isComputedPropertyType(type),
646
+ ...(valueDocument && isBlocksPropertyType(type)
647
+ ? {
648
+ blocksField: blocksFieldIdentityById.get(
649
+ blocksFieldId(valueDocument.id, definition.id),
650
+ ),
651
+ }
652
+ : {}),
578
653
  };
579
654
  });
580
655
 
@@ -724,32 +799,64 @@ export async function listPropertiesForDatabaseDocuments(
724
799
  }
725
800
  }
726
801
 
802
+ const blocksFieldIdentityById = await readBlocksFieldIdentities({
803
+ db,
804
+ fields: valueDocuments.flatMap((document) =>
805
+ definitions.flatMap((definition) => {
806
+ const type = definition.type as DocumentPropertyType;
807
+ if (!isBlocksPropertyType(type)) return [];
808
+ const options = parsePropertyOptions(definition.optionsJson);
809
+ return [
810
+ {
811
+ documentId: document.id,
812
+ propertyId: definition.id,
813
+ markdown: resolveBlocksFieldValue({
814
+ options,
815
+ documentBody: document.content,
816
+ blockFieldContent: blockContentByDocumentAndProperty.get(
817
+ propertyValueKey(document.id, definition.id),
818
+ ),
819
+ }),
820
+ },
821
+ ];
822
+ }),
823
+ ),
824
+ });
825
+
727
826
  for (const document of valueDocuments) {
728
827
  const properties = definitions.map((definition) => {
729
828
  const propertyDefinition = serializePropertyDefinition(definition);
730
829
  const storedValue = valueByDocumentAndProperty.get(
731
830
  propertyValueKey(document.id, definition.id),
732
831
  );
832
+ const value =
833
+ isComputedPropertyType(propertyDefinition.type) &&
834
+ propertyDefinition.type !== "formula"
835
+ ? computedPropertyValue(propertyDefinition.type, document, {
836
+ databaseRowNumber: rowNumberByDocumentId.get(document.id),
837
+ })
838
+ : isBlocksPropertyType(propertyDefinition.type)
839
+ ? resolveBlocksFieldValue({
840
+ options: propertyDefinition.options,
841
+ documentBody: document.content,
842
+ blockFieldContent: blockContentByDocumentAndProperty.get(
843
+ propertyValueKey(document.id, definition.id),
844
+ ),
845
+ })
846
+ : parsePropertyValue(storedValue?.valueJson);
733
847
  return {
734
848
  definition: propertyDefinition,
735
- value:
736
- isComputedPropertyType(propertyDefinition.type) &&
737
- propertyDefinition.type !== "formula"
738
- ? computedPropertyValue(propertyDefinition.type, document, {
739
- databaseRowNumber: rowNumberByDocumentId.get(document.id),
740
- })
741
- : isBlocksPropertyType(propertyDefinition.type)
742
- ? resolveBlocksFieldValue({
743
- options: propertyDefinition.options,
744
- documentBody: document.content,
745
- blockFieldContent: blockContentByDocumentAndProperty.get(
746
- propertyValueKey(document.id, definition.id),
747
- ),
748
- })
749
- : parsePropertyValue(storedValue?.valueJson),
849
+ value,
750
850
  editable:
751
851
  !definition.systemRole &&
752
852
  !isComputedPropertyType(propertyDefinition.type),
853
+ ...(isBlocksPropertyType(propertyDefinition.type)
854
+ ? {
855
+ blocksField: blocksFieldIdentityById.get(
856
+ blocksFieldId(document.id, definition.id),
857
+ ),
858
+ }
859
+ : {}),
753
860
  };
754
861
  });
755
862
 
@@ -16,6 +16,7 @@ import {
16
16
  normalizePropertyVisibility,
17
17
  type DocumentPropertyType,
18
18
  } from "../shared/properties.js";
19
+ import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js";
19
20
  import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js";
20
21
  import { lockDatabaseMemberships } from "./_database-membership-lock.js";
21
22
  import {
@@ -233,6 +234,10 @@ export default defineAction({
233
234
  ) &&
234
235
  !isBlocksPropertyType(type)
235
236
  ) {
237
+ await deleteBlocksFieldIdentity({
238
+ db: tx as unknown as ReturnType<typeof getDb>,
239
+ propertyId: args.id!,
240
+ });
236
241
  await tx
237
242
  .delete(schema.documentBlockFieldContents)
238
243
  .where(
@@ -11,6 +11,10 @@ import {
11
11
  parsePropertyOptions,
12
12
  type DocumentPropertyType,
13
13
  } from "../shared/properties.js";
14
+ import {
15
+ deleteBlocksFieldIdentity,
16
+ lockPrimaryBlocksFieldsForDocuments,
17
+ } from "./_blocks-field-identity.js";
14
18
  import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js";
15
19
  import { lockDatabaseMemberships } from "./_database-membership-lock.js";
16
20
  import {
@@ -65,14 +69,6 @@ export default defineAction({
65
69
  tx as unknown as ReturnType<typeof getDb>,
66
70
  database.id,
67
71
  );
68
- const memberships = await tx
69
- .select({ id: schema.contentDatabaseItems.id })
70
- .from(schema.contentDatabaseItems)
71
- .where(eq(schema.contentDatabaseItems.databaseId, database.id));
72
- await lockDatabaseMemberships(
73
- tx,
74
- memberships.map((membership) => membership.id),
75
- );
76
72
  const [lockedDefinition] = await tx
77
73
  .select()
78
74
  .from(schema.documentPropertyDefinitions)
@@ -100,6 +96,25 @@ export default defineAction({
100
96
  isPrimaryBlocksField(
101
97
  parsePropertyOptions(lockedDefinition.optionsJson),
102
98
  );
99
+ const items = await tx
100
+ .select({
101
+ id: schema.contentDatabaseItems.id,
102
+ documentId: schema.contentDatabaseItems.documentId,
103
+ })
104
+ .from(schema.contentDatabaseItems)
105
+ .where(eq(schema.contentDatabaseItems.databaseId, database.id));
106
+ const primaryFieldsByDocument = isPrimaryBlocks
107
+ ? await lockPrimaryBlocksFieldsForDocuments(
108
+ tx as unknown as ReturnType<typeof getDb>,
109
+ items.map((item) => item.documentId),
110
+ )
111
+ : new Map();
112
+ if (!isPrimaryBlocks) {
113
+ await lockDatabaseMemberships(
114
+ tx,
115
+ items.map((item) => item.id),
116
+ );
117
+ }
103
118
 
104
119
  await tx
105
120
  .delete(schema.documentPropertyValues)
@@ -117,6 +132,10 @@ export default defineAction({
117
132
  .where(eq(schema.documentPropertyDefinitions.id, propertyId));
118
133
 
119
134
  if (isBlocks) {
135
+ await deleteBlocksFieldIdentity({
136
+ db: tx as unknown as ReturnType<typeof getDb>,
137
+ propertyId,
138
+ });
120
139
  await tx
121
140
  .delete(schema.documentBlockFieldContents)
122
141
  .where(eq(schema.documentBlockFieldContents.propertyId, propertyId));
@@ -130,20 +149,22 @@ export default defineAction({
130
149
  })
131
150
  .where(eq(schema.contentDatabases.id, database.id));
132
151
 
133
- const items = await tx
134
- .select({ documentId: schema.contentDatabaseItems.documentId })
135
- .from(schema.contentDatabaseItems)
136
- .where(eq(schema.contentDatabaseItems.databaseId, database.id));
137
- if (items.length > 0) {
152
+ const documentsWithoutSurvivingPrimary = items
153
+ .filter(
154
+ (item) =>
155
+ !(primaryFieldsByDocument.get(item.documentId) ?? []).some(
156
+ (field: { propertyId: string }) =>
157
+ field.propertyId !== propertyId,
158
+ ),
159
+ )
160
+ .map((item) => item.documentId);
161
+ if (documentsWithoutSurvivingPrimary.length > 0) {
138
162
  const now = new Date().toISOString();
139
163
  await tx
140
164
  .update(schema.documents)
141
165
  .set({ content: "", updatedAt: now })
142
166
  .where(
143
- inArray(
144
- schema.documents.id,
145
- items.map((item) => item.documentId),
146
- ),
167
+ inArray(schema.documents.id, documentsWithoutSurvivingPrimary),
147
168
  );
148
169
  }
149
170
  }
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
8
8
  import { chunks } from "./_batch-utils.js";
9
+ import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js";
9
10
  import {
10
11
  lockContentDatabaseMutation,
11
12
  touchContentDatabase,
@@ -695,6 +696,7 @@ async function deleteCollectedDocuments(
695
696
  });
696
697
 
697
698
  await deleteWhereIn(propertyDefinitionIds, async (propertyIdBatch) => {
699
+ await deleteBlocksFieldIdentity({ db, propertyIds: propertyIdBatch });
698
700
  await db
699
701
  .delete(schema.documentPropertyValues)
700
702
  .where(
@@ -730,6 +732,7 @@ async function deleteCollectedDocuments(
730
732
  });
731
733
 
732
734
  await deleteWhereIn(documentIds, async (documentIdBatch) => {
735
+ await deleteBlocksFieldIdentity({ db, documentIds: documentIdBatch });
733
736
  await db
734
737
  .delete(schema.contentDatabaseBodyHydrationQueue)
735
738
  .where(
@@ -17,6 +17,10 @@ import { eq } from "drizzle-orm";
17
17
  import { z } from "zod";
18
18
 
19
19
  import { getDb, schema } from "../server/db/index.js";
20
+ import {
21
+ lockPrimaryBlocksFields,
22
+ persistBlocksFieldIdentity,
23
+ } from "./_blocks-field-identity.js";
20
24
 
21
25
  interface TextEdit {
22
26
  find: string;
@@ -235,11 +239,24 @@ export default defineAction({
235
239
  // Persist. The fresh updatedAt is the signal the open editor uses to tell an
236
240
  // intentional external edit apart from a stale autosave echo.
237
241
  const db = getDb();
242
+ const now = new Date().toISOString();
238
243
  await db.transaction(async (tx: any) => {
244
+ const primaryBlocksFields = await lockPrimaryBlocksFields(tx, id);
239
245
  await tx
240
246
  .update(schema.documents)
241
- .set({ content, updatedAt: new Date().toISOString() })
247
+ .set({ content, updatedAt: now })
242
248
  .where(eq(schema.documents.id, id));
249
+ for (const field of primaryBlocksFields) {
250
+ await persistBlocksFieldIdentity({
251
+ db: tx as unknown as ReturnType<typeof getDb>,
252
+ ownerEmail: field.ownerEmail,
253
+ documentId: id,
254
+ propertyId: field.propertyId,
255
+ previousMarkdown: existing.content ?? "",
256
+ markdown: content,
257
+ now,
258
+ });
259
+ }
243
260
  if (creativeContext) {
244
261
  await recordGenerationCreativeContext(
245
262
  {
@@ -3,8 +3,14 @@ import { buildDeepLink } from "@agent-native/core/server";
3
3
  import { resolveAccess } from "@agent-native/core/sharing";
4
4
  import { z } from "zod";
5
5
 
6
+ import { blocksContentHash } from "../shared/blocks-field-identity.js";
6
7
  import { buildDocumentExport } from "../shared/document-export.js";
8
+ import {
9
+ isBlocksPropertyType,
10
+ isPrimaryBlocksField,
11
+ } from "../shared/properties.js";
7
12
  import "../server/db/index.js";
13
+ import { listPropertiesForAllDocumentDatabases } from "./_property-utils.js";
8
14
 
9
15
  export default defineAction({
10
16
  description:
@@ -33,12 +39,47 @@ export default defineAction({
33
39
  if (!access) throw new Error(`Document "${id}" not found`);
34
40
 
35
41
  const doc = access.resource;
42
+ const properties = await listPropertiesForAllDocumentDatabases(doc);
43
+ const blocksFields = properties
44
+ .filter((property) => isBlocksPropertyType(property.definition.type))
45
+ .map((property) => {
46
+ if (!property.definition.databaseId) {
47
+ throw new Error(
48
+ `Blocks field "${property.definition.id}" is not attached to a database`,
49
+ );
50
+ }
51
+ if (!property.blocksField) {
52
+ throw new Error(
53
+ `Blocks field "${property.definition.id}" has no identity state`,
54
+ );
55
+ }
56
+ const markdown =
57
+ content !== undefined &&
58
+ isPrimaryBlocksField(property.definition.options)
59
+ ? content
60
+ : typeof property.value === "string"
61
+ ? property.value
62
+ : "";
63
+ const identity =
64
+ blocksContentHash(markdown) === property.blocksField.contentHash
65
+ ? property.blocksField
66
+ : { ...property.blocksField, identityStatus: "stale" as const };
67
+ return {
68
+ databaseId: property.definition.databaseId,
69
+ propertyId: property.definition.id,
70
+ name: property.definition.name,
71
+ position: property.definition.position,
72
+ markdown,
73
+ identity,
74
+ };
75
+ });
36
76
  const payload = buildDocumentExport({
37
77
  id: doc.id,
38
78
  title: title ?? doc.title,
39
79
  content: content ?? doc.content,
40
80
  updatedAt: doc.updatedAt,
41
81
  format,
82
+ blocksFields,
42
83
  });
43
84
 
44
85
  return {
@@ -4,6 +4,7 @@ import { assertAccess } from "@agent-native/core/sharing";
4
4
  import { and, eq, inArray } from "drizzle-orm";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js";
7
8
  import {
8
9
  lockContentDatabaseMutation,
9
10
  touchContentDatabase,
@@ -132,6 +133,15 @@ export default defineAction({
132
133
  )
133
134
  ).map((property) => property.id);
134
135
  if (removedDocumentIds.length > 0 && propertyIds.length > 0) {
136
+ for (const removedDocumentId of removedDocumentIds) {
137
+ for (const propertyId of propertyIds) {
138
+ await deleteBlocksFieldIdentity({
139
+ db: tx as unknown as ReturnType<typeof getDb>,
140
+ documentId: removedDocumentId,
141
+ propertyId,
142
+ });
143
+ }
144
+ }
135
145
  await tx
136
146
  .delete(schema.documentPropertyValues)
137
147
  .where(
@@ -12,6 +12,10 @@ import {
12
12
  parsePropertyOptions,
13
13
  type DocumentPropertyType,
14
14
  } from "../shared/properties.js";
15
+ import {
16
+ lockPrimaryBlocksFields,
17
+ persistBlocksFieldIdentity,
18
+ } from "./_blocks-field-identity.js";
15
19
  import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js";
16
20
  import { resolveContentDocumentAccess } from "./_content-document-access.js";
17
21
  import { lockDatabaseMemberships } from "./_database-membership-lock.js";
@@ -34,8 +38,15 @@ export default defineAction({
34
38
  ),
35
39
  propertyId: z.string().describe("Property definition ID"),
36
40
  value: z.unknown().describe("Value for the property type"),
41
+ expectedBlocksFieldRevision: z.number().int().nonnegative().optional(),
37
42
  }),
38
- run: async ({ documentId, databaseId, propertyId, value }) => {
43
+ run: async ({
44
+ documentId,
45
+ databaseId,
46
+ propertyId,
47
+ value,
48
+ expectedBlocksFieldRevision,
49
+ }) => {
39
50
  const db = getDb();
40
51
  const [definition] = await db
41
52
  .select()
@@ -85,7 +96,10 @@ export default defineAction({
85
96
  parsePropertyOptions(definition.optionsJson),
86
97
  );
87
98
  await db.transaction(async (tx) => {
88
- await lockDatabaseMemberships(tx, [membership.id]);
99
+ const primaryBlocksFields = await lockPrimaryBlocksFields(
100
+ tx as unknown as ReturnType<typeof getDb>,
101
+ documentId,
102
+ );
89
103
  const [lockedDefinition] = await tx
90
104
  .select()
91
105
  .from(schema.documentPropertyDefinitions)
@@ -116,12 +130,31 @@ export default defineAction({
116
130
  target = blocksStorageTarget(
117
131
  parsePropertyOptions(lockedDefinition.optionsJson),
118
132
  );
133
+ let previousContent = "";
119
134
  if (target === "document_body") {
135
+ const [currentDocument] = await tx
136
+ .select({ content: schema.documents.content })
137
+ .from(schema.documents)
138
+ .where(eq(schema.documents.id, documentId));
139
+ if (!currentDocument) {
140
+ throw new Error(`Document "${documentId}" not found`);
141
+ }
142
+ previousContent = currentDocument.content;
120
143
  await tx
121
144
  .update(schema.documents)
122
145
  .set({ content, updatedAt: now })
123
146
  .where(eq(schema.documents.id, documentId));
124
147
  } else {
148
+ const [currentField] = await tx
149
+ .select({ content: schema.documentBlockFieldContents.content })
150
+ .from(schema.documentBlockFieldContents)
151
+ .where(
152
+ and(
153
+ eq(schema.documentBlockFieldContents.documentId, documentId),
154
+ eq(schema.documentBlockFieldContents.propertyId, propertyId),
155
+ ),
156
+ );
157
+ previousContent = currentField?.content ?? "";
125
158
  await tx
126
159
  .insert(schema.documentBlockFieldContents)
127
160
  .values({
@@ -141,6 +174,33 @@ export default defineAction({
141
174
  set: { content, updatedAt: now },
142
175
  });
143
176
  }
177
+ const identityFields =
178
+ target === "document_body"
179
+ ? primaryBlocksFields
180
+ : [{ propertyId, ownerEmail: database.ownerEmail }];
181
+ if (
182
+ target === "document_body" &&
183
+ !identityFields.some((field) => field.propertyId === propertyId)
184
+ ) {
185
+ throw new Error(
186
+ "Primary Blocks membership changed before the operation completed.",
187
+ );
188
+ }
189
+ for (const field of identityFields) {
190
+ await persistBlocksFieldIdentity({
191
+ db: tx as unknown as ReturnType<typeof getDb>,
192
+ ownerEmail: field.ownerEmail,
193
+ documentId,
194
+ propertyId: field.propertyId,
195
+ previousMarkdown: previousContent,
196
+ markdown: content,
197
+ expectedRevision:
198
+ field.propertyId === propertyId
199
+ ? expectedBlocksFieldRevision
200
+ : undefined,
201
+ now,
202
+ });
203
+ }
144
204
  });
145
205
  return {
146
206
  documentId,
@@ -19,6 +19,10 @@ import {
19
19
  parseDocumentHideFromSearch,
20
20
  } from "../server/lib/documents.js";
21
21
  import type { DocumentUpdateResponse } from "../shared/api.js";
22
+ import {
23
+ lockPrimaryBlocksFields,
24
+ persistBlocksFieldIdentity,
25
+ } from "./_blocks-field-identity.js";
22
26
  import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js";
23
27
  import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js";
24
28
  import { resolveContentDocumentAccess } from "./_content-document-access.js";
@@ -510,6 +514,12 @@ export default defineAction({
510
514
  if (iconChanged) updates.icon = args.icon;
511
515
  let contentCasConflict = false;
512
516
  await db.transaction(async (tx) => {
517
+ const primaryBlocksFields = contentChanged
518
+ ? await lockPrimaryBlocksFields(
519
+ tx as unknown as ReturnType<typeof getDb>,
520
+ id,
521
+ )
522
+ : [];
513
523
  if (useContentCas) {
514
524
  const applied = await tx
515
525
  .update(schema.documents)
@@ -532,6 +542,20 @@ export default defineAction({
532
542
  .where(eq(schema.documents.id, id));
533
543
  }
534
544
 
545
+ if (contentChanged && content !== undefined) {
546
+ for (const field of primaryBlocksFields) {
547
+ await persistBlocksFieldIdentity({
548
+ db: tx as unknown as ReturnType<typeof getDb>,
549
+ ownerEmail: field.ownerEmail,
550
+ documentId: id,
551
+ propertyId: field.propertyId,
552
+ previousMarkdown: existing.content,
553
+ markdown: content,
554
+ now: updates.updatedAt as string,
555
+ });
556
+ }
557
+ }
558
+
535
559
  if (titleChanged || contentChanged) {
536
560
  const [latestVersion] = await tx
537
561
  .select({ createdAt: schema.documentVersions.createdAt })