@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
|
@@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { convexTest } from 'convex-test'
|
|
3
3
|
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
4
|
import type { FunctionReference } from 'convex/server'
|
|
5
|
-
import { measureMaxDepth } from '@elytracms/core/cms-core'
|
|
6
|
-
import {
|
|
5
|
+
import { fieldPath, measureMaxDepth, nodePropPath } from '@elytracms/core/cms-core'
|
|
6
|
+
import {
|
|
7
|
+
PERSISTENCE_CONTRACT_SCENARIOS,
|
|
8
|
+
publishCurrentDraft,
|
|
9
|
+
replaceAtCurrentRevision,
|
|
10
|
+
} from '@elytracms/core/persistence'
|
|
7
11
|
import type { ContractContext } from '@elytracms/core/persistence'
|
|
8
12
|
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
13
|
+
import type { ReactiveDocumentClient } from '../src/lib/persistence/convex-client'
|
|
9
14
|
import schema from './schema'
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -49,17 +54,55 @@ interface AdapterBackend {
|
|
|
49
54
|
mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
/**
|
|
58
|
+
* EC-595 — the reactive-client DOUBLE a document subscription runs on: `onUpdate` runs
|
|
59
|
+
* the query now and RE-RUNS every active subscription after each mutation through the
|
|
60
|
+
* client double (convex-test has no push transport; this models Convex's reactive
|
|
61
|
+
* re-execution deterministically). The live two-tab push is owner-run.
|
|
62
|
+
*/
|
|
63
|
+
function reactiveDouble(backend: AdapterBackend) {
|
|
64
|
+
interface Subscription {
|
|
65
|
+
ref: FunctionReference<'query'>
|
|
66
|
+
args: Record<string, unknown>
|
|
67
|
+
onResult: (value: unknown) => void
|
|
68
|
+
onError: (error: Error) => void
|
|
69
|
+
}
|
|
70
|
+
const active = new Set<Subscription>()
|
|
71
|
+
const run = async (subscription: Subscription) => {
|
|
72
|
+
try {
|
|
73
|
+
subscription.onResult(await backend.query(subscription.ref, subscription.args))
|
|
74
|
+
} catch (error) {
|
|
75
|
+
subscription.onError(error as Error)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const reactive: ReactiveDocumentClient = {
|
|
79
|
+
onUpdate: (ref, args, onResult, onError) => {
|
|
80
|
+
const subscription = { ref, args, onResult, onError }
|
|
81
|
+
active.add(subscription)
|
|
82
|
+
void run(subscription)
|
|
83
|
+
return () => {
|
|
84
|
+
active.delete(subscription)
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
onAuthLost: () => () => {},
|
|
88
|
+
}
|
|
89
|
+
return { reactive, rerun: async () => Promise.all([...active].map(run)) }
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Adapt a convex-test accessor onto the `ConvexHttpClient` call surface the
|
|
54
94
|
* adapter uses (`client.query(ref, args)` / `client.mutation(ref, args)` with
|
|
55
95
|
* `makeFunctionReference` refs — which convex-test resolves natively).
|
|
56
96
|
*/
|
|
57
|
-
function asAdapterClient(backend: AdapterBackend): ConvexHttpClient {
|
|
97
|
+
function asAdapterClient(backend: AdapterBackend, afterMutation?: () => Promise<unknown>): ConvexHttpClient {
|
|
58
98
|
return {
|
|
59
99
|
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) =>
|
|
60
100
|
backend.query(ref, args ?? {}),
|
|
61
|
-
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
|
|
62
|
-
backend.mutation(ref, args ?? {})
|
|
101
|
+
mutation: async (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
102
|
+
const result = await backend.mutation(ref, args ?? {})
|
|
103
|
+
await afterMutation?.()
|
|
104
|
+
return result
|
|
105
|
+
},
|
|
63
106
|
} as unknown as ConvexHttpClient
|
|
64
107
|
}
|
|
65
108
|
|
|
@@ -90,15 +133,15 @@ async function freshBackend() {
|
|
|
90
133
|
async function convexHarness(): Promise<ContractContext> {
|
|
91
134
|
const t = await freshBackend()
|
|
92
135
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
93
|
-
const
|
|
136
|
+
const push = reactiveDouble(asAdmin)
|
|
137
|
+
const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin, push.rerun), PROJECT_ID, {
|
|
138
|
+
reactive: () => push.reactive,
|
|
139
|
+
})
|
|
94
140
|
return {
|
|
95
141
|
adapter,
|
|
96
142
|
projectId: PROJECT_ID,
|
|
97
143
|
emptyProjectId: EMPTY_PROJECT_ID,
|
|
98
144
|
sessionUserId: SESSION_USER_ID,
|
|
99
|
-
// EC-183 (AD-11): the Convex adapter's CMS schema is config-owned (injected
|
|
100
|
-
// at construction, no `cmsSchemas` table) — seeding it is a no-op here.
|
|
101
|
-
async seedSchema() {},
|
|
102
145
|
}
|
|
103
146
|
}
|
|
104
147
|
|
|
@@ -140,7 +183,7 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
|
|
|
140
183
|
|
|
141
184
|
// A draft write derives draft rows in the same transaction — no published
|
|
142
185
|
// rows until a live version is pinned.
|
|
143
|
-
await adapter.cms
|
|
186
|
+
await replaceAtCurrentRevision(adapter.cms, projectId, doc)
|
|
144
187
|
const draft = await adapter.references.outbound(projectId, source, { variant: 'draft' })
|
|
145
188
|
expect(draft.map((r) => `${r.target.type}:${r.target.id}:${r.kind}`).sort()).toEqual([
|
|
146
189
|
'asset:asset_cover:asset-field',
|
|
@@ -151,14 +194,13 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
|
|
|
151
194
|
)
|
|
152
195
|
|
|
153
196
|
// Pinning a version re-runs maintenance: published rows mirror the draft rows.
|
|
154
|
-
|
|
155
|
-
await adapter.cms.publishDocumentVersion(projectId, ref, v.version)
|
|
197
|
+
await publishCurrentDraft(adapter.cms, projectId, ref)
|
|
156
198
|
expect(
|
|
157
199
|
(await adapter.references.outbound(projectId, source, { variant: 'published' })).length,
|
|
158
200
|
).toBe(2)
|
|
159
201
|
|
|
160
202
|
// A retargeting save REPLACES the rows (replace-all-for-source, never appends).
|
|
161
|
-
await adapter.cms
|
|
203
|
+
await replaceAtCurrentRevision(adapter.cms, projectId, {
|
|
162
204
|
...doc,
|
|
163
205
|
values: { author: { type: 'reference', collection: 'author', id: 'grace' } },
|
|
164
206
|
})
|
|
@@ -169,6 +211,35 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
|
|
|
169
211
|
await adapter.cms.deleteDocument(projectId, ref)
|
|
170
212
|
expect(await adapter.references.outbound(projectId, source)).toEqual([])
|
|
171
213
|
})
|
|
214
|
+
|
|
215
|
+
it('EC-589 (Codex P01): the published rows are the PINNED SNAPSHOT’s — a draft-only edit does not move them', async () => {
|
|
216
|
+
const { adapter, projectId } = await convexHarness()
|
|
217
|
+
const doc = {
|
|
218
|
+
collection: 'post',
|
|
219
|
+
id: 'p01-probe',
|
|
220
|
+
values: { author: { type: 'reference', collection: 'author', id: 'A' } },
|
|
221
|
+
}
|
|
222
|
+
const ref = { collection: doc.collection, id: doc.id }
|
|
223
|
+
const source = { type: 'document' as const, id: 'post:p01-probe' }
|
|
224
|
+
const targets = async (variant: 'draft' | 'published') =>
|
|
225
|
+
(await adapter.references.outbound(projectId, source, { variant })).map((r) => r.target.id)
|
|
226
|
+
|
|
227
|
+
const created = await replaceAtCurrentRevision(adapter.cms, projectId, doc)
|
|
228
|
+
// The one-step publish (EC-595): version row + pin in one transaction; the trigger
|
|
229
|
+
// reads that row by `by_doc_version`.
|
|
230
|
+
const published = await adapter.cms.publishDocument(projectId, ref, created.revision)
|
|
231
|
+
expect(published.status).toBe('ok')
|
|
232
|
+
expect(await targets('published')).toEqual(['author:A']) // control: the publish is indexed
|
|
233
|
+
|
|
234
|
+
// A draft-only save to B: the draft rows move, the published rows stay on A — what
|
|
235
|
+
// delivery serves. (Before EC-589 both variants were extracted from the draft.)
|
|
236
|
+
await replaceAtCurrentRevision(adapter.cms, projectId, {
|
|
237
|
+
...doc,
|
|
238
|
+
values: { author: { type: 'reference', collection: 'author', id: 'B' } },
|
|
239
|
+
})
|
|
240
|
+
expect(await targets('draft')).toEqual(['author:B'])
|
|
241
|
+
expect(await targets('published')).toEqual(['author:A'])
|
|
242
|
+
})
|
|
172
243
|
})
|
|
173
244
|
|
|
174
245
|
describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth cap)', () => {
|
|
@@ -179,25 +250,37 @@ describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth c
|
|
|
179
250
|
it('stores an over-budget document folded to within 16 levels; the wire stays structured', async () => {
|
|
180
251
|
const t = await freshBackend()
|
|
181
252
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
182
|
-
const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID
|
|
253
|
+
const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID)
|
|
183
254
|
|
|
184
255
|
let deep: unknown = { type: 'leaf', text: 'bottom' }
|
|
185
256
|
for (let i = 0; i < 24; i++) deep = { type: 'level', index: i, child: deep }
|
|
186
257
|
const doc = { collection: 'post', id: 'ec260-at-rest', values: { title: 'Deep', body: deep } }
|
|
187
258
|
expect(measureMaxDepth(doc).depth).toBeGreaterThan(16) // the premise: over-budget on the wire
|
|
188
259
|
|
|
189
|
-
const stored = await adapter.cms
|
|
260
|
+
const stored = await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, doc)
|
|
190
261
|
expect(stored.document.values).toEqual(doc.values) // the wire never carries an envelope
|
|
191
262
|
|
|
192
263
|
const atRest = await t.run(async (ctx) => {
|
|
193
264
|
const row = await ctx.db
|
|
194
265
|
.query('cmsDocuments')
|
|
195
|
-
.withIndex('by_doc', (q) => q.eq('collection', doc.collection).eq('docId', doc.id))
|
|
266
|
+
.withIndex('by_doc', (q) => q.eq('collection', doc.collection).eq('docId', doc.id).eq('variant', 'draft'))
|
|
196
267
|
.unique()
|
|
197
268
|
return row?.document as unknown
|
|
198
269
|
})
|
|
199
270
|
expect(atRest).toBeDefined()
|
|
200
271
|
expect(measureMaxDepth(atRest).depth).toBeLessThanOrEqual(16) // folded at rest
|
|
272
|
+
|
|
273
|
+
// EC-585: the publish step's version row now carries `kind` — a top-level scalar
|
|
274
|
+
// beside the folded snapshot, so the WHOLE stored row stays within the cap.
|
|
275
|
+
await publishCurrentDraft(adapter.cms, PROJECT_ID, { collection: doc.collection, id: doc.id })
|
|
276
|
+
const versionRow = await t.run(async (ctx) =>
|
|
277
|
+
ctx.db
|
|
278
|
+
.query('documentVersions')
|
|
279
|
+
.withIndex('by_doc_version', (q) => q.eq('collection', doc.collection).eq('docId', doc.id).eq('version', 1))
|
|
280
|
+
.unique(),
|
|
281
|
+
)
|
|
282
|
+
expect(versionRow?.kind).toBe('publish')
|
|
283
|
+
expect(measureMaxDepth(versionRow).depth).toBeLessThanOrEqual(16)
|
|
201
284
|
})
|
|
202
285
|
})
|
|
203
286
|
|
|
@@ -217,14 +300,20 @@ describe('guard capability gates through the real functions (EC-150/EC-240c, Con
|
|
|
217
300
|
new ConvexPersistenceAdapter(
|
|
218
301
|
asAdapterClient(t.withIdentity({ subject, issuer: 'https://convex.test' })),
|
|
219
302
|
PROJECT_ID,
|
|
220
|
-
[],
|
|
221
303
|
)
|
|
222
304
|
|
|
223
305
|
const viewer = forSubject('user_viewer')
|
|
224
306
|
expect(await viewer.cms.listDocuments(PROJECT_ID)).toEqual([]) // content.read granted
|
|
225
307
|
await expect(
|
|
226
|
-
viewer.cms.
|
|
308
|
+
viewer.cms.replaceDocument(PROJECT_ID, { collection: 'post', id: 'x', values: {} }, { ifAbsent: true }),
|
|
227
309
|
).rejects.toThrow(/unauthorized/) // content.write denied
|
|
310
|
+
await expect(
|
|
311
|
+
viewer.cms.patchDocument(PROJECT_ID, { collection: 'post', id: 'x' }, [{ op: 'unset', path: fieldPath('t') }], 1),
|
|
312
|
+
).rejects.toThrow(/unauthorized/) // the patch verb is content.write too
|
|
313
|
+
// …but presence is content.read: a viewer is present on a document too.
|
|
314
|
+
await expect(
|
|
315
|
+
viewer.cms.reportPresence(PROJECT_ID, { collection: 'post', id: 'x' }, { sessionId: 'v' }),
|
|
316
|
+
).resolves.toBeUndefined()
|
|
228
317
|
await expect(viewer.users.listUsers(PROJECT_ID)).rejects.toThrow(/unauthorized/) // project.administer denied
|
|
229
318
|
|
|
230
319
|
// An authenticated NON-member is rejected even on reads …
|
|
@@ -232,7 +321,72 @@ describe('guard capability gates through the real functions (EC-150/EC-240c, Con
|
|
|
232
321
|
/unauthorized/,
|
|
233
322
|
)
|
|
234
323
|
// … and so is an anonymous caller.
|
|
235
|
-
const anonymous = new ConvexPersistenceAdapter(asAdapterClient(t), PROJECT_ID
|
|
324
|
+
const anonymous = new ConvexPersistenceAdapter(asAdapterClient(t), PROJECT_ID)
|
|
236
325
|
await expect(anonymous.cms.listDocuments(PROJECT_ID)).rejects.toThrow(/unauthorized/)
|
|
237
326
|
})
|
|
238
327
|
})
|
|
328
|
+
|
|
329
|
+
describe('EC-595 — the aggregate at the Convex store (Convex-only)', () => {
|
|
330
|
+
it('(c) the variant key and the revision are REQUIRED: a pre-EC-595 row is refused by the schema', async () => {
|
|
331
|
+
const t = await freshBackend()
|
|
332
|
+
await expect(
|
|
333
|
+
t.run(async (ctx) => {
|
|
334
|
+
await ctx.db.insert('cmsDocuments', {
|
|
335
|
+
collection: 'post',
|
|
336
|
+
docId: 'legacy',
|
|
337
|
+
document: { collection: 'post', id: 'legacy', values: {} },
|
|
338
|
+
createdAt: 'x',
|
|
339
|
+
updatedAt: 'x',
|
|
340
|
+
} as never)
|
|
341
|
+
}),
|
|
342
|
+
).rejects.toThrow()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('(d) hot-row OCC: two patches in quick succession both commit, serialised — revision +2, two log rows', async () => {
|
|
346
|
+
const { adapter, projectId } = await convexHarness()
|
|
347
|
+
const ref = { collection: 'post', id: 'hot' }
|
|
348
|
+
await adapter.cms.replaceDocument(projectId, { ...ref, values: { a: 0, b: 0 } }, { ifAbsent: true })
|
|
349
|
+
const [first, second] = await Promise.all([
|
|
350
|
+
adapter.cms.patchDocument(projectId, ref, [{ op: 'set', path: fieldPath('a'), value: 1 }], 1),
|
|
351
|
+
adapter.cms.patchDocument(projectId, ref, [{ op: 'set', path: fieldPath('b'), value: 1 }], 1),
|
|
352
|
+
])
|
|
353
|
+
expect(first.status).toBe('ok')
|
|
354
|
+
expect(second.status).toBe('ok')
|
|
355
|
+
const record = await adapter.cms.getDocument(projectId, ref)
|
|
356
|
+
expect(record.revision).toBe(3)
|
|
357
|
+
expect(record.document.values).toEqual({ a: 1, b: 1 })
|
|
358
|
+
const log = await adapter.cms.listDocumentPatches(projectId, ref, 1)
|
|
359
|
+
expect(log.map((row) => row.kind)).toEqual(['patch', 'patch'])
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('critic 7: a patch-log row carrying a 24-level value is stored within 16 levels (folded like a document)', async () => {
|
|
363
|
+
const t = await freshBackend()
|
|
364
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
365
|
+
const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID)
|
|
366
|
+
let deep: unknown = { type: 'leaf', text: 'bottom' }
|
|
367
|
+
for (let i = 0; i < 24; i++) deep = { type: 'level', index: i, child: deep }
|
|
368
|
+
const ref = { collection: 'page', id: 'deep-log' }
|
|
369
|
+
const body = {
|
|
370
|
+
version: 2,
|
|
371
|
+
content: [{ type: 'elytra/component', id: 'n1', componentId: 'Text', props: { text: { value: 'x' } } }],
|
|
372
|
+
}
|
|
373
|
+
await adapter.cms.replaceDocument(PROJECT_ID, { ...ref, values: { body } }, { ifAbsent: true })
|
|
374
|
+
const patched = await adapter.cms.patchDocument(
|
|
375
|
+
PROJECT_ID,
|
|
376
|
+
ref,
|
|
377
|
+
[{ op: 'set', path: nodePropPath('body', undefined, 'n1', 'data'), value: deep }],
|
|
378
|
+
1,
|
|
379
|
+
)
|
|
380
|
+
expect(patched.status).toBe('ok')
|
|
381
|
+
const rows = await t.run(async (ctx) => ctx.db.query('documentPatches').collect())
|
|
382
|
+
const patchRow = rows.find((row) => row.kind === 'patch')
|
|
383
|
+
expect(patchRow).toBeDefined()
|
|
384
|
+
// The premise: unfolded, the row would nest row → patches → patch → value (24 levels) far past 16.
|
|
385
|
+
expect(measureMaxDepth({ ...patchRow, patches: patched.status === 'ok' ? [{ value: deep }] : [] }).depth).toBeGreaterThan(16)
|
|
386
|
+
const { _id: _ignoredId, _creationTime: _ignoredTime, ...stored } = patchRow!
|
|
387
|
+
expect(measureMaxDepth(stored).depth).toBeLessThanOrEqual(16)
|
|
388
|
+
const doc = await t.run(async (ctx) => ctx.db.query('cmsDocuments').collect())
|
|
389
|
+
const { _id: _docId, _creationTime: _docTime, ...docRow } = doc[0]!
|
|
390
|
+
expect(measureMaxDepth(docRow).depth).toBeLessThanOrEqual(16) // the document folded at rest after the merge
|
|
391
|
+
})
|
|
392
|
+
})
|
package/convex/delivery.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
|
-
import { unfoldValue } from '@elytracms/core/cms-core'
|
|
2
|
+
import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, unfoldValue, variantOf } from '@elytracms/core/cms-core'
|
|
3
|
+
import type { Perspective } from '@elytracms/core/cms-core'
|
|
3
4
|
import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
|
|
4
5
|
import type { AssetRecord } from '@elytracms/core/persistence'
|
|
5
6
|
import studioPackage from '../package.json'
|
|
@@ -46,8 +47,9 @@ import { requireCapability } from './guard'
|
|
|
46
47
|
* (unpinned, so never on the public snapshot; on the draft snapshot as a plain
|
|
47
48
|
* document the host's content client reports as `unknown-collection`).
|
|
48
49
|
*
|
|
49
|
-
* `draftSnapshot` is the draft-perspective equivalent (
|
|
50
|
-
*
|
|
50
|
+
* `draftSnapshot` is the draft-perspective equivalent (every stored row of the
|
|
51
|
+
* draft VARIANT — the working drafts — EC-595) and is gated by the deployment env var
|
|
52
|
+
* `DRAFT_PREVIEW_TOKEN`
|
|
51
53
|
* — set via `npx convex env set`. A missing or
|
|
52
54
|
* mismatching token throws `unauthorized`; an unset env var closes the
|
|
53
55
|
* endpoint entirely. The host server passes the token from its own env when
|
|
@@ -56,8 +58,12 @@ import { requireCapability } from './guard'
|
|
|
56
58
|
|
|
57
59
|
interface DeliverySnapshot {
|
|
58
60
|
projectId: string
|
|
59
|
-
perspective
|
|
60
|
-
|
|
61
|
+
/** The perspective served — `'published'` or the variant (`{ variant: 'draft' }`), EC-595. */
|
|
62
|
+
perspective: Perspective
|
|
63
|
+
/**
|
|
64
|
+
* Stored document rows visible in the perspective: published — pinned versions only;
|
|
65
|
+
* a variant — that variant's rows only.
|
|
66
|
+
*/
|
|
61
67
|
documents: unknown[]
|
|
62
68
|
/** Asset metadata in the delivery `AssetRecord` shape (with serve URLs). */
|
|
63
69
|
assets: unknown[]
|
|
@@ -102,7 +108,7 @@ export async function loadDeliveryAssets(ctx: QueryCtx, projectId: string): Prom
|
|
|
102
108
|
export async function loadSnapshot(
|
|
103
109
|
ctx: QueryCtx,
|
|
104
110
|
args: { projectId: string },
|
|
105
|
-
perspective:
|
|
111
|
+
perspective: Perspective,
|
|
106
112
|
): Promise<DeliverySnapshot> {
|
|
107
113
|
const { projectId } = args
|
|
108
114
|
|
|
@@ -117,13 +123,20 @@ export async function loadSnapshot(
|
|
|
117
123
|
// the continuously-edited draft); a row with no pin never leaves the
|
|
118
124
|
// deployment. Publish-ness is the pointer, full stop — there is no flat-state
|
|
119
125
|
// fallback (the `state` field is gone).
|
|
126
|
+
//
|
|
127
|
+
// EC-595 (binding point 1): the aggregate holds one row per (document, VARIANT), so
|
|
128
|
+
// both branches select rows by variant. A variant perspective serves the rows of
|
|
129
|
+
// exactly that variant (only `draft` exists in v1); the publish pointer lives on the
|
|
130
|
+
// draft variant's row — `cms.ts` `listPublishedDocuments` reads it the same way — so a
|
|
131
|
+
// variant row copied with its pin can never serve the pinned snapshot twice.
|
|
132
|
+
const variant = variantOf(perspective)
|
|
120
133
|
let documents: unknown[]
|
|
121
|
-
if (
|
|
122
|
-
documents = documentRows.map(unfoldDraft)
|
|
134
|
+
if (variant !== undefined) {
|
|
135
|
+
documents = documentRows.filter((row) => row.variant === variant).map(unfoldDraft)
|
|
123
136
|
} else {
|
|
124
137
|
documents = []
|
|
125
138
|
for (const row of documentRows) {
|
|
126
|
-
if (row.publishedVersion === undefined) continue
|
|
139
|
+
if (row.variant !== DRAFT_VARIANT || row.publishedVersion === undefined) continue
|
|
127
140
|
// EC-308: read THIS document's pinned version directly via `by_doc_version`
|
|
128
141
|
// (EC-307) instead of collecting the entire append-only `documentVersions`
|
|
129
142
|
// table and joining in memory — the published snapshot now scales with the
|
|
@@ -190,9 +203,9 @@ export const snapshot = query({
|
|
|
190
203
|
})
|
|
191
204
|
|
|
192
205
|
/**
|
|
193
|
-
* Draft-perspective snapshot for token-holding preview hosts:
|
|
194
|
-
*
|
|
195
|
-
* env var.
|
|
206
|
+
* Draft-perspective snapshot for token-holding preview hosts: every stored row of
|
|
207
|
+
* the draft variant (the working drafts, EC-595). Gated by the `DRAFT_PREVIEW_TOKEN`
|
|
208
|
+
* deployment env var.
|
|
196
209
|
*/
|
|
197
210
|
export const draftSnapshot = query({
|
|
198
211
|
args: { projectId: v.string(), token: v.string() },
|
|
@@ -204,7 +217,7 @@ export const draftSnapshot = query({
|
|
|
204
217
|
if (args.token !== expected) {
|
|
205
218
|
throw new Error('unauthorized: invalid draft preview token')
|
|
206
219
|
}
|
|
207
|
-
return loadSnapshot(ctx, { projectId: args.projectId },
|
|
220
|
+
return loadSnapshot(ctx, { projectId: args.projectId }, DRAFT_PERSPECTIVE)
|
|
208
221
|
},
|
|
209
222
|
})
|
|
210
223
|
|
|
@@ -37,9 +37,9 @@ const modules = {
|
|
|
37
37
|
'./_generated/api.ts': async () => ({}),
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
40
|
+
const replaceDocument = makeFunctionReference<'mutation'>('cms:replaceDocument')
|
|
41
|
+
const getDocument = makeFunctionReference<'query'>('cms:getDocument')
|
|
42
|
+
const publishDocument = makeFunctionReference<'mutation'>('cms:publishDocument')
|
|
43
43
|
const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
|
|
44
44
|
const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
|
|
45
45
|
const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
@@ -92,28 +92,29 @@ function doc(collection: string, id: string, values: Record<string, unknown>) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
async function saveDraft({ asAdmin }: Backend, collection: string, id: string, values: Record<string, unknown>) {
|
|
95
|
-
|
|
95
|
+
// EC-595: a whole-document write carries its precondition (the revision it read, or create).
|
|
96
|
+
const current = (await asAdmin.query(getDocument, { collection, docId: id })) as { revision: number } | null
|
|
97
|
+
await asAdmin.mutation(replaceDocument, {
|
|
96
98
|
collection,
|
|
97
99
|
docId: id,
|
|
98
100
|
document: doc(collection, id, values),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
precondition: current ? { revision: current.revision } : { ifAbsent: true },
|
|
102
|
+
at: STAMP,
|
|
101
103
|
})
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Publish `values` the way the one publish operation does (EC-585): the draft holds them,
|
|
108
|
+
* then the one-step `publishDocument` records a publish-kind version and pins it.
|
|
109
|
+
*/
|
|
104
110
|
async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
snapshot: doc(collection, id, values),
|
|
109
|
-
createdAt: STAMP,
|
|
110
|
-
})) as { version: number }
|
|
111
|
-
await backend.asAdmin.mutation(publishVersion, {
|
|
111
|
+
await saveDraft(backend, collection, id, values)
|
|
112
|
+
const current = (await backend.asAdmin.query(getDocument, { collection, docId: id })) as { revision: number }
|
|
113
|
+
await backend.asAdmin.mutation(publishDocument, {
|
|
112
114
|
collection,
|
|
113
115
|
docId: id,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
publishedAt: STAMP,
|
|
116
|
+
expectedRevision: current.revision,
|
|
117
|
+
at: STAMP,
|
|
117
118
|
})
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import type { FunctionReference } from 'convex/server'
|
|
5
|
+
import type { CollectionDef } from '@elytracms/core/cms-core'
|
|
6
|
+
import { createSequentialIdFactory } from '@elytracms/core/determinism'
|
|
7
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
8
|
+
import { replaceAtCurrentRevision } from '@elytracms/core/persistence'
|
|
9
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
10
|
+
import { loadHydratedCms } from '../src/lib/operations/session-hydration'
|
|
11
|
+
import schema from './schema'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* EC-606 close-out — the orphan count on the CONNECTED path, short of a signed-in
|
|
15
|
+
* connected studio (owner-run).
|
|
16
|
+
*
|
|
17
|
+
* The count itself is mode-independent: `loadHydratedCms` runs over whatever
|
|
18
|
+
* operations client the session has, and `project-session.test.tsx` already proves
|
|
19
|
+
* the content session carries its `orphans` to the rail foot. What only the connected
|
|
20
|
+
* mode adds is the backend between them — the real `cms:listDocuments` handler and the
|
|
21
|
+
* production `ConvexPersistenceAdapter`. This runs exactly that path on convex-test's
|
|
22
|
+
* in-memory backend (the harness of `contract-parity.test.ts`: the adapter's HTTP client
|
|
23
|
+
* is a call-through double onto `t.query` / `t.mutation`): a row of a collection the
|
|
24
|
+
* config does not declare must reach the session's orphan list, with its published
|
|
25
|
+
* count, and must not reach its documents.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const modules = {
|
|
29
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
30
|
+
'./_generated/api.ts': async () => ({}),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PROJECT_ID = 'orphan-hydration'
|
|
34
|
+
const SESSION_USER_ID = 'user_admin'
|
|
35
|
+
|
|
36
|
+
const DECLARED: CollectionDef[] = [{ id: 'post', kind: 'document', fields: [{ name: 'title', type: 'text' }] }]
|
|
37
|
+
|
|
38
|
+
interface AdapterBackend {
|
|
39
|
+
query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
|
|
40
|
+
mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function asAdapterClient(backend: AdapterBackend): ConvexHttpClient {
|
|
44
|
+
return {
|
|
45
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => backend.query(ref, args ?? {}),
|
|
46
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => backend.mutation(ref, args ?? {}),
|
|
47
|
+
} as unknown as ConvexHttpClient
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The production adapter over a fresh in-memory Convex backend, as the session admin. */
|
|
51
|
+
async function connectedAdapter() {
|
|
52
|
+
const t = convexTest(schema, modules)
|
|
53
|
+
const now = '2026-10-26T00:00:00.000Z'
|
|
54
|
+
await t.run(async (ctx) => {
|
|
55
|
+
await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
|
|
56
|
+
})
|
|
57
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
58
|
+
return new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The operations client a headless embedder (or the session) builds over that adapter. */
|
|
62
|
+
function operationsOver(adapter: ConvexPersistenceAdapter) {
|
|
63
|
+
return createOperations({
|
|
64
|
+
adapter,
|
|
65
|
+
collections: () => DECLARED,
|
|
66
|
+
validateDocument: () => [],
|
|
67
|
+
idFactory: createSequentialIdFactory('doc'),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('EC-606 — orphans reach a connected session through the Convex functions', () => {
|
|
72
|
+
it('a stored row of an undeclared collection is counted (published too), and kept out of the documents', async () => {
|
|
73
|
+
const adapter = await connectedAdapter()
|
|
74
|
+
await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
|
|
75
|
+
collection: 'post',
|
|
76
|
+
id: 'kept',
|
|
77
|
+
values: { title: 'Kept' },
|
|
78
|
+
})
|
|
79
|
+
await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
|
|
80
|
+
collection: 'cms-data-sources',
|
|
81
|
+
id: 'legacy-draft',
|
|
82
|
+
values: { name: 'left behind by the data-binding subsystem' },
|
|
83
|
+
})
|
|
84
|
+
const published = await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
|
|
85
|
+
collection: 'cms-data-sources',
|
|
86
|
+
id: 'legacy-live',
|
|
87
|
+
values: { name: 'and published' },
|
|
88
|
+
})
|
|
89
|
+
const pinned = await adapter.cms.publishDocument(
|
|
90
|
+
PROJECT_ID,
|
|
91
|
+
{ collection: 'cms-data-sources', id: 'legacy-live' },
|
|
92
|
+
published.revision,
|
|
93
|
+
)
|
|
94
|
+
expect(pinned.status).toBe('ok')
|
|
95
|
+
|
|
96
|
+
const result = await loadHydratedCms(operationsOver(adapter), PROJECT_ID, DECLARED)
|
|
97
|
+
expect(result.ok).toBe(true)
|
|
98
|
+
if (!result.ok || !result.value) throw new Error('hydration must answer')
|
|
99
|
+
expect(result.value.orphans).toEqual([{ collection: 'cms-data-sources', count: 2, published: 1 }])
|
|
100
|
+
expect(result.value.documents.map((document) => `${document.collection}:${document.id}`)).toEqual(['post:kept'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('CONTROL: declared rows only → no orphans (the count is not a constant)', async () => {
|
|
104
|
+
const adapter = await connectedAdapter()
|
|
105
|
+
await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
|
|
106
|
+
collection: 'post',
|
|
107
|
+
id: 'kept',
|
|
108
|
+
values: { title: 'Kept' },
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const result = await loadHydratedCms(operationsOver(adapter), PROJECT_ID, DECLARED)
|
|
112
|
+
if (!result.ok || !result.value) throw new Error('hydration must answer')
|
|
113
|
+
expect(result.value.orphans).toEqual([])
|
|
114
|
+
expect(result.value.documents).toHaveLength(1)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { PRESENCE_TTL_MS } from '@elytracms/core/persistence'
|
|
5
|
+
import schema from './schema'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* EC-595 A2 — the thin presence's COST (critic, operator-read). A heartbeat writes a
|
|
9
|
+
* `documentPresence` row; every reactive query whose read set holds that row re-runs and
|
|
10
|
+
* pushes its WHOLE result to every subscriber. Measured here: the bytes one re-run of the
|
|
11
|
+
* document subscription pushes, for a ~20 KB document with two editors present — the
|
|
12
|
+
* payload a heartbeat costs per subscriber.
|
|
13
|
+
*
|
|
14
|
+
* Reported (EC-595 Done, part A2): when the row and the presence rode ONE query
|
|
15
|
+
* (`watchDocument` returned `{ row, presence }`), every heartbeat re-pushed the 20 KB row
|
|
16
|
+
* to both editors. The split (`watchDocument` = the row, `watchPresence` = the presence,
|
|
17
|
+
* merged by the adapter behind the same `subscribeDocument`) makes a heartbeat re-run
|
|
18
|
+
* only the presence query: its read set no longer includes the row, and the row query's
|
|
19
|
+
* read set no longer includes presence (Convex re-runs a query only when data it read
|
|
20
|
+
* changed — https://docs.convex.dev/realtime).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// The same module map contract-parity.test.ts uses (no `_generated` dir; a stub key
|
|
24
|
+
// satisfies convex-test's root lookup).
|
|
25
|
+
const modules = {
|
|
26
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
27
|
+
'./_generated/api.ts': async () => ({}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SESSION_USER_ID = 'user_admin'
|
|
31
|
+
const watchDocument = makeFunctionReference<'query'>('cms:watchDocument')
|
|
32
|
+
const watchPresence = makeFunctionReference<'query'>('cms:watchPresence')
|
|
33
|
+
const replaceDocument = makeFunctionReference<'mutation'>('cms:replaceDocument')
|
|
34
|
+
const reportPresence = makeFunctionReference<'mutation'>('cms:reportPresence')
|
|
35
|
+
|
|
36
|
+
/** A body of roughly `bytes` bytes of prose (a long rich-text-ish field). */
|
|
37
|
+
function prose(bytes: number): string {
|
|
38
|
+
const sentence = 'The analytical engine weaves algebraic patterns as the Jacquard loom weaves flowers. '
|
|
39
|
+
return sentence.repeat(Math.ceil(bytes / sentence.length)).slice(0, bytes)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function backend() {
|
|
43
|
+
const t = convexTest(schema, modules)
|
|
44
|
+
const now = new Date().toISOString()
|
|
45
|
+
await t.run(async (ctx) => {
|
|
46
|
+
await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
|
|
47
|
+
})
|
|
48
|
+
return t.withIdentity({ subject: SESSION_USER_ID })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('the presence heartbeat’s cost (EC-595 A2)', () => {
|
|
52
|
+
it('a heartbeat re-pushes only the presence — never the 20 KB document', async () => {
|
|
53
|
+
const t = await backend()
|
|
54
|
+
const document = { collection: 'post', id: 'p1', values: { title: 'Ada', body: prose(20_000) } }
|
|
55
|
+
await t.mutation(replaceDocument, {
|
|
56
|
+
collection: 'post',
|
|
57
|
+
docId: 'p1',
|
|
58
|
+
document,
|
|
59
|
+
precondition: { ifAbsent: true },
|
|
60
|
+
at: new Date().toISOString(),
|
|
61
|
+
})
|
|
62
|
+
const at = new Date().toISOString()
|
|
63
|
+
for (const [sessionId, actorName] of [
|
|
64
|
+
['tab-a', 'Ada'],
|
|
65
|
+
['tab-b', 'Grace'],
|
|
66
|
+
] as const) {
|
|
67
|
+
await t.mutation(reportPresence, {
|
|
68
|
+
collection: 'post',
|
|
69
|
+
docId: 'p1',
|
|
70
|
+
sessionId,
|
|
71
|
+
actorName,
|
|
72
|
+
path: [{ kind: 'field', name: 'title' }],
|
|
73
|
+
at,
|
|
74
|
+
ttlMs: PRESENCE_TTL_MS,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
const args = { collection: 'post', docId: 'p1' }
|
|
78
|
+
const rowBytes = JSON.stringify(await t.query(watchDocument, args)).length
|
|
79
|
+
const presenceBytes = JSON.stringify(await t.query(watchPresence, args)).length
|
|
80
|
+
// Recorded for the Done section (bytes per push per subscriber).
|
|
81
|
+
console.info(`[presence-cost] row query ${rowBytes} B · presence query ${presenceBytes} B`)
|
|
82
|
+
// The document query carries the row and NOTHING of presence…
|
|
83
|
+
expect(rowBytes).toBeGreaterThan(20_000)
|
|
84
|
+
expect(JSON.stringify(await t.query(watchDocument, args))).not.toContain('tab-a')
|
|
85
|
+
// …the presence query carries the heartbeats and nothing of the row.
|
|
86
|
+
expect(presenceBytes).toBeLessThan(1_000)
|
|
87
|
+
expect(JSON.stringify(await t.query(watchPresence, args))).not.toContain('analytical engine')
|
|
88
|
+
})
|
|
89
|
+
})
|