@elytracms/studio 0.0.103 → 0.0.104

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.
@@ -10,13 +10,14 @@ import {
10
10
  signedIngestRequest,
11
11
  } from '@elytracms/core/ingest'
12
12
  import type { IngestHandlerResult, IngestPayload } from '@elytracms/core/ingest'
13
- import { defineRecordKind } from '@elytracms/core/persistence'
13
+ import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
14
14
  import schema from './schema'
15
15
  import { internalMutation } from './functions'
16
16
  import { defineStudioHttp } from './http'
17
17
  import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
18
18
  import {
19
19
  claimIngestUploads,
20
+ ingestBindingRecordKind,
20
21
  ingestHandlerArgs,
21
22
  parseIngestPayload,
22
23
  recordIngestUpload,
@@ -24,7 +25,7 @@ import {
24
25
  unclaimedIngestUploads,
25
26
  } from './ingest'
26
27
  import type { IngestHandlerRef } from './ingest'
27
- import { saveRecord } from './record-functions'
28
+ import { findRecordDoc, saveRecord } from './record-functions'
28
29
  import { createIngestRoute } from '../../next/src/ingest'
29
30
 
30
31
  beforeEach(() => {
@@ -80,7 +81,7 @@ const testIngestModule = {
80
81
  // EC-750: the row's id first — the claim binds every file to it (the owner release is asked for).
81
82
  const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
82
83
  // EC-695: one call for every upload, after every check that can refuse — all bound or none.
83
- const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, id)
84
+ const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, testSubmissionKind, id)
84
85
  if (!claim.ok) return claim
85
86
  const files = claim.files.map((file) => file.storageId)
86
87
  await saveRecord(
@@ -98,11 +99,56 @@ const testIngestModule = {
98
99
  const handle = makeFunctionReference<'mutation'>('testIngest:handle') as unknown as IngestHandlerRef
99
100
  const silentAuth = { addHttpRoutes: () => {} }
100
101
 
102
+ /**
103
+ * EC-762 (Codex r10 F51) — a third-party addon whose ONE submission handler writes two record
104
+ * kinds, each row under an EXPLICIT id the payload names (the records API accepts any nonempty
105
+ * id; ids are unique only within a kind). The target names the kind.
106
+ */
107
+ const attachmentRequestKind = defineRecordKind({
108
+ kind: 'attachmentRequest',
109
+ module: 'testCases',
110
+ fields: { note: z.string().min(1), files: z.array(z.string()).optional() },
111
+ })
112
+ const supportCaseKind = defineRecordKind({
113
+ kind: 'supportCase',
114
+ module: 'testCases',
115
+ fields: { note: z.string().min(1), files: z.array(z.string()).optional() },
116
+ })
117
+ const caseKindOf = (target: string) =>
118
+ target === 'attachmentRequest' ? attachmentRequestKind : target === 'supportCase' ? supportCaseKind : undefined
119
+
120
+ const testCasesModule = {
121
+ handle: internalMutation({
122
+ args: ingestHandlerArgs,
123
+ handler: async (ctx, args): Promise<IngestHandlerResult> => {
124
+ const payload: IngestPayload = parseIngestPayload(args.payload)
125
+ if (payload.kind === 'upload') {
126
+ return recordIngestUpload(ctx, payload, args.receivedAt, { maxBytes: 1000, contentTypes: ['image/*'] })
127
+ }
128
+ if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
129
+ const kind = caseKindOf(payload.target)
130
+ if (!kind) return { ok: false, status: 404, message: `No case kind “${payload.target}”.` }
131
+ const id = payload.data['id']
132
+ if (typeof id !== 'string' || id.length === 0) return { ok: false, status: 422, message: 'An id is required.' }
133
+ const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, kind, id)
134
+ if (!claim.ok) return claim
135
+ const files = claim.files.map((file) => file.storageId)
136
+ await saveRecord(ctx, kind, id, { note: payload.target, ...(files.length ? { files } : {}) }, {
137
+ createdAt: args.receivedAt,
138
+ updatedAt: args.receivedAt,
139
+ })
140
+ return { ok: true, id }
141
+ },
142
+ }),
143
+ }
144
+ const casesHandle = makeFunctionReference<'mutation'>('testCases:handle') as unknown as IngestHandlerRef
145
+
101
146
  function backend(handlers: Parameters<typeof defineStudioHttp>[0]['ingest'] = { submission: handle }) {
102
147
  return convexTest(schema, {
103
148
  ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
104
149
  './_generated/api.ts': async () => ({}),
105
150
  './testIngest.ts': async () => testIngestModule,
151
+ './testCases.ts': async () => testCasesModule,
106
152
  './http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest: handlers }) }),
107
153
  })
108
154
  }
@@ -456,13 +502,13 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
456
502
  const assetFile = await storeFile(t, 400, 'image/png')
457
503
  await insertAsset(t, { storageKey: assetFile })
458
504
  expect(await owners(t)).toEqual({ [own]: owner, [other]: otherOwner })
459
- const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
505
+ const released = await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))
460
506
  expect(released).toEqual({ deleted: [own], kept: [] })
461
507
  expect([await stored(t, own), await stored(t, other), await stored(t, assetFile)]).toEqual([false, true, true])
462
508
  expect(await bindings(t)).toEqual([{ storageId: other, uploadId: theirs }])
463
509
  // An owner with nothing bound any more (released already, or a row that never claimed) deletes nothing.
464
- expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [], kept: [] })
465
- expect(await t.run((ctx) => releaseIngestUploads(ctx, 'sub_never'))).toEqual({ deleted: [], kept: [] })
510
+ expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))).toEqual({ deleted: [], kept: [] })
511
+ expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, 'sub_never'))).toEqual({ deleted: [], kept: [] })
466
512
  })
467
513
 
468
514
  // EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
@@ -495,7 +541,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
495
541
  )
496
542
  // An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
497
543
  await insertAsset(t, { storageKey: promoted, thumb: preview })
498
- const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
544
+ const released = await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))
499
545
  expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
500
546
  expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
501
547
  expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
@@ -520,7 +566,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
520
566
  await t.run(async (ctx) => {
521
567
  for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
522
568
  })
523
- expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [own], kept: [] })
569
+ expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))).toEqual({ deleted: [own], kept: [] })
524
570
  expect(await stored(t, own)).toBe(false)
