@elytracms/studio 0.0.25 → 0.0.27

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.
Files changed (58) hide show
  1. package/convex/cms.ts +49 -7
  2. package/convex/delivery.ts +14 -16
  3. package/convex/references.ts +38 -2
  4. package/convex/schema.ts +6 -1
  5. package/dist/components/assets/assets-store.js +41 -30
  6. package/dist/components/assets/assets-store.js.map +1 -1
  7. package/dist/components/auth-context.d.ts +4 -2
  8. package/dist/components/auth-context.js +19 -19
  9. package/dist/components/auth-context.js.map +1 -1
  10. package/dist/components/cms/cms-store.js +5 -4
  11. package/dist/components/cms/cms-store.js.map +1 -1
  12. package/dist/components/cms/collection-table.js +10 -3
  13. package/dist/components/cms/collection-table.js.map +1 -1
  14. package/dist/components/cms/detail-editor.js +36 -9
  15. package/dist/components/cms/detail-editor.js.map +1 -1
  16. package/dist/components/cms/field-control.d.ts +2 -1
  17. package/dist/components/cms/field-control.js +161 -73
  18. package/dist/components/cms/field-control.js.map +1 -1
  19. package/dist/components/cms/versions-pane.js +28 -16
  20. package/dist/components/cms/versions-pane.js.map +1 -1
  21. package/dist/components/project-session.d.ts +11 -0
  22. package/dist/components/project-session.js.map +1 -1
  23. package/dist/components/routes/routes-shell.js +22 -22
  24. package/dist/components/routes/routes-shell.js.map +1 -1
  25. package/dist/components/studio-context.js +5 -1
  26. package/dist/components/studio-context.js.map +1 -1
  27. package/dist/lib/cms/published-documents.d.ts +6 -6
  28. package/dist/lib/cms/published-documents.js +21 -58
  29. package/dist/lib/cms/published-documents.js.map +1 -1
  30. package/dist/lib/cms-views/view-model.d.ts +7 -3
  31. package/dist/lib/cms-views/view-model.js +11 -5
  32. package/dist/lib/cms-views/view-model.js.map +1 -1
  33. package/dist/lib/data/collection-content.js +7 -5
  34. package/dist/lib/data/collection-content.js.map +1 -1
  35. package/dist/lib/data/data-store.js +34 -24
  36. package/dist/lib/data/data-store.js.map +1 -1
  37. package/dist/lib/persistence/convex-adapter.js +22 -0
  38. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  39. package/dist/lib/persistence/index.js +6 -2
  40. package/dist/lib/persistence/index.js.map +1 -1
  41. package/dist/lib/query/index.d.ts +6 -0
  42. package/dist/lib/query/index.js +6 -0
  43. package/dist/lib/query/index.js.map +1 -0
  44. package/dist/lib/query/operations-query.d.ts +85 -0
  45. package/dist/lib/query/operations-query.js +124 -0
  46. package/dist/lib/query/operations-query.js.map +1 -0
  47. package/dist/lib/references/reference-model.js +7 -1
  48. package/dist/lib/references/reference-model.js.map +1 -1
  49. package/dist/lib/references/references-store.d.ts +5 -10
  50. package/dist/lib/references/references-store.js +55 -77
  51. package/dist/lib/references/references-store.js.map +1 -1
  52. package/dist/ui/collapsible-section.d.ts +20 -0
  53. package/dist/ui/collapsible-section.js +18 -0
  54. package/dist/ui/collapsible-section.js.map +1 -0
  55. package/dist/ui/index.d.ts +1 -0
  56. package/dist/ui/index.js +1 -0
  57. package/dist/ui/index.js.map +1 -1
  58. package/package.json +7 -2
