@agent-native/core 0.154.3 → 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 (44) 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/extensions/url-safety.d.ts +7 -2
  28. package/dist/extensions/url-safety.js +27 -12
  29. package/dist/mcp-client/oauth-client.d.ts +16 -1
  30. package/dist/mcp-client/oauth-client.js +177 -83
  31. package/dist/mcp-client/remote-store.js +46 -20
  32. package/dist/oauth-tokens/index.d.ts +2 -1
  33. package/dist/oauth-tokens/index.js +2 -1
  34. package/dist/oauth-tokens/lifecycle.d.ts +87 -0
  35. package/dist/oauth-tokens/lifecycle.js +434 -0
  36. package/dist/oauth-tokens/migrations.js +10 -0
  37. package/dist/oauth-tokens/store.d.ts +22 -0
  38. package/dist/oauth-tokens/store.js +141 -18
  39. package/dist/observability/routes.d.ts +5 -5
  40. package/dist/progress/routes.d.ts +1 -1
  41. package/dist/secrets/routes.d.ts +9 -9
  42. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  43. package/dist/server/realtime-token.d.ts +1 -1
  44. package/package.json +2 -1
@@ -16,6 +16,7 @@ import {
16
16
  type ReactNode,
17
17
  type PointerEvent as ReactPointerEvent,
18
18
  } from "react";
19
+ import { toast } from "sonner";
19
20
 
20
21
  import {
21
22
  documentPropertiesResponseMatchesScope,
@@ -52,6 +53,25 @@ interface DocumentBlockFieldsProps {
52
53
  primaryEditor: ReactNode;
53
54
  }
54
55
 
56
+ function isBlocksFieldRevisionConflict(error: unknown): boolean {
57
+ if (error instanceof Error) {
58
+ return error.message.includes("Blocks field revision conflict");
59
+ }
60
+ if (!error || typeof error !== "object") return false;
61
+ const candidate = error as {
62
+ status?: unknown;
63
+ message?: unknown;
64
+ error?: unknown;
65
+ cause?: unknown;
66
+ };
67
+ if (candidate.status === 409) return true;
68
+ return [candidate.message, candidate.error, candidate.cause].some(
69
+ (value) =>
70
+ typeof value === "string" &&
71
+ value.includes("Blocks field revision conflict"),
72
+ );
73
+ }
74
+
55
75
  export function blockFieldsFromProperties(
56
76
  properties: DocumentProperty[],
57
77
  ): DocumentProperty[] {
@@ -657,24 +677,55 @@ export function useBlockFieldEditor({
657
677
  documentId,
658
678
  propertyId,
659
679
  initialContent,
680
+ initialRevision,
660
681
  save,
682
+ onRevisionConflict,
661
683
  }: {
662
684
  documentId: string;
663
685
  propertyId: string;
664
686
  initialContent: string;
687
+ initialRevision: number;
665
688
  save: (request: {
666
689
  documentId: string;
667
690
  propertyId: string;
668
691
  value: string;
692
+ expectedBlocksFieldRevision: number;
669
693
  }) => Promise<unknown>;
670
- }): { content: string; onChange: (markdown: string) => void } {
694
+ onRevisionConflict?: () => void;
695
+ }): {
696
+ content: string;
697
+ editorResetVersion: number;
698
+ onChange: (markdown: string) => void;
699
+ } {
671
700
  const key = `${documentId}:${propertyId}`;
672
701
 
673
702
  // The shared controller calls the freshest save impl through a per-key ref. The
674
703
  // save TARGET (documentId:propertyId) is fixed by the key; only the function
675
704
  // identity changes per mount, never the field it writes to.
676
705
  const implRef = blockFieldSaveImplRef(key);
677
- implRef.current = (value: string) => save({ documentId, propertyId, value });
706
+ const revisionRef = useRef(initialRevision);
707
+ const rejectedRevisionRef = useRef<number | null>(null);
708
+ const onRevisionConflictRef = useRef(onRevisionConflict);
709
+ onRevisionConflictRef.current = onRevisionConflict;
710
+ if (initialRevision > revisionRef.current) {
711
+ revisionRef.current = initialRevision;
712
+ }
713
+ implRef.current = async (value: string) => {
714
+ const response = await save({
715
+ documentId,
716
+ propertyId,
717
+ value,
718
+ expectedBlocksFieldRevision: revisionRef.current,
719
+ });
720
+ const nextRevision = (
721
+ response as DocumentPropertiesResponse
722
+ )?.properties?.find((candidate) => candidate.definition.id === propertyId)
723
+ ?.blocksField?.revision;
724
+ if (typeof nextRevision === "number") revisionRef.current = nextRevision;
725
+ return response;
726
+ };
727
+
728
+ const controllerRef = useRef<BlockFieldSaveController | null>(null);
678
729
 
679
730
  // Build (but do not yet ref-count) the controller factory for this key. The
680
731
  // formal acquire/release happens in the effect below; we only need the factory
@@ -686,12 +737,24 @@ export function useBlockFieldEditor({
686
737
  // saves across ALL editor instances for the key (there is only ever one
687
738
  // controller per key), so no cross-instance serialization lane is needed.
688
739
  save: (value) => implRef.current(value),
689
- onError: (error) =>
740
+ onError: (error) => {
741
+ if (isBlocksFieldRevisionConflict(error)) {
742
+ rejectedRevisionRef.current = revisionRef.current;
743
+ // A stale payload is not a retryable draft. If this editor unmounts
744
+ // before the invalidated query returns, flush must not replay it
745
+ // against the newly advanced revision and overwrite the winner.
746
+ // The request can reject after this hook unmounts. The per-field
747
+ // controller outlives that mount, so clear the rejected payload at
748
+ // the shared boundary instead of relying on the instance ref.
749
+ peekBlockFieldSaveController(key)?.discardPending();
750
+ onRevisionConflictRef.current?.();
751
+ }
690
752
  console.error("Failed to save Blocks field content", {
691
753
  documentId,
692
754
  propertyId,
693
755
  error,
694
- }),
756
+ });
757
+ },
695
758
  });