525
571
  })
526
572
 
@@ -625,10 +671,17 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
625
671
  const own = await storeFile(t, 400, 'image/png')
626
672
  const owner = await ownerOf(await claimWith(t, uploadId, own))
627
673
  const row = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestBinding')!)
628
- expect(row.data).toEqual({ uploadId, target: 'contact', owner, boundAt: '2026-10-26T12:00:00.000Z' })
629
- // EC-750: the owner fills the static `key1` slot (`by_kind_key1`) — no schema or index change.
630
- expect(row.key1).toBe(owner)
631
- expect(row.key2).toBeUndefined()
674
+ expect(row.data).toEqual({ uploadId, target: 'contact', ownerKind: 'testSubmission', owner, boundAt: '2026-10-26T12:00:00.000Z' })
675
+ // EC-762: the owner's kind fills the static `key1` slot and its id `key2`, read together
676
+ // through the existing `by_kind_keys` index — no schema or index change.
677
+ expect(row.key1).toBe('testSubmission')
678
+ expect(row.key2).toBe(owner)
679
+ expect(resolveRecordQuery(ingestBindingRecordKind, { where: { ownerKind: 'testSubmission', owner } })).toEqual({
680
+ index: 'by_kind_keys',
681
+ key1: 'testSubmission',
682
+ key2: owner,
683
+ order: 'asc',
684
+ })
632
685
  expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
633
686
  expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
634
687
  })
@@ -643,7 +696,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
643
696
  ),
644
697
  )
645
698
  const claimDirect = (t: ReturnType<typeof backend>, claims: { uploadId: string; storageId: string }[]) =>
646
- t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', 'sub_direct'))
699
+ t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', testSubmissionKind, 'sub_direct'))
647
700
 
648
701
  it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
649
702
  env()['ELYTRA_INGEST_SECRET'] = SECRET
@@ -713,6 +766,148 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
713
766
  })
714
767
  })
715
768
 
