@elytracms/studio 0.0.94 → 0.0.96
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/asset-alt-migration.test.ts +2 -2
- package/convex/asset-meta-patch.test.ts +218 -0
- package/convex/asset-replace-delete-race.test.ts +127 -0
- package/convex/assets.ts +27 -2
- package/convex/cms.ts +124 -8
- package/convex/delivery-redirects.test.ts +167 -0
- package/convex/delivery.ts +15 -5
- package/convex/rail-write-parity.test.ts +6 -6
- package/convex/redirects.ts +1 -1
- package/convex/schema.ts +6 -0
- package/convex/strict-reference-race.test.ts +139 -0
- package/dist/addon.d.ts +1 -1
- package/dist/addon.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.js +15 -2
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/translate-document-action.d.ts +6 -0
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.d.ts +1 -1
- package/dist/components/ai/translate-document-dialog.js +5 -5
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/assets/asset-detail.js +29 -11
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-locale.d.ts +16 -0
- package/dist/components/assets/asset-locale.js +30 -0
- package/dist/components/assets/asset-locale.js.map +1 -0
- package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
- package/dist/components/assets/asset-meta-drift-notice.js +22 -0
- package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
- package/dist/components/assets/asset-meta-label.d.ts +13 -0
- package/dist/components/assets/asset-meta-label.js +17 -0
- package/dist/components/assets/asset-meta-label.js.map +1 -0
- package/dist/components/assets/asset-metadata.d.ts +89 -15
- package/dist/components/assets/asset-metadata.js +123 -11
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.d.ts +6 -2
- package/dist/components/assets/asset-search.js +19 -3
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-thumb.js +6 -2
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +25 -9
- package/dist/components/assets/assets-collection-view-source.js +80 -26
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-store.js +17 -4
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +21 -0
- package/dist/components/builder/builder-store.js +22 -0
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
- package/dist/components/builder/canvas-commit-boundary.js +27 -0
- package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
- package/dist/components/builder/canvas-rebase.d.ts +101 -0
- package/dist/components/builder/canvas-rebase.js +179 -0
- package/dist/components/builder/canvas-rebase.js.map +1 -0
- package/dist/components/builder/canvas-render-context.d.ts +6 -3
- package/dist/components/builder/canvas-render-context.js +17 -8
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +26 -2
- package/dist/components/builder/composition-field-editor.js +46 -27
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/node-factory.js +15 -3
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/builder/property-inspector.js +3 -1
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/run-inspector.js +10 -6
- package/dist/components/builder/run-inspector.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +5 -2
- package/dist/components/cms/asset-field.js +34 -17
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +2 -2
- package/dist/components/cms/asset-picker-core.js +2 -2
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +6 -2
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/canvas-commit.d.ts +62 -0
- package/dist/components/cms/canvas-commit.js +39 -0
- package/dist/components/cms/canvas-commit.js.map +1 -0
- package/dist/components/cms/cms-store.d.ts +21 -8
- package/dist/components/cms/cms-store.js +19 -10
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +27 -18
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/commit-policy.d.ts +6 -2
- package/dist/components/cms/commit-policy.js +1 -1
- package/dist/components/cms/commit-policy.js.map +1 -1
- package/dist/components/cms/detail-editor.js +12 -2
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/draft-commit.d.ts +16 -3
- package/dist/components/cms/draft-commit.js +3 -2
- package/dist/components/cms/draft-commit.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +121 -5
- package/dist/components/cms/use-document-draft.js +197 -54
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +6 -1
- package/dist/components/cms/use-live-url.js +12 -9
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +11 -5
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +6 -4
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/deployment-setup-gate.js +10 -1
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/records/record-lens.js +3 -8
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +7 -0
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -0
- package/dist/lib/ai/tools.js +4 -0
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/assets/upload.d.ts +25 -7
- package/dist/lib/assets/upload.js +30 -21
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/cms/published-documents.d.ts +24 -6
- package/dist/lib/cms/published-documents.js +39 -6
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +25 -105
- package/dist/lib/cms-views/view-model.js +36 -103
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
- package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +21 -8
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.js +5 -0
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +20 -4
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +16 -1
- package/dist/lib/i18n/messages.en.js +22 -5
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +80 -35
- package/dist/lib/operations/cms-persistence.js +348 -85
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
- package/dist/lib/operations/config-manifest-lookup.js +3 -3
- package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +54 -8
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.js +14 -11
- package/dist/lib/persistence/deployment-readiness.js.map +1 -1
- package/dist/lib/persona/labels.js +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/query/index.d.ts +2 -2
- package/dist/lib/query/index.js +1 -1
- package/dist/lib/query/index.js.map +1 -1
- package/dist/lib/query/operations-query.d.ts +26 -0
- package/dist/lib/query/operations-query.js +12 -0
- package/dist/lib/query/operations-query.js.map +1 -1
- package/dist/lib/registry/index.js +2 -2
- package/dist/lib/registry/index.js.map +1 -1
- package/dist/lib/registry/preview.d.ts +10 -0
- package/dist/lib/registry/preview.js +13 -4
- package/dist/lib/registry/preview.js.map +1 -1
- package/dist/lib/routes/route-issues.js +6 -3
- package/dist/lib/routes/route-issues.js.map +1 -1
- package/dist/lib/workspace/workspace-source.js +6 -7
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/dist/previews/entry-node.d.ts +14 -0
- package/dist/previews/entry-node.js +22 -0
- package/dist/previews/entry-node.js.map +1 -0
- package/dist/previews/harness.js +2 -16
- package/dist/previews/harness.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import type { FunctionReference } from 'convex/server'
|
|
5
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
6
|
+
// The host's two readers, from their own modules — the package index also loads the React
|
|
7
|
+
// delivery kit, which the edge-runtime environment cannot evaluate.
|
|
8
|
+
import { assembleLiveSource } from '../../next/src/host-content'
|
|
9
|
+
import { createContentSnapshot } from '../../next/src/snapshot'
|
|
10
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
11
|
+
import schema from './schema'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The delivery snapshot's redirect strand, end to end: rows stored in the deployment's
|
|
15
|
+
* `records` table (kind `redirect`) → the REAL `delivery:snapshot` query (convex-test) →
|
|
16
|
+
* the REAL host loader (`assembleLiveSource`, `@elytracms/next`).
|
|
17
|
+
*
|
|
18
|
+
* EC-628 (Codex F04, `tasks/codex-review-2026-10-27/backend.convex.test.ts` "F04 upstream"):
|
|
19
|
+
* the record store's open data bag accepts drift the normal CRUD refuses (a dashboard edit,
|
|
20
|
+
* an older writer). Before, an enabled row with `to: 42` was SERVED by the snapshot and
|
|
21
|
+
* vanished in the host with zero issues, and a switch that is not a boolean was decided by
|
|
22
|
+
* truthiness (`'false'` served, a missing switch silently paused). Now the snapshot reads
|
|
23
|
+
* every stored redirect through the one rule (`readLiveRedirects`): what it cannot read is
|
|
24
|
+
* withheld and reported on the snapshot's `redirectIssues` strand, which the host carries
|
|
25
|
+
* into its `sourceIssues` and onto the 404 at the redirect's `from`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const modules = {
|
|
29
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
30
|
+
'./_generated/api.ts': async () => ({}),
|
|
31
|
+
}
|
|
32
|
+
const snapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
33
|
+
|
|
34
|
+
const AT = '2026-10-27T12:00:00.000Z'
|
|
35
|
+
const LOCALES = { default: 'en', locales: ['en'] }
|
|
36
|
+
|
|
37
|
+
/** Insert redirect rows as the record store holds them — raw, as drift reaches it. */
|
|
38
|
+
async function deploymentWith(rows: ReadonlyArray<{ id: string; data: Record<string, unknown> }>) {
|
|
39
|
+
const t = convexTest(schema, modules)
|
|
40
|
+
await t.run(async (ctx) => {
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
await ctx.db.insert('records', { kind: 'redirect', recordId: row.id, data: row.data, at: AT, createdAt: AT, updatedAt: AT })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
return t
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const redirect = (id: string, data: Record<string, unknown> = {}) => ({
|
|
49
|
+
id,
|
|
50
|
+
data: { from: `/${id}`, to: '/target', permanent: true, enabled: true, ...data },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('EC-628 — the snapshot names every stored redirect it cannot serve', () => {
|
|
54
|
+
it('drift is withheld and reported upstream, then located by the host (F04 + disabled-state drift)', async () => {
|
|
55
|
+
const t = await deploymentWith([
|
|
56
|
+
redirect('good'),
|
|
57
|
+
redirect('bad', { to: 42 }), // F04's row
|
|
58
|
+
redirect('paused', { enabled: false }), // a paused redirect: not live, not a finding
|
|
59
|
+
redirect('string-off', { enabled: 'false' }), // truthy: was SERVED before
|
|
60
|
+
{ id: 'no-switch', data: { from: '/no-switch', to: '/target', permanent: true } }, // falsy: was silently paused
|
|
61
|
+
])
|
|
62
|
+
const payload = (await t.query(snapshot, { projectId: 'site' })) as {
|
|
63
|
+
redirects: unknown[]
|
|
64
|
+
redirectIssues: Array<{ code: string; path: unknown[] }>
|
|
65
|
+
}
|
|
66
|
+
expect(payload.redirects).toEqual([{ id: 'good', from: '/good', to: '/target', permanent: true }])
|
|
67
|
+
expect(payload.redirectIssues.map((issue) => [issue.code, issue.path])).toEqual([
|
|
68
|
+
['invalid-redirect', ['redirects', 'bad']],
|
|
69
|
+
['invalid-redirect', ['redirects', 'no-switch']],
|
|
70
|
+
['invalid-redirect', ['redirects', 'string-off']],
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
const client = assembleLiveSource(payload as never, { locales: LOCALES, collections: [], routes: [] }, 'published').client({
|
|
74
|
+
perspective: 'published',
|
|
75
|
+
})
|
|
76
|
+
// Control: the readable redirect traverses the whole pipeline, with no issue.
|
|
77
|
+
expect(client.resolvePage('/good').status).toBe('redirect')
|
|
78
|
+
expect(client.resolvePage('/good').issues).toEqual([])
|
|
79
|
+
expect(client.sourceIssues.map((issue) => issue.path[1])).toEqual(['bad', 'no-switch', 'string-off'])
|
|
80
|
+
for (const path of ['/bad', '/string-off', '/no-switch']) {
|
|
81
|
+
const result = client.resolvePage(path)
|
|
82
|
+
expect(result.status).toBe('notFound')
|
|
83
|
+
expect(result.issues.map((issue) => issue.code)).toEqual(['invalid-redirect'])
|
|
84
|
+
}
|
|
85
|
+
// The paused redirect is a 404 with nothing to say — pausing is not drift.
|
|
86
|
+
expect(client.resolvePage('/paused').status).toBe('notFound')
|
|
87
|
+
expect(client.resolvePage('/paused').issues).toEqual([])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* EC-627 — "which redirects are live" is decided by ONE reader on every side. The same
|
|
93
|
+
* stored set is read (1) by the Convex delivery snapshot (`delivery:snapshot`, through
|
|
94
|
+
* `readLiveRedirects`) and assembled by the host's live loader, and (2) by the host's OTHER
|
|
95
|
+
* reader, `createContentSnapshot` (`next/src/snapshot.ts`, `readLiveRedirects` over
|
|
96
|
+
* `adapter.records(redirectRecordKind).list`) — through the REAL `ConvexPersistenceAdapter`
|
|
97
|
+
* over the same convex-test deployment, so both sides read the very same stored rows.
|
|
98
|
+
*
|
|
99
|
+
* The set holds what a hand-built copy of the rule gets wrong: a paused redirect (a copy that
|
|
100
|
+
* forgets the switch serves it), a switch that is not a boolean (a truthy copy serves
|
|
101
|
+
* `'false'`), a duplicate `from` (`/old` and `/old/` — the lower id applies on both sides)
|
|
102
|
+
* and an editorial `meta` bag (never on the routing record). Measured: replacing either
|
|
103
|
+
* side's call with a hand-built `.filter((r) => r.enabled)` projection turns this red.
|
|
104
|
+
*/
|
|
105
|
+
describe('EC-627 — the Convex snapshot and the host adapter snapshot serve one live set', () => {
|
|
106
|
+
const STORED = [
|
|
107
|
+
redirect('a', { meta: { note: 'editorial only' } }),
|
|
108
|
+
redirect('paused', { enabled: false }),
|
|
109
|
+
redirect('string-off', { enabled: 'false' }),
|
|
110
|
+
{ id: 'dup-1', data: { from: '/old', to: '/one', permanent: true, enabled: true } },
|
|
111
|
+
{ id: 'dup-2', data: { from: '/old/', to: '/two', permanent: false, enabled: true } },
|
|
112
|
+
]
|
|
113
|
+
const PATHS = ['/a', '/paused', '/string-off', '/old', '/old/']
|
|
114
|
+
|
|
115
|
+
/** The decision a reader makes per URL: what a visitor gets. */
|
|
116
|
+
const outcomes = (client: { resolvePage(url: string): { status: string; redirect: unknown } }) =>
|
|
117
|
+
PATHS.map((path) => {
|
|
118
|
+
const result = client.resolvePage(path)
|
|
119
|
+
return { path, status: result.status, redirect: result.redirect }
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('the same stored set yields the same live set through both readers', async () => {
|
|
123
|
+
const t = await deploymentWith(STORED)
|
|
124
|
+
await t.run(async (ctx) => {
|
|
125
|
+
await ctx.db.insert('members', { userId: 'admin', role: 'admin', createdAt: AT, updatedAt: AT })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// (1) the deployment's public snapshot → the host's live loader
|
|
129
|
+
const payload = await t.query(snapshot, { projectId: 'site' })
|
|
130
|
+
const viaSnapshot = assembleLiveSource(payload as never, { locales: LOCALES, collections: [], routes: [] }, 'published')
|
|
131
|
+
.client({ perspective: 'published' })
|
|
132
|
+
|
|
133
|
+
// (2) the host's adapter reader over the same deployment
|
|
134
|
+
const asAdmin = t.withIdentity({ subject: 'admin', issuer: 'https://convex.test' })
|
|
135
|
+
const adapter = new ConvexPersistenceAdapter(
|
|
136
|
+
{
|
|
137
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
|
|
138
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin.mutation(ref, args ?? {}),
|
|
139
|
+
} as unknown as ConvexHttpClient,
|
|
140
|
+
'site',
|
|
141
|
+
)
|
|
142
|
+
const viaAdapter = (await createContentSnapshot(adapter, 'site', { locales: LOCALES, collections: [], routes: [] }))
|
|
143
|
+
.client({ perspective: 'published' })
|
|
144
|
+
|
|
145
|
+
// The instrument can tell outcomes apart — pinned explicitly, not only compared.
|
|
146
|
+
expect(outcomes(viaSnapshot)).toEqual([
|
|
147
|
+
{ path: '/a', status: 'redirect', redirect: { target: '/target', permanent: true } },
|
|
148
|
+
{ path: '/paused', status: 'notFound', redirect: null },
|
|
149
|
+
{ path: '/string-off', status: 'notFound', redirect: null },
|
|
150
|
+
{ path: '/old', status: 'redirect', redirect: { target: '/one', permanent: true } },
|
|
151
|
+
{ path: '/old/', status: 'redirect', redirect: { target: '/one', permanent: true } },
|
|
152
|
+
])
|
|
153
|
+
expect(outcomes(viaAdapter)).toEqual(outcomes(viaSnapshot))
|
|
154
|
+
// The duplicate is one issue, the same on both sides (the router's one redirect set).
|
|
155
|
+
const duplicates = (issues: readonly { code: string; path: readonly unknown[] }[]) =>
|
|
156
|
+
issues.filter((issue) => issue.code === 'invalid-redirect').map((issue) => issue.path)
|
|
157
|
+
expect(duplicates(viaSnapshot.routerIssues)).toEqual([['redirects', 'dup-2']])
|
|
158
|
+
expect(duplicates(viaAdapter.routerIssues)).toEqual(duplicates(viaSnapshot.routerIssues))
|
|
159
|
+
|
|
160
|
+
// EC-628 (the adapter snapshot): the drifted row is NAMED on both sides, not only withheld —
|
|
161
|
+
// on the source and on the 404 at its `from`. (Before, the adapter path withheld it silently.)
|
|
162
|
+
const sourcePaths = (client: typeof viaSnapshot) => client.sourceIssues.map((issue) => [issue.code, issue.path])
|
|
163
|
+
expect(sourcePaths(viaSnapshot)).toEqual([['invalid-redirect', ['redirects', 'string-off']]])
|
|
164
|
+
expect(sourcePaths(viaAdapter)).toEqual(sourcePaths(viaSnapshot))
|
|
165
|
+
expect(viaAdapter.resolvePage('/string-off').issues.map((issue) => issue.code)).toEqual(['invalid-redirect'])
|
|
166
|
+
})
|
|
167
|
+
})
|
package/convex/delivery.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
|
-
import { DRAFT_PERSPECTIVE, DRAFT_VARIANT,
|
|
2
|
+
import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, readLiveRedirects, unfoldValue, variantOf } from '@elytracms/core/cms-core'
|
|
3
3
|
import type { Perspective } from '@elytracms/core/cms-core'
|
|
4
4
|
import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
|
|
5
5
|
import { constantTimeEquals } from '@elytracms/core/revalidation'
|
|
@@ -77,6 +77,12 @@ interface DeliverySnapshot {
|
|
|
77
77
|
* infra redirects stay native (`next.config`/`vercel`), evaluated before these.
|
|
78
78
|
*/
|
|
79
79
|
redirects: unknown[]
|
|
80
|
+
/**
|
|
81
|
+
* EC-628 — one `invalid-redirect` issue per stored redirect the live rule withheld because
|
|
82
|
+
* it could not read it (id, `from` and the kind of fault — never the target or a value).
|
|
83
|
+
* Paused redirects are not here: pausing is not drift.
|
|
84
|
+
*/
|
|
85
|
+
redirectIssues: unknown[]
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/**
|
|
@@ -161,16 +167,20 @@ export async function loadSnapshot(
|
|
|
161
167
|
// Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect is
|
|
162
168
|
// stored but never live, in either perspective. EC-616: read through the redirect
|
|
163
169
|
// kind's one server-side reader (records + the marked legacy tolerant read) and
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
170
|
+
// decided by the ONE live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
|
|
171
|
+
// was the last hand-built copy on Convex). EC-628: a stored redirect the rule cannot
|
|
172
|
+
// read (a non-boolean switch, a non-string `to`) is withheld AND reported — the host
|
|
173
|
+
// carries `redirectIssues` into its source issues — instead of being served or dropped
|
|
174
|
+
// by truthiness. The record kind's own read, not the project schema (EC-585 1e).
|
|
175
|
+
const live = readLiveRedirects(await loadRedirectEntries(ctx, projectId))
|
|
167
176
|
|
|
168
177
|
return {
|
|
169
178
|
projectId,
|
|
170
179
|
perspective,
|
|
171
180
|
documents,
|
|
172
181
|
assets,
|
|
173
|
-
redirects,
|
|
182
|
+
redirects: live.redirects,
|
|
183
|
+
redirectIssues: live.issues,
|
|
174
184
|
}
|
|
175
185
|
}
|
|
176
186
|
|
|
@@ -70,19 +70,19 @@ async function seedHero(adapter: PersistenceAdapter) {
|
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
* "Reset to centre", then clear alt — the two connected clears that never persisted.
|
|
73
|
-
* EC-587: the alt is cleared the way the options dialog clears it
|
|
74
|
-
*
|
|
73
|
+
* EC-587: the alt is cleared the way the options dialog clears it — since EC-634 the
|
|
74
|
+
* per-key patch removing `meta.alt` (its one home).
|
|
75
75
|
*/
|
|
76
76
|
async function metadataClears(adapter: PersistenceAdapter) {
|
|
77
77
|
await seedHero(adapter)
|
|
78
78
|
const assets = adapter.assets
|
|
79
79
|
const reset = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { focalPoint: null })
|
|
80
|
-
const clearedAlt = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', {
|
|
80
|
+
const clearedAlt = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { metaPatch: { alt: null } })
|
|
81
81
|
expect([reset.ok, clearedAlt.ok]).toEqual([true, true])
|
|
82
82
|
const after = await adapter.assets.getAsset(PROJECT_ID, 'asset_hero')
|
|
83
83
|
return {
|
|
84
84
|
focalPoint: after.focalPoint ?? null,
|
|
85
|
-
alt: assetAlt(after),
|
|
85
|
+
alt: assetAlt(after, null),
|
|
86
86
|
title: after.title ?? null,
|
|
87
87
|
thumb: after.thumb ?? null,
|
|
88
88
|
lqip: after.lqip ?? null,
|
|
@@ -131,7 +131,7 @@ describe('EC-577 — the asset writers mean the same thing on both adapters', ()
|
|
|
131
131
|
async get() {
|
|
132
132
|
// An editor saves alt while the backfill is fetching this original — the
|
|
133
133
|
// backfill's write lands after it, from a listing taken before it.
|
|
134
|
-
await updateAssetMetadata({ assets: adapter.assets }, PROJECT_ID, 'asset_big', {
|
|
134
|
+
await updateAssetMetadata({ assets: adapter.assets }, PROJECT_ID, 'asset_big', { metaPatch: { alt: 'Mid-run alt' } })
|
|
135
135
|
return new Uint8Array([1])
|
|
136
136
|
},
|
|
137
137
|
}
|
|
@@ -145,7 +145,7 @@ describe('EC-577 — the asset writers mean the same thing on both adapters', ()
|
|
|
145
145
|
)
|
|
146
146
|
expect(progress.generated).toBe(1)
|
|
147
147
|
const after = await adapter.assets.getAsset(PROJECT_ID, 'asset_big')
|
|
148
|
-
expect([assetAlt(after), after.thumb, after.lqip]).toEqual(['Mid-run alt', THUMB, LQIP])
|
|
148
|
+
expect([assetAlt(after, null), after.thumb, after.lqip]).toEqual(['Mid-run alt', THUMB, LQIP])
|
|
149
149
|
}
|
|
150
150
|
})
|
|
151
151
|
})
|
package/convex/redirects.ts
CHANGED
|
@@ -70,7 +70,7 @@ export const { save, get, list, remove } = defineRecordFunctions(redirectRecordK
|
|
|
70
70
|
/**
|
|
71
71
|
* Every stored redirect of this deployment (records + the tolerant legacy read), ordered
|
|
72
72
|
* by `from` — the ONE server-side reader; the delivery snapshot projects it through
|
|
73
|
-
* `
|
|
73
|
+
* `readLiveRedirects` (EC-590 / EC-627 / EC-628: the one live-redirect rule).
|
|
74
74
|
*/
|
|
75
75
|
export async function loadRedirectEntries(ctx: QueryCtx, projectId: string): Promise<RedirectEntry[]> {
|
|
76
76
|
const rows = await listRecordWire(ctx, redirectRecordKind, resolveRecordQuery(redirectRecordKind), legacyRedirects)
|
package/convex/schema.ts
CHANGED
|
@@ -277,6 +277,12 @@ export default defineSchema({
|
|
|
277
277
|
lqip: v.optional(v.string()),
|
|
278
278
|
/** Schema-driven asset metadata bag (EC-301): alt, copyright, credit, …. */
|
|
279
279
|
meta: v.optional(v.any()),
|
|
280
|
+
/**
|
|
281
|
+
* EC-632 — the per-locale values of the asset collection's `localized: true` fields,
|
|
282
|
+
* `{ [locale]: { [field]: value } }` (the document mechanism beside `meta`). Optional:
|
|
283
|
+
* every row without one reads its shared `meta`, exactly as before.
|
|
284
|
+
*/
|
|
285
|
+
localizedMeta: v.optional(v.any()),
|
|
280
286
|
createdAt: v.string(),
|
|
281
287
|
updatedAt: v.string(),
|
|
282
288
|
}).index('by_asset', ['assetId']),
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import { getFunctionName } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
7
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
8
|
+
import schema from './schema'
|
|
9
|
+
|
|
10
|
+
// convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
|
|
11
|
+
// fake timers keep it queued.
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers()
|
|
14
|
+
})
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* EC-649 — Codex F08 and its reverse, on Convex: the real `cms:*` handlers, schema and
|
|
21
|
+
* write-chokepoint triggers under convex-test, driven by the production operations over the
|
|
22
|
+
* production `ConvexPersistenceAdapter`. Each race is an explicit interleaving — the client
|
|
23
|
+
* HOLDS one operation's store mutation after its guard, runs the other operation, then sends
|
|
24
|
+
* the held mutation — and the sink REFUSES it inside its transaction. convex-test serialises
|
|
25
|
+
* mutations, so this proves the sink's data check and its refusal, not Convex's OCC retry
|
|
26
|
+
* (which is what makes the same check serializable against a truly concurrent mutation).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const modules = {
|
|
30
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
31
|
+
'./_generated/api.ts': async () => ({}),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PROJECT_ID = 'strict-race'
|
|
35
|
+
const SESSION_USER_ID = 'user_admin'
|
|
36
|
+
const projectId = PROJECT_ID
|
|
37
|
+
const author = { collection: 'author', id: 'a' }
|
|
38
|
+
const post = { collection: 'post', id: 'p' }
|
|
39
|
+
const collections = () => [
|
|
40
|
+
{ id: 'author', kind: 'document' as const, fields: [{ name: 'name', type: 'text' as const }] },
|
|
41
|
+
{ id: 'post', kind: 'document' as const, fields: [{ name: 'author', type: 'relation' as const, target: 'author', multiple: false, strict: true }] },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** A held store mutation: `atStore` resolves once it is held; `resume` sends it. */
|
|
45
|
+
interface Hold {
|
|
46
|
+
readonly atStore: Promise<void>
|
|
47
|
+
readonly resume: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function convexWorld() {
|
|
51
|
+
const t = convexTest(schema, modules)
|
|
52
|
+
const now = new Date().toISOString()
|
|
53
|
+
await t.run(async (ctx) => {
|
|
54
|
+
await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
|
|
55
|
+
})
|
|
56
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
57
|
+
const holds = new Map<string, { reached: () => void; proceed: Promise<void> }>()
|
|
58
|
+
const client = {
|
|
59
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
|
|
60
|
+
mutation: async (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
61
|
+
const key = `${getFunctionName(ref)}#${String(args?.docId)}`
|
|
62
|
+
const hold = holds.get(key)
|
|
63
|
+
if (hold) {
|
|
64
|
+
holds.delete(key)
|
|
65
|
+
hold.reached()
|
|
66
|
+
await hold.proceed
|
|
67
|
+
}
|
|
68
|
+
return asAdmin.mutation(ref, args ?? {})
|
|
69
|
+
},
|
|
70
|
+
} as unknown as ConvexHttpClient
|
|
71
|
+
const adapter = new ConvexPersistenceAdapter(client, PROJECT_ID)
|
|
72
|
+
const ops = createOperations({ adapter, collections, validateDocument: () => [] })
|
|
73
|
+
/** Hold the next `cms:<mutation>` for document `id` at the client, after every guard ran. */
|
|
74
|
+
const hold = (mutation: string, id: string): Hold => {
|
|
75
|
+
let reached!: () => void
|
|
76
|
+
let resume!: () => void
|
|
77
|
+
const atStore = new Promise<void>((resolve) => { reached = resolve })
|
|
78
|
+
const proceed = new Promise<void>((resolve) => { resume = resolve })
|
|
79
|
+
holds.set(`cms:${mutation}#${id}`, { reached, proceed })
|
|
80
|
+
return { atStore, resume }
|
|
81
|
+
}
|
|
82
|
+
for (const document of [
|
|
83
|
+
{ ...author, values: { name: 'Ada' } },
|
|
84
|
+
{ ...post, values: { author: { type: 'reference', ...author } } },
|
|
85
|
+
]) expect((await ops.documents.replace({ projectId, document, precondition: { ifAbsent: true } })).ok).toBe(true)
|
|
86
|
+
return { adapter, ops, hold }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('EC-649 — strict-reference preconditions at the Convex sink (convex-parity)', () => {
|
|
90
|
+
it('F08: the author unpublished between the post’s publish guard and its mutation — `cms:publishDocument` refuses', async () => {
|
|
91
|
+
const { adapter, ops, hold } = await convexWorld()
|
|
92
|
+
expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
|
|
93
|
+
// Known-positive control: the sequential case is refused by the guard.
|
|
94
|
+
expect((await ops.documents.publish({ projectId, ref: post })).ok).toBe(true)
|
|
95
|
+
const blocked = await ops.documents.unpublish({ projectId, ref: author })
|
|
96
|
+
expect(!blocked.ok && blocked.error.code).toBe('conflict')
|
|
97
|
+
expect((await ops.documents.unpublish({ projectId, ref: post })).ok).toBe(true)
|
|
98
|
+
|
|
99
|
+
const held = hold('publishDocument', post.id)
|
|
100
|
+
const publishing = ops.documents.publish({ projectId, ref: post })
|
|
101
|
+
await held.atStore
|
|
102
|
+
expect((await ops.documents.unpublish({ projectId, ref: author })).ok).toBe(true)
|
|
103
|
+
held.resume()
|
|
104
|
+
const published = await publishing
|
|
105
|
+
expect(published.ok).toBe(false)
|
|
106
|
+
if (!published.ok) expect(published.error.messageRef?.key).toBe('operations.error.strictTargetsUnpublishedOne')
|
|
107
|
+
expect((await adapter.cms.getDocument(projectId, author)).publishedVersion).toBeUndefined()
|
|
108
|
+
expect(await adapter.cms.listPublishedDocuments(projectId, 'post')).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('the reverse: the post published between the author’s unpublish guard and its mutation — `cms:unpublishDocument` refuses', async () => {
|
|
112
|
+
const { adapter, ops, hold } = await convexWorld()
|
|
113
|
+
expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
|
|
114
|
+
const held = hold('unpublishDocument', author.id)
|
|
115
|
+
const unpublishing = ops.documents.unpublish({ projectId, ref: author })
|
|
116
|
+
await held.atStore // the guard found no published strict referrer
|
|
117
|
+
expect((await ops.documents.publish({ projectId, ref: post })).ok).toBe(true)
|
|
118
|
+
held.resume()
|
|
119
|
+
const unpublished = await unpublishing
|
|
120
|
+
expect(unpublished.ok).toBe(false)
|
|
121
|
+
if (!unpublished.ok) {
|
|
122
|
+
expect(unpublished.error.code).toBe('conflict')
|
|
123
|
+
expect(unpublished.error.messageRef?.key).toBe('operations.error.strictReferencesBlockUnpublishOne')
|
|
124
|
+
}
|
|
125
|
+
expect((await adapter.cms.getDocument(projectId, author)).publishedVersion).toBe(1)
|
|
126
|
+
expect((await adapter.cms.getDocument(projectId, post)).publishedVersion).toBe(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('control: the held unpublish with nothing racing it lands (the sink reads the index, finds no unjudged referrer)', async () => {
|
|
130
|
+
const { adapter, ops, hold } = await convexWorld()
|
|
131
|
+
expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
|
|
132
|
+
const held = hold('unpublishDocument', author.id)
|
|
133
|
+
const unpublishing = ops.documents.unpublish({ projectId, ref: author })
|
|
134
|
+
await held.atStore
|
|
135
|
+
held.resume()
|
|
136
|
+
expect((await unpublishing).ok).toBe(true)
|
|
137
|
+
expect((await adapter.cms.getDocument(projectId, author)).publishedVersion).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
})
|
package/dist/addon.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export { assembleRailViewSource, metaBagProjection, normalizeOkResult, useRailVi
|
|
|
48
48
|
export type { OkResult, RailAdapter } from './components/rail-view-source.js';
|
|
49
49
|
export type { CollectionViewCapabilities, CollectionViewSource, FieldAccess, } from './lib/collection-view/collection-view-source.js';
|
|
50
50
|
export { useOperationCache, useOperationQuery } from './lib/query/index.js';
|
|
51
|
-
export type { OperationCache, UseOperationQueryOptions } from './lib/query/index.js';
|
|
51
|
+
export type { OperationCache, ReadState, UseOperationQueryOptions } from './lib/query/index.js';
|
|
52
52
|
export { useStudioLocale } from './lib/locale/index.js';
|
|
53
53
|
export type { StudioLocale } from './lib/locale/index.js';
|
|
54
54
|
export { useSetBreadcrumb } from './components/breadcrumb-context.js';
|
package/dist/addon.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"addon.js","sourceRoot":"","sources":["../src/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,0FAA0F;AAE1F,OAAO,EAAE,GAAG,EAAE,MAAM,wCAAwC,CAAA;AAkB5D,2FAA2F;AAE3F,8EAA8E;AAC9E,OAAO,EACL,wBAAwB,EACxB,gBAAgB,EAChB,kBAAkB,EAClB,6BAA6B,EAC7B,aAAa,EACb,aAAa,GACd,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAA;AAEjH,4FAA4F;AAC5F,OAAO,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAA;AAC5F,OAAO,EACL,sBAAsB,EACtB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AAQtC,4FAA4F;AAE5F,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAG/D,4FAA4F;AAE5F,4GAA4G;AAC5G,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AAGpE,gGAAgG;AAEhG;;;;;GAKG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AACrE,yGAAyG;AACzG,OAAO,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAChE,6FAA6F;AAC7F,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA","sourcesContent":["/**\n * `@elytracms/studio/addon` — THE public addon API (EC-616, ruling EC-565 § 6b item 2).\n *\n * An addon (`@elytracms/forms`, `@elytracms/analytics`, or your own) is an optional\n * package whose studio half contributes navigation items to a project's\n * `defineStudio({ sidebar })` — a rail bound to a record kind, or a view with its own\n * component — and whose backend half is a record kind in the generic record store\n * (`defineRecordKind` / `adapter.records(kind)` in `@elytracms/core/persistence`, its\n * Convex functions from `defineRecordFunctions` in `@elytracms/studio/convex/\n * record-functions`). Everything an addon's studio code touches is exported HERE; the\n * studio's internals (`lib/*`, `components/*`) are not part of the published package.\n * `@elytracms/studio/ui` stays the primitive set (Button, Panel, Text, …).\n *\n * ## The chrome contract\n *\n * The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;\n * deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail with its issues\n * foot (findings contributed with {@link useNavIssues}, or always-on through a view's\n * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area\n * under it — including its own title header: the shell adds no frame, title or padding.\n * A view reaches the studio through its {@link AppViewContext}: the project, its deep\n * link and search, the operations client (every read and write goes through the one\n * authorized chokepoint), the query hooks over the one cache, the content locale, and\n * the signed-in user with their role.\n *\n * ## The two navigation items\n *\n * ```tsx\n * import { nav } from '@elytracms/studio/addon'\n * import { submissionRecordKind } from './kinds'\n *\n * // a rail bound to a record kind — the portable list/edit surfaces, no component to write\n * nav.rail(submissionRecordKind, { label: 'Submissions' })\n * // a custom component — the whole content area is yours\n * nav.view({ id: 'forms-stats', label: 'Form stats', render: (ctx) => <Stats ctx={ctx} /> })\n * ```\n */\n\n// --- Navigation items and the view contract --------------------------------------------\n\nexport { nav } from './components/app-views/default-modules'\nexport type {\n CustomViewConfig,\n RailViewConfig,\n BuiltinViewConfig,\n} from './components/app-views/default-modules'\nexport type {\n AppViewContext,\n AppViewDeepLink,\n AppViewModule,\n AppViewQueryHooks,\n NavIssuesHook,\n NavPermission,\n NavPermissionContext,\n SidebarNode,\n SidebarViewNode,\n} from './components/app-views/module'\n\n// --- Rails: a record kind, or your own source -------------------------------------------\n\n/** A record rail's pieces, for an addon composing its own view around one. */\nexport {\n RECORD_RAIL_CAPABILITIES,\n RecordRailModule,\n RecordRailProvider,\n useRecordCollectionViewSource,\n useRecordList,\n useRecordRail,\n} from './components/records/record-rail'\nexport type { RecordRailModuleProps, RecordRailPaths, RecordRailStore } from './components/records/record-rail'\nexport { lensDocumentToRecordWrite, recordLensDef, recordToLensDocument } from './components/records/record-lens'\n\n/** The portable list/edit surfaces over any source, and the scaffold that assembles one. */\nexport { CollectionViewSurface } from './components/collection-view/collection-view-surface'\nexport {\n assembleRailViewSource,\n metaBagProjection,\n normalizeOkResult,\n useRailViewSource,\n} from './components/rail-view-source'\nexport type { OkResult, RailAdapter } from './components/rail-view-source'\nexport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n FieldAccess,\n} from './lib/collection-view/collection-view-source'\n\n// --- Hooks: the cache, the locale, the breadcrumb, the rail foot -------------------------\n\nexport { useOperationCache, useOperationQuery } from './lib/query'\nexport type { OperationCache, UseOperationQueryOptions } from './lib/query'\nexport { useStudioLocale } from './lib/locale'\nexport type { StudioLocale } from './lib/locale'\nexport { useSetBreadcrumb } from './components/breadcrumb-context'\nexport type { BreadcrumbItem } from './components/breadcrumb-context'\nexport { useNavIssues } from './components/nav-issues-registry'\nexport type { NavIssue } from './components/nav-issues-registry'\n\n// --- The document editor: contributed inspector panes (EC-618) ---------------------------\n\n/** A pane in the document editor's inspector — `defineStudio({ documentPanes: [documentPane({ … })] })`. */\nexport { documentPane } from './components/inspector/document-panes'\nexport type { DocumentPane, DocumentPaneContext } from './components/inspector/document-panes'\n\n// --- EC-613 (the forms addon): an own source's edit shell, its list key, the UI language -----\n\n/**\n * The portable edit shell alone (AD-19), with its form-column appendix seam (EC-396): an addon\n * that assembles its own source (`useRailViewSource`) renders one record's detail with\n * read-only sections below the editable fields — the forms inbox shows a submission's answers,\n * files and delivery log there.\n */\nexport { PortableDetailEditor } from './components/cms/detail-editor'\n/** The one query key of a kind's records in a project — patch it after a write (`useOperationCache`). */\nexport { recordListKey } from './components/records/record-rail'\n/** Resolve a `LocalizedString` (`'Text'` or `{ en, de, … }`) in the studio's UI language. */\nexport { useLabel } from './lib/i18n'\n"]}
|
|
1
|
+
{"version":3,"file":"addon.js","sourceRoot":"","sources":["../src/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,0FAA0F;AAE1F,OAAO,EAAE,GAAG,EAAE,MAAM,wCAAwC,CAAA;AAkB5D,2FAA2F;AAE3F,8EAA8E;AAC9E,OAAO,EACL,wBAAwB,EACxB,gBAAgB,EAChB,kBAAkB,EAClB,6BAA6B,EAC7B,aAAa,EACb,aAAa,GACd,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAA;AAEjH,4FAA4F;AAC5F,OAAO,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAA;AAC5F,OAAO,EACL,sBAAsB,EACtB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AAQtC,4FAA4F;AAE5F,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAG/D,4FAA4F;AAE5F,4GAA4G;AAC5G,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AAGpE,gGAAgG;AAEhG;;;;;GAKG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AACrE,yGAAyG;AACzG,OAAO,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAChE,6FAA6F;AAC7F,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA","sourcesContent":["/**\n * `@elytracms/studio/addon` — THE public addon API (EC-616, ruling EC-565 § 6b item 2).\n *\n * An addon (`@elytracms/forms`, `@elytracms/analytics`, or your own) is an optional\n * package whose studio half contributes navigation items to a project's\n * `defineStudio({ sidebar })` — a rail bound to a record kind, or a view with its own\n * component — and whose backend half is a record kind in the generic record store\n * (`defineRecordKind` / `adapter.records(kind)` in `@elytracms/core/persistence`, its\n * Convex functions from `defineRecordFunctions` in `@elytracms/studio/convex/\n * record-functions`). Everything an addon's studio code touches is exported HERE; the\n * studio's internals (`lib/*`, `components/*`) are not part of the published package.\n * `@elytracms/studio/ui` stays the primitive set (Button, Panel, Text, …).\n *\n * ## The chrome contract\n *\n * The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;\n * deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail with its issues\n * foot (findings contributed with {@link useNavIssues}, or always-on through a view's\n * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area\n * under it — including its own title header: the shell adds no frame, title or padding.\n * A view reaches the studio through its {@link AppViewContext}: the project, its deep\n * link and search, the operations client (every read and write goes through the one\n * authorized chokepoint), the query hooks over the one cache, the content locale, and\n * the signed-in user with their role.\n *\n * ## The two navigation items\n *\n * ```tsx\n * import { nav } from '@elytracms/studio/addon'\n * import { submissionRecordKind } from './kinds'\n *\n * // a rail bound to a record kind — the portable list/edit surfaces, no component to write\n * nav.rail(submissionRecordKind, { label: 'Submissions' })\n * // a custom component — the whole content area is yours\n * nav.view({ id: 'forms-stats', label: 'Form stats', render: (ctx) => <Stats ctx={ctx} /> })\n * ```\n */\n\n// --- Navigation items and the view contract --------------------------------------------\n\nexport { nav } from './components/app-views/default-modules'\nexport type {\n CustomViewConfig,\n RailViewConfig,\n BuiltinViewConfig,\n} from './components/app-views/default-modules'\nexport type {\n AppViewContext,\n AppViewDeepLink,\n AppViewModule,\n AppViewQueryHooks,\n NavIssuesHook,\n NavPermission,\n NavPermissionContext,\n SidebarNode,\n SidebarViewNode,\n} from './components/app-views/module'\n\n// --- Rails: a record kind, or your own source -------------------------------------------\n\n/** A record rail's pieces, for an addon composing its own view around one. */\nexport {\n RECORD_RAIL_CAPABILITIES,\n RecordRailModule,\n RecordRailProvider,\n useRecordCollectionViewSource,\n useRecordList,\n useRecordRail,\n} from './components/records/record-rail'\nexport type { RecordRailModuleProps, RecordRailPaths, RecordRailStore } from './components/records/record-rail'\nexport { lensDocumentToRecordWrite, recordLensDef, recordToLensDocument } from './components/records/record-lens'\n\n/** The portable list/edit surfaces over any source, and the scaffold that assembles one. */\nexport { CollectionViewSurface } from './components/collection-view/collection-view-surface'\nexport {\n assembleRailViewSource,\n metaBagProjection,\n normalizeOkResult,\n useRailViewSource,\n} from './components/rail-view-source'\nexport type { OkResult, RailAdapter } from './components/rail-view-source'\nexport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n FieldAccess,\n} from './lib/collection-view/collection-view-source'\n\n// --- Hooks: the cache, the locale, the breadcrumb, the rail foot -------------------------\n\nexport { useOperationCache, useOperationQuery } from './lib/query'\nexport type { OperationCache, ReadState, UseOperationQueryOptions } from './lib/query'\nexport { useStudioLocale } from './lib/locale'\nexport type { StudioLocale } from './lib/locale'\nexport { useSetBreadcrumb } from './components/breadcrumb-context'\nexport type { BreadcrumbItem } from './components/breadcrumb-context'\nexport { useNavIssues } from './components/nav-issues-registry'\nexport type { NavIssue } from './components/nav-issues-registry'\n\n// --- The document editor: contributed inspector panes (EC-618) ---------------------------\n\n/** A pane in the document editor's inspector — `defineStudio({ documentPanes: [documentPane({ … })] })`. */\nexport { documentPane } from './components/inspector/document-panes'\nexport type { DocumentPane, DocumentPaneContext } from './components/inspector/document-panes'\n\n// --- EC-613 (the forms addon): an own source's edit shell, its list key, the UI language -----\n\n/**\n * The portable edit shell alone (AD-19), with its form-column appendix seam (EC-396): an addon\n * that assembles its own source (`useRailViewSource`) renders one record's detail with\n * read-only sections below the editable fields — the forms inbox shows a submission's answers,\n * files and delivery log there.\n */\nexport { PortableDetailEditor } from './components/cms/detail-editor'\n/** The one query key of a kind's records in a project — patch it after a write (`useOperationCache`). */\nexport { recordListKey } from './components/records/record-rail'\n/** Resolve a `LocalizedString` (`'Text'` or `{ en, de, … }`) in the studio's UI language. */\nexport { useLabel } from './lib/i18n'\n"]}
|
|
@@ -66,6 +66,12 @@ export function AiChatPanel() {
|
|
|
66
66
|
...doc.values,
|
|
67
67
|
...(doc.localized?.[activeLocale] ?? {}),
|
|
68
68
|
}), [activeLocale]);
|
|
69
|
+
/**
|
|
70
|
+
* EC-647 — the revision each document the assistant READ was at, keyed by the value the
|
|
71
|
+
* reader returned (the token the proposal is built from): an update / translation
|
|
72
|
+
* proposal records it, and its apply writes at it.
|
|
73
|
+
*/
|
|
74
|
+
const readRevisions = useRef(new WeakMap());
|
|
69
75
|
/** Operations-backed tool readers — client-side, with the user's own session. */
|
|
70
76
|
const readers = useMemo(() => {
|
|
71
77
|
const matches = (query, ...fields) => !query || fields.some((f) => f?.toLowerCase().includes(query.toLowerCase()));
|
|
@@ -118,8 +124,12 @@ export function AiChatPanel() {
|
|
|
118
124
|
await cms.flushPersistence({ collection, id });
|
|
119
125
|
const { operations } = await getStudioOperations();
|
|
120
126
|
const result = await operations.documents.get({ projectId, ref: { collection, id } });
|
|
121
|
-
|
|
127
|
+
if (!result.ok || !result.value)
|
|
128
|
+
return null;
|
|
129
|
+
readRevisions.current.set(result.value.document, result.value.revision);
|
|
130
|
+
return result.value.document;
|
|
122
131
|
},
|
|
132
|
+
revisionOf: (document) => readRevisions.current.get(document),
|
|
123
133
|
};
|
|
124
134
|
}, [projectId, cms, localeValues]);
|
|
125
135
|
/** EC-578: a fresh document id from the session's injected id factory (UUIDs). */
|
|
@@ -338,7 +348,10 @@ export function AiChatPanel() {
|
|
|
338
348
|
if (current && current.collection === ref.collection && current.id === ref.id)
|
|
339
349
|
await current.flush();
|
|
340
350
|
if (existing && proposal.patches) {
|
|
341
|
-
|
|
351
|
+
// EC-647: AT the revision of the read the proposal was built from — a position
|
|
352
|
+
// changed since then by anyone else is refused (the apply fails visibly), never
|
|
353
|
+
// overwritten by content the assistant never saw.
|
|
354
|
+
const outcome = await cms.commitPatches(ref, proposal.patches, proposal.patches.map(() => proposal.revision));
|
|
342
355
|
if (outcome?.status === 'failed' || outcome?.status === 'refused') {
|
|
343
356
|
dispatch({ type: 'fail', message: translate('ai.error.applyFailed') });
|
|
344
357
|
return;
|