@elytracms/studio 0.0.97 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +269 -16
  4. package/convex/ingest.ts +238 -47
  5. package/convex/path-segment-refusal.test.ts +201 -0
  6. package/convex/prototype-key.test.ts +92 -0
  7. package/convex/records-column-transform.convex.test.ts +147 -0
  8. package/convex/records-zod-refinements.convex.test.ts +94 -0
  9. package/convex/schema.ts +1 -1
  10. package/dist/addon.d.ts +5 -3
  11. package/dist/addon.js +4 -3
  12. package/dist/addon.js.map +1 -1
  13. package/dist/components/app-views/default-modules.d.ts +10 -3
  14. package/dist/components/app-views/default-modules.js +5 -1
  15. package/dist/components/app-views/default-modules.js.map +1 -1
  16. package/dist/components/app-views/module.d.ts +15 -8
  17. package/dist/components/app-views/module.js.map +1 -1
  18. package/dist/components/builder/reuse-context.d.ts +29 -1
  19. package/dist/components/builder/reuse-context.js +58 -12
  20. package/dist/components/builder/reuse-context.js.map +1 -1
  21. package/dist/components/cms/cms-store.d.ts +5 -6
  22. package/dist/components/cms/cms-store.js +12 -24
  23. package/dist/components/cms/cms-store.js.map +1 -1
  24. package/dist/components/cms/field-control.d.ts +12 -1
  25. package/dist/components/cms/field-control.js +31 -6
  26. package/dist/components/cms/field-control.js.map +1 -1
  27. package/dist/components/cms/import-batch.d.ts +23 -0
  28. package/dist/components/cms/import-batch.js +43 -0
  29. package/dist/components/cms/import-batch.js.map +1 -0
  30. package/dist/components/cms/rich-text/editor.js +13 -10
  31. package/dist/components/cms/rich-text/editor.js.map +1 -1
  32. package/dist/components/cms/rich-text/value.d.ts +9 -0
  33. package/dist/components/cms/rich-text/value.js +13 -0
  34. package/dist/components/cms/rich-text/value.js.map +1 -1
  35. package/dist/components/cms/use-document-draft.d.ts +39 -3
  36. package/dist/components/cms/use-document-draft.js +46 -16
  37. package/dist/components/cms/use-document-draft.js.map +1 -1
  38. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  39. package/dist/components/cms/use-publish-controller.js +17 -3
  40. package/dist/components/cms/use-publish-controller.js.map +1 -1
  41. package/dist/components/cms/versions-pane.js +8 -5
  42. package/dist/components/cms/versions-pane.js.map +1 -1
  43. package/dist/components/delivery-health-banner.d.ts +34 -16
  44. package/dist/components/delivery-health-banner.js +59 -27
  45. package/dist/components/delivery-health-banner.js.map +1 -1
  46. package/dist/components/issue-severity.d.ts +3 -2
  47. package/dist/components/issue-severity.js.map +1 -1
  48. package/dist/components/nav-issues-registry.d.ts +39 -24
  49. package/dist/components/nav-issues-registry.js +50 -21
  50. package/dist/components/nav-issues-registry.js.map +1 -1
  51. package/dist/components/nav-issues.d.ts +23 -50
  52. package/dist/components/nav-issues.js +31 -59
  53. package/dist/components/nav-issues.js.map +1 -1
  54. package/dist/components/notifications/notice-center.d.ts +61 -0
  55. package/dist/components/notifications/notice-center.js +119 -0
  56. package/dist/components/notifications/notice-center.js.map +1 -0
  57. package/dist/components/notifications/notice-model.d.ts +137 -0
  58. package/dist/components/notifications/notice-model.js +107 -0
  59. package/dist/components/notifications/notice-model.js.map +1 -0
  60. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  61. package/dist/components/notifications/outcome-notice.js +34 -0
  62. package/dist/components/notifications/outcome-notice.js.map +1 -0
  63. package/dist/components/notifications/shell-findings.d.ts +36 -0
  64. package/dist/components/notifications/shell-findings.js +140 -0
  65. package/dist/components/notifications/shell-findings.js.map +1 -0
  66. package/dist/components/rail-view-source.js +5 -0
  67. package/dist/components/rail-view-source.js.map +1 -1
  68. package/dist/components/records/record-lens.js.map +1 -1
  69. package/dist/components/records/record-rail.d.ts +10 -2
  70. package/dist/components/records/record-rail.js +74 -4
  71. package/dist/components/records/record-rail.js.map +1 -1
  72. package/dist/components/section-nav.d.ts +15 -3
  73. package/dist/components/section-nav.js +39 -147
  74. package/dist/components/section-nav.js.map +1 -1
  75. package/dist/components/studio-project-screen.js.map +1 -1
  76. package/dist/components/studio-shell.js +8 -3
  77. package/dist/components/studio-shell.js.map +1 -1
  78. package/dist/components/use-narrow-viewport.d.ts +11 -0
  79. package/dist/components/use-narrow-viewport.js +25 -0
  80. package/dist/components/use-narrow-viewport.js.map +1 -0
  81. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  82. package/dist/components/users/role-policy-drift-issues.js +17 -8
  83. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  84. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  85. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  86. package/dist/lib/engine-version.d.ts +1 -1
  87. package/dist/lib/engine-version.js +1 -1
  88. package/dist/lib/engine-version.js.map +1 -1
  89. package/dist/lib/i18n/messages.de.js +24 -3
  90. package/dist/lib/i18n/messages.de.js.map +1 -1
  91. package/dist/lib/i18n/messages.en.d.ts +22 -2
  92. package/dist/lib/i18n/messages.en.js +27 -4
  93. package/dist/lib/i18n/messages.en.js.map +1 -1
  94. package/dist/lib/notices/toast-store.d.ts +61 -0
  95. package/dist/lib/notices/toast-store.js +116 -0
  96. package/dist/lib/notices/toast-store.js.map +1 -0
  97. package/dist/lib/operations/cms-persistence.d.ts +32 -7
  98. package/dist/lib/operations/cms-persistence.js +511 -71
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +61 -30
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +1 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/dist/ui/toast.d.ts +24 -16
  105. package/dist/ui/toast.js +23 -32
  106. package/dist/ui/toast.js.map +1 -1
  107. package/package.json +4 -3
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'
6
+ import { createOperations } from '@elytracms/core/operations'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import schema from './schema'
9
+
10
+ /**
11
+ * EC-684 (Codex round 4, F27) — the `convex-parity` measurement the refusal rests on, and the refusal
12
+ * itself over the production `ConvexPersistenceAdapter` (its client a call-through onto convex-test's
13
+ * REAL `cms.ts` mutations — the wiring of `depth-codec-lookalike.test.ts`).
14
+ *
15
+ * 1. Re-measured (Codex measured it in `codec-own-keys.convex.test.ts`): the Convex value converters
16
+ * rebuild objects by assignment (`convex/src/values/value.ts` `jsonToConvex` / `convexToJsonInternal`),
17
+ * so an own `__proto__` key written straight through the adapter — past the operations layer — is
18
+ * lost before any sink, at the top level of `values` and nested. The known-positive control: an
19
+ * ordinary own key on the same path survives, so the instrument CAN see a key survive.
20
+ * 2. So the in-memory store keeping the key would be drift; the operations layer refuses it on the raw
21
+ * input, located, in this store too, and nothing is written.
22
+ */
23
+
24
+ const modules = {
25
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
26
+ './_generated/api.ts': async () => ({}),
27
+ }
28
+ const P = 'site'
29
+ const ADMIN = 'user_admin'
30
+ const AT = '2026-10-28T12:00:00.000Z'
31
+
32
+ const collections: CollectionDef[] = [
33
+ { id: 'post', kind: 'document', fields: [{ name: 'payload', type: 'object', multiple: false, fields: [] }] },
34
+ ]
35
+
36
+ interface Backend {
37
+ query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
38
+ mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
39
+ }
40
+
41
+ function asAdapterClient(backend: Backend): ConvexHttpClient {
42
+ return {
43
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => backend.query(ref, args ?? {}),
44
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => backend.mutation(ref, args ?? {}),
45
+ } as unknown as ConvexHttpClient
46
+ }
47
+
48
+ async function harness() {
49
+ const t = convexTest(schema, modules)
50
+ await t.run(async (ctx) => {
51
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: AT, updatedAt: AT })
52
+ })
53
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })), P)
54
+ const ops = createOperations({ adapter, collections: () => collections, validateDocument: () => [] })
55
+ const rows = async () => t.run(async (ctx) => (await ctx.db.query('cmsDocuments').collect()).map((row) => row.docId))
56
+ return { adapter, ops, rows }
57
+ }
58
+
59
+ const parse = <T = Record<string, unknown>>(json: string): T => JSON.parse(json) as T
60
+ const ownProto = (value: unknown) => typeof value === 'object' && value !== null && Object.hasOwn(value, '__proto__')
61
+
62
+ describe('EC-684 on Convex — an own `__proto__` key', () => {
63
+ it('re-measured: written straight through the adapter (past operations), Convex loses it before the sink — CONTROL: an ordinary own key survives', async () => {
64
+ const { adapter } = await harness()
65
+ const document = parse<CmsDocument>('{"collection":"post","id":"direct","values":{"__proto__":{"text":"TOP"},"payload":{"__proto__":{"text":"NESTED"},"ordinary":{"text":"KEEP"}}}}')
66
+ expect(ownProto(document.values)).toBe(true)
67
+ expect(ownProto(document.values.payload)).toBe(true)
68
+ await adapter.cms.replaceDocument(P, document, { ifAbsent: true })
69
+ const stored = (await adapter.cms.getDocument(P, { collection: 'post', id: 'direct' })).document
70
+ expect((stored.values.payload as Record<string, unknown>).ordinary).toEqual({ text: 'KEEP' })
71
+ expect(ownProto(stored.values)).toBe(false)
72
+ expect(ownProto(stored.values.payload)).toBe(false)
73
+ })
74
+
75
+ it('through the operations layer it is refused on the raw input, located — and nothing reaches the store', async () => {
76
+ const { ops, rows } = await harness()
77
+ const values = parse('{"__proto__":{"text":"TOP"},"payload":{"__proto__":{"text":"NESTED"}}}')
78
+ const result = await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'refused', values }, precondition: { ifAbsent: true } })
79
+ expect(result.ok).toBe(false)
80
+ if (result.ok) return
81
+ expect(result.error.code).toBe('validation')
82
+ expect(result.error.code === 'validation' ? result.error.issues.map((issue) => [issue.code, issue.path]) : []).toEqual([
83
+ ['prototype-key', ['document', 'values', '__proto__']],
84
+ ['prototype-key', ['document', 'values', 'payload', '__proto__']],
85
+ ])
86
+ expect(await rows()).toEqual([])
87
+ // CONTROL: the same write without the key goes through.
88
+ const plain = await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'plain', values: { payload: { text: 'x' } } }, precondition: { ifAbsent: true } })
89
+ expect(plain.ok).toBe(true)
90
+ expect(await rows()).toEqual(['plain'])
91
+ })
92
+ })
@@ -0,0 +1,147 @@
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 judge the columns and send the caller's values as sent.
25
+ *
26
+ * The three copies are real zod releases (the engine's own 4.4.3; the aliased devDependencies
27
+ * `zod-copy-older` 4.1.13 and `zod-copy-newer` 4.6.7). The client is the real
28
+ * `ConvexPersistenceAdapter` over convex-test, signed in as an editor member — the probe's
29
+ * wiring (`tasks/codex-review-2026-10-28-r5/record-transform.convex.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
+ type Wrapper = (typeof WRAPPERS)[number]
40
+
41
+ const exclaimed = (vendor: Vendor, wrapper: Wrapper) => {
42
+ const inner = vendor.string().transform((value) => `${value}!`)
43
+ return wrapper === 'optional' ? inner.optional() : wrapper === 'default' ? inner.default('safe!') : inner
44
+ }
45
+ const lengthOf = (vendor: Vendor) => vendor.string().transform((value) => value.length)
46
+ const capitalised = (name: string) => `${name.charAt(0).toUpperCase()}${name.slice(1)}`
47
+
48
+ /** One kind per case, each in its own Convex module (Convex addresses a kind's functions by module). */
49
+ function kindFor(name: string, fields: Record<string, unknown>): AnyRecordKind {
50
+ return defineRecordKind({ kind: name, module: `${name}s`, fields: fields as Record<string, RecordColumnSchema> })
51
+ }
52
+
53
+ const EXCLAIMED = COPIES.flatMap(([copy, vendor]) =>
54
+ WRAPPERS.map((wrapper) => ({ copy, wrapper, column: exclaimed(vendor, wrapper), kind: kindFor(`${copy}${capitalised(wrapper)}Text`, { text: exclaimed(vendor, wrapper) }) })),
55
+ )
56
+ const LENGTHS = COPIES.map(([copy, vendor]) => ({ copy, column: lengthOf(vendor), kind: kindFor(`${copy}Count`, { count: lengthOf(vendor) }) }))
57
+ const PLAIN = kindFor('plainText', { text: z.string() })
58
+
59
+ const modules = {
60
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
61
+ './_generated/api.ts': async () => ({}),
62
+ ...Object.fromEntries(
63
+ [...EXCLAIMED.map((row) => row.kind), ...LENGTHS.map((row) => row.kind), PLAIN].map((kind) => [
64
+ `./${kind.module}.ts`,
65
+ async () => defineRecordFunctions(kind),
66
+ ]),
67
+ ),
68
+ }
69
+
70
+ const PROJECT = 'site'
71
+ const STAMP = '2026-10-28T00:00:00.000Z'
72
+
73
+ beforeEach(() => {
74
+ vi.useFakeTimers()
75
+ vi.setSystemTime(STAMP)
76
+ })
77
+ afterEach(() => vi.useRealTimers())
78
+
79
+ /** The real client adapter over convex-test, as a signed-in editor; and the operations over it. */
80
+ async function connected() {
81
+ const t = convexTest(schema, modules)
82
+ await t.run(async (ctx) => {
83
+ await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
84
+ })
85
+ const editor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
86
+ const client = {
87
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => editor.query(ref, args ?? {}),
88
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => editor.mutation(ref, args ?? {}),
89
+ } as unknown as ConvexHttpClient
90
+ const adapter = new ConvexPersistenceAdapter(client, PROJECT)
91
+ const ops = createOperations({ adapter, collections: () => [], validateDocument: () => [] })
92
+ return { t, adapter, ops }
93
+ }
94
+
95
+ describe.each(EXCLAIMED)('EC-694 — Convex, a transforming column of the $copy zod copy, $wrapper', ({ column, kind }) => {
96
+ it('the operations store what the repository stores (`seed!`, then `edit!`) — the transform runs once', async () => {
97
+ // Controls (pass with the defect present and absent): the column transforms once, and so
98
+ // does the repository through the same mutation — the value both paths must agree with.
99
+ expect((column as unknown as z.ZodType).safeParse('seed')).toMatchObject({ success: true, data: 'seed!' })
100
+ const { adapter, ops } = await connected()
101
+ const direct = adapter.records(kind)
102
+ expect(await direct.save(PROJECT, { id: 'direct', text: 'seed' })).toMatchObject({ text: 'seed!' })
103
+
104
+ // The defect row: through the operations, create then edit.
105
+ const records = ops.records(kind)
106
+ expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'seed' })).toMatchObject({ ok: true, value: { text: 'seed!' } })
107
+ expect((await direct.get(PROJECT, 'viaops')).text).toBe('seed!')
108
+ expect(await direct.save(PROJECT, { id: 'direct', text: 'edit' })).toMatchObject({ text: 'edit!' })
109
+ expect(await records.save({ projectId: PROJECT, id: 'viaops', text: 'edit' })).toMatchObject({ ok: true, value: { text: 'edit!' } })
110
+ expect((await direct.get(PROJECT, 'viaops')).text).toBe('edit!')
111
+ })
112
+ })
113
+
114
+ describe.each(LENGTHS)('EC-694 — Convex, a type-changing column of the $copy zod copy', ({ column, kind }) => {
115
+ it('a value the column accepts is stored through the operations, as through the repository (4)', async () => {
116
+ expect((column as unknown as z.ZodType).safeParse('seed')).toMatchObject({ success: true, data: 4 })
117
+ const { adapter, ops } = await connected()
118
+ const direct = adapter.records(kind)
119
+ expect(await direct.save(PROJECT, { id: 'direct', count: 'seed' })).toMatchObject({ count: 4 })
120
+
121
+ const records = ops.records(kind)
122
+ expect(await records.save({ projectId: PROJECT, id: 'viaops', count: 'seed' })).toMatchObject({ ok: true, value: { count: 4 } })
123
+ expect((await direct.get(PROJECT, 'viaops')).count).toBe(4)
124
+ expect(await records.save({ projectId: PROJECT, id: 'viaops', count: 'edited' })).toMatchObject({ ok: true, value: { count: 6 } })
125
+ })
126
+ })
127
+
128
+ describe('EC-694 — Convex, what the operations send', () => {
129
+ it('a refused value is the operation’s own refusal: no mutation runs, nothing stored', async () => {
130
+ const { t, ops } = await connected()
131
+ const kind = EXCLAIMED[0]!.kind
132
+ const refused = await ops.records(kind).save({ projectId: PROJECT, id: 'refused', text: 42 as never })
133
+ expect(refused).toMatchObject({ ok: false, error: { code: 'validation', message: `Input for “${kind.module}.save” is invalid.` } })
134
+ expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
135
+ })
136
+
137
+ // A plain column: green before EC-694 too; it fails if the caller's whole input were sent,
138
+ // because the mutation's argument validator refuses a key it does not declare.
139
+ it('only the write schema’s keys cross the wire: an undeclared key stays behind (create and edit)', async () => {
140
+ const { ops } = await connected()
141
+ const records = ops.records(PLAIN)
142
+ expect(await records.save({ projectId: PROJECT, id: 'r', text: 'seed', stray: 1 } as never)).toMatchObject({ ok: true, value: { text: 'seed' } })
143
+ const edited = await records.save({ projectId: PROJECT, id: 'r', text: 'edit', stray: 1 } as never)
144
+ expect(edited).toMatchObject({ ok: true, value: { text: 'edit', projectId: PROJECT } })
145
+ expect(edited.ok && 'stray' in edited.value).toBe(false)
146
+ })
147
+ })
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import { z } from 'zod'
5
+ import { z as newerZod } from 'zod-copy-newer'
6
+ import { z as olderZod } from 'zod-copy-older'
7
+ import { defineRecordKind } from '@elytracms/core/persistence'
8
+ import type { AnyRecordKind, RecordColumnSchema } from '@elytracms/core/persistence'
9
+ import schema from './schema'
10
+ import { defineRecordFunctions } from './record-functions'
11
+
12
+ /**
13
+ * EC-682 (Codex round 4 F24, a regression of 0.0.97) — the generated Convex record functions
14
+ * (`defineRecordFunctions(kind).save` → `saveRecord` → `resolveRecordSave`, in the transaction)
15
+ * for a kind whose column carries a refinement on its `.optional()` / `.default()` WRAPPER and
16
+ * comes from another zod release than the engine's. Measured before: the adoption judged only
17
+ * the wrapper's `innerType`, so `'forbidden'` — refused by the column's own `safeParse` — was
18
+ * STORED (create and edit). Now a supplied value is judged by the whole column; the engine's own
19
+ * copy is the control row (composed as it is, refused before too). `zod-copy-older` (4.1.13) and
20
+ * `zod-copy-newer` (4.6.7) are real second copies (aliased devDependencies), not mocks.
21
+ */
22
+
23
+ const MESSAGE = 'forbidden value'
24
+ const notForbidden = (value: string | undefined) => value !== 'forbidden'
25
+
26
+ // The copies' builders differ only in their TYPES (EC-639 owns those); the runtime is the subject here.
27
+ type Vendor = typeof z
28
+ const COPIES = [
29
+ ['own', 'the engine’s own copy (the control)', z],
30
+ ['older', 'an OLDER zod copy (4.1.13)', olderZod as unknown as Vendor],
31
+ ['newer', 'a NEWER zod copy (4.6.7)', newerZod as unknown as Vendor],
32
+ ] as const
33
+
34
+ const SHAPES = [
35
+ // The defect: the refinement is the WRAPPER's own check.
36
+ ['OptionalRefine', 'optional().refine()', (v: Vendor) => v.string().optional().refine(notForbidden, MESSAGE)],
37
+ ['DefaultRefine', 'default().refine()', (v: Vendor) => v.string().default('safe').refine(notForbidden, MESSAGE)],
38
+ // The neighbours: the refinement sits on the inner type (judged before this change too).
39
+ ['RefineOptional', 'refine().optional()', (v: Vendor) => v.string().refine(notForbidden, MESSAGE).optional()],
40
+ ['RefineDefault', 'refine().default()', (v: Vendor) => v.string().refine(notForbidden, MESSAGE).default('safe')],
41
+ ] as const
42
+
43
+ const CASES = COPIES.flatMap(([copy, copyLabel, vendor]) =>
44
+ SHAPES.map(([shape, shapeLabel, build]) => {
45
+ const kind = defineRecordKind({
46
+ kind: `${copy}${shape}`,
47
+ module: `${copy}${shape}Notes`,
48
+ fields: { code: build(vendor) as unknown as RecordColumnSchema },
49
+ })
50
+ return [`${copyLabel}, \`z.string().${shapeLabel}\``, { kind, defaulted: shape.includes('Default') }] as const
51
+ }),
52
+ )
53
+
54
+ const modules = {
55
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
56
+ './_generated/api.ts': async () => ({}),
57
+ ...Object.fromEntries(CASES.map(([, { kind }]) => [`./${kind.module}.ts`, async () => defineRecordFunctions(kind)])),
58
+ }
59
+
60
+ const STAMP = '2026-10-28T00:00:00.000Z'
61
+
62
+ async function editorBackend() {
63
+ const t = convexTest(schema, modules)
64
+ await t.run(async (ctx) => {
65
+ await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
66
+ })
67
+ return { t, editor: t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' }) }
68
+ }
69
+
70
+ describe.each(CASES)('EC-682 — Convex record save, %s', (_label, { kind, defaulted }: { kind: AnyRecordKind; defaulted: boolean }) => {
71
+ const save = makeFunctionReference<'mutation'>(`${kind.module}:save`)
72
+ const get = makeFunctionReference<'query'>(`${kind.module}:get`)
73
+ const write = (id: string, columns: Record<string, unknown> = {}) => ({ [kind.idKey]: id, ...columns, createdAt: STAMP, updatedAt: STAMP })
74
+
75
+ it('refuses `forbidden` on create with the column’s own message, in the transaction — nothing stored', async () => {
76
+ const { t, editor } = await editorBackend()
77
+ await expect(editor.mutation(save, write('created', { code: 'forbidden' }))).rejects.toThrow(/code: forbidden value/)
78
+ expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
79
+ })
80
+
81
+ it('saves `safe`, refuses an edit to `forbidden`, keeps the stored value', async () => {
82
+ const { editor } = await editorBackend()
83
+ expect(await editor.mutation(save, write('edited', { code: 'safe' }))).toMatchObject({ code: 'safe' })
84
+ await expect(editor.mutation(save, write('edited', { code: 'forbidden' }))).rejects.toThrow(/code: forbidden value/)
85
+ expect(await editor.query(get, { [kind.idKey]: 'edited' })).toMatchObject({ code: 'safe' })
86
+ })
87
+
88
+ it('an absent key is the wrapper’s: the optional stays absent, the default is the column’s own (`safe`)', async () => {
89
+ const { editor } = await editorBackend()
90
+ const created = (await editor.mutation(save, write('absent'))) as Record<string, unknown>
91
+ if (defaulted) expect(created.code).toBe('safe')
92
+ else expect('code' in created).toBe(false)
93
+ })
94
+ })
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 (`claimIngestUpload`).
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
 
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 with its issues
18
- * foot (findings contributed with {@link useNavIssues}, or always-on through a view's
19
- * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area
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 with its issues
18
- * foot (findings contributed with {@link useNavIssues}, or always-on through a view's
19
- * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,0FAA0F;AAE1F,OAAO,EAAE,GAAG,EAAE,MAAM,wCAAwC,CAAA;AAkB5D,2FAA2F;AAE3F,8EAA8E;AAC9E,OAAO,EACL,wBAAwB,EACxB,gBAAgB,EAChB,kBAAkB,EAClB,6BAA6B,EAC7B,aAAa,EACb,aAAa,GACd,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAA;AAEjH,4FAA4F;AAC5F,OAAO,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAA;AAC5F,OAAO,EACL,sBAAsB,EACtB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AAQtC,4FAA4F;AAE5F,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAG/D,4FAA4F;AAE5F,4GAA4G;AAC5G,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AAGpE,gGAAgG;AAEhG;;;;;GAKG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AACrE,yGAAyG;AACzG,OAAO,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAChE,6FAA6F;AAC7F,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA","sourcesContent":["/**\n * `@elytracms/studio/addon` — THE public addon API (EC-616, ruling EC-565 § 6b item 2).\n *\n * An addon (`@elytracms/forms`, `@elytracms/analytics`, or your own) is an optional\n * package whose studio half contributes navigation items to a project's\n * `defineStudio({ sidebar })` — a rail bound to a record kind, or a view with its own\n * component — and whose backend half is a record kind in the generic record store\n * (`defineRecordKind` / `adapter.records(kind)` in `@elytracms/core/persistence`, its\n * Convex functions from `defineRecordFunctions` in `@elytracms/studio/convex/\n * record-functions`). Everything an addon's studio code touches is exported HERE; the\n * studio's internals (`lib/*`, `components/*`) are not part of the published package.\n * `@elytracms/studio/ui` stays the primitive set (Button, Panel, Text, …).\n *\n * ## The chrome contract\n *\n * The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;\n * deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail with its issues\n * foot (findings contributed with {@link useNavIssues}, or always-on through a view's\n * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area\n * under it — including its own title header: the shell adds no frame, title or padding.\n * A view reaches the studio through its {@link AppViewContext}: the project, its deep\n * link and search, the operations client (every read and write goes through the one\n * authorized chokepoint), the query hooks over the one cache, the content locale, and\n * the signed-in user with their role.\n *\n * ## The two navigation items\n *\n * ```tsx\n * import { nav } from '@elytracms/studio/addon'\n * import { submissionRecordKind } from './kinds'\n *\n * // a rail bound to a record kind — the portable list/edit surfaces, no component to write\n * nav.rail(submissionRecordKind, { label: 'Submissions' })\n * // a custom component — the whole content area is yours\n * nav.view({ id: 'forms-stats', label: 'Form stats', render: (ctx) => <Stats ctx={ctx} /> })\n * ```\n */\n\n// --- Navigation items and the view contract --------------------------------------------\n\nexport { nav } from './components/app-views/default-modules'\nexport type {\n CustomViewConfig,\n RailViewConfig,\n BuiltinViewConfig,\n} from './components/app-views/default-modules'\nexport type {\n AppViewContext,\n AppViewDeepLink,\n AppViewModule,\n AppViewQueryHooks,\n NavIssuesHook,\n NavPermission,\n NavPermissionContext,\n SidebarNode,\n SidebarViewNode,\n} from './components/app-views/module'\n\n// --- Rails: a record kind, or your own source -------------------------------------------\n\n/** A record rail's pieces, for an addon composing its own view around one. */\nexport {\n RECORD_RAIL_CAPABILITIES,\n RecordRailModule,\n RecordRailProvider,\n useRecordCollectionViewSource,\n useRecordList,\n useRecordRail,\n} from './components/records/record-rail'\nexport type { RecordRailModuleProps, RecordRailPaths, RecordRailStore } from './components/records/record-rail'\nexport { lensDocumentToRecordWrite, recordLensDef, recordToLensDocument } from './components/records/record-lens'\n\n/** The portable list/edit surfaces over any source, and the scaffold that assembles one. */\nexport { CollectionViewSurface } from './components/collection-view/collection-view-surface'\nexport {\n assembleRailViewSource,\n metaBagProjection,\n normalizeOkResult,\n useRailViewSource,\n} from './components/rail-view-source'\nexport type { OkResult, RailAdapter } from './components/rail-view-source'\nexport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n FieldAccess,\n} from './lib/collection-view/collection-view-source'\n\n// --- Hooks: the cache, the locale, the breadcrumb, the rail foot -------------------------\n\nexport { useOperationCache, useOperationQuery } from './lib/query'\nexport type { OperationCache, ReadState, UseOperationQueryOptions } from './lib/query'\nexport { useStudioLocale } from './lib/locale'\nexport type { StudioLocale } from './lib/locale'\nexport { useSetBreadcrumb } from './components/breadcrumb-context'\nexport type { BreadcrumbItem } from './components/breadcrumb-context'\nexport { useNavIssues } from './components/nav-issues-registry'\nexport type { NavIssue } from './components/nav-issues-registry'\n\n// --- The document editor: contributed inspector panes (EC-618) ---------------------------\n\n/** A pane in the document editor's inspector — `defineStudio({ documentPanes: [documentPane({ … })] })`. */\nexport { documentPane } from './components/inspector/document-panes'\nexport type { DocumentPane, DocumentPaneContext } from './components/inspector/document-panes'\n\n// --- EC-613 (the forms addon): an own source's edit shell, its list key, the UI language -----\n\n/**\n * The portable edit shell alone (AD-19), with its form-column appendix seam (EC-396): an addon\n * that assembles its own source (`useRailViewSource`) renders one record's detail with\n * read-only sections below the editable fields — the forms inbox shows a submission's answers,\n * files and delivery log there.\n */\nexport { PortableDetailEditor } from './components/cms/detail-editor'\n/** The one query key of a kind's records in a project — patch it after a write (`useOperationCache`). */\nexport { recordListKey } from './components/records/record-rail'\n/** Resolve a `LocalizedString` (`'Text'` or `{ en, de, … }`) in the studio's UI language. */\nexport { useLabel } from './lib/i18n'\n"]}
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 rail-foot findings for this view (EC-616, see `NavIssuesHook`). */
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 rail-foot findings for this rail (EC-616, see `NavIssuesHook`). */
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 at the rail foot, never silent. */
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 rail foot localizes as one list.
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',