769
+ /**
770
+ * EC-762 (Codex r10 F51) — the first EXECUTABLE probe of F51's static trace. A record is
771
+ * identified by its kind AND its id (`record-functions.ts`, `findRecordDoc`), and an explicit id
772
+ * is unique only within its kind. One third-party handler writes `attachmentRequest/same` and
773
+ * `supportCase/same`, each claiming its OWN upload; removing one must not delete the other's.
774
+ */
775
+ describe('EC-762 probe — an upload’s owner is the record’s kind AND id (Codex r10 F51)', () => {
776
+ const REMOVED_AT = '2026-10-26T12:00:00.000Z'
777
+ async function declareFor(t: ReturnType<typeof backend>, target: string): Promise<string> {
778
+ const intent = { kind: 'upload', target, field: 'cv', contentType: 'image/png', size: 500, sha256: await sha256Of(400) } as const
779
+ const answer = await signedPost(t, intent)
780
+ expect(answer.status).toBe(200)
781
+ return ((await answer.json()) as { uploadId: string }).uploadId
782
+ }
783
+ /** A genuine case of `target`'s kind under the explicit id `id`, claiming one upload of its own. Returns its file. */
784
+ async function genuineCase(t: ReturnType<typeof backend>, target: string, id: string): Promise<string> {
785
+ const uploadId = await declareFor(t, target)
786
+ const file = await storeFile(t, 400, 'image/png')
787
+ const answer = await signedPost(t, { kind: 'submission', target, data: { id }, uploads: [{ uploadId, storageId: file }] })
788
+ expect(answer.status).toBe(200)
789
+ expect(await answer.json()).toEqual({ ok: true, id })
790
+ return file
791
+ }
792
+ /** The addon's removal of one row: release what it claimed, then delete it. */
793
+ const removeCase = (t: ReturnType<typeof backend>, kind: typeof attachmentRequestKind, id: string) =>
794
+ t.run(async (ctx) => {
795
+ const released = await releaseIngestUploads(ctx, kind, id)
796
+ const doc = await findRecordDoc(ctx, kind, id)
797
+ if (doc) await ctx.db.delete(doc._id)
798
+ return released
799
+ })
800
+ const caseBindings = (t: ReturnType<typeof backend>) =>
801
+ t.run(async (ctx) =>
802
+ (await ctx.db.query('records').collect())
803
+ .filter((row) => row.kind === 'ingestBinding')
804
+ .map((row) => {
805
+ const data = row.data as { ownerKind?: string; owner?: string }
806
+ return { storageId: row.recordId, ownerKind: data.ownerKind, owner: data.owner }
807
+ }),
808
+ )
809
+ async function twoKindsOneId() {
810
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
811
+ const t = backend({ submission: casesHandle })
812
+ const a = await genuineCase(t, 'attachmentRequest', 'same')
813
+ const b = await genuineCase(t, 'supportCase', 'same')
814
+ return { t, a, b }
815
+ }
816
+
817
+ /** A binding row as an earlier release wrote it, straight into the table. */
818
+ const plantBinding = (t: ReturnType<typeof backend>, storageId: string, data: Record<string, string>, key1?: string) =>
819
+ t.run(async (ctx) => {
820
+ await ctx.db.insert('records', {
821
+ kind: 'ingestBinding',
822
+ recordId: storageId,
823
+ data: { uploadId: `upload_${storageId}`, target: 'attachmentRequest', boundAt: REMOVED_AT, ...data },
824
+ ...(key1 !== undefined ? { key1 } : {}),
825
+ at: REMOVED_AT,
826
+ createdAt: REMOVED_AT,
827
+ updatedAt: REMOVED_AT,
828
+ })
829
+ })
830
+ async function asEditor(t: ReturnType<typeof backend>) {
831
+ await t.run(async (ctx) => {
832
+ await ctx.db.insert('members', { userId: 'user_editor', role: 'admin', createdAt: REMOVED_AT, updatedAt: REMOVED_AT })
833
+ })
834
+ return t.withIdentity({ subject: 'user_editor', issuer: 'https://convex.test' })
835
+ }
836
+ const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
837
+
838
+ it('a claim binds each file to its owner’s kind AND id', async () => {
839
+ const { t, a, b } = await twoKindsOneId()
840
+ expect(await caseBindings(t)).toEqual([
841
+ { storageId: a, ownerKind: 'attachmentRequest', owner: 'same' }, // base: ownerKind undefined
842
+ { storageId: b, ownerKind: 'supportCase', owner: 'same' },
843
+ ])
844
+ })
845
+
846
+ it('Codex’s trace: removing attachmentRequest/same keeps supportCase/same’s bytes and binding', async () => {
847
+ const { t, a, b } = await twoKindsOneId()
848
+ // Measured on the base (7c2e9261), counterexample form: `{ deleted: [a, b], kept: [] }`, both files gone, no binding left.
849
+ expect(await removeCase(t, attachmentRequestKind, 'same')).toEqual({ deleted: [a], kept: [] })
850
+ expect([await stored(t, a), await stored(t, b)]).toEqual([false, true])
851
+ expect(await caseBindings(t)).toEqual([{ storageId: b, ownerKind: 'supportCase', owner: 'same' }])
852
+ // supportCase/same still holds its file; its own removal deletes it (below).
853
+ })
854
+
855
+ it('control: removing the owner itself (same kind, same id) deletes its file and binding — the instrument sees a deletion', async () => {
856
+ const { t, a, b } = await twoKindsOneId()
857
+ const released = await removeCase(t, supportCaseKind, 'same')
858
+ expect(released.deleted).toContain(b)
859
+ expect(await stored(t, b)).toBe(false)
860
+ expect((await caseBindings(t)).map((binding) => binding.storageId)).not.toContain(b)
861
+ // …and after both rows are removed nothing is left: no residue.
862
+ await removeCase(t, attachmentRequestKind, 'same')
863
+ expect([await stored(t, a), await stored(t, b)]).toEqual([false, false])
864
+ expect(await caseBindings(t)).toEqual([])
865
+ })
866
+
867
+ it('a KIND-LESS binding (written before bindings recorded the owner’s kind) is never deleted — residue, never loss; it still holds its bytes', async () => {
868
+ const { t, a } = await twoKindsOneId()
869
+ const kindless = await storeFile(t, 300, 'image/png')
870
+ await plantBinding(t, kindless, { owner: 'same' }, 'same') // the shape EC-750 wrote: `owner` in `key1`, no kind
871
+ expect(await removeCase(t, attachmentRequestKind, 'same')).toEqual({ deleted: [a], kept: [] }) // base: deleted the kind-less file too
872
+ expect(await stored(t, kindless)).toBe(true)
873
+ expect((await caseBindings(t)).filter((binding) => binding.storageId === kindless)).toEqual([
874
+ { storageId: kindless, ownerKind: undefined, owner: 'same' },
875
+ ])
876
+ const editor = await asEditor(t)
877
+ expect(await editor.mutation(deleteBlob, { storageKey: kindless })).toEqual({ deleted: false, heldBy: 'ingest' })
878
+ expect(await stored(t, kindless)).toBe(true)
879
+ })
880
+
881
+ it('control: an OWNER-LESS binding (written before EC-750) is never deleted', async () => {
882
+ const { t, a } = await twoKindsOneId()
883
+ const ownerless = await storeFile(t, 300, 'image/png')
884
+ await plantBinding(t, ownerless, {})
885
+ await removeCase(t, attachmentRequestKind, 'same')
886
+ await removeCase(t, supportCaseKind, 'same')
887
+ expect([await stored(t, a), await stored(t, ownerless)]).toEqual([false, true])
888
+ expect(await caseBindings(t)).toEqual([{ storageId: ownerless, ownerKind: undefined, owner: undefined }])
889
+ })
890
+
891
+ it('assets:deleteBlob still keeps bytes a binding holds — after attachmentRequest/same’s removal, supportCase/same’s binding protects its file', async () => {
892
+ const { t, b } = await twoKindsOneId()
893
+ await removeCase(t, attachmentRequestKind, 'same')
894
+ await t.run(async (ctx) => {
895
+ await ctx.db.insert('assets', {
896
+ assetId: 'asset_case',
897
+ filename: 'case.png',
898
+ contentType: 'image/png',
899
+ byteSize: 400,
900
+ storageKey: b,
901
+ createdAt: REMOVED_AT,
902
+ updatedAt: REMOVED_AT,
903
+ })
904
+ })
905
+ const editor = await asEditor(t)
906
+ expect(await editor.mutation(deleteBlob, { storageKey: b })).toEqual({ deleted: false, heldBy: 'ingest' }) // base: the bytes were already gone
907
+ expect(await stored(t, b)).toBe(true)
908
+ })
909
+ })
910
+
716
911
  describe('the site’s route → the receiver, end to end (EC-617)', () => {
717
912
  it('a visitor’s submission through createIngestRoute lands a record; a mismatched site secret is a 502', async () => {
718
913
  env()['ELYTRA_INGEST_SECRET'] = SECRET
package/convex/ingest.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from '@elytracms/core/ingest'
17
17
  import type { IngestHandlerResult, IngestPayload, IngestUploadClaim } from '@elytracms/core/ingest'
18
18
  import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
19
+ import type { AnyRecordKind } from '@elytracms/core/persistence'
19
20
  import { createUuidIdFactory } from '@elytracms/core/determinism'
20
21
  import type { MutationCtx, QueryCtx } from './functions'
21
22
  import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './record-functions'
@@ -44,8 +45,8 @@ import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './re
44
45
  * claims its stored files with {@link claimIngestUploads}, which checks that the deployment
45
46
  * stored each for ITS intent (EC-671) — its bytes the ones the upload request declared by
46
47
  * SHA-256 (EC-685) — and that it matches the declared size and type, and binds them all or
47
- * none (EC-695) to the record that claims them (EC-750); {@link releaseIngestUploads}, asked
48
- * for that record, is the only way such bytes are deleted.
48
+ * none (EC-695) to the record that claims them, by its kind and id (EC-750, EC-762);
49
+ * {@link releaseIngestUploads}, asked for that record, is the only way such bytes are deleted.
49
50
  *
50
51
  * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
51
52
  * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
@@ -197,9 +198,11 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
197
198
  // which deletes only what a claim bound.
198
199
  //
199
200
  // EC-750 (Codex r9 F46) — bound to WHOM. A binding records the record that claimed the file
200
- // (`owner`), and release is asked for an owner, never for storage ids: removing a row deletes
201
- // only the files that row claimed. Before, release took the removed row's `files` ids — a column
202
- // a whole-record write can fill with another row's file — and deleted any bound one.
201
+ // — EC-762 (Codex r10 F51): its kind AND its id (`ownerKind` + `owner`), the identity the records
202
+ // table declares — and release is asked for that owner, never for storage ids: removing a row
203
+ // deletes only the files that row claimed. Before EC-750, release took the removed row's `files`
204
+ // ids — a column a whole-record write can fill with another row's file — and deleted any bound
205
+ // one; before EC-762, it took the id alone, so a row of another kind sharing the id lost its files.
203
206
  //
204
207
  // EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
205
208
  // ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
@@ -243,23 +246,34 @@ export const ingestUploadRecordKind = defineRecordKind({
243
246
  * claim's transaction; read by the claim (a file another claim bound is refused) and by
244
247
  * {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
245
248
  *
246
- * EC-750 (Codex r9 F46) — and WHOSE: `owner` is the id of the record that claimed the file (a
247
- * forms submission), its `key1` slot, so "this record's files" is one indexed read
248
- * (`by_kind_key1`) and release asks that question, never "is this id bound?". `target` is the
249
- * intent's (the form every submission to it shares), so it cannot say whose a file is. A
250
- * binding written before EC-750 has no owner and no slot: no release reads it, so it is never
251
- * deleted and keeps protecting its bytes from `assets:deleteBlob` (residue, never loss — EC-671's
252
- * no-migration precedent).
249
+ * EC-750 (Codex r9 F46) — and WHOSE: the record that claimed the file (a forms submission), so
250
+ * release asks "this record's files", never "is this id bound?". `target` is the intent's (the
251
+ * form every submission to it shares), so it cannot say whose a file is.
252
+ *
253
+ * EC-762 (Codex r10 F51) — a record is its kind AND its id (the records table's `kind` +
254
+ * `recordId`; an explicit id is unique only within its kind), so the owner is both:
255
+ * `ownerKind` (the owning kind's name) in the `key1` slot and `owner` (its id) in `key2`, read
256
+ * TOGETHER through the existing `by_kind_keys` index. Two rows of different kinds sharing an id
257
+ * never share a binding. Two string columns, never one object column (a key slot holds a
258
+ * string: an object would stringify to the same `[object Object]` for every owner) and never a
259
+ * composite string (a second encoding of an identity the table already stores as two columns).
260
+ *
261
+ * Bindings of earlier releases are never read by a release, so never deleted: one written before
262
+ * EC-750 has no owner and no slot, one written before EC-762 has its owner's id in `key1` and no
263
+ * `key2`. Each keeps protecting its bytes from `assets:deleteBlob` (residue, never loss —
264
+ * EC-671's no-migration precedent; no owner-only fallback, which would re-open F51).
253
265
  */
254
266
  export const ingestBindingRecordKind = defineRecordKind({
255
267
  kind: 'ingestBinding',
256
268
  module: 'ingest',
257
- keys: ['owner'],
269
+ keys: ['ownerKind', 'owner'],
258
270
  time: 'boundAt',
259
271
  fields: {
260
272
  uploadId: z.string().min(1),
261
273
  target: z.string().min(1),
262
- /** EC-750: the id of the record that claimed the file — what {@link releaseIngestUploads} is asked for. */
274
+ /** EC-762: the KIND of the record that claimed the file (its declaration's `kind`). */
275
+ ownerKind: z.string().min(1),
276
+ /** EC-750: the id of the record that claimed the file — with `ownerKind`, what {@link releaseIngestUploads} is asked for. */
263
277
  owner: z.string().min(1),
264
278
  boundAt: z.string().min(1),
265
279
  },
@@ -421,17 +435,20 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
421
435
  * mutation that RETURNS commits its writes (a claim made before a later refusal stays bound with
422
436
  * no row to hold it); one that THROWS commits nothing.
423
437
  *
424
- * EC-750: `owner` is the id of the record the handler is about to write — mint it BEFORE the
425
- * claim. Every binding records it, and {@link releaseIngestUploads} deletes a file only for the
426
- * owner that claimed it: a row naming another row's file (an editor's whole-record write can
427
- * put any id in its `files`) never deletes it.
438
+ * EC-750 / EC-762: the owner is the record the handler is about to write — its kind's
439
+ * declaration (`ownerKind`) and its id (`ownerId`, minted BEFORE the claim). Every binding records
440
+ * both, and {@link releaseIngestUploads} deletes a file only for the owner that claimed it: a row
441
+ * naming another row's file (an editor's whole-record write can put any id in its `files`) never
442
+ * deletes it, nor does a row of another kind that happens to share the id. The declaration, not a
443
+ * string: a misspelt kind cannot compile into bindings no release ever finds.
428
444
  */
429
445
  export async function claimIngestUploads(
430
446
  ctx: MutationCtx,
431
447
  claims: readonly IngestUploadClaim[],
432
448
  target: string,
433
449
  receivedAt: string,
434
- owner: string,
450
+ ownerKind: AnyRecordKind,
451
+ ownerId: string,
435
452
  ): Promise<IngestUploadsClaimResult> {
436
453
  const accepted: AcceptedClaim[] = []
437
454
  for (const [index, claim] of claims.entries()) {
@@ -445,7 +462,7 @@ export async function claimIngestUploads(
445
462
  ctx,
446
463
  ingestBindingRecordKind,
447
464
  claim.storageId,
448
- { uploadId: claim.uploadId, target, owner, boundAt: receivedAt },
465
+ { uploadId: claim.uploadId, target, ownerKind: ownerKind.kind, owner: ownerId, boundAt: receivedAt },
449
466
  stamps,
450
467
  )
451
468
  await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
@@ -537,13 +554,14 @@ async function judgeIngestUploadClaim(
537
554
  }
538
555
 
539
556
  /**
540
- * Delete the ingest bytes ONE record claimed (EC-671, EC-750) — the ONE way ingest bytes are
541
- * deleted. It is asked for an OWNER (the id {@link claimIngestUploads} bound the files to), never
542
- * for storage ids: it reads that owner's bindings by their key (`by_kind_key1`) and deletes each
543
- * file with its binding — so it never sees, and never writes, another owner's binding. A row's
544
- * `files` column is the visitor's and the editor's word (a whole-record write can name any id,
545
- * or clear the list), so release never reads it: a row naming another row's file deletes
546
- * nothing, and a row whose list was cleared still deletes what it claimed.
557
+ * Delete the ingest bytes ONE record claimed (EC-671, EC-750, EC-762) — the ONE way ingest bytes
558
+ * are deleted. It is asked for an OWNER — the kind's declaration and the id
559
+ * {@link claimIngestUploads} bound the files to — never for storage ids: it reads that owner's
560
+ * bindings by BOTH keys (`by_kind_keys`) and deletes each file with its binding — so it never
561
+ * sees, and never writes, another owner's binding, a row of another kind sharing the id
562
+ * included. A row's `files` column is the visitor's and the editor's word (a whole-record write
563
+ * can name any id, or clear the list), so release never reads it: a row naming another row's
564
+ * file deletes nothing, and a row whose list was cleared still deletes what it claimed.
547
565
  *
548
566
  * Of the owner's files it KEEPS, returned in `kept`:
549
567
  *
@@ -553,24 +571,27 @@ async function judgeIngestUploadClaim(
553
571
  * asset's own delete removes them later. Read in this transaction, so no asset can acquire
554
572
  * the file between the check and the delete.
555
573
  *
556
- * Never touched: a binding of another owner, and a binding written before EC-750 (no owner, no
557
- * key slot — residue, never loss; it keeps protecting its bytes from `assets:deleteBlob`).
574
+ * Never touched: a binding of another owner; a binding written before EC-750 (no owner, no key
575
+ * slot); one written before EC-762 (its owner's id alone, in `key1`, no `key2` — read by id
576
+ * alone it would be F51 again, so there is no such fallback). Residue, never loss: each keeps
577
+ * protecting its bytes from `assets:deleteBlob`.
558
578
  *
559
- * Call it when removing a row that holds claimed files — never for a refused submission, which
560
- * bound nothing ({@link claimIngestUploads}, EC-695). The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
561
- * ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
562
- * transaction.
579
+ * Call it when removing a row that holds claimed files, with that row's kind and id — never for
580
+ * a refused submission, which bound nothing ({@link claimIngestUploads}, EC-695). The mirror is
581
+ * `assets:deleteBlob`, which keeps bytes a binding holds ({@link ingestBindingHolds}): each
582
+ * delete path re-checks the OTHER owner in its own transaction.
563
583
  */
564
584
  export async function releaseIngestUploads(
565
585
  ctx: MutationCtx,
566
- owner: string,
586
+ ownerKind: AnyRecordKind,
587
+ ownerId: string,
567
588
  ): Promise<{ deleted: string[]; kept: string[] }> {
568
589
  const deleted: string[] = []
569
590
  const kept: string[] = []
570
591
  const owned = await queryRecordDocs(
571
592
  ctx,
572
593
  ingestBindingRecordKind,
573
- resolveRecordQuery(ingestBindingRecordKind, { where: { owner } }),
594
+ resolveRecordQuery(ingestBindingRecordKind, { where: { ownerKind: ownerKind.kind, owner: ownerId } }),
574
595
  )
575
596
  for (const binding of owned) {
576
597
  const storageId = binding.recordId
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { AlertTriangle, X } from 'lucide-react';
4
+ import { elytraGuidePath } from '@elytracms/core/studio-core';
4
5
  import { translate } from '../../lib/i18n/index.js';
5
6
  import { isDevRuntime } from '../../lib/persistence/index.js';
6
7
  import { Row, Text } from '@elytracms/studio/ui';
@@ -30,6 +31,8 @@ const NOTICE_KEYS = {
30
31
  'no-theme-tokens': 'builder.canvasTheme.noThemeTokensNotice',
31
32
  'no-stylesheet': 'builder.canvasTheme.noStylesheetNotice',
32
33
  };
34
+ /** EC-765: the notices point at the guide's theming chapter where the project has it installed. */
35
+ const THEMING_CHAPTER = elytraGuidePath('13-theming.md');
33
36
  export function CanvasThemeNotice({ status }) {
34
37
  // Local state only forces the re-render on dismiss; the truth is module-level.
35
38
  const [, setDismissedAt] = useState(0);
@@ -38,7 +41,7 @@ export function CanvasThemeNotice({ status }) {
38
41
  const messageKey = NOTICE_KEYS[status.kind];
39
42
  if (!messageKey || dismissedKinds.has(status.kind))
40
43
  return null;
41
- return (_jsx("div", { "data-testid": "canvas-theme-notice", "data-notice-kind": status.kind, className: "absolute top-4 left-1/2 z-20 w-max max-w-[min(460px,90%)] -translate-x-1/2", children: _jsx("div", { className: "rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg", children: _jsxs(Row, { gap: "2", align: "start", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "min-w-0 flex-1", children: translate(messageKey) }), _jsx("button", { type: "button", "aria-label": translate('common.dismiss'), onClick: () => {
44
+ return (_jsx("div", { "data-testid": "canvas-theme-notice", "data-notice-kind": status.kind, className: "absolute top-4 left-1/2 z-20 w-max max-w-[min(460px,90%)] -translate-x-1/2", children: _jsx("div", { className: "rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg", children: _jsxs(Row, { gap: "2", align: "start", children: [_jsx(AlertTriangle, { className: "mt-0.5 size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "min-w-0 flex-1", children: translate(messageKey, { guide: THEMING_CHAPTER }) }), _jsx("button", { type: "button", "aria-label": translate('common.dismiss'), onClick: () => {
42
45
  dismissedKinds.add(status.kind);
43
46
  setDismissedAt(Date.now());
44
47
  }, className: "shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(X, { className: "size-3.5" }) })] }) }) }));
@@ -1 +1 @@
1
- {"version":3,"file":"canvas-theme-notice.js","sourceRoot":"","sources":["../../../src/components/builder/canvas-theme-notice.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAChC,OAAO,EAAE,aAAa,EAAE,CAAC,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACpD,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAGhD;;;;;;;;;;;;;;GAcG;AAEH,MAAM,cAAc,GAAG,IAAI,GAAG,EAA6B,CAAA;AAE3D,sDAAsD;AACtD,MAAM,UAAU,8BAA8B;IAC5C,cAAc,CAAC,KAAK,EAAE,CAAA;AACxB,CAAC;AAED,MAAM,WAAW,GAAuD;IACtE,MAAM,EAAE,kCAAkC;IAC1C,kBAAkB,EAAE,2CAA2C;IAC/D,iBAAiB,EAAE,yCAAyC;IAC5D,eAAe,EAAE,wCAAwC;CAC1D,CAAA;AAED,MAAM,UAAU,iBAAiB,CAAC,EAAE,MAAM,EAAwC;IAChF,+EAA+E;IAC/E,MAAM,CAAC,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IACtC,IAAI,CAAC,MAAM,IAAI,CAAC,YAAY,EAAE;QAAE,OAAO,IAAI,CAAA;IAC3C,MAAM,UAAU,GAAG,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IAC3C,IAAI,CAAC,UAAU,IAAI,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAE/D,OAAO,CACL,6BACc,qBAAqB,sBACf,MAAM,CAAC,IAAI,EAC7B,SAAS,EAAC,4EAA4E,YAEtF,cAAK,SAAS,EAAC,kFAAkF,YAC/F,MAAC,GAAG,IAAC,GAAG,EAAC,GAAG,EAAC,KAAK,EAAC,OAAO,aACxB,KAAC,aAAa,IAAC,SAAS,EAAC,uCAAuC,wBAAe,EAC/E,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,SAAS,EAAC,gBAAgB,YACvC,SAAS,CAAC,UAAU,CAAC,GACjB,EACP,iBACE,IAAI,EAAC,QAAQ,gBACD,SAAS,CAAC,gBAAgB,CAAC,EACvC,OAAO,EAAE,GAAG,EAAE;4BACZ,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;4BAC/B,cAAc,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAA;wBAC5B,CAAC,EACD,SAAS,EAAC,mFAAmF,YAE7F,KAAC,CAAC,IAAC,SAAS,EAAC,UAAU,GAAG,GACnB,IACL,GACF,GACF,CACP,CAAA;AACH,CAAC","sourcesContent":["import { useState } from 'react'\nimport { AlertTriangle, X } from 'lucide-react'\nimport { translate } from '../../lib/i18n'\nimport { isDevRuntime } from '../../lib/persistence'\nimport { Row, Text } from '@elytracms/studio/ui'\nimport type { CanvasThemeStatus } from './canvas-chrome'\n\n/**\n * EC-414 AD-D item 2 — the canvas-theme runtime notice, in the established\n * validation-surface style (the `ClipboardNoticeOverlay` shape: a dismissible,\n * NEVER-blocking overlay above the canvas, in the STUDIO document — studio UI\n * primitives are fine here, this is not in-frame chrome). Dev builds only:\n *\n * - `legacy`: the probe found the merged pre-EC-414 canvas.css — a one-time\n * notice naming the migration (the canvas keeps working exactly as before).\n * - `stylesheet-error` / `no-theme-tokens` / `no-stylesheet`: the canvas\n * stylesheet is broken or missing — the invalid state is named explicitly\n * instead of silently rendering unstyled blocks.\n *\n * \"One-time\": dismissing a kind suppresses it for the rest of the studio\n * session (module state survives canvas remounts/navigation).\n */\n\nconst dismissedKinds = new Set<CanvasThemeStatus['kind']>()\n\n/** Test hook: reset the session-sticky dismissals. */\nexport function resetCanvasThemeNoticeForTests(): void {\n dismissedKinds.clear()\n}\n\nconst NOTICE_KEYS: Partial<Record<CanvasThemeStatus['kind'], string>> = {\n legacy: 'builder.canvasTheme.legacyNotice',\n 'stylesheet-error': 'builder.canvasTheme.stylesheetErrorNotice',\n 'no-theme-tokens': 'builder.canvasTheme.noThemeTokensNotice',\n 'no-stylesheet': 'builder.canvasTheme.noStylesheetNotice',\n}\n\nexport function CanvasThemeNotice({ status }: { status: CanvasThemeStatus | null }) {\n // Local state only forces the re-render on dismiss; the truth is module-level.\n const [, setDismissedAt] = useState(0)\n if (!status || !isDevRuntime()) return null\n const messageKey = NOTICE_KEYS[status.kind]\n if (!messageKey || dismissedKinds.has(status.kind)) return null\n\n return (\n <div\n data-testid=\"canvas-theme-notice\"\n data-notice-kind={status.kind}\n className=\"absolute top-4 left-1/2 z-20 w-max max-w-[min(460px,90%)] -translate-x-1/2\"\n >\n <div className=\"rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg\">\n <Row gap=\"2\" align=\"start\">\n <AlertTriangle className=\"mt-0.5 size-3.5 shrink-0 text-warning\" aria-hidden />\n <Text size=\"xs\" className=\"min-w-0 flex-1\">\n {translate(messageKey)}\n </Text>\n <button\n type=\"button\"\n aria-label={translate('common.dismiss')}\n onClick={() => {\n dismissedKinds.add(status.kind)\n setDismissedAt(Date.now())\n }}\n className=\"shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground\"\n >\n <X className=\"size-3.5\" />\n </button>\n </Row>\n </div>\n </div>\n )\n}\n"]}
1
+ {"version":3,"file":"canvas-theme-notice.js","sourceRoot":"","sources":["../../../src/components/builder/canvas-theme-notice.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAChC,OAAO,EAAE,aAAa,EAAE,CAAC,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAA;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACpD,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAGhD;;;;;;;;;;;;;;GAcG;AAEH,MAAM,cAAc,GAAG,IAAI,GAAG,EAA6B,CAAA;AAE3D,sDAAsD;AACtD,MAAM,UAAU,8BAA8B;IAC5C,cAAc,CAAC,KAAK,EAAE,CAAA;AACxB,CAAC;AAED,MAAM,WAAW,GAAuD;IACtE,MAAM,EAAE,kCAAkC;IAC1C,kBAAkB,EAAE,2CAA2C;IAC/D,iBAAiB,EAAE,yCAAyC;IAC5D,eAAe,EAAE,wCAAwC;CAC1D,CAAA;AAED,mGAAmG;AACnG,MAAM,eAAe,GAAG,eAAe,CAAC,eAAe,CAAC,CAAA;AAExD,MAAM,UAAU,iBAAiB,CAAC,EAAE,MAAM,EAAwC;IAChF,+EAA+E;IAC/E,MAAM,CAAC,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IACtC,IAAI,CAAC,MAAM,IAAI,CAAC,YAAY,EAAE;QAAE,OAAO,IAAI,CAAA;IAC3C,MAAM,UAAU,GAAG,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IAC3C,IAAI,CAAC,UAAU,IAAI,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAE/D,OAAO,CACL,6BACc,qBAAqB,sBACf,MAAM,CAAC,IAAI,EAC7B,SAAS,EAAC,4EAA4E,YAEtF,cAAK,SAAS,EAAC,kFAAkF,YAC/F,MAAC,GAAG,IAAC,GAAG,EAAC,GAAG,EAAC,KAAK,EAAC,OAAO,aACxB,KAAC,aAAa,IAAC,SAAS,EAAC,uCAAuC,wBAAe,EAC/E,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,SAAS,EAAC,gBAAgB,YACvC,SAAS,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC,GAC7C,EACP,iBACE,IAAI,EAAC,QAAQ,gBACD,SAAS,CAAC,gBAAgB,CAAC,EACvC,OAAO,EAAE,GAAG,EAAE;4BACZ,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;4BAC/B,cAAc,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAA;wBAC5B,CAAC,EACD,SAAS,EAAC,mFAAmF,YAE7F,KAAC,CAAC,IAAC,SAAS,EAAC,UAAU,GAAG,GACnB,IACL,GACF,GACF,CACP,CAAA;AACH,CAAC","sourcesContent":["import { useState } from 'react'\nimport { AlertTriangle, X } from 'lucide-react'\nimport { elytraGuidePath } from '@elytracms/core/studio-core'\nimport { translate } from '../../lib/i18n'\nimport { isDevRuntime } from '../../lib/persistence'\nimport { Row, Text } from '@elytracms/studio/ui'\nimport type { CanvasThemeStatus } from './canvas-chrome'\n\n/**\n * EC-414 AD-D item 2 — the canvas-theme runtime notice, in the established\n * validation-surface style (the `ClipboardNoticeOverlay` shape: a dismissible,\n * NEVER-blocking overlay above the canvas, in the STUDIO document — studio UI\n * primitives are fine here, this is not in-frame chrome). Dev builds only:\n *\n * - `legacy`: the probe found the merged pre-EC-414 canvas.css — a one-time\n * notice naming the migration (the canvas keeps working exactly as before).\n * - `stylesheet-error` / `no-theme-tokens` / `no-stylesheet`: the canvas\n * stylesheet is broken or missing — the invalid state is named explicitly\n * instead of silently rendering unstyled blocks.\n *\n * \"One-time\": dismissing a kind suppresses it for the rest of the studio\n * session (module state survives canvas remounts/navigation).\n */\n\nconst dismissedKinds = new Set<CanvasThemeStatus['kind']>()\n\n/** Test hook: reset the session-sticky dismissals. */\nexport function resetCanvasThemeNoticeForTests(): void {\n dismissedKinds.clear()\n}\n\nconst NOTICE_KEYS: Partial<Record<CanvasThemeStatus['kind'], string>> = {\n legacy: 'builder.canvasTheme.legacyNotice',\n 'stylesheet-error': 'builder.canvasTheme.stylesheetErrorNotice',\n 'no-theme-tokens': 'builder.canvasTheme.noThemeTokensNotice',\n 'no-stylesheet': 'builder.canvasTheme.noStylesheetNotice',\n}\n\n/** EC-765: the notices point at the guide's theming chapter where the project has it installed. */\nconst THEMING_CHAPTER = elytraGuidePath('13-theming.md')\n\nexport function CanvasThemeNotice({ status }: { status: CanvasThemeStatus | null }) {\n // Local state only forces the re-render on dismiss; the truth is module-level.\n const [, setDismissedAt] = useState(0)\n if (!status || !isDevRuntime()) return null\n const messageKey = NOTICE_KEYS[status.kind]\n if (!messageKey || dismissedKinds.has(status.kind)) return null\n\n return (\n <div\n data-testid=\"canvas-theme-notice\"\n data-notice-kind={status.kind}\n className=\"absolute top-4 left-1/2 z-20 w-max max-w-[min(460px,90%)] -translate-x-1/2\"\n >\n <div className=\"rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg\">\n <Row gap=\"2\" align=\"start\">\n <AlertTriangle className=\"mt-0.5 size-3.5 shrink-0 text-warning\" aria-hidden />\n <Text size=\"xs\" className=\"min-w-0 flex-1\">\n {translate(messageKey, { guide: THEMING_CHAPTER })}\n </Text>\n <button\n type=\"button\"\n aria-label={translate('common.dismiss')}\n onClick={() => {\n dismissedKinds.add(status.kind)\n setDismissedAt(Date.now())\n }}\n className=\"shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground\"\n >\n <X className=\"size-3.5\" />\n </button>\n </Row>\n </div>\n </div>\n )\n}\n"]}
@@ -1,10 +1,3 @@
1
- /**
2
- * The Components section landing view (EC-050, overhauled EC-320). Serves BOTH
3
- * audiences: editors get a live census of what components exist (totals,
4
- * per-category breakdown, health) and a "pick one" nudge;
5
- * devs get pointers to the real in-repo docs for registering and extending
6
- * components — no code sample to drift, no invented docs.
7
- */
8
1
  export declare function ComponentsIndex({ projectId }: {
9
2
  projectId: string;
10
3
  }): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { BookOpen, Layers, MousePointerClick, Sparkles } from 'lucide-react';
3
+ import { elytraGuidePath } from '@elytracms/core/studio-core';
3
4
  import { getStudioRegistry, registryHealth } from '../../lib/registry/index.js';
4
5
  import { translate } from '../../lib/i18n/index.js';
5
6
  import { RegistryBrowser } from './registry-browser.js';
@@ -9,9 +10,15 @@ import { Badge, Card, Code, Heading, Panel, PanelHeader, Row, Spacer, Stack, Tex
9
10
  * The Components section landing view (EC-050, overhauled EC-320). Serves BOTH
10
11
  * audiences: editors get a live census of what components exist (totals,
11
12
  * per-category breakdown, health) and a "pick one" nudge;
12
- * devs get pointers to the real in-repo docs for registering and extending
13
+ * devs get pointers to the guide's chapters on registering and extending
13
14
  * components — no code sample to drift, no invented docs.
14
15
  */
16
+ /** The guide chapters the developer panel points at (EC-765: file names in the shipped guide). */
17
+ const COMPONENTS_INDEX_CHAPTERS = {
18
+ authoring: '12-component-authoring.md',
19
+ components: '03-components.md',
20
+ overview: '00-overview.md',
21
+ };
15
22
  export function ComponentsIndex({ projectId }) {
16
23
  const studio = getStudioRegistry();
17
24
  const manifests = studio.registry.list();
@@ -20,7 +27,7 @@ export function ComponentsIndex({ projectId }) {
20
27
  return (_jsxs("div", { className: "mx-auto w-full max-w-4xl p-4 sm:p-6", children: [_jsx("div", { className: "mb-6 h-80 overflow-hidden rounded-lg border border-border md:hidden", children: _jsx(RegistryBrowser, { projectId: projectId }) }), _jsxs(Stack, { gap: "6", children: [_jsxs(Stack, { gap: "1", children: [_jsx(Heading, { size: "display", children: translate('components.index.title') }), _jsx(Text, { tone: "muted", children: translate('components.index.subtitle') })] }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-3", children: [_jsx(StatCard, { label: translate('components.index.stat.total'), value: stats.total }), _jsx(StatCard, { label: translate('components.index.stat.valid'), value: health.valid, tone: "success" }), _jsx(StatCard, { label: translate('components.index.stat.needAttention'), value: health.incompatible + health.invalid, tone: health.incompatible + health.invalid > 0 ? 'warning' : 'muted', hint: translate('components.index.stat.needAttentionHint', {
21
28
  incompatible: health.incompatible,
22
29
  invalid: health.invalid,
23
- }) })] }), stats.categories.length > 0 ? (_jsxs(Panel, { children: [_jsxs(PanelHeader, { children: [_jsx(Layers, { className: "size-4 text-muted-foreground" }), _jsx(Text, { size: "sm", weight: "semibold", children: translate('components.index.byCategory') })] }), _jsx(Row, { gap: "2", wrap: true, className: "p-4", children: stats.categories.map((category) => (_jsxs(Badge, { variant: "outline", children: [_jsx("span", { className: category.key ? 'capitalize' : undefined, children: category.key || translate('components.taxonomy.uncategorized') }), _jsx("span", { className: "font-mono text-muted-foreground", children: category.count })] }, category.key || '__uncategorized__'))) })] })) : null, _jsxs(Card, { className: "flex items-center gap-3 p-4", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground", children: _jsx(MousePointerClick, { className: "size-4" }) }), _jsx(Text, { size: "sm", tone: "muted", children: translate('components.index.pickHint') })] }), _jsxs(Panel, { children: [_jsxs(PanelHeader, { children: [_jsx(BookOpen, { className: "size-4 text-muted-foreground" }), _jsx(Text, { size: "sm", weight: "semibold", children: translate('components.index.guide.title') }), _jsx(Spacer, {}), _jsx(Badge, { variant: "outline", children: translate('components.index.guide.badge') })] }), _jsxs(Stack, { gap: "3", className: "p-4", children: [_jsx(Text, { size: "sm", tone: "muted", children: translate('components.index.guide.intro') }), _jsxs(Stack, { gap: "2", children: [_jsx(DocPointer, { path: "docs/component-authoring.md", description: translate('components.index.guide.doc.authoring') }), _jsx(DocPointer, { path: "docs/guide/03-components.md", description: translate('components.index.guide.doc.components') }), _jsx(DocPointer, { path: "docs/guide/00-overview.md", description: translate('components.index.guide.doc.guide') })] }), _jsxs(Text, { size: "xs", tone: "subtle", children: [_jsx(Sparkles, { className: "mr-1 inline size-3", "aria-hidden": true }), translate('components.index.guide.namespaceNote')] })] })] })] })] }));
30
+ }) })] }), stats.categories.length > 0 ? (_jsxs(Panel, { children: [_jsxs(PanelHeader, { children: [_jsx(Layers, { className: "size-4 text-muted-foreground" }), _jsx(Text, { size: "sm", weight: "semibold", children: translate('components.index.byCategory') })] }), _jsx(Row, { gap: "2", wrap: true, className: "p-4", children: stats.categories.map((category) => (_jsxs(Badge, { variant: "outline", children: [_jsx("span", { className: category.key ? 'capitalize' : undefined, children: category.key || translate('components.taxonomy.uncategorized') }), _jsx("span", { className: "font-mono text-muted-foreground", children: category.count })] }, category.key || '__uncategorized__'))) })] })) : null, _jsxs(Card, { className: "flex items-center gap-3 p-4", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground", children: _jsx(MousePointerClick, { className: "size-4" }) }), _jsx(Text, { size: "sm", tone: "muted", children: translate('components.index.pickHint') })] }), _jsxs(Panel, { children: [_jsxs(PanelHeader, { children: [_jsx(BookOpen, { className: "size-4 text-muted-foreground" }), _jsx(Text, { size: "sm", weight: "semibold", children: translate('components.index.guide.title') }), _jsx(Spacer, {}), _jsx(Badge, { variant: "outline", children: translate('components.index.guide.badge') })] }), _jsxs(Stack, { gap: "3", className: "p-4", children: [_jsx(Text, { size: "sm", tone: "muted", children: translate('components.index.guide.intro') }), _jsxs(Stack, { gap: "2", children: [_jsx(DocPointer, { path: elytraGuidePath(COMPONENTS_INDEX_CHAPTERS.authoring), description: translate('components.index.guide.doc.authoring') }), _jsx(DocPointer, { path: elytraGuidePath(COMPONENTS_INDEX_CHAPTERS.components), description: translate('components.index.guide.doc.components') }), _jsx(DocPointer, { path: elytraGuidePath(COMPONENTS_INDEX_CHAPTERS.overview), description: translate('components.index.guide.doc.guide') })] }), _jsxs(Text, { size: "xs", tone: "subtle", children: [_jsx(Sparkles, { className: "mr-1 inline size-3", "aria-hidden": true }), translate('components.index.guide.namespaceNote')] })] })] })] })] }));
24
31
  }
25
32
  function DocPointer({ path, description }) {
26
33
  return (_jsxs(Row, { gap: "3", align: "center", className: "rounded-md border border-border p-2.5", children: [_jsx(Code, { tone: "primary", className: "shrink-0", children: path }), _jsx(Text, { size: "xs", tone: "muted", children: description })] }));