package/convex/cms.ts CHANGED
@@ -98,11 +98,15 @@ export const saveDocumentVersion = mutation({
98
98
  },
99
99
  handler: async (ctx, args) => {
100
100
  await requireRole(ctx, 'editor') // EC-150
101
- const existing = await ctx.db
101
+ // EC-302: versions are appended monotonically, so the latest-created row holds
102
+ // the highest version. Read just that one (index-ordered, newest first) instead
103
+ // of collecting the whole — unbounded — history to compute `max+1`.
104
+ const latest = await ctx.db
102
105
  .query('documentVersions')
103
106
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
104
- .collect()
105
- const version = existing.reduce((max, r) => Math.max(max, r.version), 0) + 1
107
+ .order('desc')
108
+ .first()
109
+ const version = (latest?.version ?? 0) + 1
106
110
  const id = await ctx.db.insert('documentVersions', { ...args, version })
107
111
  return ctx.db.get(id)
108
112
  },
@@ -125,11 +129,49 @@ export const getDocumentVersion = query({
125
129
  args: { collection: v.string(), docId: v.string(), version: v.number() },
126
130
  handler: async (ctx, args) => {
127
131
  await requireRole(ctx, 'viewer') // EC-150
128
- const rows = await ctx.db
132
+ // EC-307: address the row directly via `by_doc_version` instead of collecting
133
+ // the whole — unbounded — per-document history and finding in JS. `version` is
134
+ // unique per (collection, docId), so `.unique()` both fetches and asserts that.
135
+ return ctx.db
129
136
  .query('documentVersions')
130
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
131
- .collect()
132
- return rows.find((r) => r.version === args.version) ?? null
137
+ .withIndex('by_doc_version', (q) =>
138
+ q.eq('collection', args.collection).eq('docId', args.docId).eq('version', args.version),
139
+ )
140
+ .unique()
141
+ },
142
+ })
143
+
144
+ /**
145
+ * The pinned published snapshot of every currently-published document (EC-307) —
146
+ * one bulk read collapsing the per-document `listVersions` fan-out the studio's
147
+ * published pool used to issue (`published-documents.ts`). Reads each published
148
+ * document's pinned version row directly via `by_doc_version`. Returns the
149
+ * `CmsDocument` snapshots (the live perspective); unpublished documents are absent.
150
+ */
151
+ export const listPublishedDocuments = query({
152
+ args: { collection: v.optional(v.string()) },
153
+ handler: async (ctx, args) => {
154
+ await requireRole(ctx, 'viewer') // EC-150
155
+ const collection = args.collection
156
+ const docs = collection
157
+ ? await ctx.db
158
+ .query('cmsDocuments')
159
+ .withIndex('by_collection', (q) => q.eq('collection', collection))
160
+ .collect()
161
+ : await ctx.db.query('cmsDocuments').collect()
162
+ const snapshots: unknown[] = []
163
+ for (const doc of docs) {
164
+ const version = doc.publishedVersion
165
+ if (version === undefined) continue
166
+ const pinned = await ctx.db
167
+ .query('documentVersions')
168
+ .withIndex('by_doc_version', (q) =>
169
+ q.eq('collection', doc.collection).eq('docId', doc.docId).eq('version', version),
170
+ )
171
+ .unique()
172
+ if (pinned) snapshots.push(pinned.snapshot)
173
+ }
174
+ return snapshots
133
175
  },
134
176
  })
135
177
 
@@ -136,25 +136,23 @@ export async function loadSnapshot(
136
136
  if (perspective === 'draft') {
137
137
  documents = documentRows.map((row) => row.document as unknown)
138
138
  } else {
139
- const versionsByKey = new Map<string, Map<number, unknown>>()
140
- for (const row of await ctx.db.query('documentVersions').collect()) {
141
- const key = `${row.collection}:${row.docId}`
142
- let byVersion = versionsByKey.get(key)
143
- if (!byVersion) {
144
- byVersion = new Map()
145
- versionsByKey.set(key, byVersion)
146
- }
147
- byVersion.set(row.version, row.snapshot as unknown)
148
- }
149
139
  documents = []
150
140
  for (const row of documentRows) {
151
141
  if (row.publishedVersion === undefined) continue
152
- // Serve the pinned recorded version, NOT the live draft. A dangling pin
153
- // (version missing) is dropped — it never falls back to the draft.
154
- const snapshot = versionsByKey
155
- .get(`${row.collection}:${row.docId}`)
156
- ?.get(row.publishedVersion)
157
- if (snapshot !== undefined) documents.push(snapshot)
142
+ // EC-308: read THIS document's pinned version directly via `by_doc_version`
143
+ // (EC-307) instead of collecting the entire append-only `documentVersions`
144
+ // table and joining in memory — the published snapshot now scales with the
145
+ // number of published docs, not with total edit history. Serve the pinned
146
+ // recorded version, NOT the live draft; a dangling pin (`.unique()` → null)
147
+ // is dropped, never falling back to the draft.
148
+ const version = row.publishedVersion
149
+ const pinned = await ctx.db
150
+ .query('documentVersions')
151
+ .withIndex('by_doc_version', (q) =>
152
+ q.eq('collection', row.collection).eq('docId', row.docId).eq('version', version),
153
+ )
154
+ .unique()
155
+ if (pinned) documents.push(pinned.snapshot as unknown)
158
156
  }
159
157
  }
160
158
 
@@ -187,11 +187,47 @@ export const inbound = query({
187
187
  },
188
188
  })
189
189
 
190
+ export const inboundMany = query({
191
+ args: {
192
+ targets: v.array(v.object({ targetType: targetTypeValidator, targetId: v.string() })),
193
+ variant: v.optional(variantValidator),
194
+ },
195
+ handler: async (ctx, args) => {
196
+ await requireRole(ctx, 'viewer') // EC-150
197
+ // EC-308: inbound entries for many targets in ONE round-trip — loop the
198
+ // `by_target` index server-side instead of the client issuing one `inbound`
199
+ // query per target (the bulk delete/unpublish pre-flight fan-out). One result
200
+ // list per target, in `targets` order.
201
+ const groups = []
202
+ for (const target of args.targets) {
203
+ const rows = await ctx.db
204
+ .query('referenceEntries')
205
+ .withIndex('by_target', (q) => {
206
+ const scoped = q.eq('targetType', target.targetType).eq('targetId', target.targetId)
207
+ return args.variant ? scoped.eq('variant', args.variant) : scoped
208
+ })
209
+ .collect()
210
+ groups.push(rows)
211
+ }
212
+ return groups
213
+ },
214
+ })
215
+
190
216
  export const list = query({
191
217
  args: { variant: v.optional(variantValidator) },
192
218
  handler: async (ctx, args) => {
193
219
  await requireRole(ctx, 'viewer') // EC-150
194
- const docs = await ctx.db.query('referenceEntries').collect()
195
- return args.variant ? docs.filter((d) => d.variant === args.variant) : docs
220
+ // EC-302: `by_source` leads with `variant`, so a variant filter is an index
221
+ // prefix — push it into the query instead of collecting the whole table and
222
+ // filtering in JS. Callers treat the result as an unordered set, so the shift
223
+ // from insertion order to index order is immaterial.
224
+ const variant = args.variant
225
+ if (variant !== undefined) {
226
+ return ctx.db
227
+ .query('referenceEntries')
228
+ .withIndex('by_source', (q) => q.eq('variant', variant))
229
+ .collect()
230
+ }
231
+ return ctx.db.query('referenceEntries').collect()
196
232
  },
197
233
  })
package/convex/schema.ts CHANGED
@@ -108,7 +108,12 @@ export default defineSchema({
108
108
  snapshot: v.any(),
109
109
  label: v.optional(v.string()),
110
110
  createdAt: v.string(),
111
- }).index('by_doc', ['collection', 'docId']),
111
+ })
112
+ .index('by_doc', ['collection', 'docId'])
113
+ // EC-307: address one recorded version directly (the hot restore/publish-pool
114
+ // path) instead of collecting a document's whole — unbounded — history and
115
+ // finding in JS. `version` is unique per (collection, docId), so a lookup is `.unique()`.
116
+ .index('by_doc_version', ['collection', 'docId', 'version']),
112
117
 
113
118
  /** Asset metadata; bytes live in Convex/file storage behind the blob adapter (EC-044). */
