@elytracms/studio 0.0.91 → 0.0.93
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.
- package/convex/asset-alt-migration.test.ts +127 -0
- package/convex/asset-replace.test.ts +168 -0
- package/convex/asset-revalidate.test.ts +6 -6
- package/convex/assets.ts +41 -5
- package/convex/cms.ts +537 -177
- package/convex/contract-parity.test.ts +173 -19
- package/convex/delivery.ts +26 -13
- package/convex/document-revalidate.test.ts +17 -16
- package/convex/orphan-hydration.test.ts +116 -0
- package/convex/presence-cost.test.ts +89 -0
- package/convex/rail-write-parity.test.ts +17 -14
- package/convex/referenceMaintenance.ts +15 -15
- package/convex/schema.ts +70 -5
- package/convex/triggers.ts +21 -8
- package/convex/write-limits.ts +1 -1
- package/dist/components/ai/ai-chat-panel.js +33 -22
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/proposal-card.js +2 -1
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/ai/translate-document-action.d.ts +8 -2
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.d.ts +1 -1
- package/dist/components/ai/translate-document-dialog.js +14 -8
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +19 -0
- package/dist/components/ai/translate-skipped.js +46 -0
- package/dist/components/ai/translate-skipped.js.map +1 -0
- package/dist/components/app-views/default-modules.d.ts +23 -6
- package/dist/components/app-views/default-modules.js +33 -14
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/assets/asset-detail.js +18 -8
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +15 -4
- package/dist/components/assets/asset-metadata.js +15 -2
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.js +4 -1
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-selection.d.ts +0 -8
- package/dist/components/assets/asset-selection.js +4 -23
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/asset-thumb.js +3 -2
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +3 -3
- package/dist/components/assets/assets-collection-view-source.js +9 -5
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +14 -0
- package/dist/components/assets/assets-store.js +23 -1
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/replace-file.d.ts +22 -0
- package/dist/components/assets/replace-file.js +59 -0
- package/dist/components/assets/replace-file.js.map +1 -0
- package/dist/components/builder/blocks-model.d.ts +14 -26
- package/dist/components/builder/blocks-model.js +35 -41
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.d.ts +7 -3
- package/dist/components/builder/blocks-panel.js +6 -5
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +126 -30
- package/dist/components/builder/builder-store.js +169 -67
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-body.d.ts +30 -0
- package/dist/components/builder/canvas-body.js +38 -0
- package/dist/components/builder/canvas-body.js.map +1 -0
- package/dist/components/builder/canvas-chrome-css.js +7 -0
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +7 -7
- package/dist/components/builder/canvas-dnd.js +5 -5
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +30 -22
- package/dist/components/builder/canvas-render-context.js +44 -44
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas-runs.d.ts +45 -0
- package/dist/components/builder/canvas-runs.js +51 -0
- package/dist/components/builder/canvas-runs.js.map +1 -0
- package/dist/components/builder/canvas.d.ts +5 -4
- package/dist/components/builder/canvas.js +34 -6
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +15 -0
- package/dist/components/builder/clipboard-actions.js +50 -13
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +10 -16
- package/dist/components/builder/composition-field-editor.js +30 -40
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/composition-patches.d.ts +7 -0
- package/dist/components/builder/composition-patches.js +256 -0
- package/dist/components/builder/composition-patches.js.map +1 -0
- package/dist/components/builder/delete-key.d.ts +5 -0
- package/dist/components/builder/delete-key.js +4 -0
- package/dist/components/builder/delete-key.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +17 -25
- package/dist/components/builder/insert-context.js +45 -92
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/page-tree.d.ts +3 -3
- package/dist/components/builder/page-tree.js +3 -3
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/placement-refusal.d.ts +18 -0
- package/dist/components/builder/placement-refusal.js +46 -0
- package/dist/components/builder/placement-refusal.js.map +1 -0
- package/dist/components/builder/property-inspector.js +4 -0
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +65 -26
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +79 -8
- package/dist/components/builder/reuse.js +71 -23
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/builder/run-inspector.d.ts +11 -0
- package/dist/components/builder/run-inspector.js +33 -0
- package/dist/components/builder/run-inspector.js.map +1 -0
- package/dist/components/cms/asset-field.d.ts +38 -1
- package/dist/components/cms/asset-field.js +182 -58
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +16 -0
- package/dist/components/cms/asset-picker-core.js +20 -0
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +12 -18
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/bulk-actions.d.ts +4 -20
- package/dist/components/cms/bulk-actions.js +3 -24
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +5 -5
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +87 -45
- package/dist/components/cms/cms-store.js +347 -203
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +7 -2
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/commit-policy.d.ts +24 -0
- package/dist/components/cms/commit-policy.js +37 -0
- package/dist/components/cms/commit-policy.js.map +1 -0
- package/dist/components/cms/detail-editor.js +83 -137
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-diff-view.d.ts +3 -1
- package/dist/components/cms/document-diff-view.js +12 -7
- package/dist/components/cms/document-diff-view.js.map +1 -1
- package/dist/components/cms/draft-commit.d.ts +70 -0
- package/dist/components/cms/draft-commit.js +82 -0
- package/dist/components/cms/draft-commit.js.map +1 -0
- package/dist/components/cms/duplicate-document.d.ts +11 -13
- package/dist/components/cms/duplicate-document.js +26 -34
- package/dist/components/cms/duplicate-document.js.map +1 -1
- package/dist/components/cms/field-anchors.d.ts +7 -4
- package/dist/components/cms/field-anchors.js +9 -7
- package/dist/components/cms/field-anchors.js.map +1 -1
- package/dist/components/cms/field-collaboration.d.ts +47 -0
- package/dist/components/cms/field-collaboration.js +87 -0
- package/dist/components/cms/field-collaboration.js.map +1 -0
- package/dist/components/cms/field-control-lib.d.ts +22 -1
- package/dist/components/cms/field-control-lib.js +33 -4
- package/dist/components/cms/field-control-lib.js.map +1 -1
- package/dist/components/cms/field-control.js +17 -14
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +21 -30
- package/dist/components/cms/gate-validation.js +11 -22
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/object-array-field.js +4 -4
- package/dist/components/cms/object-array-field.js.map +1 -1
- package/dist/components/cms/prop-fields.d.ts +7 -2
- package/dist/components/cms/prop-fields.js +13 -6
- package/dist/components/cms/prop-fields.js.map +1 -1
- package/dist/components/cms/reference-confirm.js +10 -4
- package/dist/components/cms/reference-confirm.js.map +1 -1
- package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
- package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +20 -9
- package/dist/components/cms/rich-text/insert.js +25 -17
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +96 -0
- package/dist/components/cms/use-document-draft.js +283 -0
- package/dist/components/cms/use-document-draft.js.map +1 -0
- package/dist/components/cms/use-document-versions.js +2 -2
- package/dist/components/cms/use-document-versions.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +6 -12
- package/dist/components/cms/use-live-url.js +16 -15
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +13 -18
- package/dist/components/cms/use-publish-controller.js +24 -41
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/use-published-perspective.d.ts +2 -3
- package/dist/components/cms/use-published-perspective.js +4 -3
- package/dist/components/cms/use-published-perspective.js.map +1 -1
- package/dist/components/cms/versions-pane.js +8 -1
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +24 -7
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +28 -23
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +31 -21
- package/dist/components/components/transfer.js +49 -35
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-session.d.ts +6 -3
- package/dist/components/project-session.js +14 -8
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/rail-view-source.js +2 -1
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/section-nav.js +1 -1
- package/dist/components/section-nav.js.map +1 -1
- package/dist/lib/ai/apply-content.js +56 -116
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +8 -0
- package/dist/lib/ai/chat-context.js +37 -6
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +14 -1
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +20 -5
- package/dist/lib/ai/constraints.js +125 -33
- package/dist/lib/ai/constraints.js.map +1 -1
- package/dist/lib/ai/fake-provider.d.ts +16 -3
- package/dist/lib/ai/fake-provider.js +22 -6
- package/dist/lib/ai/fake-provider.js.map +1 -1
- package/dist/lib/ai/index.d.ts +2 -0
- package/dist/lib/ai/index.js +2 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/leash.d.ts +34 -0
- package/dist/lib/ai/leash.js +28 -0
- package/dist/lib/ai/leash.js.map +1 -0
- package/dist/lib/ai/provenance.d.ts +1 -1
- package/dist/lib/ai/provenance.js.map +1 -1
- package/dist/lib/ai/rich-text-input.d.ts +8 -8
- package/dist/lib/ai/rich-text-input.js +2 -1
- package/dist/lib/ai/rich-text-input.js.map +1 -1
- package/dist/lib/ai/rich-text-translate.d.ts +27 -4
- package/dist/lib/ai/rich-text-translate.js +28 -10
- package/dist/lib/ai/rich-text-translate.js.map +1 -1
- package/dist/lib/ai/shape.d.ts +13 -13
- package/dist/lib/ai/shape.js +69 -93
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +36 -41
- package/dist/lib/ai/tools.js +85 -87
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.d.ts +39 -5
- package/dist/lib/ai/translate-document.js +160 -51
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +34 -23
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +21 -12
- package/dist/lib/ai/update-fields.js +118 -177
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +116 -0
- package/dist/lib/ai/writer-form.js +429 -0
- package/dist/lib/ai/writer-form.js.map +1 -0
- package/dist/lib/ai-current-document.d.ts +11 -11
- package/dist/lib/ai-current-document.js +4 -4
- package/dist/lib/ai-current-document.js.map +1 -1
- package/dist/lib/assets/upload.d.ts +31 -2
- package/dist/lib/assets/upload.js +61 -6
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
- package/dist/lib/clipboard/os-clipboard.js +8 -4
- package/dist/lib/clipboard/os-clipboard.js.map +1 -1
- package/dist/lib/clipboard/paste-target.d.ts +18 -9
- package/dist/lib/clipboard/paste-target.js +8 -7
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/cms/field-patch.d.ts +15 -0
- package/dist/lib/cms/field-patch.js +25 -0
- package/dist/lib/cms/field-patch.js.map +1 -0
- package/dist/lib/cms/published-documents.d.ts +1 -1
- package/dist/lib/cms/published-documents.js +1 -1
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +3 -3
- package/dist/lib/collection-view/__fixtures__/document-writes.d.ts +6 -0
- package/dist/lib/collection-view/__fixtures__/document-writes.js +23 -0
- package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -0
- package/dist/lib/collection-view/collection-view-source.d.ts +69 -6
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +4 -13
- package/dist/lib/data/collection-content.js +11 -5
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/dnd/drop-rules.d.ts +25 -31
- package/dist/lib/dnd/drop-rules.js +53 -59
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +72 -15
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +61 -12
- package/dist/lib/i18n/messages.en.js +76 -16
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/__fixtures__/write-document.d.ts +30 -0
- package/dist/lib/operations/__fixtures__/write-document.js +16 -0
- package/dist/lib/operations/__fixtures__/write-document.js.map +1 -0
- package/dist/lib/operations/cms-persistence.d.ts +124 -31
- package/dist/lib/operations/cms-persistence.js +447 -68
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/operations/config-manifest-lookup.d.ts +35 -0
- package/dist/lib/operations/config-manifest-lookup.js +56 -0
- package/dist/lib/operations/config-manifest-lookup.js.map +1 -0
- package/dist/lib/operations/project-runtime-config.d.ts +39 -0
- package/dist/lib/operations/project-runtime-config.js +54 -0
- package/dist/lib/operations/project-runtime-config.js.map +1 -0
- package/dist/lib/operations/publish-with-revision.d.ts +64 -0
- package/dist/lib/operations/publish-with-revision.js +66 -0
- package/dist/lib/operations/publish-with-revision.js.map +1 -0
- package/dist/lib/operations/runtime-validator.d.ts +32 -0
- package/dist/lib/operations/runtime-validator.js +18 -0
- package/dist/lib/operations/runtime-validator.js.map +1 -0
- package/dist/lib/operations/session-hydration.d.ts +31 -14
- package/dist/lib/operations/session-hydration.js +12 -16
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/operations/studio-operations.d.ts +59 -2
- package/dist/lib/operations/studio-operations.js +76 -14
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +13 -5
- package/dist/lib/persistence/convex-adapter.js +189 -74
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/convex-client.d.ts +65 -5
- package/dist/lib/persistence/convex-client.js +106 -3
- package/dist/lib/persistence/convex-client.js.map +1 -1
- package/dist/lib/persistence/index.js +17 -18
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/persona/labels.js +4 -1
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/query/operations-query.d.ts +1 -1
- package/dist/lib/query/operations-query.js +1 -1
- package/dist/lib/query/operations-query.js.map +1 -1
- package/dist/lib/references/impact.d.ts +21 -15
- package/dist/lib/references/impact.js +13 -26
- package/dist/lib/references/impact.js.map +1 -1
- package/dist/lib/references/references-store.d.ts +9 -0
- package/dist/lib/references/references-store.js +24 -4
- package/dist/lib/references/references-store.js.map +1 -1
- package/dist/lib/routes/route-issues.d.ts +6 -6
- package/dist/lib/routes/route-issues.js +26 -80
- package/dist/lib/routes/route-issues.js.map +1 -1
- package/dist/lib/workspace/config-issues.js +2 -1
- package/dist/lib/workspace/config-issues.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +31 -1
- package/dist/lib/workspace/index.js +64 -17
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/lib/workspace/switcher-source.d.ts +3 -3
- package/dist/lib/workspace/switcher-source.js.map +1 -1
- package/dist/lib/workspace/workspace-source.d.ts +2 -2
- package/dist/lib/workspace/workspace-source.js +2 -1
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/package.json +3 -3
- package/dist/components/cms/cms-composition-validators.d.ts +0 -16
- package/dist/components/cms/cms-composition-validators.js +0 -133
- package/dist/components/cms/cms-composition-validators.js.map +0 -1
- package/dist/components/cms/publish-clean.d.ts +0 -28
- package/dist/components/cms/publish-clean.js +0 -38
- package/dist/components/cms/publish-clean.js.map +0 -1
- package/dist/components/cms/use-save-mode.d.ts +0 -35
- package/dist/components/cms/use-save-mode.js +0 -54
- package/dist/components/cms/use-save-mode.js.map +0 -1
- package/dist/lib/assets/asset-placements.d.ts +0 -28
- package/dist/lib/assets/asset-placements.js +0 -83
- package/dist/lib/assets/asset-placements.js.map +0 -1
- package/dist/lib/cms/document-gate.d.ts +0 -47
- package/dist/lib/cms/document-gate.js +0 -61
- package/dist/lib/cms/document-gate.js.map +0 -1
package/convex/cms.ts
CHANGED
|
@@ -1,28 +1,57 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
DRAFT_VARIANT,
|
|
4
|
+
applyDocumentPatches,
|
|
5
|
+
documentsSemanticEqual,
|
|
6
|
+
foldDocument,
|
|
7
|
+
foldPatchesForStorage,
|
|
8
|
+
replacePreconditionHolds,
|
|
9
|
+
unfoldDocument,
|
|
10
|
+
unfoldValue,
|
|
11
|
+
} from '@elytracms/core/cms-core'
|
|
12
|
+
import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
|
|
13
|
+
import { isPinnableVersion } from '@elytracms/core/persistence'
|
|
14
|
+
import type { DocumentVersionKind } from '@elytracms/core/persistence'
|
|
4
15
|
import { mutation, query } from './functions'
|
|
16
|
+
import type { MutationCtx, QueryCtx } from './functions'
|
|
5
17
|
import { requireCapability } from './guard'
|
|
6
18
|
import { assertWithinWriteLimits } from './write-limits'
|
|
7
19
|
|
|
8
20
|
/**
|
|
9
|
-
* CMS document persistence (EC-043)
|
|
10
|
-
* (AD-11, EC-183):
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
21
|
+
* CMS document persistence (EC-043) — the revisioned document AGGREGATE (EC-595,
|
|
22
|
+
* ratified 2026-10-24). The backend stores **documents only** (AD-11, EC-183): there is
|
|
23
|
+
* no `cmsSchemas` table and no schema function here.
|
|
24
|
+
*
|
|
25
|
+
* ## Server-side trust (EC-565 1e — the boundary EC-304 Phase B inherits)
|
|
26
|
+
*
|
|
27
|
+
* Owner, verbatim: "yes keep validation client-side. server-side validation enforces
|
|
28
|
+
* migrations or could lead to invalid/lost data (the payload-sql-approach). schema should
|
|
29
|
+
* be the lens and the mask to put on the data." So these handlers enforce exactly three
|
|
30
|
+
* things, all data-only: **the revision** (every write and publish carries the one it was
|
|
31
|
+
* made against; a moved document refuses with `conflict`), **pin integrity** (a pinned
|
|
32
|
+
* version exists and belongs to this document) and **authority** (`requireCapability`).
|
|
33
|
+
* They never validate a document against a schema and never canonicalise a value — the
|
|
34
|
+
* client gate does both (a `content.write` member bypassing the studio can publish an
|
|
35
|
+
* invalid document; delivery parse raises it visibly, AD-14).
|
|
36
|
+
*
|
|
37
|
+
* ## Two write verbs, both revision-aware
|
|
38
|
+
*
|
|
39
|
+
* `replaceDocument` (whole document under a precondition: `{ revision }`, `{ ifAbsent }`,
|
|
40
|
+
* `{ ifCollectionEmpty }`) and `patchDocument` (path-level patches against a base
|
|
41
|
+
* revision, merged per path IN the transaction by THE merge, `applyDocumentPatches` from
|
|
42
|
+
* cms-core — the same function the in-memory store calls). Convex OCC makes the
|
|
43
|
+
* check-and-write atomic; the check itself is the revision argument. A refusal is a
|
|
44
|
+
* returned `conflict` outcome carrying the current row — never a throw. Every write
|
|
45
|
+
* appends ONE `documentPatches` row (the patch log) and bumps `revision`.
|
|
15
46
|
*
|
|
16
47
|
* ## EC-260 depth-budget codec (the storage boundary)
|
|
17
48
|
*
|
|
18
49
|
* Convex caps STORED documents at 16 nesting levels but allows 64 in function
|
|
19
|
-
* args/returns.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* always sees structured content. The wire never carries an envelope; only raw
|
|
25
|
-
* `ctx.db` reads / the dashboard see the folded storage form.
|
|
50
|
+
* args/returns. Every write FOLDS over-budget field values into self-describing leaf
|
|
51
|
+
* envelopes immediately before `ctx.db`, and every read UNFOLDS before the value leaves
|
|
52
|
+
* the function. A patch merge unfolds the stored draft, applies, and re-folds; the patch
|
|
53
|
+
* log folds the values it carries the same way (`foldPatchesForStorage`). The wire never
|
|
54
|
+
* carries an envelope.
|
|
26
55
|
*/
|
|
27
56
|
|
|
28
57
|
/** Unfold the `document` blob of a stored row before it leaves the function. */
|
|
@@ -35,74 +64,228 @@ function withUnfoldedSnapshot<R extends { snapshot: unknown }>(row: R): R {
|
|
|
35
64
|
return { ...row, snapshot: unfoldDocument(row.snapshot as CmsDocument) }
|
|
36
65
|
}
|
|
37
66
|
|
|
38
|
-
|
|
67
|
+
type Ctx = QueryCtx | MutationCtx
|
|
68
|
+
|
|
69
|
+
function draftRow(ctx: Ctx, collection: string, docId: string) {
|
|
70
|
+
return ctx.db
|
|
71
|
+
.query('cmsDocuments')
|
|
72
|
+
.withIndex('by_doc', (q) => q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT))
|
|
73
|
+
.first()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function versionRow(ctx: Ctx, collection: string, docId: string, version: number) {
|
|
77
|
+
return ctx.db
|
|
78
|
+
.query('documentVersions')
|
|
79
|
+
.withIndex('by_doc_version', (q) =>
|
|
80
|
+
q.eq('collection', collection).eq('docId', docId).eq('version', version),
|
|
81
|
+
)
|
|
82
|
+
.unique()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* EC-449 / EC-595 binding point 6: the changed flag DERIVED in the transaction — ONE
|
|
87
|
+
* semantic compare of the draft against the snapshot the pin selects. Unpublished → no
|
|
88
|
+
* flag; a damaged pin (no version row) keeps the previous verdict.
|
|
89
|
+
*/
|
|
90
|
+
async function changedFlag(
|
|
91
|
+
ctx: Ctx,
|
|
92
|
+
collection: string,
|
|
93
|
+
docId: string,
|
|
94
|
+
publishedVersion: number | undefined,
|
|
95
|
+
draft: CmsDocument,
|
|
96
|
+
previous: boolean | undefined,
|
|
97
|
+
): Promise<{ hasUnpublishedChanges?: boolean }> {
|
|
98
|
+
if (publishedVersion === undefined) return {}
|
|
99
|
+
const pinned = await versionRow(ctx, collection, docId, publishedVersion)
|
|
100
|
+
if (!pinned) return previous === undefined ? {} : { hasUnpublishedChanges: previous }
|
|
101
|
+
return {
|
|
102
|
+
hasUnpublishedChanges: !documentsSemanticEqual(unfoldDocument(pinned.snapshot as CmsDocument), draft),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Append one patch-log row (values folded, EC-260). */
|
|
107
|
+
async function appendLog(
|
|
108
|
+
ctx: MutationCtx,
|
|
109
|
+
entry: { collection: string; docId: string; revision: number; kind: PatchLogKind; actor: string; at: string },
|
|
110
|
+
patches: readonly DocumentPatch[] = [],
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
await ctx.db.insert('documentPatches', {
|
|
113
|
+
collection: entry.collection,
|
|
114
|
+
docId: entry.docId,
|
|
115
|
+
variant: DRAFT_VARIANT,
|
|
116
|
+
revision: entry.revision,
|
|
117
|
+
kind: entry.kind,
|
|
118
|
+
actor: entry.actor,
|
|
119
|
+
at: entry.at,
|
|
120
|
+
patches: foldPatchesForStorage(patches),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The log rows above `afterRevision`, ascending (patch values unfolded). */
|
|
125
|
+
async function logAfter(ctx: Ctx, collection: string, docId: string, afterRevision: number) {
|
|
126
|
+
const rows = await ctx.db
|
|
127
|
+
.query('documentPatches')
|
|
128
|
+
.withIndex('by_doc_revision', (q) =>
|
|
129
|
+
q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT).gt('revision', afterRevision),
|
|
130
|
+
)
|
|
131
|
+
.collect()
|
|
132
|
+
return rows.map((row) => ({
|
|
133
|
+
...row,
|
|
134
|
+
kind: row.kind as PatchLogKind,
|
|
135
|
+
patches: unfoldValue(row.patches) as DocumentPatch[],
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Delete every row of a document's patch log and presence (delete / clear cascade). */
|
|
140
|
+
async function deleteBookkeeping(ctx: MutationCtx, collection: string, docId: string): Promise<void> {
|
|
141
|
+
const log = await ctx.db
|
|
142
|
+
.query('documentPatches')
|
|
143
|
+
.withIndex('by_doc_revision', (q) => q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT))
|
|
144
|
+
.collect()
|
|
145
|
+
for (const row of log) await ctx.db.delete(row._id)
|
|
146
|
+
const presence = await ctx.db
|
|
147
|
+
.query('documentPresence')
|
|
148
|
+
.withIndex('by_doc', (q) => q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT))
|
|
149
|
+
.collect()
|
|
150
|
+
for (const row of presence) await ctx.db.delete(row._id)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const preconditionValidator = v.union(
|
|
154
|
+
v.object({ revision: v.number() }),
|
|
155
|
+
v.object({ ifAbsent: v.literal(true) }),
|
|
156
|
+
v.object({ ifCollectionEmpty: v.literal(true) }),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The whole-document write (EC-595 binding point 2, EC-578): create (`ifAbsent`), a
|
|
161
|
+
* singleton's first document (`ifCollectionEmpty`), or a replace at an exact revision —
|
|
162
|
+
* never an unconditional overwrite. Returns `{ status: 'conflict', current, existing? }`
|
|
163
|
+
* when the precondition does not hold.
|
|
164
|
+
*/
|
|
165
|
+
export const replaceDocument = mutation({
|
|
39
166
|
args: {
|
|
40
167
|
collection: v.string(),
|
|
41
168
|
docId: v.string(),
|
|
42
169
|
document: v.any(),
|
|
43
|
-
|
|
44
|
-
|
|
170
|
+
precondition: preconditionValidator,
|
|
171
|
+
at: v.string(),
|
|
45
172
|
},
|
|
46
173
|
handler: async (ctx, args) => {
|
|
47
|
-
await requireCapability(ctx, 'content.write') // EC-150
|
|
48
|
-
// EC-304 B-4: defensive server-side depth/byte clamp at the `v.any()` chokepoint
|
|
49
|
-
// BEFORE fold/write. Rejects abusive structurally-deep / oversized payloads that a
|
|
50
|
-
// direct Convex call (bypassing the client-side lens) could otherwise land. Limits
|
|
51
|
-
// are generous — legitimate content never trips them (see write-limits.ts).
|
|
174
|
+
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
175
|
+
// EC-304 B-4: defensive server-side depth/byte clamp at the `v.any()` chokepoint.
|
|
52
176
|
assertWithinWriteLimits(args.document, 'document')
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
177
|
+
const precondition = args.precondition as ReplacePrecondition
|
|
178
|
+
const existing = await draftRow(ctx, args.collection, args.docId)
|
|
179
|
+
const inCollection =
|
|
180
|
+
'ifCollectionEmpty' in precondition
|
|
181
|
+
? await ctx.db
|
|
182
|
+
.query('cmsDocuments')
|
|
183
|
+
.withIndex('by_collection', (q) => q.eq('collection', args.collection))
|
|
184
|
+
.first()
|
|
185
|
+
: null
|
|
186
|
+
if (!replacePreconditionHolds(precondition, existing ?? undefined, inCollection !== null)) {
|
|
187
|
+
return {
|
|
188
|
+
status: 'conflict' as const,
|
|
189
|
+
current: existing ? withUnfoldedDocument(existing) : null,
|
|
190
|
+
...(inCollection ? { existing: withUnfoldedDocument(inCollection) } : {}),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const draft = args.document as CmsDocument
|
|
194
|
+
const revision = (existing?.revision ?? 0) + 1
|
|
195
|
+
// EC-260: fold for storage, strictly before `ctx.db` (the trigger unfolds before
|
|
196
|
+
// extracting references, so folding never hides one from the walker).
|
|
197
|
+
const stored = foldDocument(draft)
|
|
61
198
|
if (existing) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.query('documentVersions')
|
|
75
|
-
.withIndex('by_doc_version', (q) =>
|
|
76
|
-
q
|
|
77
|
-
.eq('collection', args.collection)
|
|
78
|
-
.eq('docId', args.docId)
|
|
79
|
-
.eq('version', existing.publishedVersion as number),
|
|
80
|
-
)
|
|
81
|
-
.unique()
|
|
82
|
-
if (pinned) {
|
|
83
|
-
changedFlag = {
|
|
84
|
-
hasUnpublishedChanges: !documentsSemanticEqual(
|
|
85
|
-
unfoldDocument(pinned.snapshot as CmsDocument),
|
|
86
|
-
args.document as CmsDocument,
|
|
87
|
-
),
|
|
88
|
-
}
|
|
89
|
-
}
|
|
199
|
+
const patch = {
|
|
200
|
+
document: stored,
|
|
201
|
+
revision,
|
|
202
|
+
updatedAt: args.at,
|
|
203
|
+
...(await changedFlag(
|
|
204
|
+
ctx,
|
|
205
|
+
args.collection,
|
|
206
|
+
args.docId,
|
|
207
|
+
existing.publishedVersion,
|
|
208
|
+
draft,
|
|
209
|
+
existing.hasUnpublishedChanges,
|
|
210
|
+
)),
|
|
90
211
|
}
|
|
91
|
-
// EC-153 parity: updates preserve `createdAt`, exactly like in-memory.
|
|
92
|
-
// EC-286: the write-chokepoint trigger reindexes references in-transaction.
|
|
93
|
-
const patch = { ...stored, createdAt: existing.createdAt, ...changedFlag }
|
|
94
212
|
await ctx.db.patch(existing._id, patch)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// supplied fields, so `{ ...existing, ...patch }` is byte-identical to the
|
|
98
|
-
// stored row (system fields + untouched `publishedVersion` carried from
|
|
99
|
-
// `existing`) — cutting one full-document read off the hottest write path.
|
|
100
|
-
// EC-260: unfold the folded body back to structured form for the return.
|
|
101
|
-
return withUnfoldedDocument({ ...existing, ...patch })
|
|
213
|
+
await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'replace', actor, at: args.at })
|
|
214
|
+
return { status: 'ok' as const, row: withUnfoldedDocument({ ...existing, ...patch }) }
|
|
102
215
|
}
|
|
103
|
-
const id = await ctx.db.insert('cmsDocuments',
|
|
216
|
+
const id = await ctx.db.insert('cmsDocuments', {
|
|
217
|
+
collection: args.collection,
|
|
218
|
+
docId: args.docId,
|
|
219
|
+
variant: DRAFT_VARIANT,
|
|
220
|
+
revision,
|
|
221
|
+
document: stored,
|
|
222
|
+
createdAt: args.at,
|
|
223
|
+
updatedAt: args.at,
|
|
224
|
+
})
|
|
225
|
+
await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'replace', actor, at: args.at })
|
|
104
226
|
const row = await ctx.db.get(id)
|
|
105
|
-
return row ? withUnfoldedDocument(row) :
|
|
227
|
+
return { status: 'ok' as const, row: row ? withUnfoldedDocument(row) : null }
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Path-level patches against a base revision (EC-595 binding point 4): THE merge runs
|
|
233
|
+
* here, in the transaction, against the current draft and the log rows in
|
|
234
|
+
* `(baseRevision, revision]`. Refused positions come back with their current values;
|
|
235
|
+
* the rest apply in one write (one revision bump, one log row). `missing` when the
|
|
236
|
+
* document does not exist.
|
|
237
|
+
*/
|
|
238
|
+
export const patchDocument = mutation({
|
|
239
|
+
args: {
|
|
240
|
+
collection: v.string(),
|
|
241
|
+
docId: v.string(),
|
|
242
|
+
patches: v.any(),
|
|
243
|
+
baseRevision: v.number(),
|
|
244
|
+
at: v.string(),
|
|
245
|
+
},
|
|
246
|
+
handler: async (ctx, args) => {
|
|
247
|
+
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
248
|
+
assertWithinWriteLimits(args.patches, 'patches')
|
|
249
|
+
const existing = await draftRow(ctx, args.collection, args.docId)
|
|
250
|
+
if (!existing) return { status: 'missing' as const }
|
|
251
|
+
const merged = applyDocumentPatches({
|
|
252
|
+
document: unfoldDocument(existing.document as CmsDocument),
|
|
253
|
+
patches: args.patches as DocumentPatch[],
|
|
254
|
+
since: await logAfter(ctx, args.collection, args.docId, args.baseRevision),
|
|
255
|
+
staleBase: args.baseRevision < (existing.patchLogFloor ?? 0) || args.baseRevision > existing.revision,
|
|
256
|
+
})
|
|
257
|
+
if (merged.applied.length === 0) {
|
|
258
|
+
return {
|
|
259
|
+
status: 'conflict' as const,
|
|
260
|
+
row: withUnfoldedDocument(existing),
|
|
261
|
+
refused: merged.refused,
|
|
262
|
+
appliedCount: 0,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const revision = existing.revision + 1
|
|
266
|
+
const patch = {
|
|
267
|
+
document: foldDocument(merged.document),
|
|
268
|
+
revision,
|
|
269
|
+
updatedAt: args.at,
|
|
270
|
+
...(await changedFlag(
|
|
271
|
+
ctx,
|
|
272
|
+
args.collection,
|
|
273
|
+
args.docId,
|
|
274
|
+
existing.publishedVersion,
|
|
275
|
+
merged.document,
|
|
276
|
+
existing.hasUnpublishedChanges,
|
|
277
|
+
)),
|
|
278
|
+
}
|
|
279
|
+
await ctx.db.patch(existing._id, patch)
|
|
280
|
+
await appendLog(
|
|
281
|
+
ctx,
|
|
282
|
+
{ collection: args.collection, docId: args.docId, revision, kind: 'patch', actor, at: args.at },
|
|
283
|
+
merged.applied,
|
|
284
|
+
)
|
|
285
|
+
const row = withUnfoldedDocument({ ...existing, ...patch })
|
|
286
|
+
return merged.refused.length === 0
|
|
287
|
+
? { status: 'ok' as const, row }
|
|
288
|
+
: { status: 'conflict' as const, row, refused: merged.refused, appliedCount: merged.applied.length }
|
|
106
289
|
},
|
|
107
290
|
})
|
|
108
291
|
|
|
@@ -110,14 +293,112 @@ export const getDocument = query({
|
|
|
110
293
|
args: { collection: v.string(), docId: v.string() },
|
|
111
294
|
handler: async (ctx, args) => {
|
|
112
295
|
await requireCapability(ctx, 'content.read') // EC-150
|
|
113
|
-
const row = await ctx.
|
|
114
|
-
.query('cmsDocuments')
|
|
115
|
-
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
|
|
116
|
-
.first()
|
|
296
|
+
const row = await draftRow(ctx, args.collection, args.docId)
|
|
117
297
|
return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
|
|
118
298
|
},
|
|
119
299
|
})
|
|
120
300
|
|
|
301
|
+
/**
|
|
302
|
+
* The narrow document subscription (EC-595 binding point 7, EC-309's v1 slice): the draft
|
|
303
|
+
* row. The studio's reactive client re-runs it on every change of the row.
|
|
304
|
+
*
|
|
305
|
+
* EC-595 A2 — the row and the presence are TWO reactive queries ({@link watchPresence})
|
|
306
|
+
* behind the one `subscribeDocument` (the adapter merges them). Measured
|
|
307
|
+
* (`presence-cost.test.ts`): as one query, a ~20 KB document with two editors pushed
|
|
308
|
+
* 20,583 B to every subscriber on every presence heartbeat (2 editors × 360 heartbeats/h
|
|
309
|
+
* × 2 subscribers ≈ 29.6 MB/h per document, each push a re-run reading the row). Split,
|
|
310
|
+
* a heartbeat re-runs only the presence query (its read set holds no row, and this
|
|
311
|
+
* query's holds no presence) and pushes a few hundred bytes.
|
|
312
|
+
*/
|
|
313
|
+
export const watchDocument = query({
|
|
314
|
+
args: { collection: v.string(), docId: v.string() },
|
|
315
|
+
handler: async (ctx, args) => {
|
|
316
|
+
await requireCapability(ctx, 'content.read') // EC-150
|
|
317
|
+
const row = await draftRow(ctx, args.collection, args.docId)
|
|
318
|
+
return { row: row ? withUnfoldedDocument(row) : null }
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The presence heartbeats on one document (EC-595 thin presence) — its own reactive
|
|
324
|
+
* query so a heartbeat never re-pushes the document (see {@link watchDocument}).
|
|
325
|
+
* Liveness is the READER's call (its injected clock); this query never reads
|
|
326
|
+
* `Date.now()`.
|
|
327
|
+
*/
|
|
328
|
+
export const watchPresence = query({
|
|
329
|
+
args: { collection: v.string(), docId: v.string() },
|
|
330
|
+
handler: async (ctx, args) => {
|
|
331
|
+
await requireCapability(ctx, 'content.read') // EC-150
|
|
332
|
+
const presence = await ctx.db
|
|
333
|
+
.query('documentPresence')
|
|
334
|
+
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId).eq('variant', DRAFT_VARIANT))
|
|
335
|
+
.collect()
|
|
336
|
+
return presence.map((entry) => ({
|
|
337
|
+
sessionId: entry.sessionId,
|
|
338
|
+
...(entry.actor !== undefined ? { actor: entry.actor } : {}),
|
|
339
|
+
...(entry.actorName !== undefined ? { actorName: entry.actorName } : {}),
|
|
340
|
+
...(entry.path !== undefined ? { path: entry.path } : {}),
|
|
341
|
+
at: entry.at,
|
|
342
|
+
}))
|
|
343
|
+
},
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A field-presence heartbeat (EC-595 Stage A): upsert this session's row and delete the
|
|
348
|
+
* document's STALE heartbeats (older than the TTL relative to this heartbeat's `at`), so
|
|
349
|
+
* presence needs no timer. `content.read` suffices — a viewer is present too.
|
|
350
|
+
*/
|
|
351
|
+
export const reportPresence = mutation({
|
|
352
|
+
args: {
|
|
353
|
+
collection: v.string(),
|
|
354
|
+
docId: v.string(),
|
|
355
|
+
sessionId: v.string(),
|
|
356
|
+
actorName: v.optional(v.string()),
|
|
357
|
+
path: v.optional(v.any()),
|
|
358
|
+
at: v.string(),
|
|
359
|
+
ttlMs: v.number(),
|
|
360
|
+
},
|
|
361
|
+
handler: async (ctx, args) => {
|
|
362
|
+
const actor = await requireCapability(ctx, 'content.read')
|
|
363
|
+
const rows = await ctx.db
|
|
364
|
+
.query('documentPresence')
|
|
365
|
+
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId).eq('variant', DRAFT_VARIANT))
|
|
366
|
+
.collect()
|
|
367
|
+
const now = Date.parse(args.at)
|
|
368
|
+
let own: (typeof rows)[number] | undefined
|
|
369
|
+
for (const row of rows) {
|
|
370
|
+
if (row.sessionId === args.sessionId) own = row
|
|
371
|
+
else if (now - Date.parse(row.at) > args.ttlMs) await ctx.db.delete(row._id)
|
|
372
|
+
}
|
|
373
|
+
const entry = {
|
|
374
|
+
collection: args.collection,
|
|
375
|
+
docId: args.docId,
|
|
376
|
+
variant: DRAFT_VARIANT,
|
|
377
|
+
sessionId: args.sessionId,
|
|
378
|
+
actor,
|
|
379
|
+
...(args.actorName !== undefined ? { actorName: args.actorName } : {}),
|
|
380
|
+
path: args.path,
|
|
381
|
+
at: args.at,
|
|
382
|
+
}
|
|
383
|
+
if (own) await ctx.db.replace(own._id, entry)
|
|
384
|
+
else await ctx.db.insert('documentPresence', entry)
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
export const clearPresence = mutation({
|
|
389
|
+
args: { collection: v.string(), docId: v.string(), sessionId: v.string() },
|
|
390
|
+
handler: async (ctx, args) => {
|
|
391
|
+
await requireCapability(ctx, 'content.read')
|
|
392
|
+
const own = await ctx.db
|
|
393
|
+
.query('documentPresence')
|
|
394
|
+
.withIndex('by_doc_session', (q) =>
|
|
395
|
+
q.eq('collection', args.collection).eq('docId', args.docId).eq('variant', DRAFT_VARIANT).eq('sessionId', args.sessionId),
|
|
396
|
+
)
|
|
397
|
+
.first()
|
|
398
|
+
if (own) await ctx.db.delete(own._id)
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
|
|
121
402
|
export const listDocuments = query({
|
|
122
403
|
args: { collection: v.optional(v.string()) },
|
|
123
404
|
handler: async (ctx, args) => {
|
|
@@ -128,7 +409,15 @@ export const listDocuments = query({
|
|
|
128
409
|
.withIndex('by_collection', (q) => q.eq('collection', args.collection as string))
|
|
129
410
|
.collect()
|
|
130
411
|
: await ctx.db.query('cmsDocuments').collect()
|
|
131
|
-
return rows.map(withUnfoldedDocument) // EC-260
|
|
412
|
+
return rows.filter((row) => row.variant === DRAFT_VARIANT).map(withUnfoldedDocument) // EC-260
|
|
413
|
+
},
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
export const listDocumentPatches = query({
|
|
417
|
+
args: { collection: v.string(), docId: v.string(), afterRevision: v.optional(v.number()) },
|
|
418
|
+
handler: async (ctx, args) => {
|
|
419
|
+
await requireCapability(ctx, 'content.read') // EC-150
|
|
420
|
+
return logAfter(ctx, args.collection, args.docId, args.afterRevision ?? 0)
|
|
132
421
|
},
|
|
133
422
|
})
|
|
134
423
|
|
|
@@ -136,34 +425,25 @@ export const deleteDocument = mutation({
|
|
|
136
425
|
args: { collection: v.string(), docId: v.string() },
|
|
137
426
|
handler: async (ctx, args) => {
|
|
138
427
|
await requireCapability(ctx, 'content.write') // EC-150
|
|
139
|
-
const existing = await ctx.
|
|
140
|
-
.query('cmsDocuments')
|
|
141
|
-
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
|
|
142
|
-
.first()
|
|
428
|
+
const existing = await draftRow(ctx, args.collection, args.docId)
|
|
143
429
|
if (existing) await ctx.db.delete(existing._id)
|
|
144
|
-
// Cascade the version history (EC-224)
|
|
430
|
+
// Cascade the version history (EC-224), the patch log and presence (EC-595).
|
|
145
431
|
const versions = await ctx.db
|
|
146
432
|
.query('documentVersions')
|
|
147
433
|
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
|
|
148
434
|
.collect()
|
|
149
435
|
for (const row of versions) await ctx.db.delete(row._id)
|
|
436
|
+
await deleteBookkeeping(ctx, args.collection, args.docId)
|
|
150
437
|
// EC-286: the write-chokepoint trigger drops this document's index rows (and
|
|
151
438
|
// revalidates if it was published) when the `cmsDocuments` delete commits.
|
|
152
439
|
},
|
|
153
440
|
})
|
|
154
441
|
|
|
155
442
|
/**
|
|
156
|
-
* EC-576: drop every document
|
|
157
|
-
* explicit drop a seeder runs before writing a known world. One
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* write-chokepoint trigger, which drops the reference-index rows and schedules the
|
|
161
|
-
* host sweep for the published ones — a seeder re-publishes afterwards anyway.
|
|
162
|
-
*
|
|
163
|
-
* Convex bounds one transaction at 8192 written documents (its documented limit);
|
|
164
|
-
* a store past that size fails loudly here rather than clearing half. That is far
|
|
165
|
-
* above any v1 deployment (the largest measured, Konditorei, holds 331 rows across
|
|
166
|
-
* both tables), so it is documented, not paginated.
|
|
443
|
+
* EC-576: drop every document, version snapshot, patch-log row and presence heartbeat of
|
|
444
|
+
* the deployment — the explicit drop a seeder runs before writing a known world. One
|
|
445
|
+
* transaction (Convex bounds it at 8192 written documents — far above any v1
|
|
446
|
+
* deployment; documented, not paginated). Requires `project.administer`.
|
|
167
447
|
*/
|
|
168
448
|
export const clearDocuments = mutation({
|
|
169
449
|
args: {},
|
|
@@ -171,14 +451,49 @@ export const clearDocuments = mutation({
|
|
|
171
451
|
await requireCapability(ctx, 'project.administer')
|
|
172
452
|
for (const row of await ctx.db.query('cmsDocuments').collect()) await ctx.db.delete(row._id)
|
|
173
453
|
for (const row of await ctx.db.query('documentVersions').collect()) await ctx.db.delete(row._id)
|
|
454
|
+
for (const row of await ctx.db.query('documentPatches').collect()) await ctx.db.delete(row._id)
|
|
455
|
+
for (const row of await ctx.db.query('documentPresence').collect()) await ctx.db.delete(row._id)
|
|
174
456
|
},
|
|
175
457
|
})
|
|
176
458
|
|
|
177
459
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* `
|
|
181
|
-
|
|
460
|
+
* Insert the next monotonic version row of a document (folded); returns it. `kind` is the
|
|
461
|
+
* mutation that records it (EC-585) — decided HERE, never taken from a client argument, so
|
|
462
|
+
* a `content.write` caller cannot mint a pinnable row through `saveDocumentVersion`.
|
|
463
|
+
*/
|
|
464
|
+
async function insertVersion(
|
|
465
|
+
ctx: MutationCtx,
|
|
466
|
+
args: { collection: string; docId: string; snapshot: unknown; label?: string; origin?: string; createdAt: string },
|
|
467
|
+
kind: DocumentVersionKind,
|
|
468
|
+
) {
|
|
469
|
+
// EC-302: versions are appended monotonically, so the latest-created row holds the
|
|
470
|
+
// highest version — read just that one instead of the whole history.
|
|
471
|
+
const latest = await ctx.db
|
|
472
|
+
.query('documentVersions')
|
|
473
|
+
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
|
|
474
|
+
.order('desc')
|
|
475
|
+
.first()
|
|
476
|
+
const version = (latest?.version ?? 0) + 1
|
|
477
|
+
const id = await ctx.db.insert('documentVersions', {
|
|
478
|
+
collection: args.collection,
|
|
479
|
+
docId: args.docId,
|
|
480
|
+
version,
|
|
481
|
+
// EC-260: the snapshot folds at rest like a draft. No trigger on this table.
|
|
482
|
+
snapshot: foldDocument(args.snapshot as CmsDocument),
|
|
483
|
+
...(args.label ? { label: args.label } : {}),
|
|
484
|
+
...(args.origin ? { origin: args.origin } : {}),
|
|
485
|
+
kind,
|
|
486
|
+
createdAt: args.createdAt,
|
|
487
|
+
})
|
|
488
|
+
return (await ctx.db.get(id))!
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Document version history (EC-224): append-only snapshots in `documentVersions`; the
|
|
493
|
+
* live one is pinned by `cmsDocuments.publishedVersion`. Version numbers are 1-based,
|
|
494
|
+
* monotonic per document. Used by the pre-restore snapshot and `documents.snapshot`;
|
|
495
|
+
* publish records its version inside `publishDocument` (one step). A row written here is
|
|
496
|
+
* a `'snapshot'` (EC-585): a restore point `publishDocumentVersion` refuses to pin.
|
|
182
497
|
*/
|
|
183
498
|
export const saveDocumentVersion = mutation({
|
|
184
499
|
args: {
|
|
@@ -191,23 +506,80 @@ export const saveDocumentVersion = mutation({
|
|
|
191
506
|
},
|
|
192
507
|
handler: async (ctx, args) => {
|
|
193
508
|
await requireCapability(ctx, 'content.write') // EC-150
|
|
194
|
-
// EC-304 B-4
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
509
|
+
assertWithinWriteLimits(args.snapshot, 'snapshot') // EC-304 B-4
|
|
510
|
+
const row = await insertVersion(ctx, args, 'snapshot')
|
|
511
|
+
return withUnfoldedSnapshot(row)
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Publish ONE atomic step (EC-595 binding point 6, Codex P05): if the draft is still at
|
|
517
|
+
* `expectedRevision`, record it as the next version, pin it, derive the changed flag,
|
|
518
|
+
* bump the revision and log the publish — all in this transaction; otherwise refuse with
|
|
519
|
+
* `conflict` and the current row. The `cmsDocuments` trigger sees the pin move and
|
|
520
|
+
* reindexes + revalidates (EC-286 / EC-146) — the version row it reads exists already.
|
|
521
|
+
*/
|
|
522
|
+
export const publishDocument = mutation({
|
|
523
|
+
args: {
|
|
524
|
+
collection: v.string(),
|
|
525
|
+
docId: v.string(),
|
|
526
|
+
expectedRevision: v.number(),
|
|
527
|
+
label: v.optional(v.string()),
|
|
528
|
+
origin: v.optional(v.string()),
|
|
529
|
+
at: v.string(),
|
|
530
|
+
},
|
|
531
|
+
handler: async (ctx, args) => {
|
|
532
|
+
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
533
|
+
const existing = await draftRow(ctx, args.collection, args.docId)
|
|
534
|
+
if (!existing) return { status: 'missing' as const }
|
|
535
|
+
if (existing.revision !== args.expectedRevision) {
|
|
536
|
+
return { status: 'conflict' as const, current: withUnfoldedDocument(existing) }
|
|
537
|
+
}
|
|
538
|
+
const draft = unfoldDocument(existing.document as CmsDocument)
|
|
539
|
+
const version = await insertVersion(ctx, {
|
|
540
|
+
collection: args.collection,
|
|
541
|
+
docId: args.docId,
|
|
542
|
+
snapshot: draft,
|
|
543
|
+
label: args.label ?? 'published',
|
|
544
|
+
...(args.origin ? { origin: args.origin } : {}),
|
|
545
|
+
createdAt: args.at,
|
|
546
|
+
}, 'publish')
|
|
547
|
+
const revision = existing.revision + 1
|
|
548
|
+
// EC-595 retention: compact the log behind the PREVIOUS publish (one cycle kept).
|
|
549
|
+
const previousPublish = (await logAfter(ctx, args.collection, args.docId, 0))
|
|
550
|
+
.reverse()
|
|
551
|
+
.find((row) => row.kind === 'publish')
|
|
552
|
+
let patchLogFloor = existing.patchLogFloor
|
|
553
|
+
if (previousPublish) {
|
|
554
|
+
const compacted = await ctx.db
|
|
555
|
+
.query('documentPatches')
|
|
556
|
+
.withIndex('by_doc_revision', (q) =>
|
|
557
|
+
q
|
|
558
|
+
.eq('collection', args.collection)
|
|
559
|
+
.eq('docId', args.docId)
|
|
560
|
+
.eq('variant', DRAFT_VARIANT)
|
|
561
|
+
.lte('revision', previousPublish.revision),
|
|
562
|
+
)
|
|
563
|
+
.collect()
|
|
564
|
+
for (const row of compacted) await ctx.db.delete(row._id)
|
|
565
|
+
patchLogFloor = Math.max(patchLogFloor ?? 0, previousPublish.revision)
|
|
566
|
+
}
|
|
567
|
+
const patch = {
|
|
568
|
+
revision,
|
|
569
|
+
publishedVersion: version.version,
|
|
570
|
+
// EC-447: SET-ONCE first-publish instant (server-side, in-transaction).
|
|
571
|
+
...(existing.publishedAt === undefined ? { publishedAt: args.at } : {}),
|
|
572
|
+
...(await changedFlag(ctx, args.collection, args.docId, version.version, draft, existing.hasUnpublishedChanges)),
|
|
573
|
+
...(patchLogFloor !== undefined ? { patchLogFloor } : {}),
|
|
574
|
+
updatedAt: args.at,
|
|
575
|
+
}
|
|
576
|
+
await ctx.db.patch(existing._id, patch)
|
|
577
|
+
await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'publish', actor, at: args.at })
|
|
578
|
+
return {
|
|
579
|
+
status: 'ok' as const,
|
|
580
|
+
row: withUnfoldedDocument({ ...existing, ...patch }),
|
|
581
|
+
version: withUnfoldedSnapshot(version),
|
|
582
|
+
}
|
|
211
583
|
},
|
|
212
584
|
})
|
|
213
585
|
|
|
@@ -228,25 +600,14 @@ export const getDocumentVersion = query({
|
|
|
228
600
|
args: { collection: v.string(), docId: v.string(), version: v.number() },
|
|
229
601
|
handler: async (ctx, args) => {
|
|
230
602
|
await requireCapability(ctx, 'content.read') // EC-150
|
|
231
|
-
|
|
232
|
-
// the whole — unbounded — per-document history and finding in JS. `version` is
|
|
233
|
-
// unique per (collection, docId), so `.unique()` both fetches and asserts that.
|
|
234
|
-
const row = await ctx.db
|
|
235
|
-
.query('documentVersions')
|
|
236
|
-
.withIndex('by_doc_version', (q) =>
|
|
237
|
-
q.eq('collection', args.collection).eq('docId', args.docId).eq('version', args.version),
|
|
238
|
-
)
|
|
239
|
-
.unique()
|
|
603
|
+
const row = await versionRow(ctx, args.collection, args.docId, args.version)
|
|
240
604
|
return row ? withUnfoldedSnapshot(row) : row // EC-260: unfold before returning
|
|
241
605
|
},
|
|
242
606
|
})
|
|
243
607
|
|
|
244
608
|
/**
|
|
245
|
-
* The pinned published snapshot of every currently-published document (EC-307) —
|
|
246
|
-
*
|
|
247
|
-
* published pool used to issue (`published-documents.ts`). Reads each published
|
|
248
|
-
* document's pinned version row directly via `by_doc_version`. Returns the
|
|
249
|
-
* `CmsDocument` snapshots (the live perspective); unpublished documents are absent.
|
|
609
|
+
* The pinned published snapshot of every currently-published document (EC-307) — one
|
|
610
|
+
* bulk read. Returns the `CmsDocument` snapshots; unpublished documents are absent.
|
|
250
611
|
*/
|
|
251
612
|
export const listPublishedDocuments = query({
|
|
252
613
|
args: { collection: v.optional(v.string()) },
|
|
@@ -262,13 +623,8 @@ export const listPublishedDocuments = query({
|
|
|
262
623
|
const snapshots: unknown[] = []
|
|
263
624
|
for (const doc of docs) {
|
|
264
625
|
const version = doc.publishedVersion
|
|
265
|
-
if (version === undefined) continue
|
|
266
|
-
const pinned = await ctx.
|
|
267
|
-
.query('documentVersions')
|
|
268
|
-
.withIndex('by_doc_version', (q) =>
|
|
269
|
-
q.eq('collection', doc.collection).eq('docId', doc.docId).eq('version', version),
|
|
270
|
-
)
|
|
271
|
-
.unique()
|
|
626
|
+
if (doc.variant !== DRAFT_VARIANT || version === undefined) continue
|
|
627
|
+
const pinned = await versionRow(ctx, doc.collection, doc.docId, version)
|
|
272
628
|
// EC-260: unfold the pinned snapshot before it leaves the function.
|
|
273
629
|
if (pinned) snapshots.push(unfoldDocument(pinned.snapshot as CmsDocument))
|
|
274
630
|
}
|
|
@@ -286,78 +642,82 @@ export const listAllDocumentVersions = query({
|
|
|
286
642
|
},
|
|
287
643
|
})
|
|
288
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
|
|
647
|
+
* enforced here, in the transaction: the version must exist for THIS document
|
|
648
|
+
* (`missing-version` otherwise) and must have been recorded by `publishDocument`
|
|
649
|
+
* (`not-pinnable` otherwise — EC-585: a `saveDocumentVersion` row was never validated, so
|
|
650
|
+
* an editor calling this mutation directly cannot put one live; Codex P03). The changed
|
|
651
|
+
* flag is DERIVED against the pinned snapshot (a pin of an older version against a newer
|
|
652
|
+
* draft reads `true` — Codex P05's sibling), the revision bumps, a `pin` row is logged.
|
|
653
|
+
*
|
|
654
|
+
* Server-side trust (EC-565 1e, owner-ratified): the checks here are DATA-ONLY — revision,
|
|
655
|
+
* pin integrity, authority. Schema validation stays the client gate (`documents.publish`
|
|
656
|
+
* runs it, with the hidden-clear, before `publishDocument` records the row this pins).
|
|
657
|
+
*/
|
|
289
658
|
export const publishDocumentVersion = mutation({
|
|
290
659
|
args: {
|
|
291
660
|
collection: v.string(),
|
|
292
661
|
docId: v.string(),
|
|
293
662
|
version: v.number(),
|
|
294
663
|
updatedAt: v.string(),
|
|
295
|
-
// EC-447: the candidate first-publish instant
|
|
296
|
-
// generated client-side like `updatedAt`). The handler writes it SET-ONCE below.
|
|
664
|
+
// EC-447: the candidate first-publish instant, written SET-ONCE below.
|
|
297
665
|
publishedAt: v.string(),
|
|
298
666
|
},
|
|
299
667
|
handler: async (ctx, args) => {
|
|
300
|
-
await requireCapability(ctx, 'content.write') // EC-150
|
|
301
|
-
const doc = await ctx.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (!
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// EC-447: materialize `publishedAt` SET-ONCE — only when the row has none yet, so
|
|
310
|
-
// the FIRST-publish instant is captured and PRESERVED across republishes (the
|
|
311
|
-
// guard is server-side + in-transaction, so it can't race).
|
|
312
|
-
// EC-449: the pinned version IS the published baseline now — publish always
|
|
313
|
-
// resolves `hasUnpublishedChanges` to `false`.
|
|
314
|
-
await ctx.db.patch(doc._id, {
|
|
668
|
+
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
669
|
+
const doc = await draftRow(ctx, args.collection, args.docId)
|
|
670
|
+
if (!doc) return { status: 'missing' as const }
|
|
671
|
+
const target = await versionRow(ctx, args.collection, args.docId, args.version)
|
|
672
|
+
if (!target) return { status: 'missing-version' as const }
|
|
673
|
+
if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
|
|
674
|
+
const revision = doc.revision + 1
|
|
675
|
+
const patch = {
|
|
676
|
+
revision,
|
|
315
677
|
publishedVersion: args.version,
|
|
316
678
|
updatedAt: args.updatedAt,
|
|
317
679
|
...(doc.publishedAt === undefined ? { publishedAt: args.publishedAt } : {}),
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
680
|
+
...(await changedFlag(
|
|
681
|
+
ctx,
|
|
682
|
+
args.collection,
|
|
683
|
+
args.docId,
|
|
684
|
+
args.version,
|
|
685
|
+
unfoldDocument(doc.document as CmsDocument),
|
|
686
|
+
doc.hasUnpublishedChanges,
|
|
687
|
+
)),
|
|
688
|
+
}
|
|
689
|
+
await ctx.db.patch(doc._id, patch)
|
|
690
|
+
await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'pin', actor, at: args.updatedAt })
|
|
691
|
+
return { status: 'ok' as const, row: withUnfoldedDocument({ ...doc, ...patch }) }
|
|
322
692
|
},
|
|
323
693
|
})
|
|
324
694
|
|
|
325
695
|
export const unpublishDocument = mutation({
|
|
326
696
|
args: { collection: v.string(), docId: v.string(), updatedAt: v.string() },
|
|
327
697
|
handler: async (ctx, args) => {
|
|
328
|
-
await requireCapability(ctx, 'content.write') // EC-150
|
|
329
|
-
const doc = await ctx.
|
|
330
|
-
.query('cmsDocuments')
|
|
331
|
-
.withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
|
|
332
|
-
.first()
|
|
698
|
+
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
699
|
+
const doc = await draftRow(ctx, args.collection, args.docId)
|
|
333
700
|
if (!doc) return null
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
// drops the published index rows and revalidates
|
|
337
|
-
//
|
|
338
|
-
// first-publish stamp); `createdAt` is untouched.
|
|
339
|
-
// EC-449: `hasUnpublishedChanges` is cleared too — with no published baseline
|
|
340
|
-
// there is nothing to differ from (the dot reads `draft`/muted anyway).
|
|
701
|
+
const revision = doc.revision + 1
|
|
702
|
+
// Convex patch removes a field set to `undefined` — clears the live pin. EC-286: the
|
|
703
|
+
// trigger drops the published index rows and revalidates. EC-447/EC-449: the
|
|
704
|
+
// first-publish instant and the changed flag clear with it.
|
|
341
705
|
await ctx.db.patch(doc._id, {
|
|
706
|
+
revision,
|
|
342
707
|
publishedVersion: undefined,
|
|
343
708
|
publishedAt: undefined,
|
|
344
709
|
hasUnpublishedChanges: undefined,
|
|
345
710
|
updatedAt: args.updatedAt,
|
|
346
711
|
})
|
|
712
|
+
await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'unpublish', actor, at: args.updatedAt })
|
|
347
713
|
const row = await ctx.db.get(doc._id)
|
|
348
714
|
return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
|
|
349
715
|
},
|
|
350
716
|
})
|
|
351
717
|
|
|
352
718
|
/**
|
|
353
|
-
* One-time data migrations (EC-450 principle): a migration mutation ships
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* consumer never needs it — nothing pre-dates the change it backfills). Pattern:
|
|
358
|
-
* an `internalMutation` here delegating its per-row decision to a pure, unit-tested
|
|
359
|
-
* core in `@elytracms/core/persistence`; the operator runs it once per deployment
|
|
360
|
-
* via `npx convex run cms:<name>` after `convex deploy`, confirms all deployments,
|
|
361
|
-
* then deletes both the mutation and its core in one removal pass (see EC-447/449
|
|
362
|
-
* → EC-450 for the worked example).
|
|
719
|
+
* One-time data migrations (EC-450 principle): a migration mutation ships transiently —
|
|
720
|
+
* added when a schema/behavior change needs to backfill existing deployments, then
|
|
721
|
+
* REMOVED once every deployment has confirmed running it. EC-595 needs none: its schema
|
|
722
|
+
* change is a drop-and-reseed (the owner's ruling for pre-v1 data, EC-576).
|
|
363
723
|
*/
|