@elytracms/studio 0.0.98 → 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/ingest.test.ts +84 -7
- package/convex/ingest.ts +110 -31
- package/convex/path-segment-refusal.test.ts +201 -0
- package/convex/records-column-transform.convex.test.ts +149 -0
- package/convex/records-one-write.convex.test.ts +218 -0
- package/convex/schema.ts +1 -1
- package/convex/version-kind-stamping.test.ts +118 -0
- package/dist/addon.d.ts +5 -3
- package/dist/addon.js +4 -3
- package/dist/addon.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +10 -3
- package/dist/components/app-views/default-modules.js +5 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +15 -8
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +29 -1
- package/dist/components/builder/reuse-context.js +58 -12
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +3 -2
- package/dist/components/cms/cms-store.js +8 -2
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.js +15 -5
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +4 -1
- package/dist/components/cms/use-document-draft.js +4 -1
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +7 -0
- package/dist/components/cms/use-publish-controller.js +17 -3
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/versions-pane.js +8 -5
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +34 -16
- package/dist/components/delivery-health-banner.js +59 -27
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/issue-severity.d.ts +3 -2
- package/dist/components/issue-severity.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +39 -24
- package/dist/components/nav-issues-registry.js +50 -21
- package/dist/components/nav-issues-registry.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -50
- package/dist/components/nav-issues.js +31 -59
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/notifications/notice-center.d.ts +61 -0
- package/dist/components/notifications/notice-center.js +119 -0
- package/dist/components/notifications/notice-center.js.map +1 -0
- package/dist/components/notifications/notice-model.d.ts +137 -0
- package/dist/components/notifications/notice-model.js +107 -0
- package/dist/components/notifications/notice-model.js.map +1 -0
- package/dist/components/notifications/outcome-notice.d.ts +16 -0
- package/dist/components/notifications/outcome-notice.js +34 -0
- package/dist/components/notifications/outcome-notice.js.map +1 -0
- package/dist/components/notifications/shell-findings.d.ts +36 -0
- package/dist/components/notifications/shell-findings.js +140 -0
- package/dist/components/notifications/shell-findings.js.map +1 -0
- package/dist/components/rail-view-source.js +5 -0
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-rail.d.ts +10 -2
- package/dist/components/records/record-rail.js +85 -4
- package/dist/components/records/record-rail.js.map +1 -1
- package/dist/components/section-nav.d.ts +15 -3
- package/dist/components/section-nav.js +39 -147
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +8 -3
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/use-narrow-viewport.d.ts +11 -0
- package/dist/components/use-narrow-viewport.js +25 -0
- package/dist/components/use-narrow-viewport.js.map +1 -0
- package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
- package/dist/components/users/role-policy-drift-issues.js +17 -8
- package/dist/components/users/role-policy-drift-issues.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
- package/dist/lib/collection-view/collection-view-source.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 +22 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +20 -2
- package/dist/lib/i18n/messages.en.js +25 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/notices/toast-store.d.ts +61 -0
- package/dist/lib/notices/toast-store.js +116 -0
- package/dist/lib/notices/toast-store.js.map +1 -0
- package/dist/lib/operations/cms-persistence.d.ts +24 -4
- package/dist/lib/operations/cms-persistence.js +411 -80
- 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 +127 -31
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/ui/toast.d.ts +24 -16
- package/dist/ui/toast.js +23 -32
- package/dist/ui/toast.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,149 @@
|
|
|
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 type { FunctionReference } from 'convex/server'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { z as newerZod } from 'zod-copy-newer'
|
|
7
|
+
import { z as olderZod } from 'zod-copy-older'
|
|
8
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
9
|
+
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
10
|
+
import type { AnyRecordKind, RecordColumnSchema } from '@elytracms/core/persistence'
|
|
11
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
12
|
+
import schema from './schema'
|
|
13
|
+
import { defineRecordFunctions } from './record-functions'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* EC-694 (Codex round 5 F31), the Convex half — a record column's transform runs ONCE per write,
|
|
17
|
+
* in the generated `save` mutation's transaction (`defineRecordFunctions(kind).save` →
|
|
18
|
+
* `saveRecord` → `resolveRecordSave`, the one parse EC-682 keeps on the Convex side), whether the
|
|
19
|
+
* write comes straight through the adapter's repository or through `operations.records(kind)`.
|
|
20
|
+
*
|
|
21
|
+
* Before: the operations parsed the write and sent the PARSED values over the wire, where the
|
|
22
|
+
* mutation parsed them again — `seed!!` stored with `ok: true`, and a type-changing column
|
|
23
|
+
* (`string → length`) refused. Measured red on the wave base 21678336: 12 of 12. Now the
|
|
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`).
|
|
26
|
+
*
|
|
27
|
+
* The three copies are real zod releases (the engine's own 4.4.3; the aliased devDependencies
|
|
28
|
+
* `zod-copy-older` 4.1.13 and `zod-copy-newer` 4.6.7). The client is the real
|
|
29
|
+
* `ConvexPersistenceAdapter` over convex-test, signed in as an editor member — the probe's
|
|
30
|
+
* wiring (`tasks/codex-review-2026-10-28-r5/record-transform.convex.test.ts`).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type Vendor = typeof z
|
|
34
|
+
const COPIES = [
|
|
35
|
+
['own', z],
|
|
36
|
+
['older', olderZod as unknown as Vendor],
|
|
37
|
+
['newer', newerZod as unknown as Vendor],
|
|
38
|
+
] as const
|
|
39
|
+
const WRAPPERS = ['required', 'optional', 'default'] as const
|
|
40
|
+
type Wrapper = (typeof WRAPPERS)[number]
|
|
41
|
+
|
|
42
|
+
const exclaimed = (vendor: Vendor, wrapper: Wrapper) => {
|
|
43
|
+
const inner = vendor.string().transform((value) => `${value}!`)
|
|
44
|
+
return wrapper === 'optional' ? inner.optional() : wrapper === 'default' ? inner.default('safe!') : inner
|
|
45
|
+
}
|
|
46
|
+
const lengthOf = (vendor: Vendor) => vendor.string().transform((value) => value.length)
|
|
47
|
+
const capitalised = (name: string) => `${name.charAt(0).toUpperCase()}${name.slice(1)}`
|
|
48
|
+
|
|
49
|
+
/** One kind per case, each in its own Convex module (Convex addresses a kind's functions by module). */
|
|
50
|
+
function kindFor(name: string, fields: Record<string, unknown>): AnyRecordKind {
|
|
51
|
+
return defineRecordKind({ kind: name, module: `${name}s`, fields: fields as Record<string, RecordColumnSchema> })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EXCLAIMED = COPIES.flatMap(([copy, vendor]) =>
|
|
55
|
+
WRAPPERS.map((wrapper) => ({ copy, wrapper, column: exclaimed(vendor, wrapper), kind: kindFor(`${copy}${capitalised(wrapper)}Text`, { text: exclaimed(vendor, wrapper) }) })),
|
|
56
|
+
)
|
|
57
|
+
const LENGTHS = COPIES.map(([copy, vendor]) => ({ copy, column: lengthOf(vendor), kind: kindFor(`${copy}Count`, { count: lengthOf(vendor) }) }))
|
|
58
|
+
const PLAIN = kindFor('plainText', { text: z.string() })
|
|
59
|
+
|
|
60
|
+
const modules = {
|
|
61
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
62
|
+
'./_generated/api.ts': async () => ({}),
|
|
63
|
+
...Object.fromEntries(
|
|
64
|
+
[...EXCLAIMED.map((row) => row.kind), ...LENGTHS.map((row) => row.kind), PLAIN].map((kind) => [
|
|
65
|
+
`./${kind.module}.ts`,
|
|
66
|
+
async () => defineRecordFunctions(kind),
|
|
67
|
+
]),
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PROJECT = 'site'
|
|
72
|
+
const STAMP = '2026-10-28T00:00:00.000Z'
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.useFakeTimers()
|
|
76
|
+
vi.setSystemTime(STAMP)
|
|
77
|
+
})
|
|
78
|
+
afterEach(() => vi.useRealTimers())
|
|
79
|
+
|
|
80
|
+
/** The real client adapter over convex-test, as a signed-in editor; and the operations over it. */
|
|
81
|
+
async function connected() {
|
|
82
|
+
const t = convexTest(schema, modules)
|
|
83
|
+
await t.run(async (ctx) => {
|
|
84
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
85
|
+
})
|
|
86
|
+
const editor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
|
|
87
|
+
const client = {
|
|
88
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => editor.query(ref, args ?? {}),
|
|
89
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => editor.mutation(ref, args ?? {}),
|
|
90
|
+
} as unknown as ConvexHttpClient
|
|
91
|
+
const adapter = new ConvexPersistenceAdapter(client, PROJECT)
|
|
92
|
+
const ops = createOperations({ adapter, collections: () => [], validateDocument: () => [] })
|
|
93
|
+
return { t, adapter, ops }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe.each(EXCLAIMED)('EC-694 — Convex, a transforming column of the $copy zod copy, $wrapper', ({ column, kind }) => {
|
|
97
|
+
it('the operations store what the repository stores (`seed!`, then `edit!`) — the transform runs once', async () => {
|
|
98
|
+
// Controls (pass with the defect present and absent): the column transforms once, and so
|
|
99
|
+
// does the repository through the same mutation — the value both paths must agree with.
|
|
100
|
+
expect((column as unknown as z.ZodType).safeParse('seed')).toMatchObject({ success: true, data: 'seed!' })
|
|
101
|
+
const { adapter, ops } = await connected()
|
|
102
|
+
const direct = adapter.records(kind)
|
|
103
|
+
expect(await direct.save(PROJECT, { id: 'direct', text: 'seed' })).toMatchObject({ text: 'seed!' })
|
|
104
|
+
|
|
105
|
+
// The defect row: through the operations, create then edit.
|
|
106
|
+
const records = ops.records(kind)
|
|
107
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'seed' })).toMatchObject({ ok: true, value: { text: 'seed!' } })
|
|
108
|
+
expect((await direct.get(PROJECT, 'viaops')).text).toBe('seed!')
|
|
109
|
+
expect(await direct.save(PROJECT, { id: 'direct', text: 'edit' })).toMatchObject({ text: 'edit!' })
|
|
110
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'edit' })).toMatchObject({ ok: true, value: { text: 'edit!' } })
|
|
111
|
+
expect((await direct.get(PROJECT, 'viaops')).text).toBe('edit!')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe.each(LENGTHS)('EC-694 — Convex, a type-changing column of the $copy zod copy', ({ column, kind }) => {
|
|
116
|
+
it('a value the column accepts is stored through the operations, as through the repository (4)', async () => {
|
|
117
|
+
expect((column as unknown as z.ZodType).safeParse('seed')).toMatchObject({ success: true, data: 4 })
|
|
118
|
+
const { adapter, ops } = await connected()
|
|
119
|
+
const direct = adapter.records(kind)
|
|
120
|
+
expect(await direct.save(PROJECT, { id: 'direct', count: 'seed' })).toMatchObject({ count: 4 })
|
|
121
|
+
|
|
122
|
+
const records = ops.records(kind)
|
|
123
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', count: 'seed' })).toMatchObject({ ok: true, value: { count: 4 } })
|
|
124
|
+
expect((await direct.get(PROJECT, 'viaops')).count).toBe(4)
|
|
125
|
+
expect(await records.save({ projectId: PROJECT, id: 'viaops', count: 'edited' })).toMatchObject({ ok: true, value: { count: 6 } })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('EC-694 — Convex, what the operations send', () => {
|
|
130
|
+
it('a refused value is the mutation’s located refusal, through the operation (EC-702): nothing stored', async () => {
|
|
131
|
+
const { t, ops } = await connected()
|
|
132
|
+
const kind = EXCLAIMED[0]!.kind
|
|
133
|
+
const refused = await ops.records(kind).save({ projectId: PROJECT, id: 'refused', text: 42 as never })
|
|
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']])
|
|
136
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// A plain column: green before EC-694 too; it fails if the caller's whole input were sent,
|
|
140
|
+
// because the mutation's argument validator refuses a key it does not declare.
|
|
141
|
+
it('only the write schema’s keys cross the wire: an undeclared key stays behind (create and edit)', async () => {
|
|
142
|
+
const { ops } = await connected()
|
|
143
|
+
const records = ops.records(PLAIN)
|
|
144
|
+
expect(await records.save({ projectId: PROJECT, id: 'r', text: 'seed', stray: 1 } as never)).toMatchObject({ ok: true, value: { text: 'seed' } })
|
|
145
|
+
const edited = await records.save({ projectId: PROJECT, id: 'r', text: 'edit', stray: 1 } as never)
|
|
146
|
+
expect(edited).toMatchObject({ ok: true, value: { text: 'edit', projectId: PROJECT } })
|
|
147
|
+
expect(edited.ok && 'stray' in edited.value).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -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
|
+
})
|
package/convex/schema.ts
CHANGED
|
@@ -302,7 +302,7 @@ export default defineSchema({
|
|
|
302
302
|
updatedAt: v.string(),
|
|
303
303
|
})
|
|
304
304
|
.index('by_asset', ['assetId'])
|
|
305
|
-
// EC-671: an ingest claim refuses a storage id an asset row references (`
|
|
305
|
+
// EC-671: an ingest claim refuses a storage id an asset row references (`claimIngestUploads`, EC-695).
|
|
306
306
|
.index('by_storage', ['storageKey'])
|
|
307
307
|
.index('by_thumb_storage', ['thumb.storageKey']),
|
|
308
308
|
|
|
@@ -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
|
+
})
|
package/dist/addon.d.ts
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* ## The chrome contract
|
|
15
15
|
*
|
|
16
16
|
* The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;
|
|
17
|
-
* deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail, the issues badge
|
|
18
|
+
* and tray in the header (EC-690: findings contributed with {@link useNavIssues}, or
|
|
19
|
+
* always-on through a view's `useIssues`; a finding may carry a {@link NoticeLocation} the
|
|
20
|
+
* tray's "Go to" opens) and the theme. A VIEW owns the whole scrollable content area
|
|
20
21
|
* under it — including its own title header: the shell adds no frame, title or padding.
|
|
21
22
|
* A view reaches the studio through its {@link AppViewContext}: the project, its deep
|
|
22
23
|
* link and search, the operations client (every read and write goes through the one
|
|
@@ -55,6 +56,7 @@ export { useSetBreadcrumb } from './components/breadcrumb-context.js';
|
|
|
55
56
|
export type { BreadcrumbItem } from './components/breadcrumb-context.js';
|
|
56
57
|
export { useNavIssues } from './components/nav-issues-registry.js';
|
|
57
58
|
export type { NavIssue } from './components/nav-issues-registry.js';
|
|
59
|
+
export type { NoticeLocation } from './components/notifications/notice-model.js';
|
|
58
60
|
/** A pane in the document editor's inspector — `defineStudio({ documentPanes: [documentPane({ … })] })`. */
|
|
59
61
|
export { documentPane } from './components/inspector/document-panes.js';
|
|
60
62
|
export type { DocumentPane, DocumentPaneContext } from './components/inspector/document-panes.js';
|
package/dist/addon.js
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* ## The chrome contract
|
|
15
15
|
*
|
|
16
16
|
* The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;
|
|
17
|
-
* deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail, the issues badge
|
|
18
|
+
* and tray in the header (EC-690: findings contributed with {@link useNavIssues}, or
|
|
19
|
+
* always-on through a view's `useIssues`; a finding may carry a {@link NoticeLocation} the
|
|
20
|
+
* tray's "Go to" opens) and the theme. A VIEW owns the whole scrollable content area
|
|
20
21
|
* under it — including its own title header: the shell adds no frame, title or padding.
|
|
21
22
|
* A view reaches the studio through its {@link AppViewContext}: the project, its deep
|
|
22
23
|
* link and search, the operations client (every read and write goes through the one
|
package/dist/addon.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"addon.js","sourceRoot":"","sources":["../src/addon.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"addon.js","sourceRoot":"","sources":["../src/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;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;AAI/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, the issues badge\n * and tray in the header (EC-690: findings contributed with {@link useNavIssues}, or\n * always-on through a view's `useIssues`; a finding may carry a {@link NoticeLocation} the\n * tray's \"Go to\" opens) 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'\nexport type { NoticeLocation } from './components/notifications/notice-model'\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"]}
|
|
@@ -108,8 +108,15 @@ export interface CustomViewConfig {
|
|
|
108
108
|
availabilitySection?: SectionId;
|
|
109
109
|
/** OPTIONAL, default-open nav-permission predicate (EC-240 / AD-18). See {@link BuiltinViewConfig.visible}. */
|
|
110
110
|
visible?: NavPermission;
|
|
111
|
-
/** Always-on
|
|
111
|
+
/** Always-on findings for the shell's issues tray (EC-616, see `NavIssuesHook`). */
|
|
112
112
|
useIssues?: NavIssuesHook;
|
|
113
|
+
/**
|
|
114
|
+
* EC-690 — the record kind this view lists, when it is a custom surface over one (the forms
|
|
115
|
+
* inbox lists `submission`s): a finding located at a record of that kind
|
|
116
|
+
* (`{ kind: 'record', recordKind, id? }`) then opens HERE, and reaches whoever can open this
|
|
117
|
+
* view. A record rail (`nav.rail(kind)`) declares it itself.
|
|
118
|
+
*/
|
|
119
|
+
recordKind?: string;
|
|
113
120
|
}
|
|
114
121
|
/**
|
|
115
122
|
* Derive a stable, deterministic id from the CMS focus (overridable via `config.id`).
|
|
@@ -184,7 +191,7 @@ export interface RailViewConfig extends BuiltinViewConfig {
|
|
|
184
191
|
/** Narrow the default capabilities (create, edit, remove, bulk delete). */
|
|
185
192
|
capabilities?: Partial<CollectionViewCapabilities>;
|
|
186
193
|
availabilitySection?: SectionId;
|
|
187
|
-
/** Always-on
|
|
194
|
+
/** Always-on findings for the shell's issues tray (EC-616, see `NavIssuesHook`). */
|
|
188
195
|
useIssues?: NavIssuesHook;
|
|
189
196
|
/** Copy for the loading / failure states (default: generic catalog copy naming the label). */
|
|
190
197
|
messages?: {
|
|
@@ -272,7 +279,7 @@ export interface SidebarIdKeyspace {
|
|
|
272
279
|
* else passes through untouched, including the merely-ambiguous EC-477 case.
|
|
273
280
|
*/
|
|
274
281
|
views: AppViewModule[];
|
|
275
|
-
/** What was found — visible
|
|
282
|
+
/** What was found — visible in the issues tray, never silent. */
|
|
276
283
|
issues: CustomizationIssue[];
|
|
277
284
|
}
|
|
278
285
|
/**
|
|
@@ -239,6 +239,9 @@ export function railView(kind, config = {}) {
|
|
|
239
239
|
icon: config.icon ?? Database,
|
|
240
240
|
...(config.availabilitySection ? { availabilitySection: config.availabilitySection } : {}),
|
|
241
241
|
...(config.useIssues ? { useIssues: config.useIssues } : {}),
|
|
242
|
+
// EC-690: a finding located at a record (`{ kind: 'record', recordKind }`) resolves to
|
|
243
|
+
// the rail listing that kind — whatever view id the consumer gave it.
|
|
244
|
+
recordKind: kind.kind,
|
|
242
245
|
parsePath: recordDeepLink(kind.idKey),
|
|
243
246
|
render: (ctx) => ctx.search.state && ctx.search.state !== 'working' ? (_jsx(SectionStateView, { state: ctx.search.state, title: label() })) : (_jsx(RecordRailModule, { kind: kind, projectId: ctx.projectId, recordId: ctx.deepLink[kind.idKey] ?? null, schema: schema, paths: paths, capabilities: capabilities, label: label(), ...(config.messages ? { messages: config.messages } : {}) })),
|
|
244
247
|
};
|
|
@@ -371,6 +374,7 @@ export function customView(config) {
|
|
|
371
374
|
...(config.parsePath ? { parsePath: config.parsePath } : {}),
|
|
372
375
|
...(config.availabilitySection ? { availabilitySection: config.availabilitySection } : {}),
|
|
373
376
|
...(config.useIssues ? { useIssues: config.useIssues } : {}),
|
|
377
|
+
...(config.recordKind ? { recordKind: config.recordKind } : {}),
|
|
374
378
|
};
|
|
375
379
|
}
|
|
376
380
|
/**
|
|
@@ -400,7 +404,7 @@ const SIDEBAR_POINT = 'sidebar';
|
|
|
400
404
|
/**
|
|
401
405
|
* The catalog keys for the two keyspace findings (EC-478). This module stays pure —
|
|
402
406
|
* it names a key and its identifier values; `lib/persona/labels.ts` resolves them at
|
|
403
|
-
* the render site, so the
|
|
407
|
+
* the render site, so the issues tray localizes as one list.
|
|
404
408
|
*/
|
|
405
409
|
const KEYSPACE_KEYS = {
|
|
406
410
|
builtinCollision: 'customization.issue.derivedIdBuiltinCollision',
|