@elytracms/studio 0.0.44 → 0.0.45

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,4 +1,6 @@
1
1
  import { v } from 'convex/values'
2
+ import { foldDocument, unfoldDocument } from '@elytracms/core/cms-core'
3
+ import type { CmsDocument } from '@elytracms/core/cms-core'
2
4
  import { mutation, query } from './functions'
3
5
  import { requireCapability } from './guard'
4
6
 
@@ -9,8 +11,29 @@ import { requireCapability } from './guard'
9
11
  * table and no schema read/write function here. Collection-membership
10
12
  * validation lives in `@elytracms/core/cms-core` `validateDocument` (the studio runs it
11
13
  * pre-dispatch); this layer is a pure document store.
14
+ *
15
+ * ## EC-260 depth-budget codec (the storage boundary)
16
+ *
17
+ * Convex caps STORED documents at 16 nesting levels but allows 64 in function
18
+ * args/returns. Ordinary content (deep rich text, nested compositions) trips 16 at
19
+ * rest, so every write here FOLDS over-budget field values into self-describing
20
+ * leaf envelopes immediately before `ctx.db`, and every read UNFOLDS before the
21
+ * value leaves the function. Mutations receive the UNFOLDED value over the wire, so
22
+ * the AD-15 reference walker (in the write-chokepoint trigger, which unfolds first)
23
+ * always sees structured content. The wire never carries an envelope; only raw
24
+ * `ctx.db` reads / the dashboard see the folded storage form.
12
25
  */
13
26
 