696
759
 
697
760
  // Acquire the ONE shared controller for this field key, and release it on
@@ -706,7 +769,6 @@ export function useBlockFieldEditor({
706
769
  // `factory`/`implRef` are intentionally not effect deps: the impl ref is updated
707
770
  // every render, and the identity key forces a remount for a DIFFERENT key, so
708
771
  // within one mount the key is stable and we acquire exactly once.
709
- const controllerRef = useRef<BlockFieldSaveController | null>(null);
710
772
  useEffect(() => {
711
773
  controllerRef.current = acquireBlockFieldSaveController(key, factory);
712
774
  return () => {
@@ -741,6 +803,7 @@ export function useBlockFieldEditor({
741
803
  }
742
804
  return initialContent;
743
805
  });
806
+ const [editorResetVersion, setEditorResetVersion] = useState(0);
744
807
 
745
808
  // Adopt fresh server content when it is a GENUINELY newer external update (e.g.
746
809
  // an agent edit) — not when it is merely stale server props lagging a local
@@ -761,6 +824,27 @@ export function useBlockFieldEditor({
761
824
  useEffect(() => {
762
825
  const controller = controllerRef.current;
763
826
  if (!controller) return;
827
+ const rejectedRevision = rejectedRevisionRef.current;
828
+ if (rejectedRevision !== null && initialRevision > rejectedRevision) {
829
+ if (controller.pending !== controller.lastSaved) {
830
+ // The user typed again while the winner was being refetched. Preserve
831
+ // that newer draft; revisionRef already reflects initialRevision, so
832
+ // its pending save will use the newly accepted boundary.
833
+ rejectedRevisionRef.current = null;
834
+ return;
835
+ }
836
+ // The server rejected our stale revision and the invalidated query has
837
+ // now delivered the winner. The rejected draft is not retryable against
838
+ // the new baseline: adopt the accepted value and clear the dirty latch.
839
+ setContent(initialContent);
840
+ controller.mark(initialContent);
841
+ rejectedRevisionRef.current = null;
842
+ // VisualEditor owns an imperative TipTap instance. Reset it only for this
843
+ // explicit conflict recovery so the accepted server value replaces the
844
+ // rejected draft without disrupting ordinary save cursor/undo state.
845
+ setEditorResetVersion((version) => version + 1);
846
+ return;
847
+ }
764
848
  // Never adopt over a dirty local edit.
765
849
  if (controller.pending !== controller.lastSaved) return;
766
850
  if (initialContent === controller.lastSaved) {
@@ -780,10 +864,10 @@ export function useBlockFieldEditor({
780
864
  }
781
865
  // else: server props are stale, lagging a local save the server hasn't
782
866
  // echoed yet. Keep showing lastSaved and wait for the echo above to clear
783
- // the latch. (Cross-client concurrent same-field edits remain last-write-
784
- // wins for v1; true coherence needs the deferred server-side versioning.)
867
+ // the latch. Concurrent writes are guarded by the field revision; a
868
+ // rejected stale write follows the explicit conflict branch above.
785
869
  // eslint-disable-next-line react-hooks/exhaustive-deps
786
- }, [initialContent]);
870
+ }, [initialContent, initialRevision]);
787
871
 
788
872
  function onChange(markdown: string) {
789
873
  setContent(markdown);
@@ -792,7 +876,7 @@ export function useBlockFieldEditor({
792
876
  controllerRef.current?.change(markdown);
793
877
  }
794
878
 
795
- return { content, onChange };
879
+ return { content, editorResetVersion, onChange };
796
880
  }
797
881
 
798
882
  /**
@@ -811,6 +895,7 @@ function AdditionalBlockEditor({
811
895
  property: DocumentProperty;
812
896
  canEdit: boolean;
813
897
  }) {
898
+ const t = useT();
814
899
  const setProperty = useSetDocumentProperty(
815
900
  documentId,
816
901
  property.definition.databaseId!,
@@ -819,16 +904,19 @@ function AdditionalBlockEditor({
819
904
  const propertyId = property.definition.id;
820
905
  const initialContent =
821
906
  typeof property.value === "string" ? property.value : "";
822
- const { content, onChange } = useBlockFieldEditor({
907
+ const { content, editorResetVersion, onChange } = useBlockFieldEditor({
823
908
  documentId,
824
909
  propertyId,
825
910
  initialContent,
911
+ initialRevision: property.blocksField?.revision ?? 0,
826
912
  save: setProperty.mutateAsync,
913
+ onRevisionConflict: () =>
914
+ toast.error(t("editor.blocksFieldRevisionConflict")),
827
915
  });
828
916
 
829
917
  return (
830
918
  <VisualEditor
831
- key={propertyId}
919
+ key={`${propertyId}:${editorResetVersion}`}
832
920
  documentId={documentId}
833
921
  content={content}
834
922
  onChange={onChange}
@@ -40,6 +40,8 @@ export interface BlockFieldSaveController {
40
40
  cancel(): void;
41
41
  /** Adopt `content` as the confirmed-saved baseline (no save scheduled). */
42
42
  mark(content: string): void;
43
+ /** Drop a rejected pending write so an unmount flush cannot retry it. */
44
+ discardPending(): void;
43
45
  /** The value last CONFIRMED persisted. */
44
46
  readonly lastSaved: string;
45
47
  /** The latest value the user has typed (may differ from lastSaved). */
@@ -161,6 +163,10 @@ export function createBlockFieldSaveController(args: {
161
163
  // hasn't echoed — so server props are no longer "behind" this controller.
162
164
  hasSavedLocally = false;
163
165
  },
166
+ discardPending() {
167
+ clearTimer();
168
+ pending = lastSaved;
169
+ },
164
170
  get lastSaved() {
165
171
  return lastSaved;
166
172
  },
@@ -132,7 +132,7 @@ export function useSetDocumentProperty(
132
132
  );
133
133
  return { previous };
134
134
  },
135
- onError: (_error, _variables, context) => {
135
+ onError: (_error, variables, context) => {
136
136
  const rollback = context as
137
137
  | {
138
138
  previous?: Array<[readonly unknown[], unknown]>;
@@ -141,6 +141,9 @@ export function useSetDocumentProperty(
141
141
  for (const [queryKey, data] of rollback?.previous ?? []) {
142
142
  queryClient.setQueryData(queryKey, data);
143
143
  }
144
+ queryClient.invalidateQueries({
145
+ queryKey: documentPropertiesQueryKey(variables.documentId, databaseId),
146
+ });
144
147
  },
145
148
  onSuccess: (data, variables) => {
146
149
  const savedValue =
@@ -3119,6 +3119,8 @@ const enUS = {
3119
3119
  searchLanguages: "Search languages...",
3120
3120
  noBlocksFields: "No Blocks fields. Add one from the property menu.",
3121
3121
  noDocumentSelected: "No document selected",
3122
+ blocksFieldRevisionConflict:
3123
+ "This Blocks field changed elsewhere. Your edit wasn't saved; the latest version is now shown.",
3122
3124
  couldNotReadLocalSourceFile: "Could not read local source file",
3123
3125
  couldNotSaveLocalFile: "Could not save local file",
3124
3126
  collabConnectingReadOnly:
@@ -5619,6 +5621,8 @@ const databaseExactEnglishMessagesByLocale = {
5619
5621
  const editorMessagesByLocale = {
5620
5622
  "zh-CN": {
5621
5623
  noDocumentSelected: "未选择文档",
5624
+ blocksFieldRevisionConflict:
5625
+ "此 Blocks 字段已在其他位置更改。你的编辑未保存;现已显示最新版本。",
5622
5626
  collabConnectingReadOnly: "正在连接实时编辑器。显示只读快照。",
5623
5627
  liveDocumentSaveBeforeSyncFailed: "实时文档无法在同步前保存。",
5624
5628
  builderBodySyncing: "内容仍在从 Builder 同步",
@@ -5966,6 +5970,8 @@ const editorMessagesByLocale = {
5966
5970
  },
5967
5971
  "es-ES": {
5968
5972
  noDocumentSelected: "Ningún documento seleccionado",
5973
+ blocksFieldRevisionConflict:
5974
+ "Este campo de bloques cambió en otro lugar. Tu edición no se guardó; ahora se muestra la versión más reciente.",
5969
5975
  collabConnectingReadOnly:
5970
5976
  "Conectando el editor en vivo. Mostrando una instantánea de solo lectura.",
5971
5977
  liveDocumentSaveBeforeSyncFailed:
@@ -6326,6 +6332,8 @@ const editorMessagesByLocale = {
6326
6332
  },
6327
6333
  "fr-FR": {
6328
6334
  noDocumentSelected: "Aucun document sélectionné",
6335
+ blocksFieldRevisionConflict:
6336
+ "Ce champ de blocs a été modifié ailleurs. Votre modification n’a pas été enregistrée ; la dernière version est maintenant affichée.",
6329
6337
  collabConnectingReadOnly:
6330
6338
  "Connexion de l'éditeur en direct. Affichage d'un instantané en lecture seule.",
6331
6339
  liveDocumentSaveBeforeSyncFailed:
@@ -6691,6 +6699,8 @@ const editorMessagesByLocale = {
6691
6699
  },
6692
6700
  "de-DE": {
6693
6701
  noDocumentSelected: "Kein Dokument ausgewählt",
6702
+ blocksFieldRevisionConflict:
6703
+ "Dieses Blocks-Feld wurde an anderer Stelle geändert. Deine Bearbeitung wurde nicht gespeichert; jetzt wird die neueste Version angezeigt.",
6694
6704
  collabConnectingReadOnly:
6695
6705
  "Live-Editor wird verbunden. Schreibgeschützte Momentaufnahme wird angezeigt.",
6696
6706
  liveDocumentSaveBeforeSyncFailed:
@@ -7059,6 +7069,8 @@ const editorMessagesByLocale = {
7059
7069
  },
7060
7070
  "ja-JP": {
7061
7071
  noDocumentSelected: "ドキュメントが選択されていません",
7072
+ blocksFieldRevisionConflict:
7073
+ "このブロックフィールドは別の場所で変更されました。編集内容は保存されず、最新バージョンが表示されています。",
7062
7074
  collabConnectingReadOnly:
7063
7075
  "ライブエディターに接続中。読み取り専用のスナップショットを表示しています。",
7064
7076
  liveDocumentSaveBeforeSyncFailed:
@@ -7416,6 +7428,8 @@ const editorMessagesByLocale = {
7416
7428
  },
7417
7429
  "ko-KR": {
7418
7430
  noDocumentSelected: "선택한 문서가 없습니다.",
7431
+ blocksFieldRevisionConflict:
7432
+ "이 블록 필드가 다른 곳에서 변경되었습니다. 편집 내용은 저장되지 않았으며 최신 버전이 표시됩니다.",
7419
7433
  collabConnectingReadOnly:
7420
7434
  "라이브 편집기에 연결하는 중입니다. 읽기 전용 스냅샷을 표시합니다.",
7421
7435
  liveDocumentSaveBeforeSyncFailed:
@@ -7772,6 +7786,8 @@ const editorMessagesByLocale = {
7772
7786
  },
7773
7787
  "pt-BR": {
7774
7788
  noDocumentSelected: "Nenhum documento selecionado",
7789
+ blocksFieldRevisionConflict:
7790
+ "Este campo de blocos foi alterado em outro lugar. Sua edição não foi salva; a versão mais recente agora está sendo exibida.",
7775
7791
  collabConnectingReadOnly:
7776
7792
  "Conectando o editor ao vivo. Exibindo um instantâneo somente leitura.",
7777
7793
  liveDocumentSaveBeforeSyncFailed:
@@ -8134,6 +8150,8 @@ const editorMessagesByLocale = {
8134
8150
  },
8135
8151
  "hi-IN": {
8136
8152
  noDocumentSelected: "कोई दस्तावेज़ चयनित नहीं",
8153
+ blocksFieldRevisionConflict:
8154
+ "यह ब्लॉक फ़ील्ड कहीं और बदल गया है। आपका संपादन सहेजा नहीं गया; अब नवीनतम संस्करण दिखाया जा रहा है।",
8137
8155
  collabConnectingReadOnly:
8138
8156
  "लाइव संपादक कनेक्ट हो रहा है। केवल-पठन स्नैपशॉट दिखाया जा रहा है।",
8139
8157
  liveDocumentSaveBeforeSyncFailed:
@@ -8486,6 +8504,8 @@ const editorMessagesByLocale = {
8486
8504
  },
8487
8505
  "ar-SA": {
8488
8506
  noDocumentSelected: "لم يتم تحديد أي مستند",
8507
+ blocksFieldRevisionConflict:
8508
+ "تم تغيير حقل الكتل هذا في مكان آخر. لم يتم حفظ تعديلك؛ ويظهر الآن أحدث إصدار.",
8489
8509
  collabConnectingReadOnly:
8490
8510
  "جارٍ الاتصال بالمحرر المباشر. يتم عرض لقطة للقراءة فقط.",
8491
8511
  liveDocumentSaveBeforeSyncFailed: "تعذّر حفظ المستند المباشر قبل المزامنة.",
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-10
4
+ ---
5
+
6
+ Database Blocks fields now keep logical block identity through editing, reordering, deletion recovery, and reloads
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-13
4
+ ---
5
+
6
+ Blocks field conflicts now keep the newer saved version visible instead of leaving a rejected edit on screen
@@ -6,7 +6,7 @@ name: "Blocks"
6
6
  user_promise: "A Block is a stable addressable unit of rich content inside its owning field."
7
7
  primary_user_job: "Edit and point to a meaningful part of content without fragile position-only anchors."
8
8
  kind: "primitive"
9
- state: "approved_shape"
9
+ state: "in_progress"
10
10
  publicness: "public"
11
11
  availability: "universal"
12
12
  dependencies: []
@@ -21,9 +21,10 @@ proof_requirements:
21
21
  "Comment anchors retain historical target context",
22
22
  "Shared Action/UI editing, conflict, undo, and reload behavior",
23
23
  ]
24
- evidence: []
24
+ evidence:
25
+ ["shared/blocks-field-identity.ts", "actions/blocks-seeding.db.test.ts"]
25
26
  superseded_by: null
26
- last_reviewed: "2026-07-29"
27
+ last_reviewed: "2026-08-10"
27
28
  ---
28
29
 
29
30
  # Blocks
@@ -62,7 +63,7 @@ Given a Block reference in another Page, when an authorized reader opens it, the
62
63
 
63
64
  ## Current evidence
64
65
 
65
- The current editor stores rich document content and supports anchored comments, but the repository does not yet demonstrate stable universal Block IDs, reference serialization, or recovery across all typed Block operations. This remains `approved_shape`.
66
+ Database Blocks fields now have a field-scoped ordered identity sidecar with deterministic legacy IDs, persisted revisions, and bounded tombstone recovery. Deterministic tests cover editing, reorder, insertion, deletion, recovery, reload, and field independence. Agent-facing exact Block actions, reference/comment anchors, actor-aware history, and real-interface proof remain incomplete, so this is `in_progress`, not verified.
66
67
 
67
68
  ## Proof plan
68
69
 
@@ -6,7 +6,7 @@ name: "Blocks fields"
6
6
  user_promise: "Every editable rich-content body uses one Blocks-field grammar and keeps its own stable revision boundary."
7
7
  primary_user_job: "Write rich content in Pages and collaboration surfaces without each body inventing incompatible editing and history rules."
8
8
  kind: "primitive"
9
- state: "approved_shape"
9
+ state: "in_progress"
10
10
  publicness: "public"
11
11
  availability: "universal"
12
12
  dependencies: ["content.object.block"]
@@ -24,9 +24,14 @@ proof_requirements:
24
24
  "Owner-scoped access and typed rendering including unavailable content",
25
25
  "Shared Action/UI behavior for concurrency, history, and portable output",
26
26
  ]
27
- evidence: []
27
+ evidence:
28
+ [
29
+ "server/db/schema.ts",
30
+ "actions/_blocks-field-identity.ts",
31
+ "actions/blocks-seeding.db.test.ts",
32
+ ]
28
33
  superseded_by: null
29
- last_reviewed: "2026-07-29"
34
+ last_reviewed: "2026-08-10"
30
35
  ---
31
36
 
32
37
  # Blocks fields
@@ -65,7 +70,7 @@ Given a Page with two Blocks fields, when an authorized editor restores one fiel
65
70
 
66
71
  ## Current evidence
67
72
 
68
- The document editor proves rich Page-body editing and comments provide collaboration substrate. The repository does not yet prove one generalized Blocks-field grammar or independent field history across all owners; this remains `approved_shape`.
73
+ Primary and additional database Blocks properties now retain distinct field identities, ordered Block identities, and independent monotonic revisions around their existing Markdown stores. Export reports each field and its identity status without changing plain NFM. Comment/Discussion owners, attributable history, arbitrary restore, shared mutation actions, and real-interface proof remain incomplete, so this is `in_progress`, not verified.
69
74
 
70
75
  ## Proof plan
71
76
 
@@ -16,8 +16,8 @@ This index summarizes the atomic product contracts beneath the public roadmap. E
16
16
  | Verified | 3 |
17
17
  | Failing | 1 |
18
18
  | Stale | 0 |
19
- | In Progress | 16 |
20
- | Approved Shape | 91 |
19
+ | In Progress | 18 |
20
+ | Approved Shape | 89 |
21
21
  | Exploring | 8 |
22
22
  | Deferred | 0 |
23
23
  | Superseded | 5 |
@@ -358,8 +358,8 @@ graph LR
358
358
 
359
359
  | Capability | State | User promise |
360
360
  | -------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------- |
361
- | [Blocks](capabilities/content.object.block.md) | Approved Shape | A Block is a stable addressable unit of rich content inside its owning field. |
362
- | [Blocks fields](capabilities/content.object.blocks-field.md) | Approved Shape | Every editable rich-content body uses one Blocks-field grammar and keeps its own stable revision boundary. |
361
+ | [Blocks](capabilities/content.object.block.md) | In Progress | A Block is a stable addressable unit of rich content inside its owning field. |
362
+ | [Blocks fields](capabilities/content.object.blocks-field.md) | In Progress | Every editable rich-content body uses one Blocks-field grammar and keeps its own stable revision boundary. |
363
363
  | [Databases](capabilities/content.object.database.md) | Verified | Database as a Page-backed typed collection |
364
364
  | [Multiple Database memberships](capabilities/content.object.multi-membership.md) | In Progress | One Page can belong to several Databases without copies or a hidden primary home. |
365
365
  | [Pages](capabilities/content.object.page.md) | Verified | A durable Page keeps its identity, body, properties, access, discussion, and portable representation wherever it appears. |
@@ -523,4 +523,65 @@ export const documentBlockFieldContents = table(
523
523
  },
524
524
  );
525
525
 
526
+ // Stable identity and revision boundary for one database Blocks property. The
527
+ // Markdown body remains in documents.content or document_block_field_contents;
528
+ // this row binds the ordered identity sidecar to those exact bytes.
529
+ export const documentBlockFields = table(
530
+ "document_block_fields",
531
+ {
532
+ id: text("id").primaryKey(),
533
+ ownerEmail: text("owner_email").notNull().default("local@localhost"),
534
+ documentId: text("document_id").notNull(),
535
+ propertyId: text("property_id").notNull(),
536
+ revision: integer("revision").notNull().default(0),
537
+ contentHash: text("content_hash").notNull(),
538
+ createdAt: text("created_at").notNull().default(now()),
539
+ updatedAt: text("updated_at").notNull().default(now()),
540
+ },
541
+ (field) => [
542
+ uniqueIndex("document_block_fields_document_property_unique").on(
543
+ field.documentId,
544
+ field.propertyId,
545
+ ),
546
+ index("document_block_fields_owner_document_idx").on(
547
+ field.ownerEmail,
548
+ field.documentId,
549
+ ),
550
+ ],
551
+ );
552
+
553
+ // Ordered block identity index plus bounded tombstones. This is deliberately
554
+ // not an actor-aware history log: it records only current nodes and the minimum
555
+ // deleted fragment needed for editor undo to recover the same logical ID.
556
+ export const documentBlocks = table(
557
+ "document_blocks",
558
+ {
559
+ id: text("id").primaryKey(),
560
+ ownerEmail: text("owner_email").notNull().default("local@localhost"),
561
+ fieldId: text("field_id").notNull(),
562
+ parentId: text("parent_id"),
563
+ kind: text("kind").notNull(),
564
+ position: integer("position").notNull(),
565
+ sortIndex: integer("sort_index").notNull(),
566
+ addressable: integer("addressable", { mode: "boolean" })
567
+ .notNull()
568
+ .default(true),
569
+ contentHash: text("content_hash").notNull(),
570
+ markdown: text("markdown").notNull().default(""),
571
+ state: text("state").notNull().default("live"),
572
+ deletedAtRevision: integer("deleted_at_revision"),
573
+ recoveredAtRevision: integer("recovered_at_revision"),
574
+ createdAt: text("created_at").notNull().default(now()),
575
+ updatedAt: text("updated_at").notNull().default(now()),
576
+ },
577
+ (block) => [
578
+ index("document_blocks_field_state_sort_idx").on(
579
+ block.fieldId,
580
+ block.state,
581
+ block.sortIndex,
582
+ ),
583
+ index("document_blocks_parent_idx").on(block.parentId),
584
+ ],
585
+ );
586
+
526
587
  export const documentShares = createSharesTable("document_shares");
@@ -956,6 +956,61 @@ export const runContentMigrations = runMigrations(
956
956
  CREATE INDEX IF NOT EXISTS content_database_migration_receipts_owner_database_idx
957
957
  ON content_database_migration_receipts (owner_email, database_id)`,
958
958
  },
959
+ {
960
+ version: 81,
961
+ name: "content-block-field-identities",
962
+ sql: `CREATE TABLE IF NOT EXISTS document_block_fields (
963
+ id TEXT PRIMARY KEY,
964
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
965
+ document_id TEXT NOT NULL,
966
+ property_id TEXT NOT NULL,
967
+ revision INTEGER NOT NULL DEFAULT 0,
968
+ content_hash TEXT NOT NULL,
969
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
970
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
971
+ );
972
+ CREATE UNIQUE INDEX IF NOT EXISTS document_block_fields_document_property_unique
973
+ ON document_block_fields (document_id, property_id);
974
+ CREATE INDEX IF NOT EXISTS document_block_fields_owner_document_idx
975
+ ON document_block_fields (owner_email, document_id)`,
976
+ },
977
+ {
978
+ version: 82,
979
+ name: "content-block-identities-and-tombstones",
980
+ sql: `CREATE TABLE IF NOT EXISTS document_blocks (
981
+ id TEXT PRIMARY KEY,
982
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
983
+ field_id TEXT NOT NULL,
984
+ parent_id TEXT,
985
+ kind TEXT NOT NULL,
986
+ position INTEGER NOT NULL,
987
+ sort_index INTEGER NOT NULL,
988
+ addressable INTEGER NOT NULL DEFAULT 1,
989
+ content_hash TEXT NOT NULL,
990
+ markdown TEXT NOT NULL DEFAULT '',
991
+ state TEXT NOT NULL DEFAULT 'live',
992
+ deleted_at_revision INTEGER,
993
+ recovered_at_revision INTEGER,
994
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
995
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
996
+ );
997
+ CREATE INDEX IF NOT EXISTS document_blocks_field_state_sort_idx
998
+ ON document_blocks (field_id, state, sort_index);
999
+ CREATE INDEX IF NOT EXISTS document_blocks_parent_idx
1000
+ ON document_blocks (parent_id)`,
1001
+ },
1002
+ // The portable schema maps integer({ mode: "boolean" }) to BOOLEAN on
1003
+ // Postgres, while the raw INTEGER migration above is adapted to BIGINT.
1004
+ // Convert the stored column before Drizzle sends boolean values.
1005
+ {
1006
+ version: 83,
1007
+ name: "content-block-addressable-postgres-boolean",
1008
+ sql: {
1009
+ postgres: `ALTER TABLE document_blocks ALTER COLUMN addressable DROP DEFAULT;
1010
+ ALTER TABLE document_blocks ALTER COLUMN addressable TYPE boolean USING addressable::text::boolean;
1011
+ ALTER TABLE document_blocks ALTER COLUMN addressable SET DEFAULT true`,
1012
+ },
1013
+ },
959
1014
  ],
960
1015
  { table: "content_migrations" },
961
1016
  );
@@ -1,3 +1,4 @@
1
+ import type { BlocksFieldIdentity } from "./blocks-field-identity";
1
2
  import type {
2
3
  DocumentPropertyOptions,
3
4
  DocumentPropertyOption,
@@ -206,6 +207,7 @@ export interface DocumentProperty {
206
207
  definition: DocumentPropertyDefinition;
207
208
  value: DocumentPropertyValue;
208
209
  editable: boolean;
210
+ blocksField?: BlocksFieldIdentity;
209
211
  }
210
212
 
211
213
  export interface DocumentPropertiesResponse {
@@ -230,6 +232,7 @@ export interface SetDocumentPropertyRequest {
230
232
  databaseId: string;
231
233
  propertyId: string;
232
234
  value: DocumentPropertyValue;
235
+ expectedBlocksFieldRevision?: number;
233
236
  }
234
237
 
235
238
  export interface DuplicateDocumentPropertyRequest {