@elytracms/studio 0.0.99 → 0.0.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/cms.ts +29 -2
- package/convex/records-column-transform.convex.test.ts +5 -3
- package/convex/records-one-write.convex.test.ts +218 -0
- package/convex/version-kind-stamping.test.ts +118 -0
- package/dist/components/records/record-rail.js +16 -5
- package/dist/components/records/record-rail.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 +1 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +1 -0
- package/dist/lib/i18n/messages.en.js +1 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.js +9 -15
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +2 -0
- package/dist/lib/persistence/convex-adapter.js +67 -2
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/package.json +3 -3
package/convex/cms.ts
CHANGED
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
unfoldValue,
|
|
13
13
|
} from '@elytracms/core/cms-core'
|
|
14
14
|
import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
|
|
15
|
-
import { isPinnableVersion } from '@elytracms/core/persistence'
|
|
15
|
+
import { isPinnableVersion, planVersionKindStamps } from '@elytracms/core/persistence'
|
|
16
16
|
import type { DocumentVersionKind } from '@elytracms/core/persistence'
|
|
17
|
-
import { mutation, query } from './functions'
|
|
17
|
+
import { internalMutation, mutation, query } from './functions'
|
|
18
18
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
19
19
|
import { requireCapability } from './guard'
|
|
20
20
|
import { assertWithinWriteLimits } from './write-limits'
|
|
@@ -866,6 +866,33 @@ export const listAllDocumentVersions = query({
|
|
|
866
866
|
},
|
|
867
867
|
})
|
|
868
868
|
|
|
869
|
+
/**
|
|
870
|
+
* EC-707 — the EC-585 kind stamping: every version row recorded before EC-585 (no `kind`)
|
|
871
|
+
* gets the kind `isPinnableVersion` already reads it as — `label: 'published'` → `'publish'`,
|
|
872
|
+
* anything else → `'snapshot'` (core `planVersionKindStamps`, the same inference) — so
|
|
873
|
+
* pinnability is unchanged for every row, and once every deployment reads 0 kind-less rows
|
|
874
|
+
* the tolerant branch and the optional `kind` can go (EC-707's deletions). Idempotent: a
|
|
875
|
+
* second run stamps 0; a row that names a kind is never touched; nothing but `kind` is written.
|
|
876
|
+
*
|
|
877
|
+
* Internal: an admin runs it from the CLI, once per deployment — `npx convex run
|
|
878
|
+
* cms:stampVersionKinds` (no args) → `{ scanned, stamped }`; `pnpm demo:reseed` runs it twice.
|
|
879
|
+
* ONE transaction at a documented bound (`VERSION_KIND_STAMP_BOUND`: 4096 rows / 4 MiB of
|
|
880
|
+
* rewritten rows, half of Convex's per-transaction write limits): it reads every version row,
|
|
881
|
+
* as `listAllDocumentVersions` already does for delivery, and above the bound it throws
|
|
882
|
+
* before writing anything — a refusal is whole, never a partial stamping. `documentVersions`
|
|
883
|
+
* has no write-chokepoint trigger, so the stamp schedules no revalidation (delivery reads the
|
|
884
|
+
* same pins before and after).
|
|
885
|
+
*/
|
|
886
|
+
export const stampVersionKinds = internalMutation({
|
|
887
|
+
args: {},
|
|
888
|
+
handler: async (ctx) => {
|
|
889
|
+
const rows = await ctx.db.query('documentVersions').collect()
|
|
890
|
+
const stamps = planVersionKindStamps(rows)
|
|
891
|
+
for (const { row, kind } of stamps) await ctx.db.patch(row._id, { kind })
|
|
892
|
+
return { scanned: rows.length, stamped: stamps.length }
|
|
893
|
+
},
|
|
894
|
+
})
|
|
895
|
+
|
|
869
896
|
/**
|
|
870
897
|
* Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
|
|
871
898
|
* enforced here, in the transaction: the version must exist for THIS document
|
|
@@ -21,7 +21,8 @@ import { defineRecordFunctions } from './record-functions'
|
|
|
21
21
|
* Before: the operations parsed the write and sent the PARSED values over the wire, where the
|
|
22
22
|
* mutation parsed them again — `seed!!` stored with `ok: true`, and a type-changing column
|
|
23
23
|
* (`string → length`) refused. Measured red on the wave base 21678336: 12 of 12. Now the
|
|
24
|
-
* operations
|
|
24
|
+
* operations send the values as sent (EC-694), and judge no column themselves (EC-702): the
|
|
25
|
+
* mutation is the one executor, and its refusal arrives located (`records-one-write.convex.test.ts`).
|
|
25
26
|
*
|
|
26
27
|
* The three copies are real zod releases (the engine's own 4.4.3; the aliased devDependencies
|
|
27
28
|
* `zod-copy-older` 4.1.13 and `zod-copy-newer` 4.6.7). The client is the real
|
|
@@ -126,11 +127,12 @@ describe.each(LENGTHS)('EC-694 — Convex, a type-changing column of the $copy z
|
|
|
126
127
|
})
|
|
127
128
|
|
|
128
129
|
describe('EC-694 — Convex, what the operations send', () => {
|
|
129
|
-
it('a refused value is the
|
|
130
|
+
it('a refused value is the mutation’s located refusal, through the operation (EC-702): nothing stored', async () => {
|
|
130
131
|
const { t, ops } = await connected()
|
|
131
132
|
const kind = EXCLAIMED[0]!.kind
|
|
132
133
|
const refused = await ops.records(kind).save({ projectId: PROJECT, id: 'refused', text: 42 as never })
|
|
133
|
-
expect(refused).toMatchObject({ ok: false, error: { code: 'validation'
|
|
134
|
+
expect(refused).toMatchObject({ ok: false, error: { code: 'validation' } })
|
|
135
|
+
expect(!refused.ok && refused.error.code === 'validation' && refused.error.issues.map((issue) => issue.path)).toEqual([['text']])
|
|
134
136
|
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
135
137
|
})
|
|
136
138
|
|
|
@@ -0,0 +1,218 @@
|
|
|
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 { z } from 'zod'
|
|
7
|
+
import { z as newerZod } from 'zod-copy-newer'
|
|
8
|
+
import { z as olderZod } from 'zod-copy-older'
|
|
9
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
10
|
+
import type { AuthorizationHook, OperationEvent } from '@elytracms/core/operations'
|
|
11
|
+
import { PersistenceValidationError, defineRecordKind } from '@elytracms/core/persistence'
|
|
12
|
+
import type { AnyRecordKind, RecordColumnSchema } from '@elytracms/core/persistence'
|
|
13
|
+
import { ConvexPersistenceAdapter, UNCARRIABLE_VALUE } from '../src/lib/persistence/convex-adapter'
|
|
14
|
+
import schema from './schema'
|
|
15
|
+
import { defineRecordFunctions } from './record-functions'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* EC-702 (Codex round 6 F31 residual + F32), the Convex half — a record save through
|
|
19
|
+
* `operations.records(kind)` is ONE write: captured when the call starts, authorized as captured,
|
|
20
|
+
* executed exactly as authorized, and parsed ONCE, in the kind's `save` mutation
|
|
21
|
+
* (`defineRecordFunctions(kind).save` → `resolveRecordSave`).
|
|
22
|
+
*
|
|
23
|
+
* Measured red on the wave base 85b983de through this file's as-written copy of
|
|
24
|
+
* `codex-review-2026-10-28-r6/record-raw-contracts.ts` (22 of 22 on Convex): a save authorized as
|
|
25
|
+
* `allowed` stored `forbidden`; a transform ran twice; a mutating normaliser stored `seed!!`.
|
|
26
|
+
*
|
|
27
|
+
* The client is the real `ConvexPersistenceAdapter` over convex-test, signed in as an editor
|
|
28
|
+
* member (the probe's wiring), with the kind's functions in the kind's module. The in-memory half
|
|
29
|
+
* is `core/src/operations/records-one-write.test.ts`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
type Vendor = typeof z
|
|
33
|
+
const COPIES = [
|
|
34
|
+
['own', z],
|
|
35
|
+
['older', olderZod as unknown as Vendor],
|
|
36
|
+
['newer', newerZod as unknown as Vendor],
|
|
37
|
+
] as const
|
|
38
|
+
const WRAPPERS = ['required', 'optional', 'default'] as const
|
|
39
|
+
const FORMS = ['custom transform', 'preprocess'] as const
|
|
40
|
+
type Wrapper = (typeof WRAPPERS)[number]
|
|
41
|
+
type Form = (typeof FORMS)[number]
|
|
42
|
+
|
|
43
|
+
const PROJECT = 'site'
|
|
44
|
+
const STAMP = '2026-10-29T00:00:00.000Z'
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.useFakeTimers()
|
|
48
|
+
vi.setSystemTime(STAMP)
|
|
49
|
+
})
|
|
50
|
+
afterEach(() => vi.useRealTimers())
|
|
51
|
+
|
|
52
|
+
function kindOf(fields: Record<string, unknown>): AnyRecordKind {
|
|
53
|
+
return defineRecordKind({
|
|
54
|
+
kind: 'reviewTransform',
|
|
55
|
+
module: 'reviewTransforms',
|
|
56
|
+
fields: fields as Record<string, RecordColumnSchema>,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The real client adapter over convex-test, signed in as an editor; the operations over it. */
|
|
61
|
+
async function connected(fields: Record<string, unknown>, authorize?: AuthorizationHook) {
|
|
62
|
+
const kind = kindOf(fields)
|
|
63
|
+
const t = convexTest(schema, {
|
|
64
|
+
'./_generated/api.ts': async () => ({}),
|
|
65
|
+
[`./${kind.module}.ts`]: async () => defineRecordFunctions(kind),
|
|
66
|
+
})
|
|
67
|
+
await t.run(async (ctx) => {
|
|
68
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
69
|
+
})
|
|
70
|
+
const editor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
|
|
71
|
+
const calls: string[] = []
|
|
72
|
+
const client = {
|
|
73
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => editor.query(ref, args ?? {}),
|
|
74
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
75
|
+
calls.push(getFunctionName(ref))
|
|
76
|
+
return editor.mutation(ref, args ?? {})
|
|
77
|
+
},
|
|
78
|
+
} as unknown as ConvexHttpClient
|
|
79
|
+
const adapter = new ConvexPersistenceAdapter(client, PROJECT)
|
|
80
|
+
const ops = createOperations({ adapter, collections: () => [], validateDocument: () => [], ...(authorize ? { authorize } : {}) })
|
|
81
|
+
const events: OperationEvent[] = []
|
|
82
|
+
ops.events.subscribe((event) => events.push(event))
|
|
83
|
+
return { t, direct: adapter.records(kind), records: ops.records(kind), events, calls }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalising(vendor: Vendor, form: Form, wrapper: Wrapper) {
|
|
87
|
+
const normalize = (value: { text: string }) => {
|
|
88
|
+
value.text += '!'
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
const isText = (value: unknown) => typeof value === 'object' && value !== null && typeof (value as { text?: unknown }).text === 'string'
|
|
92
|
+
const inner =
|
|
93
|
+
form === 'custom transform'
|
|
94
|
+
? vendor.custom<{ text: string }>(isText).transform(normalize)
|
|
95
|
+
: vendor.preprocess((value) => (isText(value) ? normalize(value as { text: string }) : value), vendor.object({ text: vendor.string() }))
|
|
96
|
+
return wrapper === 'optional' ? inner.optional() : wrapper === 'default' ? inner.default({ text: 'safe' }) : inner
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pathsOf = (result: { ok: boolean; error?: unknown }) =>
|
|
100
|
+
!result.ok && (result.error as { code: string }).code === 'validation'
|
|
101
|
+
? (result.error as { issues: { path?: unknown }[] }).issues.map((issue) => issue.path)
|
|
102
|
+
: undefined
|
|
103
|
+
|
|
104
|
+
describe('EC-702 — F32 on Convex: the write authorized is the write executed', () => {
|
|
105
|
+
it('a caller that rewrites its input while authorization awaits changes nothing: the authorized write is stored, never the denied one', async () => {
|
|
106
|
+
const decisions: string[] = []
|
|
107
|
+
let reached!: () => void
|
|
108
|
+
let resume!: () => void
|
|
109
|
+
const atHook = new Promise<void>((resolve) => (reached = resolve))
|
|
110
|
+
const gate = new Promise<void>((resolve) => (resume = resolve))
|
|
111
|
+
const { direct, records } = await connected({ text: z.string() }, async (request) => {
|
|
112
|
+
const id = (request.input as { id: string }).id
|
|
113
|
+
decisions.push(id)
|
|
114
|
+
if (id === 'allowed') {
|
|
115
|
+
reached()
|
|
116
|
+
await gate
|
|
117
|
+
}
|
|
118
|
+
return id === 'allowed' ? { allow: true } : { allow: false, reason: `“${id}” is not yours.` }
|
|
119
|
+
})
|
|
120
|
+
// Positive control: this hook denies a direct save to `forbidden`.
|
|
121
|
+
expect(await records.save({ projectId: PROJECT, id: 'forbidden', text: 'denied' })).toMatchObject({ ok: false, error: { code: 'unauthorized' } })
|
|
122
|
+
expect(await direct.find(PROJECT, 'forbidden')).toBeUndefined()
|
|
123
|
+
|
|
124
|
+
const input = { projectId: PROJECT, id: 'allowed', text: 'original' }
|
|
125
|
+
const pending = records.save(input)
|
|
126
|
+
await atHook
|
|
127
|
+
input.id = 'forbidden'
|
|
128
|
+
input.text = 'changed after parse'
|
|
129
|
+
resume()
|
|
130
|
+
expect(await pending).toMatchObject({ ok: true, value: { id: 'allowed', text: 'original' } })
|
|
131
|
+
expect(decisions).toEqual(['forbidden', 'allowed'])
|
|
132
|
+
expect(await direct.get(PROJECT, 'allowed')).toMatchObject({ text: 'original' })
|
|
133
|
+
expect(await direct.find(PROJECT, 'forbidden')).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('EC-702 — F31 on Convex: a column transform runs once per write, in the mutation', () => {
|
|
138
|
+
it.each(WRAPPERS)('%s: the counter reads 1 per write through the operations, as through the repository (create and edit)', async (wrapper) => {
|
|
139
|
+
let calls = 0
|
|
140
|
+
const inner = z.string().transform((value) => `${value}:${++calls}`)
|
|
141
|
+
const column = wrapper === 'optional' ? inner.optional() : wrapper === 'default' ? inner.default('safe') : inner
|
|
142
|
+
const { direct, records } = await connected({ text: column })
|
|
143
|
+
calls = 0
|
|
144
|
+
expect(await direct.save(PROJECT, { id: 'direct', text: 'seed' })).toMatchObject({ text: 'seed:1' })
|
|
145
|
+
expect(calls).toBe(1)
|
|
146
|
+
calls = 0
|
|
147
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'seed' })).toMatchObject({ ok: true, value: { text: 'seed:1' } })
|
|
148
|
+
expect(calls).toBe(1)
|
|
149
|
+
calls = 0
|
|
150
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'edit' })).toMatchObject({ ok: true, value: { text: 'edit:1' } })
|
|
151
|
+
expect(calls).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
for (const [copy, vendor] of COPIES) {
|
|
155
|
+
for (const form of FORMS) {
|
|
156
|
+
it.each(WRAPPERS)(`${copy} zod, ${form}, %s: a mutating normaliser stores \`seed!\` then \`edit!\` through the operations, as through the repository`, async (wrapper) => {
|
|
157
|
+
const { direct, records } = await connected({ payload: normalising(vendor, form, wrapper) })
|
|
158
|
+
expect(await direct.save(PROJECT, { id: 'direct', payload: { text: 'seed' } })).toMatchObject({ payload: { text: 'seed!' } })
|
|
159
|
+
expect(await direct.save(PROJECT, { id: 'direct', payload: { text: 'edit' } })).toMatchObject({ payload: { text: 'edit!' } })
|
|
160
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', payload: { text: 'seed' } })).toMatchObject({ ok: true, value: { payload: { text: 'seed!' } } })
|
|
161
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', payload: { text: 'edit' } })).toMatchObject({ ok: true, value: { payload: { text: 'edit!' } } })
|
|
162
|
+
expect((await direct.get(PROJECT, 'viaops')).payload).toEqual({ text: 'edit!' })
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('EC-702 — the store is the one judge of a column, located (Convex)', () => {
|
|
169
|
+
it('a value the column refuses (a number for `z.string()`) is the operation’s located `validation` failure, after authorization (`failed`) — the mutation refused it, nothing stored', async () => {
|
|
170
|
+
const { t, records, events, calls } = await connected({ text: z.string() })
|
|
171
|
+
const refused = await records.save({ projectId: PROJECT, id: 'refused', text: 42 as never })
|
|
172
|
+
expect(refused).toMatchObject({ ok: false, error: { code: 'validation' } })
|
|
173
|
+
expect(pathsOf(refused)).toEqual([['text']])
|
|
174
|
+
expect(events.map((event) => event.status)).toEqual(['failed'])
|
|
175
|
+
// The server judged it: one mutation ran, and its transaction stored nothing.
|
|
176
|
+
expect(calls).toEqual(['reviewTransforms:save'])
|
|
177
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('a `Date` in a `z.string()` column — a value the wire cannot carry — is refused located at `[text]`, before any request', async () => {
|
|
181
|
+
const { t, records, events, calls } = await connected({ text: z.string() })
|
|
182
|
+
const refused = await records.save({ projectId: PROJECT, id: 'dated', text: new Date(0) as never })
|
|
183
|
+
expect(refused).toMatchObject({ ok: false, error: { code: 'validation' } })
|
|
184
|
+
expect(pathsOf(refused)).toEqual([['text']])
|
|
185
|
+
expect(!refused.ok && refused.error.code === 'validation' && refused.error.issues[0]!.code).toBe(UNCARRIABLE_VALUE)
|
|
186
|
+
expect(events.map((event) => event.status)).toEqual(['failed'])
|
|
187
|
+
expect(calls).toEqual([])
|
|
188
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('the repository itself refuses an uncarriable value as a located `PersistenceValidationError` — at the deepest value or key that fails', async () => {
|
|
192
|
+
const { direct, calls } = await connected({ payload: z.unknown() })
|
|
193
|
+
const cases: [unknown, (string | number)[]][] = [
|
|
194
|
+
[new Date(0), ['payload']],
|
|
195
|
+
[{ nested: { when: new Date(0) } }, ['payload', 'nested', 'when']],
|
|
196
|
+
[{ list: ['a', new Map()] }, ['payload', 'list', 1]],
|
|
197
|
+
[{ nested: { $junk: 1 } }, ['payload', 'nested', '$junk']],
|
|
198
|
+
[{ ['ü']: 1 }, ['payload', 'ü']],
|
|
199
|
+
[[1, undefined], ['payload', 1]],
|
|
200
|
+
]
|
|
201
|
+
for (const [payload, path] of cases) {
|
|
202
|
+
const error = await direct.save(PROJECT, { id: 'r', payload }).then(
|
|
203
|
+
() => undefined,
|
|
204
|
+
(thrown: unknown) => thrown,
|
|
205
|
+
)
|
|
206
|
+
expect(error).toBeInstanceOf(PersistenceValidationError)
|
|
207
|
+
expect((error as PersistenceValidationError).issues.map((issue) => [issue.code, issue.path])).toEqual([[UNCARRIABLE_VALUE, path]])
|
|
208
|
+
}
|
|
209
|
+
expect(calls).toEqual([])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('control: a carriable value crosses and is stored', async () => {
|
|
213
|
+
const { direct } = await connected({ payload: z.unknown() })
|
|
214
|
+
expect(await direct.save(PROJECT, { id: 'r', payload: { nested: { when: '1970-01-01', n: 1n, list: [1, null] } } })).toMatchObject({
|
|
215
|
+
payload: { nested: { when: '1970-01-01', list: [1, null] } },
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { foldDocument } from '@elytracms/core/cms-core'
|
|
5
|
+
import type { CmsDocument } from '@elytracms/core/cms-core'
|
|
6
|
+
import { VERSION_KIND_STAMP_BOUND, isPinnableVersion, scanStoredShapes } from '@elytracms/core/persistence'
|
|
7
|
+
import schema from './schema'
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers()
|
|
11
|
+
})
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* EC-707 — the EC-585 kind stamping, the REAL internal mutation (`cms:stampVersionKinds`,
|
|
18
|
+
* the line an admin runs with `npx convex run`) on convex-test's backend: planted kind-less
|
|
19
|
+
* rows of each label are stamped with exactly the kind `isPinnableVersion` reads them as,
|
|
20
|
+
* a row that names a kind is untouched, pinnability is unchanged for every row, a second
|
|
21
|
+
* run stamps 0, and above the documented bound the run refuses whole.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const modules = {
|
|
25
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
26
|
+
'./_generated/api.ts': async () => ({}),
|
|
27
|
+
}
|
|
28
|
+
const stampVersionKinds = makeFunctionReference<'mutation'>('cms:stampVersionKinds')
|
|
29
|
+
|
|
30
|
+
const STAMP = '2026-10-29T00:00:00.000Z'
|
|
31
|
+
const snapshot = (id: string) =>
|
|
32
|
+
foldDocument({ collection: 'page', id, values: { title: id } } as unknown as CmsDocument)
|
|
33
|
+
|
|
34
|
+
/** The planted rows: kind-less of each label, and two that name a kind. */
|
|
35
|
+
const PLANTED = [
|
|
36
|
+
{ docId: 'a', version: 1, label: 'published' },
|
|
37
|
+
{ docId: 'a', version: 2, label: 'pre-restore' },
|
|
38
|
+
{ docId: 'a', version: 3, label: 'AI snapshot' },
|
|
39
|
+
{ docId: 'b', version: 1 },
|
|
40
|
+
{ docId: 'b', version: 2, label: 'published', kind: 'snapshot' as const },
|
|
41
|
+
{ docId: 'b', version: 3, label: 'pre-restore', kind: 'publish' as const },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
async function plant(
|
|
45
|
+
rows: ReadonlyArray<{ docId: string; version: number; label?: string; kind?: 'publish' | 'snapshot' }>,
|
|
46
|
+
) {
|
|
47
|
+
const t = convexTest(schema, modules)
|
|
48
|
+
await t.run(async (ctx) => {
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
await ctx.db.insert('documentVersions', {
|
|
51
|
+
collection: 'page',
|
|
52
|
+
snapshot: snapshot(row.docId),
|
|
53
|
+
createdAt: STAMP,
|
|
54
|
+
...row,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
return t
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stored = (t: Awaited<ReturnType<typeof plant>>) =>
|
|
62
|
+
t.run(async (ctx) =>
|
|
63
|
+
(await ctx.db.query('documentVersions').collect()).sort((x, y) =>
|
|
64
|
+
x.docId + x.version < y.docId + y.version ? -1 : 1,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
describe('EC-707 — cms:stampVersionKinds stamps what isPinnableVersion infers, once', () => {
|
|
69
|
+
it('stamps every kind-less row by its label, leaves a kinded row alone, keeps pinnability; a second run stamps 0', async () => {
|
|
70
|
+
const t = await plant(PLANTED)
|
|
71
|
+
const before = await stored(t)
|
|
72
|
+
expect(before.filter((row) => row.kind === undefined)).toHaveLength(4)
|
|
73
|
+
|
|
74
|
+
expect(await t.mutation(stampVersionKinds, {})).toEqual({ scanned: 6, stamped: 4 })
|
|
75
|
+
const after = await stored(t)
|
|
76
|
+
expect(after.map((row) => `${row.docId}@v${row.version} ${row.kind}`)).toEqual([
|
|
77
|
+
'a@v1 publish',
|
|
78
|
+
'a@v2 snapshot',
|
|
79
|
+
'a@v3 snapshot',
|
|
80
|
+
'b@v1 snapshot',
|
|
81
|
+
'b@v2 snapshot', // named a kind: untouched (its label says published, its kind wins)
|
|
82
|
+
'b@v3 publish',
|
|
83
|
+
])
|
|
84
|
+
// The migration preserves meaning: every row pins exactly as it did kind-less.
|
|
85
|
+
expect(after.map(isPinnableVersion)).toEqual(before.map(isPinnableVersion))
|
|
86
|
+
// Nothing but `kind` was written: label, snapshot (still folded), createdAt as planted.
|
|
87
|
+
for (const [index, row] of after.entries()) {
|
|
88
|
+
const { kind: _k, ...rest } = row
|
|
89
|
+
const { kind: _b, ...was } = before[index]!
|
|
90
|
+
expect(rest).toEqual(was)
|
|
91
|
+
}
|
|
92
|
+
// The at-rest scan now reads 0 kind-less rows here.
|
|
93
|
+
const scan = scanStoredShapes({ cmsDocuments: [], documentVersions: after }, { collections: [] })
|
|
94
|
+
expect(scan.byShape['kind-less-version']).toBe(0)
|
|
95
|
+
|
|
96
|
+
expect(await t.mutation(stampVersionKinds, {})).toEqual({ scanned: 6, stamped: 0 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('CONTROL: before the stamping the at-rest scan counts the kind-less rows (the scan can see them)', async () => {
|
|
100
|
+
const t = await plant(PLANTED)
|
|
101
|
+
const scan = scanStoredShapes({ cmsDocuments: [], documentVersions: await stored(t) }, { collections: [] })
|
|
102
|
+
expect(scan.byShape['kind-less-version']).toBe(4)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('refuses WHOLE above the documented row bound — nothing stamped', async () => {
|
|
106
|
+
const rows = Array.from({ length: VERSION_KIND_STAMP_BOUND.rows + 1 }, (_, index) => ({
|
|
107
|
+
docId: 'c',
|
|
108
|
+
version: index + 1,
|
|
109
|
+
label: 'published',
|
|
110
|
+
}))
|
|
111
|
+
const t = await plant(rows)
|
|
112
|
+
await expect(t.mutation(stampVersionKinds, {})).rejects.toThrow(
|
|
113
|
+
/exceed the one-transaction stamping bound of 4096 — nothing was stamped/,
|
|
114
|
+
)
|
|
115
|
+
const after = await stored(t)
|
|
116
|
+
expect(after.every((row) => row.kind === undefined)).toBe(true)
|
|
117
|
+
}, 60_000)
|
|
118
|
+
})
|
|
@@ -185,10 +185,18 @@ function sortForRail(kind, records) {
|
|
|
185
185
|
* Keyed by record kind; a kind with no set rule has no set findings. Redirects: core's one
|
|
186
186
|
* normalised redirect set and router (`routeValidationIssues` — duplicates after
|
|
187
187
|
* trailing-slash normalisation, loops, unreadable rows), over the project's routes.
|
|
188
|
+
*
|
|
189
|
+
* EC-703 (Codex round 6 F34): a `Map`, so a lookup finds only a rule declared here. As a plain
|
|
190
|
+
* object, a kind named `constructor` (`toString`, `valueOf`, …) found the inherited function and
|
|
191
|
+
* called it as a validator, and the rail threw (measured). `defineRecordKind` refuses those names
|
|
192
|
+
* since EC-697; the registry is own-safe regardless, for a kind object built below it.
|
|
188
193
|
*/
|
|
189
|
-
const RECORD_SET_RULES =
|
|
190
|
-
[
|
|
191
|
-
|
|
194
|
+
const RECORD_SET_RULES = new Map([
|
|
195
|
+
[
|
|
196
|
+
redirectRecordKind.kind,
|
|
197
|
+
(records) => routeValidationIssues({ routes: resolveDeploymentRoutes(), collections: resolveDeploymentCollections() }, records),
|
|
198
|
+
],
|
|
199
|
+
]);
|
|
192
200
|
/** The set rule's issues located AT one record (`[<domain>, <id>]`), keyed by record id. */
|
|
193
201
|
function issuesById(issues) {
|
|
194
202
|
const byId = new Map();
|
|
@@ -208,7 +216,7 @@ function issuesById(issues) {
|
|
|
208
216
|
* report that, field by field). Pure; exported for the tests.
|
|
209
217
|
*/
|
|
210
218
|
export function recordSetIssues(kind, records, doc) {
|
|
211
|
-
const rule = RECORD_SET_RULES
|
|
219
|
+
const rule = RECORD_SET_RULES.get(kind.kind);
|
|
212
220
|
if (!rule)
|
|
213
221
|
return [];
|
|
214
222
|
const columns = recordColumnNames(kind);
|
|
@@ -256,7 +264,10 @@ export function useRecordCollectionViewSource(kind, projectId, schema, paths, ca
|
|
|
256
264
|
// the stored set's verdict is computed once per list; a draft that changed a column is
|
|
257
265
|
// re-judged against its siblings.
|
|
258
266
|
const records = store.records;
|
|
259
|
-
const storedSetIssues = useMemo(() =>
|
|
267
|
+
const storedSetIssues = useMemo(() => {
|
|
268
|
+
const rule = RECORD_SET_RULES.get(kind.kind);
|
|
269
|
+
return rule ? issuesById(rule(records)) : undefined;
|
|
270
|
+
}, [kind, records]);
|
|
260
271
|
const validate = useCallback((doc) => {
|
|
261
272
|
const own = validateDocument(registry, doc, { localeConfig: RAIL_LOCALE_CONFIG });
|
|
262
273
|
if (!storedSetIssues)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-rail.js","sourceRoot":"","sources":["../../../src/components/records/record-rail.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAE9D,OAAO,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAExG,OAAO,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAA;AAEhE,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAA;AACrE,OAAO,EAAE,4BAA4B,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;AAC3F,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACtF,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAClE,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAK1D,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAC1E,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,4CAA4C,CAAA;AAClF,OAAO,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AAElG;;;;;;;;;;;;;GAaG;AAEH,kFAAkF;AAClF,MAAM,WAAW,GAAW,IAAI,CAAA;AAChC,MAAM,kBAAkB,GAAiB,iBAAiB,CAAC,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,WAAW,CAAC,EAAE,CAAC,CAAA;AAE5G,wHAAwH;AACxH,MAAM,CAAC,MAAM,wBAAwB,GAA+B;IAClE,OAAO,EAAE,KAAK;IACd,QAAQ,EAAE,KAAK;IACf,OAAO,EAAE,KAAK;IACd,UAAU,EAAE,KAAK;IACjB,MAAM,EAAE,IAAI;IACZ,SAAS,EAAE,KAAK;IAChB,MAAM,EAAE,IAAI;IACZ,IAAI,EAAE,IAAI;IACV,OAAO,EAAE,KAAK;IACd,SAAS,EAAE,KAAK;IAChB,YAAY,EAAE,KAAK;IACnB,SAAS,EAAE,KAAK;CACjB,CAAA;AAED,0DAA0D;AAC1D,MAAM,UAAU,aAAa,CAAC,IAAmB,EAAE,SAAiB;IAClE,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAA;AAC1C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAA0B,IAAO,EAAE,SAAiB;IAC/E,OAAO,iBAAiB,CAAqB;QAC3C,QAAQ,EAAE,aAAa,CAAC,IAAI,EAAE,SAAS,CAAC;QACxC,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE;YAC3B,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,CAAC,CAAA;YACjE,IAAI,CAAC,MAAM,CAAC,EAAE;gBAAE,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;YACrD,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;QAC1B,CAAC;KACF,CAAC,CAAA;AACJ,CAAC;AAqBD,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,oBAAoB,EAAE,GACrE,qBAAqB,CAAkB,YAAY,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAA;AAExE,uEAAuE;AACvE,MAAM,UAAU,aAAa;IAC3B,OAAO,oBAAoB,EAAmC,CAAA;AAChE,CAAC;AAED,IAAI,aAAyC,CAAA;AAE7C,MAAM,UAAU,kBAAkB,CAA0B,EAC1D,IAAI,EACJ,SAAS,EACT,QAAQ,GAKT;IACC,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,EAAE,SAAS,CAAC,CAAA;IAC5C,MAAM,KAAK,GAAG,iBAAiB,EAAE,CAAA;IACjC,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,IAAI,KAAK,CAAA;IACrC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,cAAc,CAAC,KAAK,CAAC,CAAA;IACtD,4EAA4E;IAC5E,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAmC,EAAE,CAAC,CAAA;IAC1E,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAA;IAChC,SAAS,CAAC,OAAO,GAAG,MAAM,CAAA;IAE1B,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAA;IAC7B,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QACrC,MAAM,OAAO,EAAE,CAAA;IACjB,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAA;IAEb,MAAM,GAAG,GAAG,WAAW,CACrB,CAAC,EAAU,EAAE,EAAE,CACb,MAAM,CAAC,EAAE,CAAC,IAAK,SAAgC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAE,MAAyB,CAAC,EAAE,KAAK,EAAE,CAAC,EACxG,CAAC,MAAM,EAAE,SAAS,CAAC,CACpB,CAAA;IAED,MAAM,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE;QACnC,iFAAiF;QACjF,4EAA4E;QAC5E,aAAa,KAAK,mBAAmB,EAAE,CAAA;QACvC,MAAM,EAAE,GAAG,GAAG,IAAI,CAAC,QAAQ,IAAI,aAAa,EAAE,EAAE,CAAA;QAChD,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;QACpC,MAAM,KAAK,GAAG,EAAE,GAAG,kBAAkB,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,EAAiC,CAAA;QAC3H,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAA;QAC/C,OAAO,EAAE,CAAA;IACX,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAA;IAErB,MAAM,IAAI,GAAG,WAAW,CACtB,KAAK,EAAE,KAAK,EAAE,EAAE;QACd,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,mBAAmB,EAAE,CAAA;QAClD,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAW,CAAC,CAAA;QACpF,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,CAAA;QACnE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAyB,CAAA;QAC9C,MAAM,OAAO,GAAI,KAAwB,CAAC,EAAE,CAAA;QAC5C,+EAA+E;QAC/E,uEAAuE;QACvE,KAAK,CAAC,KAAK,CAAqB,aAAa,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE;YACvE,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAE,MAAyB,CAAC,EAAE,KAAK,OAAO,CAAC,CAAA;YACvF,OAAO,WAAW,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,CAAC,CAAA;QAC5C,CAAC,CAAC,CAAA;QACF,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,CAAC,OAAO,IAAI,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAA;YACnC,MAAM,IAAI,GAAG,EAAE,GAAG,IAAI,EAAE,CAAA;YACxB,OAAO,IAAI,CAAC,OAAO,CAAC,CAAA;YACpB,OAAO,IAAI,CAAA;QACb,CAAC,CAAC,CAAA;QACF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;IACpC,CAAC,EACD,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CACzB,CAAA;IAED,MAAM,MAAM,GAAG,WAAW,CACxB,KAAK,EAAE,EAAE,EAAE,EAAE;QACX,8EAA8E;QAC9E,IAAI,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;YAC1B,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;gBACjB,MAAM,IAAI,GAAG,EAAE,GAAG,IAAI,EAAE,CAAA;gBACxB,OAAO,IAAI,CAAC,EAAE,CAAC,CAAA;gBACf,OAAO,IAAI,CAAA;YACb,CAAC,CAAC,CAAA;YACF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;QACrB,CAAC;QACD,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,mBAAmB,EAAE,CAAA;QAClD,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC,CAAA;QACvE,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,CAAA;QACnE,KAAK,CAAC,KAAK,CAAqB,aAAa,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CACvE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAClD,CAAA;QACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;IACrB,CAAC,EACD,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CACzB,CAAA;IAED,MAAM,KAAK,GAAG,OAAO,CACnB,GAAG,EAAE,CAAC,CAAC;QACL,IAAI;QACJ,SAAS;QACT,MAAM;QACN,GAAG,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,OAAO,EAAE,SAA+B;QACxC,GAAG;QACH,WAAW;QACX,IAAI;QACJ,MAAM;QACN,OAAO;KACR,CAAC,EACF,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,CAC5F,CAAA;IAED,OAAO,KAAC,iBAAiB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAmC,YAAG,QAAQ,GAA8B,CAAA;AACxH,CAAC;AAED,MAAM,KAAK,GAAqB,EAAE,CAAA;AAElC;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAmB;IACpD,MAAM,OAAO,GAA4B,EAAE,CAAA;IAC3C,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QACxD,MAAM,MAAM,GAAI,KAA6E,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;QAClH,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS;YAAE,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,CAAA;aACvE,IAAK,KAA6C,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAA;IACxG,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,4FAA4F;AAC5F,SAAS,WAAW,CAAI,IAAmB,EAAE,OAAY;IACvD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,IAAI,WAAW,CAAA;IAC1C,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QAChC,MAAM,IAAI,GAAG,MAAM,CAAE,CAA6B,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;QACjE,MAAM,KAAK,GAAG,MAAM,CAAE,CAA6B,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;QAClE,OAAO,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACjD,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,gBAAgB,GAA6G;IACjI,CAAC,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,CACrC,qBAAqB,CACnB,EAAE,MAAM,EAAE,uBAAuB,EAAE,EAAE,WAAW,EAAE,4BAA4B,EAAE,EAAE,EAClF,OAA8C,CAC/C;CACJ,CAAA;AAED,4FAA4F;AAC5F,SAAS,UAAU,CAAC,MAAqC;IACvD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAgC,CAAA;IACpD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACxB,IAAI,OAAO,EAAE,KAAK,QAAQ;YAAE,SAAQ;QACpC,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,CAAA;IAChD,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC7B,IAAmB,EACnB,OAA2C,EAC3C,GAAgB;IAEhB,MAAM,IAAI,GAAG,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACxC,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,MAAM,OAAO,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;IACvC,MAAM,KAAK,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CAAA;IACjE,MAAM,YAAY,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAC/G,MAAM,GAAG,GAAG,EAAE,GAAG,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,YAAY,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,CAAA;IACpF,MAAM,GAAG,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;IACvG,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAA;IACvD,OAAO,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;AAClG,CAAC;AAED,mGAAmG;AACnG,SAAS,kBAAkB,CAAC,IAAmB,EAAE,MAA+B,EAAE,GAAgB;IAChG,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,EAAE,CAAA;IAC/B,KAAK,MAAM,MAAM,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC;QAC7C,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;YAAE,OAAO,KAAK,CAAA;IAC9D,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAQD;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAC3C,IAAO,EACP,SAAiB,EACjB,MAAqB,EACrB,KAAsB,EACtB,eAA2C,wBAAwB;IAEnE,MAAM,KAAK,GAAG,aAAa,EAAK,CAAA;IAChC,gFAAgF;IAChF,mFAAmF;IACnF,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,wBAAwB,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAC5E,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,EAAE,aAAa,CAAA;IAC3C,MAAM,UAAU,GAAG,OAAO,MAAM,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,SAAS,CAAA;IAExF,MAAM,SAAS,GAAG,WAAW,CAC3B,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,oBAAoB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,EACvE,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CACtB,CAAA;IACD,MAAM,GAAG,GAAG,WAAW,CACrB,CAAC,GAAgB,EAA2B,EAAE;QAC5C,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAChC,OAAO,MAAM,CAAC,CAAC,CAAC,oBAAoB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IAChE,CAAC,EACD,CAAC,IAAI,EAAE,KAAK,CAAC,CACd,CAAA;IACD,MAAM,OAAO,GAAG,WAAW,CACzB,CAAC,GAAgB,EAAU,EAAE;QAC3B,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAwC,CAAA;QACvE,MAAM,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QAC3D,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,KAAK,CAAA;QAC/D,OAAO,SAAS,CAAC,kBAAkB,EAAE,EAAE,KAAK,EAAE,cAAc,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;IACxF,CAAC,EACD,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,IAAI,CAAC,CACpC,CAAA;IACD,8FAA8F;IAC9F,uFAAuF;IACvF,kCAAkC;IAClC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAwD,CAAA;IAC9E,MAAM,eAAe,GAAG,OAAO,CAC7B,GAAG,EAAE,CAAC,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,EACnG,CAAC,IAAI,EAAE,OAAO,CAAC,CAChB,CAAA;IACD,MAAM,QAAQ,GAAG,WAAW,CAC1B,CAAC,GAAgB,EAAE,EAAE;QACnB,MAAM,GAAG,GAAG,gBAAgB,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE,YAAY,EAAE,kBAAkB,EAAE,CAAC,CAAA;QACjF,IAAI,CAAC,eAAe;YAAE,OAAO,GAAG,CAAA;QAChC,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CAAA;QAC7D,MAAM,GAAG,GACP,MAAM,IAAI,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC;YAC7C,CAAC,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;YACrC,CAAC,CAAC,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,CAAC,CAAA;QACzC,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAA;IAChD,CAAC,EACD,CAAC,QAAQ,EAAE,eAAe,EAAE,OAAO,EAAE,IAAI,CAAC,CAC3C,CAAA;IACD,MAAM,MAAM,GAAG,WAAW,CAAC,GAAgB,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,EAAE,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAA;IAClH,yFAAyF;IACzF,6FAA6F;IAC7F,2FAA2F;IAC3F,0DAA0D;IAC1D,MAAM,IAAI,GAAG,WAAW,CACtB,CAAC,GAAgB,EAAE,IAAkB,EAAE,EAAE;QACvC,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAE,MAAyB,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CAAA;QACvF,OAAO,KAAK,CAAC,IAAI,CAAC,yBAAyB,CAAC,IAAI,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IACpF,CAAC,EACD,CAAC,IAAI,EAAE,KAAK,CAAC,CACd,CAAA;IACD,MAAM,MAAM,GAAG,WAAW,CAAC,CAAC,GAAgB,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;IAE/E,OAAO,iBAAiB,CAAC;QACvB,SAAS;QACT,MAAM;QACN,YAAY;QACZ,YAAY,EAAE,kBAAkB;QAChC,SAAS;QACT,GAAG;QACH,OAAO;QACP,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,QAAQ;QACR,MAAM;QACN,IAAI;QACJ,MAAM;QACN,mBAAmB,EAAE,iBAAiB;QACtC,qBAAqB,EAAE,iBAAiB;KACzC,CAAC,CAAA;AACJ,CAAC;AAgBD,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAA0B,KAA+B;IACvF,OAAO,CACL,KAAC,kBAAkB,IAAC,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,YAC9D,KAAC,iBAAiB,OAAK,KAAK,GAAI,GACb,CACtB,CAAA;AACH,CAAC;AAED,SAAS,iBAAiB,CAA0B,EAClD,IAAI,EACJ,SAAS,EACT,QAAQ,EACR,MAAM,EACN,KAAK,EACL,YAAY,EACZ,KAAK,EACL,QAAQ,GACiB;IACzB,MAAM,KAAK,GAAG,aAAa,EAAK,CAAA;IAChC,MAAM,MAAM,GAAG,6BAA6B,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC,CAAA;IAE1F,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;QAC7B,OAAO,CACL,KAAC,eAAe,IACd,KAAK,EAAE,KAAK,EACZ,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,IAAI,SAAS,CAAC,oBAAoB,EAAE,EAAE,KAAK,EAAE,CAAC,EAClF,YAAY,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,IAAI,SAAS,CAAC,iBAAiB,EAAE,EAAE,KAAK,EAAE,CAAC,EAC9E,WAAW,EAAE,GAAG,IAAI,CAAC,IAAI,aAAa,GACtC,CACH,CAAA;IACH,CAAC;IACD,oFAAoF;IACpF,OAAO,KAAC,qBAAqB,IAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,GAAI,CAAA;AAC9F,CAAC","sourcesContent":["import { useCallback, useMemo, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport { createCollectionRegistry, parseLocaleConfig, validateDocument } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CmsValidationIssue, CollectionDef, DocumentRef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\nimport { redirectRecordKind } from '@elytracms/core/persistence'\nimport type { AnyRecordKind, RecordEntryOf, RecordWriteOf, RedirectEntry } from '@elytracms/core/persistence'\nimport { routeValidationIssues } from '../../lib/routes/route-issues'\nimport { resolveDeploymentCollections, resolveDeploymentRoutes } from '../../lib/workspace'\nimport { createUuidIdFactory } from '@elytracms/core/determinism'\nimport { getStudioOperations } from '../../lib/operations'\nimport { listStoreState, useOperationCache, useOperationQuery } from '../../lib/query'\nimport { createRequiredContext } from '../../lib/required-context'\nimport { localizedLabel, translate } from '../../lib/i18n'\nimport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n} from '../../lib/collection-view/collection-view-source'\nimport { normalizeOkResult, useRailViewSource } from '../rail-view-source'\nimport { RailStoreStatus } from '../rail-store-status'\nimport { CollectionViewSurface } from '../collection-view/collection-view-surface'\nimport { lensDocumentToRecordWrite, recordColumnNames, recordToLensDocument } from './record-lens'\n\n/**\n * EC-616 — the generic RECORD RAIL: a record kind (`defineRecordKind`) on the portable\n * list/edit surfaces (AD-19), read and written through the operations chokepoint\n * (`operations.records(kind)`) and the one query cache (AD-17). A rail is `nav.rail(kind)`;\n * the engine's redirects rail is exactly this over `redirectRecordKind`.\n *\n * - **Store** ({@link RecordRailProvider}): the kind's records through ONE query key per\n * kind and project ({@link useRecordList} — the issues tray and the rail share it); the\n * \"New\" flow keeps an unsaved record as a LOCAL draft until the first explicit save, so\n * an abandoned \"New\" never writes a stub and never shows in the list.\n * - **Source** ({@link useRecordCollectionViewSource}): the lens projections\n * (`record-lens.ts`), the lens's own validation, the rail's capabilities.\n * - **Surface** ({@link RecordRailModule}): the list, or one record's detail.\n */\n\n/** A record rail is not localized — one implicit locale keeps the lens honest. */\nconst RAIL_LOCALE: Locale = 'en'\nconst RAIL_LOCALE_CONFIG: LocaleConfig = parseLocaleConfig({ default: RAIL_LOCALE, locales: [RAIL_LOCALE] })\n\n/** The default capability profile of a record rail: create, edit, remove, bulk delete — content, not administration. */\nexport const RECORD_RAIL_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: true,\n duplicate: false,\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\n/** The one query key of a kind's records in a project. */\nexport function recordListKey(kind: AnyRecordKind, projectId: string): readonly [string, string, string] {\n return ['records', kind.kind, projectId]\n}\n\n/**\n * A kind's records in a project, read through the shared operations cache — the ONE query\n * a rail and any reader outside it (the issues tray's URL-map validation reads redirects)\n * share. One key, one cache entry: a save's `cache.patch` updates both at once.\n */\nexport function useRecordList<K extends AnyRecordKind>(kind: K, projectId: string) {\n return useOperationQuery<RecordEntryOf<K>[]>({\n queryKey: recordListKey(kind, projectId),\n loader: async (operations) => {\n const result = await operations.records(kind).list({ projectId })\n if (!result.ok) throw new Error(result.error.message)\n return [...result.value]\n },\n })\n}\n\nexport interface RecordRailStore<K extends AnyRecordKind = AnyRecordKind> {\n kind: K\n projectId: string\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n /** The persisted records, in the kind's order. Unsaved drafts are excluded. */\n records: readonly RecordEntryOf<K>[]\n /** One record: a local unsaved draft if present, else a persisted record. */\n get(id: string): RecordEntryOf<K> | undefined\n /** Mint a blank in-memory draft and return its id (the \"New\" navigation target). */\n createDraft(): string\n /** Persist a create/modify; drops the matching draft on success. */\n save(input: RecordWriteOf<K>): Promise<{ ok: true; record: RecordEntryOf<K> } | { ok: false; message: string }>\n /** Delete a persisted record, or silently discard an unsaved draft. */\n remove(id: string): Promise<{ ok: true } | { ok: false; message: string }>\n /** Re-list from the backend. */\n refresh(): Promise<void>\n}\n\nconst { Context: RecordRailContext, useRequired: useRecordRailContext } =\n createRequiredContext<RecordRailStore>('RecordRail', { article: 'a' })\n\n/** The record rail store of the nearest {@link RecordRailProvider}. */\nexport function useRecordRail<K extends AnyRecordKind>(): RecordRailStore<K> {\n return useRecordRailContext() as unknown as RecordRailStore<K>\n}\n\nlet mintDraftUuid: (() => string) | undefined\n\nexport function RecordRailProvider<K extends AnyRecordKind>({\n kind,\n projectId,\n children,\n}: {\n kind: K\n projectId: string\n children: ReactNode\n}) {\n const query = useRecordList(kind, projectId)\n const cache = useOperationCache()\n const persisted = query.data ?? EMPTY\n const { status, errorMessage } = listStoreState(query)\n // In-memory unsaved \"New\" drafts, keyed by id. Not listed; only get()-able.\n const [drafts, setDrafts] = useState<Record<string, RecordEntryOf<K>>>({})\n const draftsRef = useRef(drafts)\n draftsRef.current = drafts\n\n const refetch = query.refetch\n const refresh = useCallback(async () => {\n await refetch()\n }, [refetch])\n\n const get = useCallback(\n (id: string) =>\n drafts[id] ?? (persisted as RecordEntryOf<K>[]).find((record) => (record as { id: string }).id === id),\n [drafts, persisted],\n )\n\n const createDraft = useCallback(() => {\n // The draft id becomes the record's real id on its first save: the production id\n // factory (EC-578 — never a clock or a per-session counter, which collide).\n mintDraftUuid ??= createUuidIdFactory()\n const id = `${kind.idPrefix}_${mintDraftUuid()}`\n const now = new Date().toISOString()\n const draft = { ...recordDraftColumns(kind), id, projectId, createdAt: now, updatedAt: now } as unknown as RecordEntryOf<K>\n setDrafts((prev) => ({ ...prev, [id]: draft }))\n return id\n }, [kind, projectId])\n\n const save = useCallback<RecordRailStore<K>['save']>(\n async (input) => {\n const { operations } = await getStudioOperations()\n const result = await operations.records(kind).save({ projectId, ...input } as never)\n if (!result.ok) return { ok: false, message: result.error.message }\n const saved = result.value as RecordEntryOf<K>\n const savedId = (saved as { id: string }).id\n // The server returns the kind's order on the next read; place the save now and\n // let the patched list keep its order key (re-read on the next fetch).\n cache.patch<RecordEntryOf<K>[]>(recordListKey(kind, projectId), (prev) => {\n const rest = (prev ?? []).filter((record) => (record as { id: string }).id !== savedId)\n return sortForRail(kind, [...rest, saved])\n })\n setDrafts((prev) => {\n if (!(savedId in prev)) return prev\n const next = { ...prev }\n delete next[savedId]\n return next\n })\n return { ok: true, record: saved }\n },\n [kind, projectId, cache],\n )\n\n const remove = useCallback<RecordRailStore<K>['remove']>(\n async (id) => {\n // An unsaved draft was never persisted — discard it locally, no backend call.\n if (draftsRef.current[id]) {\n setDrafts((prev) => {\n const next = { ...prev }\n delete next[id]\n return next\n })\n return { ok: true }\n }\n const { operations } = await getStudioOperations()\n const result = await operations.records(kind).remove({ projectId, id })\n if (!result.ok) return { ok: false, message: result.error.message }\n cache.patch<RecordEntryOf<K>[]>(recordListKey(kind, projectId), (prev) =>\n (prev ?? []).filter((record) => record.id !== id),\n )\n return { ok: true }\n },\n [kind, projectId, cache],\n )\n\n const store = useMemo<RecordRailStore<K>>(\n () => ({\n kind,\n projectId,\n status,\n ...(errorMessage !== undefined ? { errorMessage } : {}),\n records: persisted as RecordEntryOf<K>[],\n get,\n createDraft,\n save,\n remove,\n refresh,\n }),\n [kind, projectId, status, errorMessage, persisted, get, createDraft, save, remove, refresh],\n )\n\n return <RecordRailContext.Provider value={store as unknown as RecordRailStore}>{children}</RecordRailContext.Provider>\n}\n\nconst EMPTY: readonly never[] = []\n\n/**\n * The columns a blank \"New\" draft shows: each column's CREATE default (a new redirect\n * reads enabled + permanent — the reason you create one is for it to work), and `''` for\n * a required text column. The save then sends what the editor saw; the adapter still\n * applies the defaults itself for a write that leaves them out (EC-577).\n */\nexport function recordDraftColumns(kind: AnyRecordKind): Record<string, unknown> {\n const columns: Record<string, unknown> = {}\n for (const [name, field] of Object.entries(kind.fields)) {\n const parsed = (field as { safeParse(value: unknown): { success: boolean; data?: unknown } }).safeParse(undefined)\n if (parsed.success && parsed.data !== undefined) columns[name] = parsed.data\n else if ((field as { _zod: { def: { type: string } } })._zod.def.type === 'string') columns[name] = ''\n }\n return columns\n}\n\n/** The kind's own list order for a locally patched list (`orderBy`, else creation time). */\nfunction sortForRail<R>(kind: AnyRecordKind, records: R[]): R[] {\n const column = kind.orderBy ?? 'createdAt'\n return [...records].sort((a, b) => {\n const left = String((a as Record<string, unknown>)[column] ?? '')\n const right = String((b as Record<string, unknown>)[column] ?? '')\n return left < right ? -1 : left > right ? 1 : 0\n })\n}\n\n/**\n * EC-690 (critic point 5) — the findings a record has only as a member of its kind's SET,\n * judged by the ONE producer the studio's issues tray reads, so the record's own Issues pane\n * and the tray can never disagree. Before this, the losing row of a duplicate redirect pair\n * said \"No issues\" while the rail foot named it (measured live, wave-0100): the rail\n * validated one record without its siblings.\n *\n * Keyed by record kind; a kind with no set rule has no set findings. Redirects: core's one\n * normalised redirect set and router (`routeValidationIssues` — duplicates after\n * trailing-slash normalisation, loops, unreadable rows), over the project's routes.\n */\nconst RECORD_SET_RULES: Readonly<Record<string, (records: readonly Record<string, unknown>[]) => readonly CmsValidationIssue[]>> = {\n [redirectRecordKind.kind]: (records) =>\n routeValidationIssues(\n { routes: resolveDeploymentRoutes(), collections: resolveDeploymentCollections() },\n records as unknown as readonly RedirectEntry[],\n ),\n}\n\n/** The set rule's issues located AT one record (`[<domain>, <id>]`), keyed by record id. */\nfunction issuesById(issues: readonly CmsValidationIssue[]): Map<string, CmsValidationIssue[]> {\n const byId = new Map<string, CmsValidationIssue[]>()\n for (const issue of issues) {\n const id = issue.path[1]\n if (typeof id !== 'string') continue\n byId.set(id, [...(byId.get(id) ?? []), issue])\n }\n return byId\n}\n\n/**\n * The set findings of `doc` as the editor sees it NOW: the stored set with this record's\n * columns replaced by the draft's, so fixing a duplicate `from` clears the finding as you\n * type. An unsaved \"New\" draft joins the set as an extra row; it is only ever told about a\n * DUPLICATE (a half-typed draft is not an \"unreadable stored redirect\" — its required fields\n * report that, field by field). Pure; exported for the tests.\n */\nexport function recordSetIssues(\n kind: AnyRecordKind,\n records: readonly Record<string, unknown>[],\n doc: CmsDocument,\n): CmsValidationIssue[] {\n const rule = RECORD_SET_RULES[kind.kind]\n if (!rule) return []\n const columns = recordColumnNames(kind)\n const index = records.findIndex((record) => record.id === doc.id)\n const draftColumns = Object.fromEntries(Object.entries(doc.values ?? {}).filter(([name]) => columns.has(name)))\n const own = { ...(index === -1 ? {} : records[index]), ...draftColumns, id: doc.id }\n const set = index === -1 ? [...records, own] : records.map((record, i) => (i === index ? own : record))\n const located = issuesById(rule(set)).get(doc.id) ?? []\n return index === -1 ? located.filter((issue) => issue.meta?.duplicateOf !== undefined) : located\n}\n\n/** True when the draft's columns are the stored record's — the memoised stored verdict applies. */\nfunction draftMatchesStored(kind: AnyRecordKind, stored: Record<string, unknown>, doc: CmsDocument): boolean {\n const values = doc.values ?? {}\n for (const column of recordColumnNames(kind)) {\n if (!Object.is(values[column], stored[column])) return false\n }\n return true\n}\n\n/** How a rail addresses its list and one record's detail (the view's own route segment). */\nexport interface RecordRailPaths {\n listPath: string\n detailPath(id: string): string\n}\n\n/**\n * The {@link CollectionViewSource} of a record rail (must be mounted under its\n * {@link RecordRailProvider}). `schema` is the rail's lens — the kind's default lens or a\n * mount-customized one (EC-311 overrides by name; additive fields persist in `meta`).\n */\nexport function useRecordCollectionViewSource<K extends AnyRecordKind>(\n kind: K,\n projectId: string,\n schema: CollectionDef,\n paths: RecordRailPaths,\n capabilities: CollectionViewCapabilities = RECORD_RAIL_CAPABILITIES,\n): CollectionViewSource {\n const store = useRecordRail<K>()\n // Run the (possibly customized) lens's validation — required/pattern and custom\n // `validate` fns on an overridden field must actually fire in the editor (EC-311).\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n const singular = schema.form?.labelSingular\n const titleField = typeof schema.titleField === 'string' ? schema.titleField : undefined\n\n const documents = useCallback(\n () => store.records.map((record) => recordToLensDocument(kind, record)),\n [kind, store.records],\n )\n const get = useCallback(\n (ref: DocumentRef): CmsDocument | undefined => {\n const record = store.get(ref.id)\n return record ? recordToLensDocument(kind, record) : undefined\n },\n [kind, store],\n )\n const titleOf = useCallback(\n (ref: DocumentRef): string => {\n const record = store.get(ref.id) as Record<string, unknown> | undefined\n const title = titleField ? record?.[titleField] : undefined\n if (typeof title === 'string' && title.length > 0) return title\n return translate('records.untitled', { label: localizedLabel(singular) ?? kind.kind })\n },\n [store, titleField, singular, kind],\n )\n // EC-690: the record's own fields AND its set-level findings (a duplicate redirect, a loop) —\n // the stored set's verdict is computed once per list; a draft that changed a column is\n // re-judged against its siblings.\n const records = store.records as unknown as readonly Record<string, unknown>[]\n const storedSetIssues = useMemo(\n () => (RECORD_SET_RULES[kind.kind] ? issuesById(RECORD_SET_RULES[kind.kind]!(records)) : undefined),\n [kind, records],\n )\n const validate = useCallback(\n (doc: CmsDocument) => {\n const own = validateDocument(registry, doc, { localeConfig: RAIL_LOCALE_CONFIG })\n if (!storedSetIssues) return own\n const stored = records.find((record) => record.id === doc.id)\n const set =\n stored && draftMatchesStored(kind, stored, doc)\n ? (storedSetIssues.get(doc.id) ?? [])\n : recordSetIssues(kind, records, doc)\n return set.length > 0 ? [...own, ...set] : own\n },\n [registry, storedSetIssues, records, kind],\n )\n const create = useCallback((): DocumentRef => ({ collection: kind.kind, id: store.createDraft() }), [kind, store])\n // EC-616 (multi-session): an edit of a STORED record writes only what the editor changed\n // against the record they opened (`base`, from the edit shell) — the EC-577 rule keeps every\n // other column, so another writer's change made meanwhile survives this save. A record the\n // editor is creating (an unsaved draft) is written whole.\n const save = useCallback(\n (doc: CmsDocument, base?: CmsDocument) => {\n const stored = store.records.some((record) => (record as { id: string }).id === doc.id)\n return store.save(lensDocumentToRecordWrite(kind, doc, stored ? base : undefined))\n },\n [kind, store],\n )\n const remove = useCallback((ref: DocumentRef) => store.remove(ref.id), [store])\n\n return useRailViewSource({\n projectId,\n schema,\n capabilities,\n localeConfig: RAIL_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n detailPath: paths.detailPath,\n listPath: paths.listPath,\n validate,\n create,\n save,\n remove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeOkResult,\n })\n}\n\nexport interface RecordRailModuleProps<K extends AnyRecordKind> {\n kind: K\n projectId: string\n /** `null` → the list; an id → that record's detail. */\n recordId: string | null\n schema: CollectionDef\n paths: RecordRailPaths\n capabilities?: CollectionViewCapabilities\n /** The rail's label, for its loading / failure states. */\n label: string\n /** Optional copy for the loading / failure states (default: the generic catalog keys). */\n messages?: { loading?: () => string; loadFailed?: () => string }\n}\n\n/** The surface of a record rail: the portable list, or one record's detail. */\nexport function RecordRailModule<K extends AnyRecordKind>(props: RecordRailModuleProps<K>) {\n return (\n <RecordRailProvider kind={props.kind} projectId={props.projectId}>\n <RecordRailSurface {...props} />\n </RecordRailProvider>\n )\n}\n\nfunction RecordRailSurface<K extends AnyRecordKind>({\n kind,\n projectId,\n recordId,\n schema,\n paths,\n capabilities,\n label,\n messages,\n}: RecordRailModuleProps<K>) {\n const store = useRecordRail<K>()\n const source = useRecordCollectionViewSource(kind, projectId, schema, paths, capabilities)\n\n if (store.status !== 'ready') {\n return (\n <RailStoreStatus\n store={store}\n errorLabel={messages?.loadFailed?.() ?? translate('records.loadFailed', { label })}\n loadingLabel={messages?.loading?.() ?? translate('records.loading', { label })}\n errorTestId={`${kind.kind}-rail-error`}\n />\n )\n }\n // One record's detail opens in the portable edit shell (AD-19); the list otherwise.\n return <CollectionViewSurface source={source} projectId={projectId} documentId={recordId} />\n}\n"]}
|
|
1
|
+
{"version":3,"file":"record-rail.js","sourceRoot":"","sources":["../../../src/components/records/record-rail.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport { createCollectionRegistry, parseLocaleConfig, validateDocument } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CmsValidationIssue, CollectionDef, DocumentRef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\nimport { redirectRecordKind } from '@elytracms/core/persistence'\nimport type { AnyRecordKind, RecordEntryOf, RecordWriteOf, RedirectEntry } from '@elytracms/core/persistence'\nimport { routeValidationIssues } from '../../lib/routes/route-issues'\nimport { resolveDeploymentCollections, resolveDeploymentRoutes } from '../../lib/workspace'\nimport { createUuidIdFactory } from '@elytracms/core/determinism'\nimport { getStudioOperations } from '../../lib/operations'\nimport { listStoreState, useOperationCache, useOperationQuery } from '../../lib/query'\nimport { createRequiredContext } from '../../lib/required-context'\nimport { localizedLabel, translate } from '../../lib/i18n'\nimport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n} from '../../lib/collection-view/collection-view-source'\nimport { normalizeOkResult, useRailViewSource } from '../rail-view-source'\nimport { RailStoreStatus } from '../rail-store-status'\nimport { CollectionViewSurface } from '../collection-view/collection-view-surface'\nimport { lensDocumentToRecordWrite, recordColumnNames, recordToLensDocument } from './record-lens'\n\n/**\n * EC-616 — the generic RECORD RAIL: a record kind (`defineRecordKind`) on the portable\n * list/edit surfaces (AD-19), read and written through the operations chokepoint\n * (`operations.records(kind)`) and the one query cache (AD-17). A rail is `nav.rail(kind)`;\n * the engine's redirects rail is exactly this over `redirectRecordKind`.\n *\n * - **Store** ({@link RecordRailProvider}): the kind's records through ONE query key per\n * kind and project ({@link useRecordList} — the issues tray and the rail share it); the\n * \"New\" flow keeps an unsaved record as a LOCAL draft until the first explicit save, so\n * an abandoned \"New\" never writes a stub and never shows in the list.\n * - **Source** ({@link useRecordCollectionViewSource}): the lens projections\n * (`record-lens.ts`), the lens's own validation, the rail's capabilities.\n * - **Surface** ({@link RecordRailModule}): the list, or one record's detail.\n */\n\n/** A record rail is not localized — one implicit locale keeps the lens honest. */\nconst RAIL_LOCALE: Locale = 'en'\nconst RAIL_LOCALE_CONFIG: LocaleConfig = parseLocaleConfig({ default: RAIL_LOCALE, locales: [RAIL_LOCALE] })\n\n/** The default capability profile of a record rail: create, edit, remove, bulk delete — content, not administration. */\nexport const RECORD_RAIL_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: true,\n duplicate: false,\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\n/** The one query key of a kind's records in a project. */\nexport function recordListKey(kind: AnyRecordKind, projectId: string): readonly [string, string, string] {\n return ['records', kind.kind, projectId]\n}\n\n/**\n * A kind's records in a project, read through the shared operations cache — the ONE query\n * a rail and any reader outside it (the issues tray's URL-map validation reads redirects)\n * share. One key, one cache entry: a save's `cache.patch` updates both at once.\n */\nexport function useRecordList<K extends AnyRecordKind>(kind: K, projectId: string) {\n return useOperationQuery<RecordEntryOf<K>[]>({\n queryKey: recordListKey(kind, projectId),\n loader: async (operations) => {\n const result = await operations.records(kind).list({ projectId })\n if (!result.ok) throw new Error(result.error.message)\n return [...result.value]\n },\n })\n}\n\nexport interface RecordRailStore<K extends AnyRecordKind = AnyRecordKind> {\n kind: K\n projectId: string\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n /** The persisted records, in the kind's order. Unsaved drafts are excluded. */\n records: readonly RecordEntryOf<K>[]\n /** One record: a local unsaved draft if present, else a persisted record. */\n get(id: string): RecordEntryOf<K> | undefined\n /** Mint a blank in-memory draft and return its id (the \"New\" navigation target). */\n createDraft(): string\n /** Persist a create/modify; drops the matching draft on success. */\n save(input: RecordWriteOf<K>): Promise<{ ok: true; record: RecordEntryOf<K> } | { ok: false; message: string }>\n /** Delete a persisted record, or silently discard an unsaved draft. */\n remove(id: string): Promise<{ ok: true } | { ok: false; message: string }>\n /** Re-list from the backend. */\n refresh(): Promise<void>\n}\n\nconst { Context: RecordRailContext, useRequired: useRecordRailContext } =\n createRequiredContext<RecordRailStore>('RecordRail', { article: 'a' })\n\n/** The record rail store of the nearest {@link RecordRailProvider}. */\nexport function useRecordRail<K extends AnyRecordKind>(): RecordRailStore<K> {\n return useRecordRailContext() as unknown as RecordRailStore<K>\n}\n\nlet mintDraftUuid: (() => string) | undefined\n\nexport function RecordRailProvider<K extends AnyRecordKind>({\n kind,\n projectId,\n children,\n}: {\n kind: K\n projectId: string\n children: ReactNode\n}) {\n const query = useRecordList(kind, projectId)\n const cache = useOperationCache()\n const persisted = query.data ?? EMPTY\n const { status, errorMessage } = listStoreState(query)\n // In-memory unsaved \"New\" drafts, keyed by id. Not listed; only get()-able.\n const [drafts, setDrafts] = useState<Record<string, RecordEntryOf<K>>>({})\n const draftsRef = useRef(drafts)\n draftsRef.current = drafts\n\n const refetch = query.refetch\n const refresh = useCallback(async () => {\n await refetch()\n }, [refetch])\n\n const get = useCallback(\n (id: string) =>\n drafts[id] ?? (persisted as RecordEntryOf<K>[]).find((record) => (record as { id: string }).id === id),\n [drafts, persisted],\n )\n\n const createDraft = useCallback(() => {\n // The draft id becomes the record's real id on its first save: the production id\n // factory (EC-578 — never a clock or a per-session counter, which collide).\n mintDraftUuid ??= createUuidIdFactory()\n const id = `${kind.idPrefix}_${mintDraftUuid()}`\n const now = new Date().toISOString()\n const draft = { ...recordDraftColumns(kind), id, projectId, createdAt: now, updatedAt: now } as unknown as RecordEntryOf<K>\n setDrafts((prev) => ({ ...prev, [id]: draft }))\n return id\n }, [kind, projectId])\n\n const save = useCallback<RecordRailStore<K>['save']>(\n async (input) => {\n const { operations } = await getStudioOperations()\n const result = await operations.records(kind).save({ projectId, ...input } as never)\n if (!result.ok) return { ok: false, message: result.error.message }\n const saved = result.value as RecordEntryOf<K>\n const savedId = (saved as { id: string }).id\n // The server returns the kind's order on the next read; place the save now and\n // let the patched list keep its order key (re-read on the next fetch).\n cache.patch<RecordEntryOf<K>[]>(recordListKey(kind, projectId), (prev) => {\n const rest = (prev ?? []).filter((record) => (record as { id: string }).id !== savedId)\n return sortForRail(kind, [...rest, saved])\n })\n setDrafts((prev) => {\n if (!(savedId in prev)) return prev\n const next = { ...prev }\n delete next[savedId]\n return next\n })\n return { ok: true, record: saved }\n },\n [kind, projectId, cache],\n )\n\n const remove = useCallback<RecordRailStore<K>['remove']>(\n async (id) => {\n // An unsaved draft was never persisted — discard it locally, no backend call.\n if (draftsRef.current[id]) {\n setDrafts((prev) => {\n const next = { ...prev }\n delete next[id]\n return next\n })\n return { ok: true }\n }\n const { operations } = await getStudioOperations()\n const result = await operations.records(kind).remove({ projectId, id })\n if (!result.ok) return { ok: false, message: result.error.message }\n cache.patch<RecordEntryOf<K>[]>(recordListKey(kind, projectId), (prev) =>\n (prev ?? []).filter((record) => record.id !== id),\n )\n return { ok: true }\n },\n [kind, projectId, cache],\n )\n\n const store = useMemo<RecordRailStore<K>>(\n () => ({\n kind,\n projectId,\n status,\n ...(errorMessage !== undefined ? { errorMessage } : {}),\n records: persisted as RecordEntryOf<K>[],\n get,\n createDraft,\n save,\n remove,\n refresh,\n }),\n [kind, projectId, status, errorMessage, persisted, get, createDraft, save, remove, refresh],\n )\n\n return <RecordRailContext.Provider value={store as unknown as RecordRailStore}>{children}</RecordRailContext.Provider>\n}\n\nconst EMPTY: readonly never[] = []\n\n/**\n * The columns a blank \"New\" draft shows: each column's CREATE default (a new redirect\n * reads enabled + permanent — the reason you create one is for it to work), and `''` for\n * a required text column. The save then sends what the editor saw; the adapter still\n * applies the defaults itself for a write that leaves them out (EC-577).\n */\nexport function recordDraftColumns(kind: AnyRecordKind): Record<string, unknown> {\n const columns: Record<string, unknown> = {}\n for (const [name, field] of Object.entries(kind.fields)) {\n const parsed = (field as { safeParse(value: unknown): { success: boolean; data?: unknown } }).safeParse(undefined)\n if (parsed.success && parsed.data !== undefined) columns[name] = parsed.data\n else if ((field as { _zod: { def: { type: string } } })._zod.def.type === 'string') columns[name] = ''\n }\n return columns\n}\n\n/** The kind's own list order for a locally patched list (`orderBy`, else creation time). */\nfunction sortForRail<R>(kind: AnyRecordKind, records: R[]): R[] {\n const column = kind.orderBy ?? 'createdAt'\n return [...records].sort((a, b) => {\n const left = String((a as Record<string, unknown>)[column] ?? '')\n const right = String((b as Record<string, unknown>)[column] ?? '')\n return left < right ? -1 : left > right ? 1 : 0\n })\n}\n\n/**\n * EC-690 (critic point 5) — the findings a record has only as a member of its kind's SET,\n * judged by the ONE producer the studio's issues tray reads, so the record's own Issues pane\n * and the tray can never disagree. Before this, the losing row of a duplicate redirect pair\n * said \"No issues\" while the rail foot named it (measured live, wave-0100): the rail\n * validated one record without its siblings.\n *\n * Keyed by record kind; a kind with no set rule has no set findings. Redirects: core's one\n * normalised redirect set and router (`routeValidationIssues` — duplicates after\n * trailing-slash normalisation, loops, unreadable rows), over the project's routes.\n *\n * EC-703 (Codex round 6 F34): a `Map`, so a lookup finds only a rule declared here. As a plain\n * object, a kind named `constructor` (`toString`, `valueOf`, …) found the inherited function and\n * called it as a validator, and the rail threw (measured). `defineRecordKind` refuses those names\n * since EC-697; the registry is own-safe regardless, for a kind object built below it.\n */\nconst RECORD_SET_RULES: ReadonlyMap<string, (records: readonly Record<string, unknown>[]) => readonly CmsValidationIssue[]> = new Map([\n [\n redirectRecordKind.kind,\n (records: readonly Record<string, unknown>[]) =>\n routeValidationIssues(\n { routes: resolveDeploymentRoutes(), collections: resolveDeploymentCollections() },\n records as unknown as readonly RedirectEntry[],\n ),\n ],\n])\n\n/** The set rule's issues located AT one record (`[<domain>, <id>]`), keyed by record id. */\nfunction issuesById(issues: readonly CmsValidationIssue[]): Map<string, CmsValidationIssue[]> {\n const byId = new Map<string, CmsValidationIssue[]>()\n for (const issue of issues) {\n const id = issue.path[1]\n if (typeof id !== 'string') continue\n byId.set(id, [...(byId.get(id) ?? []), issue])\n }\n return byId\n}\n\n/**\n * The set findings of `doc` as the editor sees it NOW: the stored set with this record's\n * columns replaced by the draft's, so fixing a duplicate `from` clears the finding as you\n * type. An unsaved \"New\" draft joins the set as an extra row; it is only ever told about a\n * DUPLICATE (a half-typed draft is not an \"unreadable stored redirect\" — its required fields\n * report that, field by field). Pure; exported for the tests.\n */\nexport function recordSetIssues(\n kind: AnyRecordKind,\n records: readonly Record<string, unknown>[],\n doc: CmsDocument,\n): CmsValidationIssue[] {\n const rule = RECORD_SET_RULES.get(kind.kind)\n if (!rule) return []\n const columns = recordColumnNames(kind)\n const index = records.findIndex((record) => record.id === doc.id)\n const draftColumns = Object.fromEntries(Object.entries(doc.values ?? {}).filter(([name]) => columns.has(name)))\n const own = { ...(index === -1 ? {} : records[index]), ...draftColumns, id: doc.id }\n const set = index === -1 ? [...records, own] : records.map((record, i) => (i === index ? own : record))\n const located = issuesById(rule(set)).get(doc.id) ?? []\n return index === -1 ? located.filter((issue) => issue.meta?.duplicateOf !== undefined) : located\n}\n\n/** True when the draft's columns are the stored record's — the memoised stored verdict applies. */\nfunction draftMatchesStored(kind: AnyRecordKind, stored: Record<string, unknown>, doc: CmsDocument): boolean {\n const values = doc.values ?? {}\n for (const column of recordColumnNames(kind)) {\n if (!Object.is(values[column], stored[column])) return false\n }\n return true\n}\n\n/** How a rail addresses its list and one record's detail (the view's own route segment). */\nexport interface RecordRailPaths {\n listPath: string\n detailPath(id: string): string\n}\n\n/**\n * The {@link CollectionViewSource} of a record rail (must be mounted under its\n * {@link RecordRailProvider}). `schema` is the rail's lens — the kind's default lens or a\n * mount-customized one (EC-311 overrides by name; additive fields persist in `meta`).\n */\nexport function useRecordCollectionViewSource<K extends AnyRecordKind>(\n kind: K,\n projectId: string,\n schema: CollectionDef,\n paths: RecordRailPaths,\n capabilities: CollectionViewCapabilities = RECORD_RAIL_CAPABILITIES,\n): CollectionViewSource {\n const store = useRecordRail<K>()\n // Run the (possibly customized) lens's validation — required/pattern and custom\n // `validate` fns on an overridden field must actually fire in the editor (EC-311).\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n const singular = schema.form?.labelSingular\n const titleField = typeof schema.titleField === 'string' ? schema.titleField : undefined\n\n const documents = useCallback(\n () => store.records.map((record) => recordToLensDocument(kind, record)),\n [kind, store.records],\n )\n const get = useCallback(\n (ref: DocumentRef): CmsDocument | undefined => {\n const record = store.get(ref.id)\n return record ? recordToLensDocument(kind, record) : undefined\n },\n [kind, store],\n )\n const titleOf = useCallback(\n (ref: DocumentRef): string => {\n const record = store.get(ref.id) as Record<string, unknown> | undefined\n const title = titleField ? record?.[titleField] : undefined\n if (typeof title === 'string' && title.length > 0) return title\n return translate('records.untitled', { label: localizedLabel(singular) ?? kind.kind })\n },\n [store, titleField, singular, kind],\n )\n // EC-690: the record's own fields AND its set-level findings (a duplicate redirect, a loop) —\n // the stored set's verdict is computed once per list; a draft that changed a column is\n // re-judged against its siblings.\n const records = store.records as unknown as readonly Record<string, unknown>[]\n const storedSetIssues = useMemo(() => {\n const rule = RECORD_SET_RULES.get(kind.kind)\n return rule ? issuesById(rule(records)) : undefined\n }, [kind, records])\n const validate = useCallback(\n (doc: CmsDocument) => {\n const own = validateDocument(registry, doc, { localeConfig: RAIL_LOCALE_CONFIG })\n if (!storedSetIssues) return own\n const stored = records.find((record) => record.id === doc.id)\n const set =\n stored && draftMatchesStored(kind, stored, doc)\n ? (storedSetIssues.get(doc.id) ?? [])\n : recordSetIssues(kind, records, doc)\n return set.length > 0 ? [...own, ...set] : own\n },\n [registry, storedSetIssues, records, kind],\n )\n const create = useCallback((): DocumentRef => ({ collection: kind.kind, id: store.createDraft() }), [kind, store])\n // EC-616 (multi-session): an edit of a STORED record writes only what the editor changed\n // against the record they opened (`base`, from the edit shell) — the EC-577 rule keeps every\n // other column, so another writer's change made meanwhile survives this save. A record the\n // editor is creating (an unsaved draft) is written whole.\n const save = useCallback(\n (doc: CmsDocument, base?: CmsDocument) => {\n const stored = store.records.some((record) => (record as { id: string }).id === doc.id)\n return store.save(lensDocumentToRecordWrite(kind, doc, stored ? base : undefined))\n },\n [kind, store],\n )\n const remove = useCallback((ref: DocumentRef) => store.remove(ref.id), [store])\n\n return useRailViewSource({\n projectId,\n schema,\n capabilities,\n localeConfig: RAIL_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n detailPath: paths.detailPath,\n listPath: paths.listPath,\n validate,\n create,\n save,\n remove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeOkResult,\n })\n}\n\nexport interface RecordRailModuleProps<K extends AnyRecordKind> {\n kind: K\n projectId: string\n /** `null` → the list; an id → that record's detail. */\n recordId: string | null\n schema: CollectionDef\n paths: RecordRailPaths\n capabilities?: CollectionViewCapabilities\n /** The rail's label, for its loading / failure states. */\n label: string\n /** Optional copy for the loading / failure states (default: the generic catalog keys). */\n messages?: { loading?: () => string; loadFailed?: () => string }\n}\n\n/** The surface of a record rail: the portable list, or one record's detail. */\nexport function RecordRailModule<K extends AnyRecordKind>(props: RecordRailModuleProps<K>) {\n return (\n <RecordRailProvider kind={props.kind} projectId={props.projectId}>\n <RecordRailSurface {...props} />\n </RecordRailProvider>\n )\n}\n\nfunction RecordRailSurface<K extends AnyRecordKind>({\n kind,\n projectId,\n recordId,\n schema,\n paths,\n capabilities,\n label,\n messages,\n}: RecordRailModuleProps<K>) {\n const store = useRecordRail<K>()\n const source = useRecordCollectionViewSource(kind, projectId, schema, paths, capabilities)\n\n if (store.status !== 'ready') {\n return (\n <RailStoreStatus\n store={store}\n errorLabel={messages?.loadFailed?.() ?? translate('records.loadFailed', { label })}\n loadingLabel={messages?.loading?.() ?? translate('records.loading', { label })}\n errorTestId={`${kind.kind}-rail-error`}\n />\n )\n }\n // One record's detail opens in the portable edit shell (AD-19); the list otherwise.\n return <CollectionViewSurface source={source} projectId={projectId} documentId={recordId} />\n}\n"]}
|
|
@@ -13,4 +13,4 @@
|
|
|
13
13
|
* (The convex-side stamp does NOT read this mirror — `convex/delivery.ts` imports the
|
|
14
14
|
* package.json declaration directly; its bundler allows what tsc's rootDir forbids.)
|
|
15
15
|
*/
|
|
16
|
-
export declare const ENGINE_VERSION = "0.0.
|
|
16
|
+
export declare const ENGINE_VERSION = "0.0.100";
|
|
@@ -13,5 +13,5 @@
|
|
|
13
13
|
* (The convex-side stamp does NOT read this mirror — `convex/delivery.ts` imports the
|
|
14
14
|
* package.json declaration directly; its bundler allows what tsc's rootDir forbids.)
|
|
15
15
|
*/
|
|
16
|
-
export const ENGINE_VERSION = '0.0.
|
|
16
|
+
export const ENGINE_VERSION = '0.0.100';
|
|
17
17
|
//# sourceMappingURL=engine-version.js.map
|