27
+ /** Unfold the `document` blob of a stored row before it leaves the function. */
28
+ function withUnfoldedDocument<R extends { document: unknown }>(row: R): R {
29
+ return { ...row, document: unfoldDocument(row.document as CmsDocument) }
30
+ }
31
+
32
+ /** Unfold the `snapshot` blob of a version row before it leaves the function. */
33
+ function withUnfoldedSnapshot<R extends { snapshot: unknown }>(row: R): R {
34
+ return { ...row, snapshot: unfoldDocument(row.snapshot as CmsDocument) }
35
+ }
36
+
14
37
  export const upsertDocument = mutation({
15
38
  args: {
16
39
  collection: v.string(),
@@ -21,6 +44,10 @@ export const upsertDocument = mutation({
21
44
  },
22
45
  handler: async (ctx, args) => {
23
46
  await requireCapability(ctx, 'content.write') // EC-150
47
+ // EC-260: fold for storage. `args.document` arrives UNFOLDED over the 64-level
48
+ // arg wire; the trigger unfolds before extracting references, so folding here
49
+ // (strictly before the `ctx.db` write) never hides a reference from the walker.
50
+ const stored = { ...args, document: foldDocument(args.document as CmsDocument) }
24
51
  const existing = await ctx.db
25
52
  .query('cmsDocuments')
26
53
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
@@ -28,17 +55,19 @@ export const upsertDocument = mutation({
28
55
  if (existing) {
29
56
  // EC-153 parity: updates preserve `createdAt`, exactly like in-memory.
30
57
  // EC-286: the write-chokepoint trigger reindexes references in-transaction.
31
- const patch = { ...args, createdAt: existing.createdAt }
58
+ const patch = { ...stored, createdAt: existing.createdAt }
32
59
  await ctx.db.patch(existing._id, patch)
33
60
  // EC-322: return the merged row directly instead of re-reading the full
34
61
  // (up-to-~20 KB) document body via `ctx.db.get`. `patch` only overwrites the
35
62
  // supplied fields, so `{ ...existing, ...patch }` is byte-identical to the
36
63
  // stored row (system fields + untouched `publishedVersion` carried from
37
64
  // `existing`) — cutting one full-document read off the hottest write path.
38
- return { ...existing, ...patch }
65
+ // EC-260: unfold the folded body back to structured form for the return.
66
+ return withUnfoldedDocument({ ...existing, ...patch })
39
67
  }
40
- const id = await ctx.db.insert('cmsDocuments', args)
41
- return ctx.db.get(id)
68
+ const id = await ctx.db.insert('cmsDocuments', stored)
69
+ const row = await ctx.db.get(id)
70
+ return row ? withUnfoldedDocument(row) : row
42
71
  },
43
72
  })
44
73
 
@@ -46,10 +75,11 @@ export const getDocument = query({
46
75
  args: { collection: v.string(), docId: v.string() },
47
76
  handler: async (ctx, args) => {
48
77
  await requireCapability(ctx, 'content.read') // EC-150
49
- return ctx.db
78
+ const row = await ctx.db
50
79
  .query('cmsDocuments')
51
80
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
52
81
  .first()
82
+ return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
53
83
  },
54
84
  })
55
85
 
@@ -57,14 +87,13 @@ export const listDocuments = query({
57
87
  args: { collection: v.optional(v.string()) },
58
88
  handler: async (ctx, args) => {
59
89
  await requireCapability(ctx, 'content.read') // EC-150
60
- if (args.collection) {
61
- const collection = args.collection
62
- return ctx.db
63
- .query('cmsDocuments')
64
- .withIndex('by_collection', (q) => q.eq('collection', collection))
65
- .collect()
66
- }
67
- return ctx.db.query('cmsDocuments').collect()
90
+ const rows = args.collection
91
+ ? await ctx.db
92
+ .query('cmsDocuments')
93
+ .withIndex('by_collection', (q) => q.eq('collection', args.collection as string))
94
+ .collect()
95
+ : await ctx.db.query('cmsDocuments').collect()
96
+ return rows.map(withUnfoldedDocument) // EC-260: unfold before returning
68
97
  },
69
98
  })
70
99
 
@@ -113,8 +142,12 @@ export const saveDocumentVersion = mutation({
113
142
  .order('desc')
114
143
  .first()
115
144
  const version = (latest?.version ?? 0) + 1
116
- const id = await ctx.db.insert('documentVersions', { ...args, version })
117
- return ctx.db.get(id)
145
+ // EC-260: the snapshot embeds the CmsDocument at draft depth, so it folds at rest
146
+ // identically. `documentVersions` has no trigger — no walker to keep structured.
147
+ const stored = { ...args, snapshot: foldDocument(args.snapshot as CmsDocument), version }
148
+ const id = await ctx.db.insert('documentVersions', stored)
149
+ const row = await ctx.db.get(id)
150
+ return row ? withUnfoldedSnapshot(row) : row
118
151
  },
119
152
  })
120
153
 
@@ -127,7 +160,7 @@ export const listDocumentVersions = query({
127
160
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
128
161
  .collect()
129
162
  // Newest first (parity with the in-memory `listDocumentVersions`).
130
- return rows.sort((a, b) => b.version - a.version)
163
+ return rows.sort((a, b) => b.version - a.version).map(withUnfoldedSnapshot) // EC-260
131
164
  },
132
165
  })
133
166
 
@@ -138,12 +171,13 @@ export const getDocumentVersion = query({
138
171
  // EC-307: address the row directly via `by_doc_version` instead of collecting
139
172
  // the whole — unbounded — per-document history and finding in JS. `version` is
140
173
  // unique per (collection, docId), so `.unique()` both fetches and asserts that.
141
- return ctx.db
174
+ const row = await ctx.db
142
175
  .query('documentVersions')
143
176
  .withIndex('by_doc_version', (q) =>
144
177
  q.eq('collection', args.collection).eq('docId', args.docId).eq('version', args.version),
145
178
  )
146
179
  .unique()
180
+ return row ? withUnfoldedSnapshot(row) : row // EC-260: unfold before returning
147
181
  },
148
182
  })
149
183
 
@@ -175,7 +209,8 @@ export const listPublishedDocuments = query({
175
209
  q.eq('collection', doc.collection).eq('docId', doc.docId).eq('version', version),
176
210
  )
177
211
  .unique()
178
- if (pinned) snapshots.push(pinned.snapshot)
212
+ // EC-260: unfold the pinned snapshot before it leaves the function.
213
+ if (pinned) snapshots.push(unfoldDocument(pinned.snapshot as CmsDocument))
179
214
  }
180
215
  return snapshots
181
216
  },
