@elytracms/studio 0.0.99 → 0.0.101
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/analytics-cost.test.ts +1 -1
- package/convex/cms.ts +29 -2
- package/convex/records-column-transform.convex.test.ts +5 -3
- package/convex/records-one-write.convex.test.ts +218 -0
- package/convex/redirect-migration.test.ts +1 -1
- package/convex/redirects.ts +2 -2
- package/convex/shared-capture.convex.test.ts +165 -0
- package/convex/version-kind-stamping.test.ts +118 -0
- package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
- package/dist/components/ai/ai-chat-panel-lazy.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/proposal-unfilled.d.ts +14 -0
- package/dist/components/ai/proposal-unfilled.js +33 -0
- package/dist/components/ai/proposal-unfilled.js.map +1 -0
- package/dist/components/ai/translate-document-action.js +8 -6
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +12 -12
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +37 -1
- package/dist/components/builder/builder-store.js +21 -6
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
- package/dist/components/builder/canvas-chrome-css.js +1 -1
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-chrome.d.ts +1 -1
- package/dist/components/builder/canvas-chrome.js +1 -1
- package/dist/components/builder/canvas-chrome.js.map +1 -1
- package/dist/components/builder/canvas-rebase.d.ts +4 -4
- package/dist/components/builder/canvas-rebase.js +3 -2
- package/dist/components/builder/canvas-rebase.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +1 -1
- package/dist/components/builder/canvas-render-context.js +1 -1
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/composition-field-editor-lazy.js +2 -1
- package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +8 -2
- package/dist/components/builder/reuse-context.js +62 -7
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +106 -70
- package/dist/components/builder/reuse.js +125 -108
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +9 -1
- package/dist/components/cms/cms-store.js +1 -0
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/detail-editor.js +13 -13
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/prop-fields.js +1 -1
- package/dist/components/cms/prop-fields.js.map +1 -1
- package/dist/components/cms/rich-text/lazy.js +2 -1
- package/dist/components/cms/rich-text/lazy.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
- package/dist/components/cms/rich-text/toolbar-state.js +1 -1
- package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
- package/dist/components/cms/use-field-focus.js +3 -3
- package/dist/components/cms/use-field-focus.js.map +1 -1
- package/dist/components/cms/version-compare-lazy.js +2 -1
- package/dist/components/cms/version-compare-lazy.js.map +1 -1
- package/dist/components/components/components-index.js +1 -1
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/deployment-setup-gate.d.ts +1 -1
- package/dist/components/deployment-setup-gate.js +1 -1
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/load-failure.d.ts +62 -0
- package/dist/components/load-failure.js +110 -0
- package/dist/components/load-failure.js.map +1 -0
- package/dist/components/notifications/shell-findings.js +3 -3
- package/dist/components/notifications/shell-findings.js.map +1 -1
- package/dist/components/records/record-rail.js +16 -5
- package/dist/components/records/record-rail.js.map +1 -1
- package/dist/customization.d.ts +3 -3
- package/dist/customization.js.map +1 -1
- package/dist/lib/ai/apply-content.js +99 -28
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +73 -7
- package/dist/lib/ai/chat-context.js +133 -14
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +7 -1
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +5 -0
- package/dist/lib/ai/constraints.js +21 -6
- package/dist/lib/ai/constraints.js.map +1 -1
- package/dist/lib/ai/fake-provider.d.ts +76 -26
- package/dist/lib/ai/fake-provider.js +210 -66
- package/dist/lib/ai/fake-provider.js.map +1 -1
- package/dist/lib/ai/index.d.ts +1 -0
- package/dist/lib/ai/index.js +1 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/keyless-assistant.d.ts +37 -0
- package/dist/lib/ai/keyless-assistant.js +193 -0
- package/dist/lib/ai/keyless-assistant.js.map +1 -0
- package/dist/lib/ai/shape.d.ts +25 -2
- package/dist/lib/ai/shape.js +58 -7
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +11 -1
- package/dist/lib/ai/tools.js +51 -6
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.js +6 -5
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +22 -7
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.js +9 -6
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +11 -3
- package/dist/lib/ai/writer-form.js +105 -18
- package/dist/lib/ai/writer-form.js.map +1 -1
- package/dist/lib/auth-errors.d.ts +1 -1
- package/dist/lib/auth-errors.js +1 -1
- package/dist/lib/auth-errors.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.d.ts +1 -1
- package/dist/lib/i18n/messages.de.js +24 -7
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +19 -3
- package/dist/lib/i18n/messages.en.js +30 -9
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +18 -0
- package/dist/lib/operations/cms-persistence.js +56 -15
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +2 -0
- package/dist/lib/persistence/convex-adapter.js +67 -2
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +3 -3
- package/dist/lib/workspace/index.js +3 -3
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/router/navigate-once.d.ts +1 -1
- package/dist/router/navigate-once.js.map +1 -1
- package/dist/router/root.d.ts +3 -2
- package/dist/router/root.js +25 -4
- package/dist/router/root.js.map +1 -1
- package/package.json +3 -3
|
@@ -485,7 +485,7 @@ function pick<T>(random: () => number, weighted: readonly (readonly [T, number])
|
|
|
485
485
|
}
|
|
486
486
|
|
|
487
487
|
const SLUGS = [
|
|
488
|
-
'sommerfest-im-garten-der-
|
|
488
|
+
'sommerfest-im-garten-der-stadthalle',
|
|
489
489
|
'unsere-torten-fuer-hochzeiten',
|
|
490
490
|
'ueber-uns',
|
|
491
491
|
'kontakt-und-anfahrt',
|
package/convex/cms.ts
CHANGED
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
unfoldValue,
|
|
13
13
|
} from '@elytracms/core/cms-core'
|
|
14
14
|
import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
|
|
15
|
-
import { isPinnableVersion } from '@elytracms/core/persistence'
|
|
15
|
+
import { isPinnableVersion, planVersionKindStamps } from '@elytracms/core/persistence'
|
|
16
16
|
import type { DocumentVersionKind } from '@elytracms/core/persistence'
|
|
17
|
-
import { mutation, query } from './functions'
|
|
17
|
+
import { internalMutation, mutation, query } from './functions'
|
|
18
18
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
19
19
|
import { requireCapability } from './guard'
|
|
20
20
|
import { assertWithinWriteLimits } from './write-limits'
|
|
@@ -866,6 +866,33 @@ export const listAllDocumentVersions = query({
|
|
|
866
866
|
},
|
|
867
867
|
})
|
|
868
868
|
|
|
869
|
+
/**
|
|
870
|
+
* EC-707 — the EC-585 kind stamping: every version row recorded before EC-585 (no `kind`)
|
|
871
|
+
* gets the kind `isPinnableVersion` already reads it as — `label: 'published'` → `'publish'`,
|
|
872
|
+
* anything else → `'snapshot'` (core `planVersionKindStamps`, the same inference) — so
|
|
873
|
+
* pinnability is unchanged for every row, and once every deployment reads 0 kind-less rows
|
|
874
|
+
* the tolerant branch and the optional `kind` can go (EC-707's deletions). Idempotent: a
|
|
875
|
+
* second run stamps 0; a row that names a kind is never touched; nothing but `kind` is written.
|
|
876
|
+
*
|
|
877
|
+
* Internal: an admin runs it from the CLI, once per deployment — `npx convex run
|
|
878
|
+
* cms:stampVersionKinds` (no args) → `{ scanned, stamped }`; `pnpm demo:reseed` runs it twice.
|
|
879
|
+
* ONE transaction at a documented bound (`VERSION_KIND_STAMP_BOUND`: 4096 rows / 4 MiB of
|
|
880
|
+
* rewritten rows, half of Convex's per-transaction write limits): it reads every version row,
|
|
881
|
+
* as `listAllDocumentVersions` already does for delivery, and above the bound it throws
|
|
882
|
+
* before writing anything — a refusal is whole, never a partial stamping. `documentVersions`
|
|
883
|
+
* has no write-chokepoint trigger, so the stamp schedules no revalidation (delivery reads the
|
|
884
|
+
* same pins before and after).
|
|
885
|
+
*/
|
|
886
|
+
export const stampVersionKinds = internalMutation({
|
|
887
|
+
args: {},
|
|
888
|
+
handler: async (ctx) => {
|
|
889
|
+
const rows = await ctx.db.query('documentVersions').collect()
|
|
890
|
+
const stamps = planVersionKindStamps(rows)
|
|
891
|
+
for (const { row, kind } of stamps) await ctx.db.patch(row._id, { kind })
|
|
892
|
+
return { scanned: rows.length, stamped: stamps.length }
|
|
893
|
+
},
|
|
894
|
+
})
|
|
895
|
+
|
|
869
896
|
/**
|
|
870
897
|
* Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
|
|
871
898
|
* enforced here, in the transaction: the version must exist for THIS document
|
|
@@ -21,7 +21,8 @@ import { defineRecordFunctions } from './record-functions'
|
|
|
21
21
|
* Before: the operations parsed the write and sent the PARSED values over the wire, where the
|
|
22
22
|
* mutation parsed them again — `seed!!` stored with `ok: true`, and a type-changing column
|
|
23
23
|
* (`string → length`) refused. Measured red on the wave base 21678336: 12 of 12. Now the
|
|
24
|
-
* operations
|
|
24
|
+
* operations send the values as sent (EC-694), and judge no column themselves (EC-702): the
|
|
25
|
+
* mutation is the one executor, and its refusal arrives located (`records-one-write.convex.test.ts`).
|
|
25
26
|
*
|
|
26
27
|
* The three copies are real zod releases (the engine's own 4.4.3; the aliased devDependencies
|
|
27
28
|
* `zod-copy-older` 4.1.13 and `zod-copy-newer` 4.6.7). The client is the real
|
|
@@ -126,11 +127,12 @@ describe.each(LENGTHS)('EC-694 — Convex, a type-changing column of the $copy z
|
|
|
126
127
|
})
|
|
127
128
|
|
|
128
129
|
describe('EC-694 — Convex, what the operations send', () => {
|
|
129
|
-
it('a refused value is the
|
|
130
|
+
it('a refused value is the mutation’s located refusal, through the operation (EC-702): nothing stored', async () => {
|
|
130
131
|
const { t, ops } = await connected()
|
|
131
132
|
const kind = EXCLAIMED[0]!.kind
|
|
132
133
|
const refused = await ops.records(kind).save({ projectId: PROJECT, id: 'refused', text: 42 as never })
|
|
133
|
-
expect(refused).toMatchObject({ ok: false, error: { code: 'validation'
|
|
134
|
+
expect(refused).toMatchObject({ ok: false, error: { code: 'validation' } })
|
|
135
|
+
expect(!refused.ok && refused.error.code === 'validation' && refused.error.issues.map((issue) => issue.path)).toEqual([['text']])
|
|
134
136
|
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
135
137
|
})
|
|
136
138
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import { getFunctionName } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { z as newerZod } from 'zod-copy-newer'
|
|
8
|
+
import { z as olderZod } from 'zod-copy-older'
|
|
9
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
10
|
+
import type { AuthorizationHook, OperationEvent } from '@elytracms/core/operations'
|
|
11
|
+
import { PersistenceValidationError, defineRecordKind } from '@elytracms/core/persistence'
|
|
12
|
+
import type { AnyRecordKind, RecordColumnSchema } from '@elytracms/core/persistence'
|
|
13
|
+
import { ConvexPersistenceAdapter, UNCARRIABLE_VALUE } from '../src/lib/persistence/convex-adapter'
|
|
14
|
+
import schema from './schema'
|
|
15
|
+
import { defineRecordFunctions } from './record-functions'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* EC-702 (Codex round 6 F31 residual + F32), the Convex half — a record save through
|
|
19
|
+
* `operations.records(kind)` is ONE write: captured when the call starts, authorized as captured,
|
|
20
|
+
* executed exactly as authorized, and parsed ONCE, in the kind's `save` mutation
|
|
21
|
+
* (`defineRecordFunctions(kind).save` → `resolveRecordSave`).
|
|
22
|
+
*
|
|
23
|
+
* Measured red on the wave base 85b983de through this file's as-written copy of
|
|
24
|
+
* `codex-review-2026-10-28-r6/record-raw-contracts.ts` (22 of 22 on Convex): a save authorized as
|
|
25
|
+
* `allowed` stored `forbidden`; a transform ran twice; a mutating normaliser stored `seed!!`.
|
|
26
|
+
*
|
|
27
|
+
* The client is the real `ConvexPersistenceAdapter` over convex-test, signed in as an editor
|
|
28
|
+
* member (the probe's wiring), with the kind's functions in the kind's module. The in-memory half
|
|
29
|
+
* is `core/src/operations/records-one-write.test.ts`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
type Vendor = typeof z
|
|
33
|
+
const COPIES = [
|
|
34
|
+
['own', z],
|
|
35
|
+
['older', olderZod as unknown as Vendor],
|
|
36
|
+
['newer', newerZod as unknown as Vendor],
|
|
37
|
+
] as const
|
|
38
|
+
const WRAPPERS = ['required', 'optional', 'default'] as const
|
|
39
|
+
const FORMS = ['custom transform', 'preprocess'] as const
|
|
40
|
+
type Wrapper = (typeof WRAPPERS)[number]
|
|
41
|
+
type Form = (typeof FORMS)[number]
|
|
42
|
+
|
|
43
|
+
const PROJECT = 'site'
|
|
44
|
+
const STAMP = '2026-10-29T00:00:00.000Z'
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.useFakeTimers()
|
|
48
|
+
vi.setSystemTime(STAMP)
|
|
49
|
+
})
|
|
50
|
+
afterEach(() => vi.useRealTimers())
|
|
51
|
+
|
|
52
|
+
function kindOf(fields: Record<string, unknown>): AnyRecordKind {
|
|
53
|
+
return defineRecordKind({
|
|
54
|
+
kind: 'reviewTransform',
|
|
55
|
+
module: 'reviewTransforms',
|
|
56
|
+
fields: fields as Record<string, RecordColumnSchema>,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The real client adapter over convex-test, signed in as an editor; the operations over it. */
|
|
61
|
+
async function connected(fields: Record<string, unknown>, authorize?: AuthorizationHook) {
|
|
62
|
+
const kind = kindOf(fields)
|
|
63
|
+
const t = convexTest(schema, {
|
|
64
|
+
'./_generated/api.ts': async () => ({}),
|
|
65
|
+
[`./${kind.module}.ts`]: async () => defineRecordFunctions(kind),
|
|
66
|
+
})
|
|
67
|
+
await t.run(async (ctx) => {
|
|
68
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
69
|
+
})
|
|
70
|
+
const editor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
|
|
71
|
+
const calls: string[] = []
|
|
72
|
+
const client = {
|
|
73
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => editor.query(ref, args ?? {}),
|
|
74
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
75
|
+
calls.push(getFunctionName(ref))
|
|
76
|
+
return editor.mutation(ref, args ?? {})
|
|
77
|
+
},
|
|
78
|
+
} as unknown as ConvexHttpClient
|
|
79
|
+
const adapter = new ConvexPersistenceAdapter(client, PROJECT)
|
|
80
|
+
const ops = createOperations({ adapter, collections: () => [], validateDocument: () => [], ...(authorize ? { authorize } : {}) })
|
|
81
|
+
const events: OperationEvent[] = []
|
|
82
|
+
ops.events.subscribe((event) => events.push(event))
|
|
83
|
+
return { t, direct: adapter.records(kind), records: ops.records(kind), events, calls }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalising(vendor: Vendor, form: Form, wrapper: Wrapper) {
|
|
87
|
+
const normalize = (value: { text: string }) => {
|
|
88
|
+
value.text += '!'
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
const isText = (value: unknown) => typeof value === 'object' && value !== null && typeof (value as { text?: unknown }).text === 'string'
|
|
92
|
+
const inner =
|
|
93
|
+
form === 'custom transform'
|
|
94
|
+
? vendor.custom<{ text: string }>(isText).transform(normalize)
|
|
95
|
+
: vendor.preprocess((value) => (isText(value) ? normalize(value as { text: string }) : value), vendor.object({ text: vendor.string() }))
|
|
96
|
+
return wrapper === 'optional' ? inner.optional() : wrapper === 'default' ? inner.default({ text: 'safe' }) : inner
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pathsOf = (result: { ok: boolean; error?: unknown }) =>
|
|
100
|
+
!result.ok && (result.error as { code: string }).code === 'validation'
|
|
101
|
+
? (result.error as { issues: { path?: unknown }[] }).issues.map((issue) => issue.path)
|
|
102
|
+
: undefined
|
|
103
|
+
|
|
104
|
+
describe('EC-702 — F32 on Convex: the write authorized is the write executed', () => {
|
|
105
|
+
it('a caller that rewrites its input while authorization awaits changes nothing: the authorized write is stored, never the denied one', async () => {
|
|
106
|
+
const decisions: string[] = []
|
|
107
|
+
let reached!: () => void
|
|
108
|
+
let resume!: () => void
|
|
109
|
+
const atHook = new Promise<void>((resolve) => (reached = resolve))
|
|
110
|
+
const gate = new Promise<void>((resolve) => (resume = resolve))
|
|
111
|
+
const { direct, records } = await connected({ text: z.string() }, async (request) => {
|
|
112
|
+
const id = (request.input as { id: string }).id
|
|
113
|
+
decisions.push(id)
|
|
114
|
+
if (id === 'allowed') {
|
|
115
|
+
reached()
|
|
116
|
+
await gate
|
|
117
|
+
}
|
|
118
|
+
return id === 'allowed' ? { allow: true } : { allow: false, reason: `“${id}” is not yours.` }
|
|
119
|
+
})
|
|
120
|
+
// Positive control: this hook denies a direct save to `forbidden`.
|
|
121
|
+
expect(await records.save({ projectId: PROJECT, id: 'forbidden', text: 'denied' })).toMatchObject({ ok: false, error: { code: 'unauthorized' } })
|
|
122
|
+
expect(await direct.find(PROJECT, 'forbidden')).toBeUndefined()
|
|
123
|
+
|
|
124
|
+
const input = { projectId: PROJECT, id: 'allowed', text: 'original' }
|
|
125
|
+
const pending = records.save(input)
|
|
126
|
+
await atHook
|
|
127
|
+
input.id = 'forbidden'
|
|
128
|
+
input.text = 'changed after parse'
|
|
129
|
+
resume()
|
|
130
|
+
expect(await pending).toMatchObject({ ok: true, value: { id: 'allowed', text: 'original' } })
|
|
131
|
+
expect(decisions).toEqual(['forbidden', 'allowed'])
|
|
132
|
+
expect(await direct.get(PROJECT, 'allowed')).toMatchObject({ text: 'original' })
|
|
133
|
+
expect(await direct.find(PROJECT, 'forbidden')).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('EC-702 — F31 on Convex: a column transform runs once per write, in the mutation', () => {
|
|
138
|
+
it.each(WRAPPERS)('%s: the counter reads 1 per write through the operations, as through the repository (create and edit)', async (wrapper) => {
|
|
139
|
+
let calls = 0
|
|
140
|
+
const inner = z.string().transform((value) => `${value}:${++calls}`)
|
|
141
|
+
const column = wrapper === 'optional' ? inner.optional() : wrapper === 'default' ? inner.default('safe') : inner
|
|
142
|
+
const { direct, records } = await connected({ text: column })
|
|
143
|
+
calls = 0
|
|
144
|
+
expect(await direct.save(PROJECT, { id: 'direct', text: 'seed' })).toMatchObject({ text: 'seed:1' })
|
|
145
|
+
expect(calls).toBe(1)
|
|
146
|
+
calls = 0
|
|
147
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'seed' })).toMatchObject({ ok: true, value: { text: 'seed:1' } })
|
|
148
|
+
expect(calls).toBe(1)
|
|
149
|
+
calls = 0
|
|
150
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'edit' })).toMatchObject({ ok: true, value: { text: 'edit:1' } })
|
|
151
|
+
expect(calls).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
for (const [copy, vendor] of COPIES) {
|
|
155
|
+
for (const form of FORMS) {
|
|
156
|
+
it.each(WRAPPERS)(`${copy} zod, ${form}, %s: a mutating normaliser stores \`seed!\` then \`edit!\` through the operations, as through the repository`, async (wrapper) => {
|
|
157
|
+
const { direct, records } = await connected({ payload: normalising(vendor, form, wrapper) })
|
|
158
|
+
expect(await direct.save(PROJECT, { id: 'direct', payload: { text: 'seed' } })).toMatchObject({ payload: { text: 'seed!' } })
|
|
159
|
+
expect(await direct.save(PROJECT, { id: 'direct', payload: { text: 'edit' } })).toMatchObject({ payload: { text: 'edit!' } })
|
|
160
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', payload: { text: 'seed' } })).toMatchObject({ ok: true, value: { payload: { text: 'seed!' } } })
|
|
161
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', payload: { text: 'edit' } })).toMatchObject({ ok: true, value: { payload: { text: 'edit!' } } })
|
|
162
|
+
expect((await direct.get(PROJECT, 'viaops')).payload).toEqual({ text: 'edit!' })
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('EC-702 — the store is the one judge of a column, located (Convex)', () => {
|
|
169
|
+
it('a value the column refuses (a number for `z.string()`) is the operation’s located `validation` failure, after authorization (`failed`) — the mutation refused it, nothing stored', async () => {
|
|
170
|
+
const { t, records, events, calls } = await connected({ text: z.string() })
|
|
171
|
+
const refused = await records.save({ projectId: PROJECT, id: 'refused', text: 42 as never })
|
|
172
|
+
expect(refused).toMatchObject({ ok: false, error: { code: 'validation' } })
|
|
173
|
+
expect(pathsOf(refused)).toEqual([['text']])
|
|
174
|
+
expect(events.map((event) => event.status)).toEqual(['failed'])
|
|
175
|
+
// The server judged it: one mutation ran, and its transaction stored nothing.
|
|
176
|
+
expect(calls).toEqual(['reviewTransforms:save'])
|
|
177
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('a `Date` in a `z.string()` column — a value the wire cannot carry — is refused located at `[text]`, before any request', async () => {
|
|
181
|
+
const { t, records, events, calls } = await connected({ text: z.string() })
|
|
182
|
+
const refused = await records.save({ projectId: PROJECT, id: 'dated', text: new Date(0) as never })
|
|
183
|
+
expect(refused).toMatchObject({ ok: false, error: { code: 'validation' } })
|
|
184
|
+
expect(pathsOf(refused)).toEqual([['text']])
|
|
185
|
+
expect(!refused.ok && refused.error.code === 'validation' && refused.error.issues[0]!.code).toBe(UNCARRIABLE_VALUE)
|
|
186
|
+
expect(events.map((event) => event.status)).toEqual(['failed'])
|
|
187
|
+
expect(calls).toEqual([])
|
|
188
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('the repository itself refuses an uncarriable value as a located `PersistenceValidationError` — at the deepest value or key that fails', async () => {
|
|
192
|
+
const { direct, calls } = await connected({ payload: z.unknown() })
|
|
193
|
+
const cases: [unknown, (string | number)[]][] = [
|
|
194
|
+
[new Date(0), ['payload']],
|
|
195
|
+
[{ nested: { when: new Date(0) } }, ['payload', 'nested', 'when']],
|
|
196
|
+
[{ list: ['a', new Map()] }, ['payload', 'list', 1]],
|
|
197
|
+
[{ nested: { $junk: 1 } }, ['payload', 'nested', '$junk']],
|
|
198
|
+
[{ ['ü']: 1 }, ['payload', 'ü']],
|
|
199
|
+
[[1, undefined], ['payload', 1]],
|
|
200
|
+
]
|
|
201
|
+
for (const [payload, path] of cases) {
|
|
202
|
+
const error = await direct.save(PROJECT, { id: 'r', payload }).then(
|
|
203
|
+
() => undefined,
|
|
204
|
+
(thrown: unknown) => thrown,
|
|
205
|
+
)
|
|
206
|
+
expect(error).toBeInstanceOf(PersistenceValidationError)
|
|
207
|
+
expect((error as PersistenceValidationError).issues.map((issue) => [issue.code, issue.path])).toEqual([[UNCARRIABLE_VALUE, path]])
|
|
208
|
+
}
|
|
209
|
+
expect(calls).toEqual([])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('control: a carriable value crosses and is stored', async () => {
|
|
213
|
+
const { direct } = await connected({ payload: z.unknown() })
|
|
214
|
+
expect(await direct.save(PROJECT, { id: 'r', payload: { nested: { when: '1970-01-01', n: 1n, list: [1, null] } } })).toMatchObject({
|
|
215
|
+
payload: { nested: { when: '1970-01-01', list: [1, null] } },
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -14,7 +14,7 @@ afterEach(() => {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* EC-616 — the redirect move from the `redirects` table into `records` (kind `redirect`)
|
|
17
|
-
* is a STORED-DATA move (
|
|
17
|
+
* is a STORED-DATA move (both production consumers hold real redirects), done the EC-587
|
|
18
18
|
* way: writes to the new home, a marked tolerant read of the old one, an idempotent
|
|
19
19
|
* per-deployment migration. These run the REAL functions on convex-test over legacy rows
|
|
20
20
|
* inserted as a pre-EC-616 deployment holds them, and pin "no gap": a deployment with the
|
package/convex/redirects.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { recordEntryFromWire } from '@elytracms/core/persistence'
|
|
|
22
22
|
* `export * from '@elytracms/studio/convex/redirects'` line are unchanged. Content-gated
|
|
23
23
|
* (a viewer reads, an editor writes), NOT admin-only — redirects are content.
|
|
24
24
|
*
|
|
25
|
-
* ## The move from the `redirects` table (stored data —
|
|
25
|
+
* ## The move from the `redirects` table (stored data — both production consumers hold real redirects)
|
|
26
26
|
*
|
|
27
27
|
* The EC-587 precedent, with no gap:
|
|
28
28
|
*
|
|
@@ -92,7 +92,7 @@ export async function loadRedirectEntries(ctx: QueryCtx, projectId: string): Pro
|
|
|
92
92
|
* identical before and after by construction (the tolerant read), so the move needs no
|
|
93
93
|
* revalidation — through the triggers it would schedule two sweeps per row (an insert
|
|
94
94
|
* into `records`, a delete from `redirects`). One transaction: Convex bounds it at 8192
|
|
95
|
-
* written rows, far above any editorial redirect set (
|
|
95
|
+
* written rows, far above any editorial redirect set (a production site's import holds 192).
|
|
96
96
|
*/
|
|
97
97
|
export const migrateLegacy = (internalMutationGeneric as MutationBuilder<DataModel, 'internal'>)({
|
|
98
98
|
args: {},
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import { getFunctionName } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { UNCOPYABLE_VALUE, createOperations } from '@elytracms/core/operations'
|
|
8
|
+
import type { AuthorizationHook, OperationEvent, OperationIssue } from '@elytracms/core/operations'
|
|
9
|
+
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
10
|
+
import { ConvexPersistenceAdapter, UNCARRIABLE_VALUE } from '../src/lib/persistence/convex-adapter'
|
|
11
|
+
import schema from './schema'
|
|
12
|
+
import { defineRecordFunctions } from './record-functions'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* EC-713 (Codex round 7 F32 residual), the Convex half — Codex's 24 shared/private cases through
|
|
16
|
+
* `operations.records(kind)` over the real `ConvexPersistenceAdapter` (convex-test, signed-in
|
|
17
|
+
* editor). This project runs in the edge runtime, whose `structuredClone` shares a
|
|
18
|
+
* `SharedArrayBuffer` too (measured, wave-0102): the capture's walk of the clone is what refuses it.
|
|
19
|
+
*
|
|
20
|
+
* Before (measured on the wave base 188e35bf): a shared input was captured, AUTHORIZED, and only then
|
|
21
|
+
* refused by the record repository's wire check (`uncarriable-value`, `failed`) — so Codex called it
|
|
22
|
+
* no Convex bypass, but authorization had judged memory the caller could still change. Now it is the
|
|
23
|
+
* capture's `uncopyable-value` (`rejected`) before any step: the hook never sees it, no mutation runs.
|
|
24
|
+
* The private controls keep their outcome: an `ArrayBuffer` crosses the wire and the authorized byte
|
|
25
|
+
* is stored; a typed array, a `DataView` or a `Map` is still the wire's located refusal.
|
|
26
|
+
*
|
|
27
|
+
* The in-memory half is `core/src/operations/shared-capture.test.ts`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Binary = ArrayBuffer | SharedArrayBuffer | Uint8Array | DataView
|
|
31
|
+
type Segment = string | number
|
|
32
|
+
|
|
33
|
+
const PROJECT = 'site'
|
|
34
|
+
const STAMP = '2026-10-31T00:00:00.000Z'
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.useFakeTimers()
|
|
38
|
+
vi.setSystemTime(STAMP)
|
|
39
|
+
})
|
|
40
|
+
afterEach(() => vi.useRealTimers())
|
|
41
|
+
|
|
42
|
+
const byteOf = (value: Binary): number =>
|
|
43
|
+
value instanceof Uint8Array ? Number(value[0]) : value instanceof DataView ? value.getUint8(0) : Number(new Uint8Array(value)[0])
|
|
44
|
+
|
|
45
|
+
const PLACES = [
|
|
46
|
+
{ name: 'column', put: (v: Binary): unknown => v, get: (v: unknown) => v as Binary, path: ['value'] as Segment[] },
|
|
47
|
+
{
|
|
48
|
+
name: 'object',
|
|
49
|
+
put: (v: Binary): unknown => ({ nested: { bytes: v } }),
|
|
50
|
+
get: (v: unknown) => (v as { nested: { bytes: Binary } }).nested.bytes,
|
|
51
|
+
path: ['value', 'nested', 'bytes'] as Segment[],
|
|
52
|
+
},
|
|
53
|
+
{ name: 'list', put: (v: Binary): unknown => [v], get: (v: unknown) => (v as Binary[])[0]!, path: ['value', 0] as Segment[] },
|
|
54
|
+
{
|
|
55
|
+
name: 'map',
|
|
56
|
+
put: (v: Binary): unknown => new Map([['bytes', v]]),
|
|
57
|
+
get: (v: unknown) => (v as Map<string, Binary>).get('bytes')!,
|
|
58
|
+
path: ['value'] as Segment[],
|
|
59
|
+
},
|
|
60
|
+
] as const
|
|
61
|
+
const SHAPES = ['buffer', 'uint8', 'dataView'] as const
|
|
62
|
+
|
|
63
|
+
function issuesOf(result: { ok: boolean; error?: unknown }): readonly OperationIssue[] {
|
|
64
|
+
return !result.ok && (result.error as { code: string }).code === 'validation' ? (result.error as { issues: readonly OperationIssue[] }).issues : []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The real client adapter over convex-test, signed in as an editor; the operations over it. */
|
|
68
|
+
async function connected(read: (value: unknown) => number, authorize: AuthorizationHook) {
|
|
69
|
+
const kind = defineRecordKind({ kind: 'byte', module: 'bytes', fields: { value: z.unknown().transform(read) } })
|
|
70
|
+
const t = convexTest(schema, {
|
|
71
|
+
'./_generated/api.ts': async () => ({}),
|
|
72
|
+
[`./${kind.module}.ts`]: async () => defineRecordFunctions(kind),
|
|
73
|
+
})
|
|
74
|
+
await t.run(async (ctx) => {
|
|
75
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
76
|
+
})
|
|
77
|
+
const editor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
|
|
78
|
+
const calls: string[] = []
|
|
79
|
+
const client = {
|
|
80
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => editor.query(ref, args ?? {}),
|
|
81
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
82
|
+
calls.push(getFunctionName(ref))
|
|
83
|
+
return editor.mutation(ref, args ?? {})
|
|
84
|
+
},
|
|
85
|
+
} as unknown as ConvexHttpClient
|
|
86
|
+
const adapter = new ConvexPersistenceAdapter(client, PROJECT)
|
|
87
|
+
const ops = createOperations({ adapter, collections: () => [], validateDocument: () => [], authorize })
|
|
88
|
+
const events: OperationEvent[] = []
|
|
89
|
+
ops.events.subscribe((event) => events.push(event))
|
|
90
|
+
return { t, direct: adapter.records(kind), records: ops.records(kind), events, calls }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('EC-713 — Codex round 7 F32 residual on Convex: shared memory is refused at the capture, before authorization', () => {
|
|
94
|
+
for (const shape of SHAPES)
|
|
95
|
+
for (const place of PLACES)
|
|
96
|
+
for (const shared of [false, true]) {
|
|
97
|
+
// What the wire carries (Convex's own `convexToJson`): an `ArrayBuffer`, in a column, an object or a list.
|
|
98
|
+
const carriable = shape === 'buffer' && place.name !== 'map'
|
|
99
|
+
const label = shared
|
|
100
|
+
? 'shared: `uncopyable-value` before authorization, no mutation'
|
|
101
|
+
: carriable
|
|
102
|
+
? 'private control: the authorized 1 is stored'
|
|
103
|
+
: 'private control: still the wire’s located refusal, after authorization'
|
|
104
|
+
it(`${shape}/${place.name}/${label}`, async () => {
|
|
105
|
+
const read = (v: unknown) => byteOf(place.get(v))
|
|
106
|
+
let reached!: () => void
|
|
107
|
+
let resume!: () => void
|
|
108
|
+
const atHook = new Promise<void>((resolve) => (reached = resolve))
|
|
109
|
+
const gate = new Promise<void>((resolve) => (resume = resolve))
|
|
110
|
+
const judged: number[] = []
|
|
111
|
+
const { t, records, events, calls } = await connected(read, async (request) => {
|
|
112
|
+
const value = read((request.input as { value: unknown }).value)
|
|
113
|
+
judged.push(value)
|
|
114
|
+
if (value !== 1) return { allow: false, reason: 'only byte 1' }
|
|
115
|
+
reached()
|
|
116
|
+
await gate
|
|
117
|
+
return { allow: true }
|
|
118
|
+
})
|
|
119
|
+
const carry = (buffer: ArrayBuffer | SharedArrayBuffer) =>
|
|
120
|
+
place.put(shape === 'buffer' ? buffer : shape === 'uint8' ? new Uint8Array(buffer) : new DataView(buffer))
|
|
121
|
+
|
|
122
|
+
// The instrument: the hook denies byte 9, before any request.
|
|
123
|
+
const denied = new ArrayBuffer(1)
|
|
124
|
+
new Uint8Array(denied)[0] = 9
|
|
125
|
+
expect(await records.save({ projectId: PROJECT, id: 'denied', value: carry(denied) })).toMatchObject({
|
|
126
|
+
ok: false,
|
|
127
|
+
error: { code: 'unauthorized' },
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const buffer = shared ? new SharedArrayBuffer(1) : new ArrayBuffer(1)
|
|
131
|
+
new Uint8Array(buffer)[0] = 1
|
|
132
|
+
const pending = records.save({ projectId: PROJECT, id: 'r', value: carry(buffer) })
|
|
133
|
+
const first = await Promise.race([pending.then(() => 'returned' as const), atHook.then(() => 'authorizing' as const)])
|
|
134
|
+
if (first === 'authorizing') {
|
|
135
|
+
new Uint8Array(buffer)[0] = 9
|
|
136
|
+
resume()
|
|
137
|
+
}
|
|
138
|
+
const result = await pending
|
|
139
|
+
const issues = issuesOf(result)
|
|
140
|
+
const stored = await t.run(async (ctx) => ctx.db.query('records').collect())
|
|
141
|
+
|
|
142
|
+
if (shared) {
|
|
143
|
+
expect(issues.map((issue) => [issue.code, issue.path])).toEqual([[UNCOPYABLE_VALUE, place.path]])
|
|
144
|
+
expect(issues[0]!.message).toContain('shared memory')
|
|
145
|
+
expect(judged).toEqual([9])
|
|
146
|
+
expect(events.map((event) => event.status)).toEqual(['denied', 'rejected'])
|
|
147
|
+
expect(calls).toEqual([])
|
|
148
|
+
expect(stored).toEqual([])
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
expect(first).toBe('authorizing')
|
|
152
|
+
expect(judged).toEqual([9, 1])
|
|
153
|
+
if (carriable) {
|
|
154
|
+
expect(result).toMatchObject({ ok: true, value: { id: 'r', value: 1 } })
|
|
155
|
+
expect(calls).toEqual(['bytes:save'])
|
|
156
|
+
expect(stored).toHaveLength(1)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
expect(issues.map((issue) => [issue.code, issue.path?.[0]])).toEqual([[UNCARRIABLE_VALUE, 'value']])
|
|
160
|
+
expect(events.map((event) => event.status)).toEqual(['denied', 'failed'])
|
|
161
|
+
expect(calls).toEqual([])
|
|
162
|
+
expect(stored).toEqual([])
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { foldDocument } from '@elytracms/core/cms-core'
|
|
5
|
+
import type { CmsDocument } from '@elytracms/core/cms-core'
|
|
6
|
+
import { VERSION_KIND_STAMP_BOUND, isPinnableVersion, scanStoredShapes } from '@elytracms/core/persistence'
|
|
7
|
+
import schema from './schema'
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers()
|
|
11
|
+
})
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* EC-707 — the EC-585 kind stamping, the REAL internal mutation (`cms:stampVersionKinds`,
|
|
18
|
+
* the line an admin runs with `npx convex run`) on convex-test's backend: planted kind-less
|
|
19
|
+
* rows of each label are stamped with exactly the kind `isPinnableVersion` reads them as,
|
|
20
|
+
* a row that names a kind is untouched, pinnability is unchanged for every row, a second
|
|
21
|
+
* run stamps 0, and above the documented bound the run refuses whole.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const modules = {
|
|
25
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
26
|
+
'./_generated/api.ts': async () => ({}),
|
|
27
|
+
}
|
|
28
|
+
const stampVersionKinds = makeFunctionReference<'mutation'>('cms:stampVersionKinds')
|
|
29
|
+
|
|
30
|
+
const STAMP = '2026-10-29T00:00:00.000Z'
|
|
31
|
+
const snapshot = (id: string) =>
|
|
32
|
+
foldDocument({ collection: 'page', id, values: { title: id } } as unknown as CmsDocument)
|
|
33
|
+
|
|
34
|
+
/** The planted rows: kind-less of each label, and two that name a kind. */
|
|
35
|
+
const PLANTED = [
|
|
36
|
+
{ docId: 'a', version: 1, label: 'published' },
|
|
37
|
+
{ docId: 'a', version: 2, label: 'pre-restore' },
|
|
38
|
+
{ docId: 'a', version: 3, label: 'AI snapshot' },
|
|
39
|
+
{ docId: 'b', version: 1 },
|
|
40
|
+
{ docId: 'b', version: 2, label: 'published', kind: 'snapshot' as const },
|
|
41
|
+
{ docId: 'b', version: 3, label: 'pre-restore', kind: 'publish' as const },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
async function plant(
|
|
45
|
+
rows: ReadonlyArray<{ docId: string; version: number; label?: string; kind?: 'publish' | 'snapshot' }>,
|
|
46
|
+
) {
|
|
47
|
+
const t = convexTest(schema, modules)
|
|
48
|
+
await t.run(async (ctx) => {
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
await ctx.db.insert('documentVersions', {
|
|
51
|
+
collection: 'page',
|
|
52
|
+
snapshot: snapshot(row.docId),
|
|
53
|
+
createdAt: STAMP,
|
|
54
|
+
...row,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
return t
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stored = (t: Awaited<ReturnType<typeof plant>>) =>
|
|
62
|
+
t.run(async (ctx) =>
|
|
63
|
+
(await ctx.db.query('documentVersions').collect()).sort((x, y) =>
|
|
64
|
+
x.docId + x.version < y.docId + y.version ? -1 : 1,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
describe('EC-707 — cms:stampVersionKinds stamps what isPinnableVersion infers, once', () => {
|
|
69
|
+
it('stamps every kind-less row by its label, leaves a kinded row alone, keeps pinnability; a second run stamps 0', async () => {
|
|
70
|
+
const t = await plant(PLANTED)
|
|
71
|
+
const before = await stored(t)
|
|
72
|
+
expect(before.filter((row) => row.kind === undefined)).toHaveLength(4)
|
|
73
|
+
|
|
74
|
+
expect(await t.mutation(stampVersionKinds, {})).toEqual({ scanned: 6, stamped: 4 })
|
|
75
|
+
const after = await stored(t)
|
|
76
|
+
expect(after.map((row) => `${row.docId}@v${row.version} ${row.kind}`)).toEqual([
|
|
77
|
+
'a@v1 publish',
|
|
78
|
+
'a@v2 snapshot',
|
|
79
|
+
'a@v3 snapshot',
|
|
80
|
+
'b@v1 snapshot',
|
|
81
|
+
'b@v2 snapshot', // named a kind: untouched (its label says published, its kind wins)
|
|
82
|
+
'b@v3 publish',
|
|
83
|
+
])
|
|
84
|
+
// The migration preserves meaning: every row pins exactly as it did kind-less.
|
|
85
|
+
expect(after.map(isPinnableVersion)).toEqual(before.map(isPinnableVersion))
|
|
86
|
+
// Nothing but `kind` was written: label, snapshot (still folded), createdAt as planted.
|
|
87
|
+
for (const [index, row] of after.entries()) {
|
|
88
|
+
const { kind: _k, ...rest } = row
|
|
89
|
+
const { kind: _b, ...was } = before[index]!
|
|
90
|
+
expect(rest).toEqual(was)
|
|
91
|
+
}
|
|
92
|
+
// The at-rest scan now reads 0 kind-less rows here.
|
|
93
|
+
const scan = scanStoredShapes({ cmsDocuments: [], documentVersions: after }, { collections: [] })
|
|
94
|
+
expect(scan.byShape['kind-less-version']).toBe(0)
|
|
95
|
+
|
|
96
|
+
expect(await t.mutation(stampVersionKinds, {})).toEqual({ scanned: 6, stamped: 0 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('CONTROL: before the stamping the at-rest scan counts the kind-less rows (the scan can see them)', async () => {
|
|
100
|
+
const t = await plant(PLANTED)
|
|
101
|
+
const scan = scanStoredShapes({ cmsDocuments: [], documentVersions: await stored(t) }, { collections: [] })
|
|
102
|
+
expect(scan.byShape['kind-less-version']).toBe(4)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('refuses WHOLE above the documented row bound — nothing stamped', async () => {
|
|
106
|
+
const rows = Array.from({ length: VERSION_KIND_STAMP_BOUND.rows + 1 }, (_, index) => ({
|
|
107
|
+
docId: 'c',
|
|
108
|
+
version: index + 1,
|
|
109
|
+
label: 'published',
|
|
110
|
+
}))
|
|
111
|
+
const t = await plant(rows)
|
|
112
|
+
await expect(t.mutation(stampVersionKinds, {})).rejects.toThrow(
|
|
113
|
+
/exceed the one-transaction stamping bound of 4096 — nothing was stamped/,
|
|
114
|
+
)
|
|
115
|
+
const after = await stored(t)
|
|
116
|
+
expect(after.every((row) => row.kind === undefined)).toBe(true)
|
|
117
|
+
}, 60_000)
|
|
118
|
+
})
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Suspense, lazy } from 'react';
|
|
3
3
|
import { Text } from '@elytracms/studio/ui';
|
|
4
4
|
import { translate } from '../../lib/i18n/index.js';
|
|
5
|
+
import { LazySurfaceBoundary } from '../load-failure.js';
|
|
5
6
|
/**
|
|
6
7
|
* Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The
|
|
7
8
|
* panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),
|
|
@@ -12,10 +13,14 @@ import { translate } from '../../lib/i18n/index.js';
|
|
|
12
13
|
* `version-compare-lazy` / `rich-text/lazy`.
|
|
13
14
|
*/
|
|
14
15
|
const AiChatPanelImpl = lazy(() => import('./ai-chat-panel.js').then((module) => ({ default: module.AiChatPanel })));
|
|
16
|
+
/** The panel's aside — shared by the loading state and a failed load (EC-717). */
|
|
17
|
+
function AiPanelFrame({ children }) {
|
|
18
|
+
return (_jsx("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: children }));
|
|
19
|
+
}
|
|
15
20
|
function AiPanelFallback() {
|
|
16
|
-
return (_jsx(
|
|
21
|
+
return (_jsx(AiPanelFrame, { children: _jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-6", children: _jsx(Text, { size: "sm", tone: "muted", children: translate('ai.panel.loading') }) }) }));
|
|
17
22
|
}
|
|
18
23
|
export function AiChatPanel() {
|
|
19
|
-
return (_jsx(Suspense, { fallback: _jsx(AiPanelFallback, {}), children: _jsx(AiChatPanelImpl, {}) }));
|
|
24
|
+
return (_jsx(LazySurfaceBoundary, { frame: (notice) => _jsx(AiPanelFrame, { children: notice }), children: _jsx(Suspense, { fallback: _jsx(AiPanelFallback, {}), children: _jsx(AiChatPanelImpl, {}) }) }));
|
|
20
25
|
}
|
|
21
26
|
//# sourceMappingURL=ai-chat-panel-lazy.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AAEtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAErD;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,kFAAkF;AAClF,SAAS,YAAY,CAAC,EAAE,QAAQ,EAA2B;IACzD,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC9F,QAAQ,GACH,CACT,CAAA;AACH,CAAC;AAED,SAAS,eAAe;IACtB,OAAO,CACL,KAAC,YAAY,cACX,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACO,CAChB,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,mBAAmB,IAAC,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,KAAC,YAAY,cAAE,MAAM,GAAgB,YAC3E,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,GACS,CACvB,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport type { ReactNode } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { LazySurfaceBoundary } from '../load-failure'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The\n * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),\n * the client tool executor, `applyContentWithShape`, the provider adapters — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\n/** The panel's aside — shared by the loading state and a failed load (EC-717). */\nfunction AiPanelFrame({ children }: { children: ReactNode }) {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n {children}\n </aside>\n )\n}\n\nfunction AiPanelFallback() {\n return (\n <AiPanelFrame>\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </AiPanelFrame>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <LazySurfaceBoundary frame={(notice) => <AiPanelFrame>{notice}</AiPanelFrame>}>\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n </LazySurfaceBoundary>\n )\n}\n"]}
|