@elytracms/studio 0.0.76 → 0.0.78

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/cms.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { v } from 'convex/values'
2
2
  import { documentsSemanticEqual, foldDocument, unfoldDocument } from '@elytracms/core/cms-core'
3
3
  import type { CmsDocument } from '@elytracms/core/cms-core'
4
- import { backfillHasUnpublishedChanges, backfillPublishedAt } from '@elytracms/core/persistence'
5
- import { internalMutation, mutation, query } from './functions'
4
+ import { mutation, query } from './functions'
6
5
  import { requireCapability } from './guard'
7
6
  import { assertWithinWriteLimits } from './write-limits'
8
7
 
@@ -330,109 +329,14 @@ export const unpublishDocument = mutation({
330
329
  })
331
330
 
332
331
  /**
333
- * EC-447 one-time backfill — close-reconstruct `publishedAt` on documents published
334
- * BEFORE it was materialized. For each currently-published doc that has NONE, sets
335
- * `publishedAt` to its PINNED published version's `createdAt` (the best-available
336
- * approximation of the real publish moment, owner-blessed). The decision is the pure,
337
- * unit-tested `backfillPublishedAt` (`@elytracms/core/persistence`) — one source of
338
- * truth: an already-stamped doc, an unpublished doc, or one whose pinned version row
339
- * is missing is SKIPPED, so a re-run performs ZERO writes (idempotent). Touches ONLY
340
- * `publishedAt` (never `createdAt`/`updatedAt`/the pin), so the write-chokepoint
341
- * trigger fires no revalidation (`publishedVersion` is unchanged — see triggers.ts).
342
- *
343
- * INTERNAL (scheduler/CLI only, EC-150): the operator runs it once per deployment
344
- * after `convex deploy` (e.g. `npx convex run cms:backfillPublishedAtColumn`); it is
345
- * not client-callable. Returns a small audit summary (`{ scanned, stamped }`).
346
- */
347
- export const backfillPublishedAtColumn = internalMutation({
348
- args: {},
349
- handler: async (ctx) => {
350
- const docs = await ctx.db.query('cmsDocuments').collect()
351
- let scanned = 0
352
- let stamped = 0
353
- for (const doc of docs) {
354
- scanned += 1
355
- // Skip the version read for the certain-skip cases (draft / already-stamped);
356
- // otherwise resolve the pinned version and let the pure helper decide.
357
- if (doc.publishedVersion === undefined || doc.publishedAt !== undefined) continue
358
- const pinned = await ctx.db
359
- .query('documentVersions')
360
- .withIndex('by_doc_version', (q) =>
361
- q
362
- .eq('collection', doc.collection)
363
- .eq('docId', doc.docId)
364
- .eq('version', doc.publishedVersion as number),
365
- )
366
- .unique()
367
- const publishedAt = backfillPublishedAt(
368
- { publishedVersion: doc.publishedVersion, publishedAt: doc.publishedAt },
369
- pinned?.createdAt,
370
- )
371
- if (publishedAt === undefined) continue
372
- await ctx.db.patch(doc._id, { publishedAt })
373
- stamped += 1
374
- }
375
- return { scanned, stamped }
376
- },
377
- })
378
-
379
- /**
380
- * EC-449 one-time backfill — materialize `hasUnpublishedChanges` on documents
381
- * published BEFORE the flag was recorded, so the amber "changed" dot is correct
382
- * immediately after the repoint instead of understating until the next save. For
383
- * each currently-published doc that has NO flag yet, runs the SAME semantic
384
- * compare the write chokepoint runs going forward (`documentsSemanticEqual`,
385
- * schema-less lens) of the stored draft against its pinned published snapshot —
386
- * the full compare was chosen over the ratified default-`false` fallback because
387
- * it costs one indexed version read + one compare per unstamped published doc
388
- * (the same order of work as `cms:backfillPublishedAtColumn`). The decision is
389
- * the pure, unit-tested `backfillHasUnpublishedChanges`
390
- * (`@elytracms/core/persistence`): an already-flagged doc, an unpublished doc,
391
- * or one whose pinned version row is missing is SKIPPED, so a re-run performs
392
- * ZERO writes (idempotent). Touches ONLY `hasUnpublishedChanges` (never the
393
- * pin/`document`/timestamps), so the write-chokepoint trigger fires no host
394
- * revalidation (`publishedVersion` unchanged — see triggers.ts).
395
- *
396
- * INTERNAL (scheduler/CLI only, EC-150): the operator runs it once per deployment
397
- * after `convex deploy` (e.g. `npx convex run cms:backfillHasUnpublishedChangesColumn`);
398
- * it is not client-callable. Returns a small audit summary (`{ scanned, stamped }`).
332
+ * One-time data migrations (EC-450 principle): a migration mutation ships
333
+ * transiently — added when a schema/behavior change needs to backfill existing
334
+ * deployments, then REMOVED once every deployment has confirmed running it. It
335
+ * never lingers in the permanent `@elytracms/*` shipped surface (a brand-new
336
+ * consumer never needs it — nothing pre-dates the change it backfills). Pattern:
337
+ * an `internalMutation` here delegating its per-row decision to a pure, unit-tested
338
+ * core in `@elytracms/core/persistence`; the operator runs it once per deployment
339
+ * via `npx convex run cms:<name>` after `convex deploy`, confirms all deployments,
340
+ * then deletes both the mutation and its core in one removal pass (see EC-447/449
341
+ * → EC-450 for the worked example).
399
342
  */