@@ -186,7 +221,8 @@ export const listAllDocumentVersions = query({
186
221
  args: {},
187
222
  handler: async (ctx) => {
188
223
  await requireCapability(ctx, 'content.read') // EC-150
189
- return ctx.db.query('documentVersions').collect()
224
+ const rows = await ctx.db.query('documentVersions').collect()
225
+ return rows.map(withUnfoldedSnapshot) // EC-260: unfold before returning
190
226
  },
191
227
  })
192
228
 
@@ -208,7 +244,8 @@ export const publishDocumentVersion = mutation({
208
244
  // write-chokepoint trigger sees — it reindexes the published reference rows
209
245
  // and fires host revalidation (EC-146 instant publish). No manual calls here.
210
246
  await ctx.db.patch(doc._id, { publishedVersion: args.version, updatedAt: args.updatedAt })
211
- return ctx.db.get(doc._id)
247
+ const row = await ctx.db.get(doc._id)
248
+ return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
212
249
  },
213
250
  })
214
251
 
@@ -225,6 +262,7 @@ export const unpublishDocument = mutation({
225
262
  // EC-286: clearing the pin is a change the write-chokepoint trigger sees — it
226
263
  // drops the published index rows and revalidates the host. No manual calls.
227
264
  await ctx.db.patch(doc._id, { publishedVersion: undefined, updatedAt: args.updatedAt })
228
- return ctx.db.get(doc._id)
265
+ const row = await ctx.db.get(doc._id)
266
+ return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
229
267
  },
230
268
  })
