@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 +11 -107
- package/convex/contract-parity.test.ts +238 -0
- package/convex/schema.ts +6 -5
- package/convex/tsconfig.json +3 -1
- package/dist/components/cms/collection-table.js +1 -1
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/doc-state.d.ts +3 -2
- package/dist/components/cms/doc-state.js +3 -2
- package/dist/components/cms/doc-state.js.map +1 -1
- package/dist/lib/auth/route-guard.d.ts +24 -3
- package/dist/lib/auth/route-guard.js +25 -4
- package/dist/lib/auth/route-guard.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +3 -3
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/router/root.js +13 -3
- package/dist/router/root.js.map +1 -1
- package/dist/ui/status-dot.d.ts +11 -8
- package/dist/ui/status-dot.js +7 -5
- package/dist/ui/status-dot.js.map +1 -1
- package/package.json +3 -2
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 {
|
|
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
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* `
|
|
341
|
-
*
|
|
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
|
|
148
|
-
*
|
|
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
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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(),
|
package/convex/tsconfig.json
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
"moduleResolution": "Bundler",
|
|
7
7
|
"jsx": "react-jsx",
|
|
8
8
|
"skipLibCheck": true,
|
|
9
|
-
|
|
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,
|
|
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';
|