114
119
  assets: defineTable({
@@ -1,43 +1,54 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
3
3
  import { deleteAsset, runUpload, xhrUploadTransport } from '@elytracms/studio/lib/assets';
4
4
  import { loadInboundAssetReferenceViews } from '@elytracms/studio/lib/references';
5
5
  import { getStudioOperations, operationsAssetRepository } from '@elytracms/studio/lib/operations';
6
+ import { useOperationCache, useOperationQuery } from '@elytracms/studio/lib/query';
6
7
  import { updateAssetMetadata } from './asset-metadata';
7
8
  const AssetsContext = createContext(null);
9
+ const EMPTY_ASSETS = [];
10
+ /** Cache key for a project's asset library (shared by the query and its patches). */
11
+ function assetsQueryKey(projectId) {
12
+ return ['assets', projectId];
13
+ }
14
+ /** Load a project's asset library (pure over the operations client). A list
15
+ * failure throws so the query surfaces it as an error status. */
16
+ async function loadAssets(operations, projectId) {
17
+ const result = await operations.assets.list({ projectId });
18
+ if (!result.ok)
19
+ throw new Error(result.error.message);
20
+ return [...result.value];
21
+ }
8
22
  export function AssetsProvider({ projectId, children, }) {
9
23
  const uploadFilesRef = useRef(new Map());
10
24
  const uploadSeq = useRef(0);
11
- const [status, setStatus] = useState('loading');
12
- const [errorMessage, setErrorMessage] = useState(undefined);
13
- const [assets, setAssets] = useState([]);
14
25
  const [uploads, setUploads] = useState([]);
15
26
  /** The shared studio operations bundle + the metadata-write facade over it. */
16
27
  const studio = useCallback(async () => {
17
28
  const bundle = await getStudioOperations();
18
29
  return { ...bundle, assetRepo: operationsAssetRepository(bundle.operations) };
19
30
  }, []);
31
+ // The library list, read through the one operations-backed query cache
32
+ // (EC-308/AD-17). No `invalidateOn`: this provider is the sole writer of its
33
+ // project's assets, so its own mutations patch the cache in place (the EC-301
34
+ // rule — a metadata or focal-point edit must never trigger a full re-list).
35
+ const query = useOperationQuery({
36
+ queryKey: assetsQueryKey(projectId),
37
+ loader: (operations) => loadAssets(operations, projectId),
38
+ });
39
+ const cache = useOperationCache();
40
+ const refetch = query.refetch;
41
+ const assets = query.data ?? EMPTY_ASSETS;
42
+ const status = query.isPending
43
+ ? 'loading'
44
+ : query.isError
45
+ ? 'error'
46
+ : 'ready';
47
+ const errorMessage = query.isError && query.error instanceof Error ? query.error.message : undefined;
48
+ /** Re-list from the backend (the public escape hatch + post-upload reconcile). */
20
49
  const refresh = useCallback(async () => {
21
- try {
22
- const { operations } = await studio();
23
- const result = await operations.assets.list({ projectId });
24
- if (!result.ok) {
25
- setStatus('error');
26
- setErrorMessage(result.error.message);
27
- return;
28
- }
29
- setAssets(result.value);
30
- setStatus('ready');
31
- setErrorMessage(undefined);
32
- }
33
- catch (error) {
34
- setStatus('error');
35
- setErrorMessage(error instanceof Error ? error.message : String(error));
36
- }
37
- }, [studio, projectId]);
38
- useEffect(() => {
39
- void refresh();
40
- }, [refresh]);
50
+ await refetch();
51
+ }, [refetch]);
41
52
  const setUploadState = useCallback((uploadId, state) => {
42
53
  setUploads((prev) => prev.map((u) => (u.id === uploadId ? { ...u, state } : u)));
43
54
  }, []);
@@ -94,29 +105,29 @@ export function AssetsProvider({ projectId, children, }) {
94
105
  // facade); blob byte deletion stays on the adapter; the usage gate reads
95
106
  // the EC-155 reference index (`adapter.references`, EC-202).
96
107
  const result = await deleteAsset({ assets: assetRepo, blobs: adapter.blobs, references: adapter.references }, projectId, assetId, options);
97
- // Drop the one removed record from local state rather than re-listing the whole
108
+ // Drop the one removed record from the cache rather than re-listing the whole
98
109
  // library (EC-301 perf, mirrors updateMetadata) — a delete must not trigger a full
99
110
  // `assets.list` round-trip on top of the deletion itself.
100
111
  if (result.outcome === 'deleted' || result.outcome === 'not-found') {
101
- setAssets((prev) => prev.filter((a) => a.id !== assetId));
112
+ cache.patch(assetsQueryKey(projectId), (prev) => (prev ?? []).filter((a) => a.id !== assetId));
102
113
  }
103
114
  return result;
104
- }, [studio, projectId]);
115
+ }, [studio, projectId, cache]);
105
116
  const updateMetadata = useCallback(async (assetId, patch) => {
106
117
  // Metadata writes go through the `assets.upsert` command via the
107
118
  // operations facade (EC-141) — never straight at a repository.
108
119
  const { assetRepo } = await studio();
109
120
  const result = await updateAssetMetadata({ assets: assetRepo }, projectId, assetId, patch);
110
121
  if (result.ok) {
111
- // Patch the one updated record into local state rather than re-listing the
122
+ // Patch the one updated record into the cache rather than re-listing the
112
123
  // whole library (EC-301). A focal-point or alt edit must not trigger a full
113
124
  // `assets.list` round-trip — that re-list (plus the marker only moving once
114
125
  // it returned) was the focal-point picker's lag and query storm.
115
126
  const updated = result.asset;
116
- setAssets((prev) => prev.map((a) => (a.id === assetId ? updated : a)));
127
+ cache.patch(assetsQueryKey(projectId), (prev) => (prev ?? []).map((a) => (a.id === assetId ? updated : a)));
117
128
  }
118
129
  return result;
119
- }, [studio, projectId]);
130
+ }, [studio, projectId, cache]);
120
131
  const store = useMemo(() => ({
121
132
  projectId,
122
133
  status,
@@ -1 +1 @@
1
- {"version":3,"file":"assets-store.js","sourceRoot":"","sources":["../../../src/components/assets/assets-store.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport type { AssetRecord, AssetRepository } from '@elytracms/core/persistence'\nimport { deleteAsset, runUpload, xhrUploadTransport } from '@elytracms/studio/lib/assets'\nimport type { DeleteAssetResult, UploadState, UploadableFile } from '@elytracms/studio/lib/assets'\nimport { loadInboundAssetReferenceViews } from '@elytracms/studio/lib/references'\nimport type { ReferenceEntryView } from '@elytracms/studio/lib/references'\nimport { getStudioOperations, operationsAssetRepository } from '@elytracms/studio/lib/operations'\nimport type { StudioOperations } from '@elytracms/studio/lib/operations'\nimport { updateAssetMetadata } from './asset-metadata'\nimport type { AssetMetadataPatch, UpdateAssetMetadataResult } from './asset-metadata'\n\n/**\n * Asset library store (EC-151, EC-141). Asset *metadata* mutations go through\n * the shared operations client (`assets.*` commands — validate → authorize →\n * mutate → event); blob *bytes* ride on `adapter.blobs`, which is transport,\n * not domain. The UI works identically against the in-memory seed and a live\n * Convex deployment.\n */\n\nexport interface UploadEntry {\n id: string\n filename: string\n state: UploadState\n}\n\nexport interface AssetsStore {\n /** The active project (e.g. for reference deep links, EC-202). */\n projectId: string\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n assets: readonly AssetRecord[]\n uploads: readonly UploadEntry[]\n upload(files: Iterable<UploadableFile>): void\n retryUpload(uploadId: string): void\n dismissUpload(uploadId: string): void\n /**\n * Index-accurate inbound references of an asset (EC-202): the \"used by\"\n * pre-flight of the block-or-confirm delete dialog, read from the EC-155\n * reference index (not the retired string scan).\n */\n usagesOf(assetId: string): Promise<ReferenceEntryView[]>\n remove(assetId: string, options?: { confirmInUse?: boolean }): Promise<DeleteAssetResult>\n /** Edit alt/title (EC-152); persists through the operations asset facade. */\n updateMetadata(assetId: string, patch: AssetMetadataPatch): Promise<UpdateAssetMetadataResult>\n refresh(): Promise<void>\n}\n\nconst AssetsContext = createContext<AssetsStore | null>(null)\n\nexport function AssetsProvider({\n projectId,\n children,\n}: {\n projectId: string\n children: ReactNode\n}) {\n const uploadFilesRef = useRef(new Map<string, UploadableFile>())\n const uploadSeq = useRef(0)\n const [status, setStatus] = useState<AssetsStore['status']>('loading')\n const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)\n const [assets, setAssets] = useState<readonly AssetRecord[]>([])\n const [uploads, setUploads] = useState<readonly UploadEntry[]>([])\n\n /** The shared studio operations bundle + the metadata-write facade over it. */\n const studio = useCallback(async (): Promise<\n StudioOperations & { assetRepo: AssetRepository }\n > => {\n const bundle = await getStudioOperations()\n return { ...bundle, assetRepo: operationsAssetRepository(bundle.operations) }\n }, [])\n\n const refresh = useCallback(async () => {\n try {\n const { operations } = await studio()\n const result = await operations.assets.list({ projectId })\n if (!result.ok) {\n setStatus('error')\n setErrorMessage(result.error.message)\n return\n }\n setAssets(result.value)\n setStatus('ready')\n setErrorMessage(undefined)\n } catch (error) {\n setStatus('error')\n setErrorMessage(error instanceof Error ? error.message : String(error))\n }\n }, [studio, projectId])\n\n useEffect(() => {\n void refresh()\n }, [refresh])\n\n const setUploadState = useCallback((uploadId: string, state: UploadState) => {\n setUploads((prev) => prev.map((u) => (u.id === uploadId ? { ...u, state } : u)))\n }, [])\n\n const runEntry = useCallback(\n async (uploadId: string, file: UploadableFile, previous?: UploadState) => {\n const { adapter, assetRepo } = await studio()\n const result = await runUpload(\n {\n // Bytes ride on blob storage (transport); the metadata record is\n // confirmed through the `assets.upsert` operation command.\n blobs: adapter.blobs,\n assets: assetRepo,\n ...(typeof XMLHttpRequest !== 'undefined' ? { transport: xhrUploadTransport } : {}),\n },\n {\n projectId,\n file,\n onState: (state) => setUploadState(uploadId, state),\n },\n previous,\n )\n if (result.ok) {\n // Completed uploads leave the queue; the asset appears in the library.\n uploadFilesRef.current.delete(uploadId)\n setUploads((prev) => prev.filter((u) => u.id !== uploadId))\n await refresh()\n }\n },\n [studio, projectId, refresh, setUploadState],\n )\n\n const upload = useCallback(\n (files: Iterable<UploadableFile>) => {\n for (const file of files) {\n uploadSeq.current += 1\n const uploadId = `upload-${uploadSeq.current}`\n uploadFilesRef.current.set(uploadId, file)\n setUploads((prev) => [\n ...prev,\n { id: uploadId, filename: file.name, state: { phase: 'idle', progress: 0, attempt: 0 } },\n ])\n void runEntry(uploadId, file)\n }\n },\n [runEntry],\n )\n\n const retryUpload = useCallback(\n (uploadId: string) => {\n const file = uploadFilesRef.current.get(uploadId)\n const entry = uploads.find((u) => u.id === uploadId)\n if (!file || !entry || entry.state.phase !== 'failed') return\n void runEntry(uploadId, file, entry.state)\n },\n [runEntry, uploads],\n )\n\n const dismissUpload = useCallback((uploadId: string) => {\n uploadFilesRef.current.delete(uploadId)\n setUploads((prev) => prev.filter((u) => u.id !== uploadId))\n }, [])\n\n const usagesOf = useCallback(\n // Index-accurate (EC-202): inbound references from the EC-155 index via the\n // operations queries — the same machinery the References panel uses.\n async (assetId: string) => loadInboundAssetReferenceViews(projectId, assetId),\n [projectId],\n )\n\n const remove = useCallback(\n async (assetId: string, options?: { confirmInUse?: boolean }) => {\n const { adapter, assetRepo } = await studio()\n // Metadata removal goes through the `assets.remove` command (via the\n // facade); blob byte deletion stays on the adapter; the usage gate reads\n // the EC-155 reference index (`adapter.references`, EC-202).\n const result = await deleteAsset(\n { assets: assetRepo, blobs: adapter.blobs, references: adapter.references },\n projectId,\n assetId,\n options,\n )\n // Drop the one removed record from local state rather than re-listing the whole\n // library (EC-301 perf, mirrors updateMetadata) — a delete must not trigger a full\n // `assets.list` round-trip on top of the deletion itself.\n if (result.outcome === 'deleted' || result.outcome === 'not-found') {\n setAssets((prev) => prev.filter((a) => a.id !== assetId))\n }\n return result\n },\n [studio, projectId],\n )\n\n const updateMetadata = useCallback(\n async (assetId: string, patch: AssetMetadataPatch) => {\n // Metadata writes go through the `assets.upsert` command via the\n // operations facade (EC-141) — never straight at a repository.\n const { assetRepo } = await studio()\n const result = await updateAssetMetadata({ assets: assetRepo }, projectId, assetId, patch)\n if (result.ok) {\n // Patch the one updated record into local state rather than re-listing the\n // whole library (EC-301). A focal-point or alt edit must not trigger a full\n // `assets.list` round-trip — that re-list (plus the marker only moving once\n // it returned) was the focal-point picker's lag and query storm.\n const updated = result.asset\n setAssets((prev) => prev.map((a) => (a.id === assetId ? updated : a)))\n }\n return result\n },\n [studio, projectId],\n )\n\n const store = useMemo<AssetsStore>(\n () => ({\n projectId,\n status,\n ...(errorMessage !== undefined ? { errorMessage } : {}),\n assets,\n uploads,\n upload,\n retryUpload,\n dismissUpload,\n usagesOf,\n remove,\n updateMetadata,\n refresh,\n }),\n [\n projectId,\n status,\n errorMessage,\n assets,\n uploads,\n upload,\n retryUpload,\n dismissUpload,\n usagesOf,\n remove,\n updateMetadata,\n refresh,\n ],\n )\n\n return <AssetsContext.Provider value={store}>{children}</AssetsContext.Provider>\n}\n\nexport function useAssets(): AssetsStore {\n const store = useContext(AssetsContext)\n if (!store) throw new Error('useAssets must be used within an <AssetsProvider>.')\n return store\n}\n\n/**\n * The assets store when an `<AssetsProvider>` is mounted above, else `null`.\n * Lets surfaces outside the Assets section (the CMS asset picker, EC-152)\n * reuse an ambient provider or mount their own.\n */\nexport function useOptionalAssets(): AssetsStore | null {\n return useContext(AssetsContext)\n}\n"]}
1
+ {"version":3,"file":"assets-store.js","sourceRoot":"","sources":["../../../src/components/assets/assets-store.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport type { AssetRecord, AssetRepository } from '@elytracms/core/persistence'\nimport { deleteAsset, runUpload, xhrUploadTransport } from '@elytracms/studio/lib/assets'\nimport type { DeleteAssetResult, UploadState, UploadableFile } from '@elytracms/studio/lib/assets'\nimport { loadInboundAssetReferenceViews } from '@elytracms/studio/lib/references'\nimport type { ReferenceEntryView } from '@elytracms/studio/lib/references'\nimport { getStudioOperations, operationsAssetRepository } from '@elytracms/studio/lib/operations'\nimport type { OperationsClient, StudioOperations } from '@elytracms/studio/lib/operations'\nimport { useOperationCache, useOperationQuery } from '@elytracms/studio/lib/query'\nimport { updateAssetMetadata } from './asset-metadata'\nimport type { AssetMetadataPatch, UpdateAssetMetadataResult } from './asset-metadata'\n\n/**\n * Asset library store (EC-151, EC-141). Asset *metadata* mutations go through\n * the shared operations client (`assets.*` commands — validate → authorize →\n * mutate → event); blob *bytes* ride on `adapter.blobs`, which is transport,\n * not domain. The UI works identically against the in-memory seed and a live\n * Convex deployment.\n */\n\nexport interface UploadEntry {\n id: string\n filename: string\n state: UploadState\n}\n\nexport interface AssetsStore {\n /** The active project (e.g. for reference deep links, EC-202). */\n projectId: string\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n assets: readonly AssetRecord[]\n uploads: readonly UploadEntry[]\n upload(files: Iterable<UploadableFile>): void\n retryUpload(uploadId: string): void\n dismissUpload(uploadId: string): void\n /**\n * Index-accurate inbound references of an asset (EC-202): the \"used by\"\n * pre-flight of the block-or-confirm delete dialog, read from the EC-155\n * reference index (not the retired string scan).\n */\n usagesOf(assetId: string): Promise<ReferenceEntryView[]>\n remove(assetId: string, options?: { confirmInUse?: boolean }): Promise<DeleteAssetResult>\n /** Edit alt/title (EC-152); persists through the operations asset facade. */\n updateMetadata(assetId: string, patch: AssetMetadataPatch): Promise<UpdateAssetMetadataResult>\n refresh(): Promise<void>\n}\n\nconst AssetsContext = createContext<AssetsStore | null>(null)\n\nconst EMPTY_ASSETS: readonly AssetRecord[] = []\n\n/** Cache key for a project's asset library (shared by the query and its patches). */\nfunction assetsQueryKey(projectId: string): readonly [string, string] {\n return ['assets', projectId]\n}\n\n/** Load a project's asset library (pure over the operations client). A list\n * failure throws so the query surfaces it as an error status. */\nasync function loadAssets(operations: OperationsClient, projectId: string): Promise<AssetRecord[]> {\n const result = await operations.assets.list({ projectId })\n if (!result.ok) throw new Error(result.error.message)\n return [...result.value]\n}\n\nexport function AssetsProvider({\n projectId,\n children,\n}: {\n projectId: string\n children: ReactNode\n}) {\n const uploadFilesRef = useRef(new Map<string, UploadableFile>())\n const uploadSeq = useRef(0)\n const [uploads, setUploads] = useState<readonly UploadEntry[]>([])\n\n /** The shared studio operations bundle + the metadata-write facade over it. */\n const studio = useCallback(async (): Promise<\n StudioOperations & { assetRepo: AssetRepository }\n > => {\n const bundle = await getStudioOperations()\n return { ...bundle, assetRepo: operationsAssetRepository(bundle.operations) }\n }, [])\n\n // The library list, read through the one operations-backed query cache\n // (EC-308/AD-17). No `invalidateOn`: this provider is the sole writer of its\n // project's assets, so its own mutations patch the cache in place (the EC-301\n // rule — a metadata or focal-point edit must never trigger a full re-list).\n const query = useOperationQuery<AssetRecord[]>({\n queryKey: assetsQueryKey(projectId),\n loader: (operations) => loadAssets(operations, projectId),\n })\n const cache = useOperationCache()\n const refetch = query.refetch\n const assets = query.data ?? EMPTY_ASSETS\n const status: AssetsStore['status'] = query.isPending\n ? 'loading'\n : query.isError\n ? 'error'\n : 'ready'\n const errorMessage =\n query.isError && query.error instanceof Error ? query.error.message : undefined\n\n /** Re-list from the backend (the public escape hatch + post-upload reconcile). */\n const refresh = useCallback(async () => {\n await refetch()\n }, [refetch])\n\n const setUploadState = useCallback((uploadId: string, state: UploadState) => {\n setUploads((prev) => prev.map((u) => (u.id === uploadId ? { ...u, state } : u)))\n }, [])\n\n const runEntry = useCallback(\n async (uploadId: string, file: UploadableFile, previous?: UploadState) => {\n const { adapter, assetRepo } = await studio()\n const result = await runUpload(\n {\n // Bytes ride on blob storage (transport); the metadata record is\n // confirmed through the `assets.upsert` operation command.\n blobs: adapter.blobs,\n assets: assetRepo,\n ...(typeof XMLHttpRequest !== 'undefined' ? { transport: xhrUploadTransport } : {}),\n },\n {\n projectId,\n file,\n onState: (state) => setUploadState(uploadId, state),\n },\n previous,\n )\n if (result.ok) {\n // Completed uploads leave the queue; the asset appears in the library.\n uploadFilesRef.current.delete(uploadId)\n setUploads((prev) => prev.filter((u) => u.id !== uploadId))\n await refresh()\n }\n },\n [studio, projectId, refresh, setUploadState],\n )\n\n const upload = useCallback(\n (files: Iterable<UploadableFile>) => {\n for (const file of files) {\n uploadSeq.current += 1\n const uploadId = `upload-${uploadSeq.current}`\n uploadFilesRef.current.set(uploadId, file)\n setUploads((prev) => [\n ...prev,\n { id: uploadId, filename: file.name, state: { phase: 'idle', progress: 0, attempt: 0 } },\n ])\n void runEntry(uploadId, file)\n }\n },\n [runEntry],\n )\n\n const retryUpload = useCallback(\n (uploadId: string) => {\n const file = uploadFilesRef.current.get(uploadId)\n const entry = uploads.find((u) => u.id === uploadId)\n if (!file || !entry || entry.state.phase !== 'failed') return\n void runEntry(uploadId, file, entry.state)\n },\n [runEntry, uploads],\n )\n\n const dismissUpload = useCallback((uploadId: string) => {\n uploadFilesRef.current.delete(uploadId)\n setUploads((prev) => prev.filter((u) => u.id !== uploadId))\n }, [])\n\n const usagesOf = useCallback(\n // Index-accurate (EC-202): inbound references from the EC-155 index via the\n // operations queries — the same machinery the References panel uses.\n async (assetId: string) => loadInboundAssetReferenceViews(projectId, assetId),\n [projectId],\n )\n\n const remove = useCallback(\n async (assetId: string, options?: { confirmInUse?: boolean }) => {\n const { adapter, assetRepo } = await studio()\n // Metadata removal goes through the `assets.remove` command (via the\n // facade); blob byte deletion stays on the adapter; the usage gate reads\n // the EC-155 reference index (`adapter.references`, EC-202).\n const result = await deleteAsset(\n { assets: assetRepo, blobs: adapter.blobs, references: adapter.references },\n projectId,\n assetId,\n options,\n )\n // Drop the one removed record from the cache rather than re-listing the whole\n // library (EC-301 perf, mirrors updateMetadata) — a delete must not trigger a full\n // `assets.list` round-trip on top of the deletion itself.\n if (result.outcome === 'deleted' || result.outcome === 'not-found') {\n cache.patch<AssetRecord[]>(assetsQueryKey(projectId), (prev) =>\n (prev ?? []).filter((a) => a.id !== assetId),\n )\n }\n return result\n },\n [studio, projectId, cache],\n )\n\n const updateMetadata = useCallback(\n async (assetId: string, patch: AssetMetadataPatch) => {\n // Metadata writes go through the `assets.upsert` command via the\n // operations facade (EC-141) — never straight at a repository.\n const { assetRepo } = await studio()\n const result = await updateAssetMetadata({ assets: assetRepo }, projectId, assetId, patch)\n if (result.ok) {\n // Patch the one updated record into the cache rather than re-listing the\n // whole library (EC-301). A focal-point or alt edit must not trigger a full\n // `assets.list` round-trip — that re-list (plus the marker only moving once\n // it returned) was the focal-point picker's lag and query storm.\n const updated = result.asset\n cache.patch<AssetRecord[]>(assetsQueryKey(projectId), (prev) =>\n (prev ?? []).map((a) => (a.id === assetId ? updated : a)),\n )\n }\n return result\n },\n [studio, projectId, cache],\n )\n\n const store = useMemo<AssetsStore>(\n () => ({\n projectId,\n status,\n ...(errorMessage !== undefined ? { errorMessage } : {}),\n assets,\n uploads,\n upload,\n retryUpload,\n dismissUpload,\n usagesOf,\n remove,\n updateMetadata,\n refresh,\n }),\n [\n projectId,\n status,\n errorMessage,\n assets,\n uploads,\n upload,\n retryUpload,\n dismissUpload,\n usagesOf,\n remove,\n updateMetadata,\n refresh,\n ],\n )\n\n return <AssetsContext.Provider value={store}>{children}</AssetsContext.Provider>\n}\n\nexport function useAssets(): AssetsStore {\n const store = useContext(AssetsContext)\n if (!store) throw new Error('useAssets must be used within an <AssetsProvider>.')\n return store\n}\n\n/**\n * The assets store when an `<AssetsProvider>` is mounted above, else `null`.\n * Lets surfaces outside the Assets section (the CMS asset picker, EC-152)\n * reuse an ambient provider or mount their own.\n */\nexport function useOptionalAssets(): AssetsStore | null {\n return useContext(AssetsContext)\n}\n"]}
@@ -32,7 +32,9 @@ export declare function useAuth(): AuthStore;
32
32
  export declare function useOptionalAuth(): AuthStore | null;
33
33
  /**
34
34
  * The signed-in user's role on a project (for the shell's role badge).
35
- * Resolves via the members read store; `null` while loading or when the user
36
- * is not a member.
35
+ * Read through the one operations-backed query cache (EC-308/AD-17) via the
36
+ * `members.find` query — no longer reaching past the spine into
37
+ * `adapter.members` — and re-read when membership changes. `null` while loading
38
+ * or when the user is not a member.
37
39
  */
38
40
  export declare function useProjectRole(projectId: string | null): ProjectRole | null;
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
3
3
  import { selectAuthAdapter, sessionPrincipal } from '@elytracms/studio/lib/auth';
4
- import { getStudioOperations, setStudioSession } from '@elytracms/studio/lib/operations';
4
+ import { setStudioSession } from '@elytracms/studio/lib/operations';
5
+ import { useOperationQuery } from '@elytracms/studio/lib/query';
5
6
  const AuthContext = createContext(null);
6
7
  export function AuthProvider({ children, adapter }) {
7
8
  const [auth] = useState(() => adapter ?? selectAuthAdapter());
@@ -62,27 +63,26 @@ export function useOptionalAuth() {
62
63
  }
63
64
  /**
64
65
  * The signed-in user's role on a project (for the shell's role badge).
65
- * Resolves via the members read store; `null` while loading or when the user
66
- * is not a member.
66
+ * Read through the one operations-backed query cache (EC-308/AD-17) via the
67
+ * `members.find` query — no longer reaching past the spine into
68
+ * `adapter.members` — and re-read when membership changes. `null` while loading
69
+ * or when the user is not a member.
67
70
  */
68
71
  export function useProjectRole(projectId) {
69
72
  const { session } = useAuth();
70
- const [role, setRole] = useState(null);
71
73
  const userId = session?.user.id ?? null;
72
- useEffect(() => {
73
- let cancelled = false;
74
- setRole(null);
75
- if (!projectId || !userId)
76
- return;
77
- void getStudioOperations().then(async ({ adapter }) => {
78
- const membership = await adapter.members.findMember(projectId, userId);
79
- if (!cancelled)
80
- setRole(membership?.role ?? null);
81
- });
82
- return () => {
83
- cancelled = true;
84
- };
85
- }, [projectId, userId]);
86
- return role;
74
+ const query = useOperationQuery({
75
+ queryKey: ['projectRole', projectId ?? '∅', userId ?? '∅'],
76
+ loader: async (operations) => {
77
+ const result = await operations.members.find({
78
+ projectId: projectId,
79
+ userId: userId,
80
+ });
81
+ return result.ok ? (result.value?.role ?? null) : null;
82
+ },
83
+ invalidateOn: ['members'],
84
+ enabled: projectId !== null && userId !== null,
85
+ });
86
+ return query.data ?? null;
87
87
  }
88
88
  //# sourceMappingURL=auth-context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-context.js","sourceRoot":"","sources":["../../src/components/auth-context.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport type { ProjectRole } from '@elytracms/core/persistence'\nimport { selectAuthAdapter, sessionPrincipal } from '@elytracms/studio/lib/auth'\nimport type { AuthAdapter, AuthSession, AuthStatus, SignInResult } from '@elytracms/studio/lib/auth'\nimport { getStudioOperations, setStudioSession } from '@elytracms/studio/lib/operations'\n\n/**\n * Session store (EC-150). Owns the one `AuthAdapter` the app talks to, keeps\n * the session in React state, and binds/unbinds the operations-layer principal\n * (`setStudioSession`) so every studio mutation runs through the role hook as\n * the signed-in user. Auth state machine: `loading` (restoring) →\n * `signed-in` | `signed-out` — all three are deterministic, renderable states.\n */\n\nexport interface AuthStore {\n status: AuthStatus\n session: AuthSession | null\n signIn(email: string, password: string): Promise<SignInResult>\n /** Register the first/new user on the active backend, then sign in (EC-179). */\n signUp(email: string, password: string): Promise<SignInResult>\n signOut(): Promise<void>\n}\n\nconst AuthContext = createContext<AuthStore | null>(null)\n\nexport interface AuthProviderProps {\n children: ReactNode\n /** Test seam: inject an adapter; defaults to `selectAuthAdapter()`. */\n adapter?: AuthAdapter\n}\n\nexport function AuthProvider({ children, adapter }: AuthProviderProps) {\n const [auth] = useState<AuthAdapter>(() => adapter ?? selectAuthAdapter())\n const [status, setStatus] = useState<AuthStatus>('loading')\n const [session, setSession] = useState<AuthSession | null>(null)\n\n const apply = useCallback((next: AuthSession | null) => {\n setStudioSession(next ? sessionPrincipal(next) : null)\n setSession(next)\n setStatus(next ? 'signed-in' : 'signed-out')\n }, [])\n\n // Restore the session once on the client (SSR renders the 'loading' state).\n useEffect(() => {\n let cancelled = false\n void auth.currentSession().then((restored) => {\n if (!cancelled) apply(restored)\n })\n return () => {\n cancelled = true\n }\n }, [auth, apply])\n\n const store = useMemo<AuthStore>(\n () => ({\n status,\n session,\n signIn: async (email, password) => {\n const result = await auth.signIn({ email, password })\n if (result.ok) apply(result.session)\n return result\n },\n signUp: async (email, password) => {\n const result = await auth.signUp({ email, password })\n if (result.ok) apply(result.session)\n return result\n },\n signOut: async () => {\n await auth.signOut()\n apply(null)\n },\n }),\n [auth, status, session, apply],\n )\n\n return <AuthContext.Provider value={store}>{children}</AuthContext.Provider>\n}\n\nexport function useAuth(): AuthStore {\n const store = useContext(AuthContext)\n if (!store) throw new Error('useAuth must be used within an <AuthProvider>.')\n return store\n}\n\n/**\n * Like {@link useAuth}, but tolerant of provider-less renders. For shared\n * leaf surfaces (e.g. the operation-error banner's \"Sign in again\" action)\n * that are also rendered standalone in tests/styleguides: they degrade\n * gracefully instead of demanding the full provider tree.\n */\nexport function useOptionalAuth(): AuthStore | null {\n return useContext(AuthContext)\n}\n\n/**\n * The signed-in user's role on a project (for the shell's role badge).\n * Resolves via the members read store; `null` while loading or when the user\n * is not a member.\n */\nexport function useProjectRole(projectId: string | null): ProjectRole | null {\n const { session } = useAuth()\n const [role, setRole] = useState<ProjectRole | null>(null)\n const userId = session?.user.id ?? null\n\n useEffect(() => {\n let cancelled = false\n setRole(null)\n if (!projectId || !userId) return\n void getStudioOperations().then(async ({ adapter }) => {\n const membership = await adapter.members.findMember(projectId, userId)\n if (!cancelled) setRole(membership?.role ?? null)\n })\n return () => {\n cancelled = true\n }\n }, [projectId, userId])\n\n return role\n}\n"]}
1
+ {"version":3,"file":"auth-context.js","sourceRoot":"","sources":["../../src/components/auth-context.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport type { ProjectRole } from '@elytracms/core/persistence'\nimport { selectAuthAdapter, sessionPrincipal } from '@elytracms/studio/lib/auth'\nimport type { AuthAdapter, AuthSession, AuthStatus, SignInResult } from '@elytracms/studio/lib/auth'\nimport { setStudioSession } from '@elytracms/studio/lib/operations'\nimport { useOperationQuery } from '@elytracms/studio/lib/query'\n\n/**\n * Session store (EC-150). Owns the one `AuthAdapter` the app talks to, keeps\n * the session in React state, and binds/unbinds the operations-layer principal\n * (`setStudioSession`) so every studio mutation runs through the role hook as\n * the signed-in user. Auth state machine: `loading` (restoring) →\n * `signed-in` | `signed-out` — all three are deterministic, renderable states.\n */\n\nexport interface AuthStore {\n status: AuthStatus\n session: AuthSession | null\n signIn(email: string, password: string): Promise<SignInResult>\n /** Register the first/new user on the active backend, then sign in (EC-179). */\n signUp(email: string, password: string): Promise<SignInResult>\n signOut(): Promise<void>\n}\n\nconst AuthContext = createContext<AuthStore | null>(null)\n\nexport interface AuthProviderProps {\n children: ReactNode\n /** Test seam: inject an adapter; defaults to `selectAuthAdapter()`. */\n adapter?: AuthAdapter\n}\n\nexport function AuthProvider({ children, adapter }: AuthProviderProps) {\n const [auth] = useState<AuthAdapter>(() => adapter ?? selectAuthAdapter())\n const [status, setStatus] = useState<AuthStatus>('loading')\n const [session, setSession] = useState<AuthSession | null>(null)\n\n const apply = useCallback((next: AuthSession | null) => {\n setStudioSession(next ? sessionPrincipal(next) : null)\n setSession(next)\n setStatus(next ? 'signed-in' : 'signed-out')\n }, [])\n\n // Restore the session once on the client (SSR renders the 'loading' state).\n useEffect(() => {\n let cancelled = false\n void auth.currentSession().then((restored) => {\n if (!cancelled) apply(restored)\n })\n return () => {\n cancelled = true\n }\n }, [auth, apply])\n\n const store = useMemo<AuthStore>(\n () => ({\n status,\n session,\n signIn: async (email, password) => {\n const result = await auth.signIn({ email, password })\n if (result.ok) apply(result.session)\n return result\n },\n signUp: async (email, password) => {\n const result = await auth.signUp({ email, password })\n if (result.ok) apply(result.session)\n return result\n },\n signOut: async () => {\n await auth.signOut()\n apply(null)\n },\n }),\n [auth, status, session, apply],\n )\n\n return <AuthContext.Provider value={store}>{children}</AuthContext.Provider>\n}\n\nexport function useAuth(): AuthStore {\n const store = useContext(AuthContext)\n if (!store) throw new Error('useAuth must be used within an <AuthProvider>.')\n return store\n}\n\n/**\n * Like {@link useAuth}, but tolerant of provider-less renders. For shared\n * leaf surfaces (e.g. the operation-error banner's \"Sign in again\" action)\n * that are also rendered standalone in tests/styleguides: they degrade\n * gracefully instead of demanding the full provider tree.\n */\nexport function useOptionalAuth(): AuthStore | null {\n return useContext(AuthContext)\n}\n\n/**\n * The signed-in user's role on a project (for the shell's role badge).\n * Read through the one operations-backed query cache (EC-308/AD-17) via the\n * `members.find` query — no longer reaching past the spine into\n * `adapter.members` — and re-read when membership changes. `null` while loading\n * or when the user is not a member.\n */\nexport function useProjectRole(projectId: string | null): ProjectRole | null {\n const { session } = useAuth()\n const userId = session?.user.id ?? null\n const query = useOperationQuery<ProjectRole | null>({\n queryKey: ['projectRole', projectId ?? '∅', userId ?? '∅'],\n loader: async (operations) => {\n const result = await operations.members.find({\n projectId: projectId as string,\n userId: userId as string,\n })\n return result.ok ? (result.value?.role ?? null) : null\n },\n invalidateOn: ['members'],\n enabled: projectId !== null && userId !== null,\n })\n return query.data ?? null\n}\n"]}
@@ -197,10 +197,11 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
197
197
  const collection = registry.get(ref.collection);
198
198
  if (!doc || !collection)
199
199
  return ref.id;
200
- // EC-296: a schema `preview` function composes the title from the document's
201
- // resolved values (active locale). Only resolve the full value set when a
202
- // preview is declared — the common case still resolves a single field.
203
- if (collection.preview) {
200
+ // EC-296/EC-242: a function `titleField` (title pattern) composes the title
201
+ // from the document's resolved values (active locale). Only resolve the full
202
+ // value set when a function is declared — the common string `titleField` case
203
+ // still resolves the single title field below (primaryField === the title field).
204
+ if (typeof collection.titleField === 'function') {
204
205
  const values = {};
205
206
  for (const field of collection.fields) {
206
207
  values[field.name] = resolveField(collection, doc, field.name, activeLocale, localeConfig).value;