package/convex/content.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { documentKey, documentSchema } from '@elytracms/core/cms-core'
1
+ import { documentKey, documentSchema, unfoldValue } from '@elytracms/core/cms-core'
2
2
  import type {
3
3
  CmsDocument,
4
4
  CmsValidationIssue,
@@ -157,7 +157,9 @@ export async function createContent(
157
157
  // a published read can serve the *pinned* version rather than the live draft.
158
158
  const versionsByKey = new Map<string, Map<number, CmsDocument>>()
159
159
  for (const row of await ctx.db.query('documentVersions').collect()) {
160
- const parsed = documentSchema.safeParse(row.snapshot)
160
+ // EC-260: unfold the folded storage form before parsing — the kit resolves over
161
+ // STRUCTURED values, exactly like the Next-side snapshot (byte-equivalence).
162
+ const parsed = documentSchema.safeParse(unfoldValue(row.snapshot))
161
163
  if (!parsed.success) continue
162
164
  const key = `${row.collection}:${row.docId}`
163
165
  let byVersion = versionsByKey.get(key)
@@ -176,7 +178,8 @@ export async function createContent(
176
178
  // resolves exactly as before.
177
179
  const parsedSources: { key: string; source: ContentDocumentSource }[] = []
178
180
  for (const row of documentRows) {
179
- const parsed = documentSchema.safeParse(row.document)
181
+ // EC-260: unfold the folded storage form before parsing (see the versions loop).
182
+ const parsed = documentSchema.safeParse(unfoldValue(row.document))
180
183
  if (!parsed.success) {
181
184
  loadIssues.push({
182
185
  code: 'invalid-stored-document',
@@ -1,4 +1,5 @@
1
1
  import { v } from 'convex/values'
2
+ import { unfoldValue } from '@elytracms/core/cms-core'
2
3
  import { dataSourceSchema } from '@elytracms/core/data-binding'
3
4
  import { query } from './functions'
4
5
  import type { QueryCtx } from './functions'
@@ -134,6 +135,10 @@ export async function loadSnapshot(
134
135
  const { projectId } = args
135
136
 
136
137
  const documentRows = await ctx.db.query('cmsDocuments').collect()
138
+ // EC-260: the stored `document`/`snapshot` blobs are folded; unfold every one
139
+ // before it enters the delivery snapshot so the host receives STRUCTURED content
140
+ // (no client ever sees an envelope). `unfoldValue` is identity on legacy data.
141
+ const unfoldDraft = (row: { document: unknown }): unknown => unfoldValue(row.document)
137
142
  // THE published-content invariant (the trust boundary): the public snapshot
138
143
  // ships ONLY published content — never a draft. Under the EC-224 pointer model
139
144
  // a published row is served as its *pinned version snapshot* (decoupled from
@@ -142,7 +147,7 @@ export async function loadSnapshot(
142
147
  // fallback (the `state` field is gone).
143
148
  let documents: unknown[]
144
149
  if (perspective === 'draft') {
145
- documents = documentRows.map((row) => row.document as unknown)
150
+ documents = documentRows.map(unfoldDraft)
146
151
  } else {
147
152
  documents = []
148
153
  for (const row of documentRows) {
@@ -160,7 +165,7 @@ export async function loadSnapshot(
160
165
  q.eq('collection', row.collection).eq('docId', row.docId).eq('version', version),
161
166
  )
162
167
  .unique()
163
- if (pinned) documents.push(pinned.snapshot as unknown)
168
+ if (pinned) documents.push(unfoldValue(pinned.snapshot))
164
169
  }
165
170
  }
166
171
 
@@ -202,9 +207,7 @@ export async function loadSnapshot(
202
207
  // extracted from every stored row (independent of publish state) — published
203
208
  // bindings must always resolve their definitions, even when the defining
204
209
  // `cms-data-sources` row is not itself "published".
205
- const sources = dataSourcesFromSystemCollection(
206
- documentRows.map((row) => row.document as unknown),
207
- )
210
+ const sources = dataSourcesFromSystemCollection(documentRows.map(unfoldDraft))
208
211
 
209
212
  // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect
210
213
  // is stored but never live, in either perspective. Projected to the routing
@@ -1,3 +1,4 @@
1
+ import { unfoldDocument } from '@elytracms/core/cms-core'
1
2
  import type { CmsDocument } from '@elytracms/core/cms-core'
2
3
  import { Triggers } from 'convex-helpers/server/triggers'
3
4
  import type { DataModel } from './functions'
@@ -42,11 +43,17 @@ triggers.register('cmsDocuments', async (ctx, change) => {
42
43
  return
43
44
  }
44
45
  const doc = change.newDoc
46
+ // EC-260: the stored `document` is FOLDED (deep field values collapsed to leaf
47
+ // envelopes). Unfold it here so `extractReferences` walks the STRUCTURED value and
48
+ // still finds references buried inside a folded field — this is the write chokepoint
49
+ // (EC-286), so every writer's references are derived from structured content by
50
+ // construction, regardless of the folded storage form. `unfoldDocument` is identity
51
+ // on already-structural data, so a legacy unfolded row costs nothing.
45
52
  await maintainDocumentReferences(
46
53
  ctx,
47
54
  doc.collection,
48
55
  doc.docId,
49
- doc.document as CmsDocument,
56
+ unfoldDocument(doc.document as CmsDocument),
50
57
  doc.publishedVersion !== undefined,
51
58
  )
52
59
  const before = change.oldDoc?.publishedVersion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/studio",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.44",
4
+ "version": "0.0.45",
5
5
  "private": false,
6
6
  "description": "The Elytra studio engine (UI + lib + routes + data-only Convex schema) as an importable package. Extraction staged in EXTRACTION.md (EC-223); Stages 1-2 = UI primitives + router-free lib leaves.",
7
7
  "license": "MIT",
@@ -136,7 +136,7 @@
136
136
  "lucide-react": "^1.23.0",
137
137
  "tailwind-merge": "^3.6.0",
138
138
  "zod": "^4.4.3",
139
- "@elytracms/core": "^0.0.44"
139
+ "@elytracms/core": "^0.0.45"
140
140
  },
141
141
  "peerDependencies": {
142
142
  "@tanstack/react-router": "^1.170.0",