400
- export const backfillHasUnpublishedChangesColumn = internalMutation({
401
- args: {},
402
- handler: async (ctx) => {
403
- const docs = await ctx.db.query('cmsDocuments').collect()
404
- let scanned = 0
405
- let stamped = 0
406
- for (const doc of docs) {
407
- scanned += 1
408
- // Skip the version read for the certain-skip cases (draft / already-flagged);
409
- // otherwise resolve the pinned snapshot, compare once, and let the pure
410
- // helper decide.
411
- if (doc.publishedVersion === undefined || doc.hasUnpublishedChanges !== undefined) continue
412
- const pinned = await ctx.db
413
- .query('documentVersions')
414
- .withIndex('by_doc_version', (q) =>
415
- q
416
- .eq('collection', doc.collection)
417
- .eq('docId', doc.docId)
418
- .eq('version', doc.publishedVersion as number),
419
- )
420
- .unique()
421
- const differs = pinned
422
- ? !documentsSemanticEqual(
423
- undefined,
424
- unfoldDocument(pinned.snapshot as CmsDocument),
425
- unfoldDocument(doc.document as CmsDocument),
426
- )
427
- : undefined
428
- const hasUnpublishedChanges = backfillHasUnpublishedChanges(
429
- { publishedVersion: doc.publishedVersion, hasUnpublishedChanges: doc.hasUnpublishedChanges },
430
- differs,
431
- )
432
- if (hasUnpublishedChanges === undefined) continue
433
- await ctx.db.patch(doc._id, { hasUnpublishedChanges })
434
- stamped += 1
435
- }
436
- return { scanned, stamped }
437
- },
438
- })
@@ -0,0 +1,238 @@
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 { measureMaxDepth } from '@elytracms/core/cms-core'
6
+ import { PERSISTENCE_CONTRACT_SCENARIOS } from '@elytracms/core/persistence'
7
+ import type { ContractContext } from '@elytracms/core/persistence'
8
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
9
+ import schema from './schema'
10
+
11
+ /**
12
+ * EC-454 — Convex-side contract parity, restored.
13
+ *
14
+ * `PERSISTENCE_CONTRACT_SCENARIOS` (EC-049 → EC-153) runs here against the REAL
15
+ * Convex function handlers (`convex/*.ts`) + the real `schema.ts` + the EC-286
16
+ * write-chokepoint triggers, executed by `convex-test`'s in-memory backend — no
17
+ * deployment, CI-shaped. The scenarios drive the production
18
+ * `ConvexPersistenceAdapter` itself: its `ConvexHttpClient` is replaced by a
19
+ * call-through double onto `t.query`/`t.mutation` (the only production layer
20
+ * bypassed is HTTP transport), so the adapter's client-side normalization
21
+ * (ordering, not-found preflights, EC-447 publishedAt stamping, error mapping)
22
+ * is under test exactly as production composes it.
23
+ *
24
+ * Runs in the `convex-parity` vitest project (edge-runtime environment — the
25
+ * scoped exception to the house node-env pattern; convex-test targets the
26
+ * Convex isolate's API surface, which edge-runtime replicates).
27
+ */
28
+
29
+ // convex-test locates the module root by finding a `_generated` path among the
30
+ // glob keys, but this package deliberately ships NO `_generated` directory
31
+ // (`functions.ts` binds the schema explicitly — EC-223). The stub key below
32
+ // satisfies the root lookup (it is only split on to compute the path prefix and
33
+ // is never imported); real local `_generated` output (gitignored codegen) is
34
+ // excluded so the module map is identical on every machine.
35
+ const modules = {
36
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
37
+ './_generated/api.ts': async () => ({}),
38
+ }
39
+
40
+ /** The config-owned slug this deployment's records are stamped with (EC-179). */
41
+ const PROJECT_ID = 'contract-parity'
42
+ /** Never written to: single-tenant excludes the cross-project scenarios below. */
43
+ const EMPTY_PROJECT_ID = 'contract-parity-empty'
44
+ const SESSION_USER_ID = 'user_admin'
45
+
46
+ /** The two calls the persistence adapter issues on a `ConvexHttpClient`. */
47
+ interface AdapterBackend {
48
+ query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
49
+ mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
50
+ }
51
+
52
+ /**
53
+ * Adapt a convex-test accessor onto the `ConvexHttpClient` call surface the
54
+ * adapter uses (`client.query(ref, args)` / `client.mutation(ref, args)` with
55
+ * `makeFunctionReference` refs — which convex-test resolves natively).
56
+ */
57
+ function asAdapterClient(backend: AdapterBackend): ConvexHttpClient {
58
+ return {
59
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) =>
60
+ backend.query(ref, args ?? {}),
61
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
62
+ backend.mutation(ref, args ?? {}),
63
+ } as unknown as ConvexHttpClient
64
+ }
65
+
66
+ /** A fresh backend with the session admin's membership bootstrapped. */
67
+ async function freshBackend() {
68
+ const t = convexTest(schema, modules)
69
+ // Bootstrap the first admin membership via raw db access: in production the
70
+ // auth boundary (`auth.ts`) creates it on first sign-in; `members:set` itself
71
+ // is admin-gated, so the harness cannot call it before a member exists.
72
+ const now = new Date().toISOString()
73
+ await t.run(async (ctx) => {
74
+ await ctx.db.insert('members', {
75
+ userId: SESSION_USER_ID,
76
+ role: 'admin',
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ })
80
+ })
81
+ return t
82
+ }
83
+
84
+ /**
85
+ * A fresh {@link ContractContext} per scenario. Identity: `guard.ts` resolves
86
+ * the caller via Convex Auth's `getAuthUserId` = `subject.split('|')[0]`, so a
87
+ * plain `subject` on convex-test's identity IS the member `userId` — the guards
88
+ * run unmodified.
89
+ */
90
+ async function convexHarness(): Promise<ContractContext> {
91
+ const t = await freshBackend()
92
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
93
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID, [])
94
+ return {
95
+ adapter,
96
+ projectId: PROJECT_ID,
97
+ emptyProjectId: EMPTY_PROJECT_ID,
98
+ 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
+ }
103
+ }
104
+
105
+ const singleTenantScenarios = PERSISTENCE_CONTRACT_SCENARIOS.filter((s) => !s.crossProject)
106
+ const crossProjectScenarios = PERSISTENCE_CONTRACT_SCENARIOS.filter((s) => s.crossProject)
107
+
108
+ describe('adapter contract — Convex (EC-153 cross-adapter parity suite, EC-454)', () => {
109
+ for (const scenario of singleTenantScenarios) {
110
+ it(`${scenario.domain}: ${scenario.name}`, async () => {
111
+ await scenario.run(await convexHarness())
112
+ })
113
+ }
114
+ // `crossProject` scenarios assert isolation between TWO projects in one store.
115
+ // A Convex deployment IS one project (EC-179), so the scenario contract itself
116
+ // excludes them for single-tenant backends (see `ContractScenario.crossProject`).
117
+ // Kept visible as skips so the exclusion can never silently widen.
118
+ for (const scenario of crossProjectScenarios) {
119
+ it.skip(`single-tenant exclusion (EC-179): ${scenario.domain}: ${scenario.name}`, () => {})
120
+ }
121
+ })
122
+
123
+ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction reference maintenance)', () => {
124
+ // Not a shared scenario: the in-memory adapter does not self-maintain the
125
+ // reference index (`maintainsReferenceIndex` is Convex-only — the studio
126
+ // attaches a client-side subscriber for other providers). Here the trigger
127
+ // must derive index rows from document writes by construction.
128
+ it('derives, republishes, retargets, and drops reference-index rows from document writes', async () => {
129
+ const { adapter, projectId } = await convexHarness()
130
+ const doc = {
131
+ collection: 'post',
132
+ id: 'trigger-probe',
133
+ values: {
134
+ author: { type: 'reference', collection: 'author', id: 'ada' },
135
+ cover: { type: 'asset', id: 'asset_cover' },
136
+ },
137
+ }
138
+ const ref = { collection: doc.collection, id: doc.id }
139
+ const source = { type: 'document' as const, id: 'post:trigger-probe' }
140
+
141
+ // A draft write derives draft rows in the same transaction — no published
142
+ // rows until a live version is pinned.
143
+ await adapter.cms.upsertDocument(projectId, doc)
144
+ const draft = await adapter.references.outbound(projectId, source, { variant: 'draft' })
145
+ expect(draft.map((r) => `${r.target.type}:${r.target.id}:${r.kind}`).sort()).toEqual([
146
+ 'asset:asset_cover:asset-field',
147
+ 'document:author:ada:relation-field',
148
+ ])
149
+ expect(await adapter.references.outbound(projectId, source, { variant: 'published' })).toEqual(
150
+ [],
151
+ )
152
+
153
+ // Pinning a version re-runs maintenance: published rows mirror the draft rows.
154
+ const v = await adapter.cms.saveDocumentVersion(projectId, doc, { label: 'published' })
155
+ await adapter.cms.publishDocumentVersion(projectId, ref, v.version)
156
+ expect(
157
+ (await adapter.references.outbound(projectId, source, { variant: 'published' })).length,
158
+ ).toBe(2)
159
+
160
+ // A retargeting save REPLACES the rows (replace-all-for-source, never appends).
161
+ await adapter.cms.upsertDocument(projectId, {
162
+ ...doc,
163
+ values: { author: { type: 'reference', collection: 'author', id: 'grace' } },
164
+ })
165
+ const retargeted = await adapter.references.outbound(projectId, source, { variant: 'draft' })
166
+ expect(retargeted.map((r) => r.target.id)).toEqual(['author:grace'])
167
+
168
+ // Deleting the document drops every row in both variants.
169
+ await adapter.cms.deleteDocument(projectId, ref)
170
+ expect(await adapter.references.outbound(projectId, source)).toEqual([])
171
+ })
172
+ })
173
+
174
+ describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth cap)', () => {
175
+ // The shared round-trip scenario proves handlers unfold on every read path;
176
+ // convex-test does NOT replicate Convex's 16-level at-rest nesting cap, so
177
+ // this reads the RAW stored row and proves the write path actually FOLDED it
178
+ // under the cap (the storage-boundary guarantee the codec exists for).
179
+ it('stores an over-budget document folded to within 16 levels; the wire stays structured', async () => {
180
+ const t = await freshBackend()
181
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
182
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID, [])
183
+
184
+ let deep: unknown = { type: 'leaf', text: 'bottom' }
185
+ for (let i = 0; i < 24; i++) deep = { type: 'level', index: i, child: deep }
186
+ const doc = { collection: 'post', id: 'ec260-at-rest', values: { title: 'Deep', body: deep } }
187
+ expect(measureMaxDepth(doc).depth).toBeGreaterThan(16) // the premise: over-budget on the wire
188
+
189
+ const stored = await adapter.cms.upsertDocument(PROJECT_ID, doc)
190
+ expect(stored.document.values).toEqual(doc.values) // the wire never carries an envelope
191
+
192
+ const atRest = await t.run(async (ctx) => {
193
+ const row = await ctx.db
194
+ .query('cmsDocuments')
195
+ .withIndex('by_doc', (q) => q.eq('collection', doc.collection).eq('docId', doc.id))
196
+ .unique()
197
+ return row?.document as unknown
198
+ })
199
+ expect(atRest).toBeDefined()
200
+ expect(measureMaxDepth(atRest).depth).toBeLessThanOrEqual(16) // folded at rest
201
+ })
202
+ })
203
+
204
+ describe('guard capability gates through the real functions (EC-150/EC-240c, Convex-only)', () => {
205
+ it('admits reads for a viewer but rejects writes, administration, non-members, and anonymous calls', async () => {
206
+ const t = await freshBackend()
207
+ const now = new Date().toISOString()
208
+ await t.run(async (ctx) => {
209
+ await ctx.db.insert('members', {
210
+ userId: 'user_viewer',
211
+ role: 'viewer',
212
+ createdAt: now,
213
+ updatedAt: now,
214
+ })
215
+ })
216
+ const forSubject = (subject: string) =>
217
+ new ConvexPersistenceAdapter(
218
+ asAdapterClient(t.withIdentity({ subject, issuer: 'https://convex.test' })),
219
+ PROJECT_ID,
220
+ [],
221
+ )
222
+
223
+ const viewer = forSubject('user_viewer')
224
+ expect(await viewer.cms.listDocuments(PROJECT_ID)).toEqual([]) // content.read granted
225
+ await expect(
226
+ viewer.cms.upsertDocument(PROJECT_ID, { collection: 'post', id: 'x', values: {} }),
227
+ ).rejects.toThrow(/unauthorized/) // content.write denied
228
+ await expect(viewer.cliTokens.listTokens()).rejects.toThrow(/unauthorized/) // project.administer denied
229
+
230
+ // An authenticated NON-member is rejected even on reads …
231
+ await expect(forSubject('user_stranger').cms.listDocuments(PROJECT_ID)).rejects.toThrow(
232
+ /unauthorized/,
233
+ )
234
+ // … and so is an anonymous caller.
235
+ const anonymous = new ConvexPersistenceAdapter(asAdapterClient(t), PROJECT_ID, [])
236
+ await expect(anonymous.cms.listDocuments(PROJECT_ID)).rejects.toThrow(/unauthorized/)
237
+ })
238
+ })
package/convex/schema.ts CHANGED
@@ -144,8 +144,9 @@ export default defineSchema({
144
144
  * publish-action ISO timestamp captured AT publish (NOT a revision `createdAt`),
145
145
  * written set-once on publish and cleared on unpublish. A distinct materialized
146
146
  * value alongside `createdAt`/`updatedAt`, backing the `$publishedAt` list column.
147
- * Optional (additive): existing rows stay valid; a one-time backfill
148
- * (`cms:backfillPublishedAt`) close-reconstructs it for pre-EC-447 published docs.
147
+ * Optional (additive): existing rows stay valid. Pre-EC-447 published docs on the
148
+ * three original deployments were close-reconstructed by a one-time backfill,
149
+ * since removed (EC-450 — ship-then-remove; a fresh deployment never needs it).
149
150
  */
150
151
  publishedAt: v.optional(v.string()),
151
152
  /**
@@ -155,9 +156,9 @@ export default defineSchema({
155
156
  * against the pinned published snapshot; publish writes `false`; unpublish
156
157
  * clears it. NOT derived from `updatedAt > publishedAt` (set-once EC-447
157
158
  * semantics would false-positive on every republish). Optional (additive):
158
- * existing rows stay valid; a one-time backfill
159
- * (`cms:backfillHasUnpublishedChangesColumn`) materializes it for
160
- * pre-EC-449 published docs.
159
+ * existing rows stay valid. Pre-EC-449 published docs on the three original
160
+ * deployments were materialized by a one-time backfill, since removed
161
+ * (EC-450 — ship-then-remove; a fresh deployment never needs it).
161
162
  */
162
163
  hasUnpublishedChanges: v.optional(v.boolean()),
163
164
  createdAt: v.string(),
@@ -6,7 +6,9 @@
6
6
  "moduleResolution": "Bundler",
7
7
  "jsx": "react-jsx",
8
8
  "skipLibCheck": true,
9
- "types": ["node"]
9
+ // `vite/client` types `import.meta.glob` (the convex-test module map in
10
+ // `contract-parity.test.ts`, EC-454).
11
+ "types": ["node", "vite/client"]
10
12
  },
11
13
  "include": ["./**/*.ts"]
12
14
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Link, STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
2
+ import { Link, STUDIO_PROJECT_ROUTE, useNavigate } from '@elytracms/studio/router';
3
3
  import { ArrowDown, ArrowUp, CircleDot, Copy, CornerDownRight, Download, Eye, EyeOff, ExternalLink, MoreHorizontal, Pencil, Plus, Trash2, Upload, } from 'lucide-react';
4
4
  import { useMemo, useState } from 'react';
5
5
  import { orderHierarchically, resolveDocumentForLocale, resolveDocumentTitle, validateCollectionShape, } from '@elytracms/core/cms-core';