@elytracms/studio 0.0.77 → 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.
@@ -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
+ })
@@ -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';