smoodly 0.0.6 → 0.0.8

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 (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. package/package.json +1 -1
package/README.md CHANGED
@@ -38,40 +38,49 @@ Implemented:
38
38
  pages runtime: policies normalized to plain data, tree nodes mapped to
39
39
  registered components, fail-soft on unknown types
40
40
  - `collectionSQL` / `coreTablesSQL` — the stable schema (DESIGN.md §3):
41
- `coreTablesSQL` emits the once-ever core tables including `entries`
42
- (`collection` + `fields`/`i18n` jsonb, `space_id` multi-tenancy with
43
- a default space for self-hosted, `sort` for manual ordering, ONE
44
- generic slug-uniqueness index across all spaces and collections,
45
- scoped to siblings `(space_id, collection, parent_id, slug)`, like
46
- pages, partial on a non-null slug);
41
+ `coreTablesSQL` emits the once-ever core tables including `entries`,
42
+ `entry_locales` and `entry_versions` (shared fields on the node,
43
+ localized fields, slug and status per locale row, merged snapshots
44
+ per locale for versioned collections; `space_id` multi-tenancy with
45
+ a default space for self-hosted, `sort` for manual ordering, and no
46
+ sibling-slug index at all sibling uniqueness is the primary key of
47
+ `paths` plus a pre-check the stores make per locale, for entries and
48
+ pages alike);
47
49
  `collectionSQL` emits only the disposable per-collection guardrails —
48
- a published-only read view over `entries` (typed casts, snake_case
49
- aliases, refs as plain id values) and partial btree indexes for
50
- the declared `order.by` field, on the jsonb VALUE (`("fields"->'x')`)
51
- - `PageStore` contract + `MemoryPageStore` id-keyed page persistence:
52
- every read and write takes the record's `id`, and the URL is derived
53
- from the tree, never stored on the record. Pages form a tree (`parentId`,
54
- sibling `sort`, a null `template` = folder, `pages.tree.depth` capping
55
- how deep editors may nest), carry a `title` and a per-locale `i18n`
56
- overlay, and expose `rename` (per-locale slug/title), `move`,
57
- `setTemplate`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
58
- the draft/publish semantics (versions accumulate; publish moves a
59
- pointer; drafts never leak into published reads). The contract is a
60
- reusable suite (`test/page-store-contract.ts`); every adapter runs the
61
- same tests.
50
+ a published-only read view joining `entries` and `entry_locales` (one
51
+ row per published locale, a `locale` column; a versioned collection
52
+ reads the published snapshot; typed casts, snake_case aliases, refs as
53
+ plain id values) and partial btree indexes for the declared `order.by`
54
+ field, on the jsonb VALUE (`("fields"->'x')`)
55
+ - `PageStore` contract + `MemoryPageStore` id-keyed page persistence
56
+ over pages/page_locales/page_versions/paths one node, one row per
57
+ locale, each locale with its own tree, draft, publish and history
58
+ (spec 2026-09-06). Every read and write takes the record's `id`, and
59
+ the URL is derived from the tree, never stored on the record. Pages
60
+ form a tree (`parentId`, sibling `sort`, a null `template` = folder,
61
+ `pages.tree.depth` capping how deep editors may nest), and expose
62
+ `rename` (a locale's slug/title), `move`, `setTemplate`, `addLocale`,
63
+ `removeLocale`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
64
+ the draft/publish semantics (versions accumulate; publish moves that
65
+ locale's pointer; drafts never leak into published reads). The contract
66
+ is a reusable suite (`test/page-store-contract.ts`); every adapter runs
67
+ the same tests.
62
68
  - `SupabasePageStore` — the same id-keyed contract over
63
- pages/page_versions/paths (a server-side client from
69
+ pages/page_locales/page_versions/paths (a server-side client from
64
70
  `createSmoodlyClient`: the service role in self-host, a write key in
65
71
  the cloud). Contract-tested against a real local stack: `supabase
66
72
  start` at the repo root (the generated DDL is its one migration), then
67
73
  `npm run test:supabase`; plain `npm test` skips it and never needs
68
74
  Docker.
69
75
  - `paths.ts` — the ONE place a URL is derived: pure, framework-free
70
- functions (`segmentFor`, `chainOf`, `pagePath`, `pagePathRows`,
71
- `entryPathRows`, `buildPageTree`, segment and depth assertions) that both
72
- adapters and the tree query call, so the index and every derived `path`
73
- agree by construction. `pagePath` is the single page-path derivation —
74
- `pagePathRows`, `buildPageTree` and the site layer all go through it.
76
+ functions (`segmentFor` a locale's own segment, no fallback —
77
+ `chainOf`, `pagePath`, `pagePathIn` (nullable, for the admin),
78
+ `pagePathRows`, `entryPathRows`, `entrySegment` (a locale's own slug,
79
+ no fallback), `assertSupportedLocale` (both stores), `buildPageTree`,
80
+ segment and depth assertions) that both adapters and the tree query call, so the index
81
+ and every derived `path` agree by construction. `pagePath` is the
82
+ single page-path derivation — `pagePathRows`, `buildPageTree` and the
83
+ site layer all go through it.
75
84
  - `PathIndex` — the derived URL index, `MemoryPathIndex` and
76
85
  `SupabasePathIndex` over the `paths` table ((space, locale, path) → page
77
86
  or entry). Pages and entries share ONE instance, so a page and an entry
@@ -98,12 +107,11 @@ Implemented:
98
107
  collection's descriptors, for the entry-page route: the shared task
99
108
  collection and batched rounds are factored out, so a ref site behaves
100
109
  identically whether it sits in a page tree or in an entry.
101
- - `localize.ts` — per-field locale fallback over the two overlays the
102
- stores carry: `titleFor` (a page's `i18n[locale].title`, else the default
103
- locale's), `localizeFields` (an entry's `i18n[locale]`, per field, with a
104
- blank overlay value falling back) and `fixedPathFor` (the path a fixed
105
- page registration is served at in one locale — the escape hatch's
106
- addressing). Slugs stay `paths.ts`'s business.
110
+ - `localize.ts` — `fixedPathFor` (the path a fixed page registration
111
+ is served at in one locale the escape hatch's addressing; a slug map
112
+ that omits a locale means "the same segment", since a registration is
113
+ code, not a record). Pages themselves have no fallback: title and slug
114
+ live on `page_locales`, and slugs stay `paths.ts`'s business.
107
115
 
108
116
  - `collectEntryRefs` / `collectTreeRefs` — the refs-index feeders: walk
109
117
  an entry or a page tree against its schemas and list every outgoing
@@ -124,16 +132,23 @@ Implemented:
124
132
  `MemoryRefIndex` in memory); the cross-store contract
125
133
  (`test/page-refs-contract.ts`) runs against memory AND Supabase.
126
134
  - `EntryStore` contract + `MemoryEntryStore` + `SupabaseEntryStore` —
127
- collection-entry persistence (status-only versioning in v1): saves
128
- rewrite the entry's refs rows, `referencesTo` answers the reverse
135
+ collection-entry persistence over entries/entry_locales/entry_versions/paths
136
+ one node, one row per locale, each locale with its own slug, status
137
+ and `.localized()` fields, shared fields on the node, and a per-locale
138
+ version history for collections with `versions: true` (spec 2026-09-06
139
+ §7): `create({ locale })`, `update(id, locale, fields)`,
140
+ `setStatus(id, locale, status)`, `addLocale`, `removeLocale`,
141
+ `listVersions`; saves rewrite the entry's refs rows over every locale's
142
+ live field set, `referencesTo` answers the reverse
129
143
  question, `getMany` is the batched read whose signature is
130
- `resolveTree`'s `EntryFetcher` (with `{ status: "published" }` for the
144
+ `resolveTree`'s `EntryFetcher`, now `(collection, ids, locale, { status })`,
145
+ the published read of a versioned collection being the snapshot
146
+ (with `{ status: "published" }` for the
131
147
  published-route read — a post-publish demotion resolves as missing
132
148
  instead of leaking draft fields — DESIGN.md §3 "Page edges in refs"), deletes are blocked while
133
149
  referenced,
134
- entry slugs are unique among SIBLINGS in both adapters (the same rule
135
- pages have: `/docs/guide-a/intro` and `/docs/guide-b/intro` coexist,
136
- a slug-less entry never conflicts),
150
+ entry slugs are unique among SIBLINGS per locale in both adapters
151
+ (a code pre-check; the `paths` primary key is the invariant),
137
152
  and `reorder` rewrites sort 1..N through the `smoodly_reorder` SQL
138
153
  function (one statement, `updated_at` untouched). Same
139
154
  reusable-contract pattern as PageStore; `npm run test:supabase` runs
@@ -157,9 +172,11 @@ Implemented:
157
172
  so they read through the same policy set rather than their owner's
158
173
  privileges. Live acceptance test in `test/rls.supabase.test.ts`. Note
159
174
  for self-host: the anon key now reads published rows straight off the
160
- base tables (`pages`, `entries` including the `i18n` overlay, a page's
161
- published `page_versions` tree, `global_sections`), not only through
162
- the generated views.
175
+ base tables (`pages` and `entries` published in any locale, a published
176
+ `page_locales`/`entry_locales` row, the `page_versions`/`entry_versions`
177
+ row a published locale points at — shared content included, since it
178
+ is stored as entries, spec 2026-09-07), not only through the generated
179
+ views.
163
180
 
164
181
  - Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
165
182
  `LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
@@ -195,6 +212,8 @@ Implemented:
195
212
  - The admin is the page TREE (spec 2026-09-05 §7): the ops seam addresses
196
213
  pages by id — `pages.list`/`get`/`create`/`saveDraft`/`publish`/
197
214
  `delete`/`rename`/`move`/`setTemplate`, plus `entries.move`.
215
+ `entries.get`/`create`/`save`/`setStatus` take a locale;
216
+ `entries.addLocale`/`removeLocale`.
198
217
  `pages.list` materializes the code-owned root nodes before an editor
199
218
  touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
200
219
  mounts from the registry, never from a column), and every guard asks
@@ -214,11 +233,12 @@ Implemented:
214
233
  `admin/shell/PagesList.tsx` with a row menu over the tree ops (New
215
234
  subpage, New subfolder, Add page here, Rename…, Move up / Move down,
216
235
  Delete, and Edit index page on a mount a fixed page claims). The editor
217
- is keyed by id, with a locale switcher pointing the canvas at
218
- `urls[locale]` and, while no block is selected, page settings
219
- (`admin/editor/PageSettings.tsx`: title and slug per locale through
220
- `pages.rename`; SEO/meta fields stay OQ #17). The entry form prefixes
221
- its slug field with the collection's mount path. The serialized
236
+ is keyed by id, with a locale switcher loading that locale's own tree
237
+ and pointing the canvas at `urls[locale]` and, while no block is
238
+ selected, page settings (`admin/editor/PageSettings.tsx`: the title and
239
+ slug of the ACTIVE locale through `pages.rename`; SEO/meta fields stay
240
+ OQ #17). The entry form prefixes its slug field with the collection's
241
+ mount path. The serialized
222
242
  registry carries `collections[].path`/`depth`, `pages[].slugs` and
223
243
  `pageDepth`, and `defineConfig` validates `homePageSlug` — a real
224
244
  segment, and never a collection's mount.
@@ -229,10 +249,12 @@ Implemented:
229
249
  reads, folders or fallback. `locate(locale, path)` is one primary-key
230
250
  hit on the `paths` index; `load(target, locale, path, { draft? })`
231
251
  fetches the record and its tree (draft or published), resolves refs,
232
- applies the `i18n` overlays, and returns a `SitePage` — `record`,
252
+ reads the locale's own rows, and returns a `SitePage` — `record`,
233
253
  `template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
234
254
  and `paths` (locale → path, for alternates) — or a `SiteEntry` with the
235
- entry's fields localized and resolved. `resolve(locale, path, { draft? })`
255
+ locale's merged fields (a versioned collection's published snapshot on
256
+ the public path) resolved, and `paths` for its published locales.
257
+ `resolve(locale, path, { draft? })`
236
258
  is the two in sequence — `locate` then `load` — for a caller that just
237
259
  wants the hit. A folder (null template), an
238
260
  unpublished record outside draft mode, or a missing one is `null`.
@@ -280,13 +302,25 @@ Implemented:
280
302
  the only way the node gets there — it also covers pages that existed
281
303
  before a locked zone was added to their schema.
282
304
 
305
+ - Shared content (spec 2026-09-07): `smoodly.shared({ name, title, section })`
306
+ registers a visual item, `smoodly.shared({ name, title, fields })` an
307
+ object item; one registration materializes as a singleton entry in
308
+ every supported locale. `site.getShared(item, { locale })` and the Next
309
+ glue's `getSmoodlyShared(item, { locale })` fetch it code-side; the
310
+ editor places a visual item in a zone from the "+" picker as a linked
311
+ `{ type: "shared", fields: { item, ref } }` node the resolve pass
312
+ rewrites into the section, and `z.locked(footer)` binds a zone to one.
313
+ Edited on its own admin page under Shared content (`/admin/shared/<name>`),
314
+ with "used on N pages" from `refs`. Wired through the `shared` option
315
+ on the entry store and the site layer.
316
+
283
317
  Not yet: richtext components (TipTap; `plainToRichtext`/
284
318
  `richtextToPlain` exist but no rich editor widget), drag-and-drop
285
319
  reordering (move-up/move-down buttons in the editor, Move up / Move down
286
320
  in the Pages list's row menu), undo/redo, sample/placeholder content
287
- marking, per-locale section field values (OQ #3 — the editor's locale
288
- switcher moves the canvas and page settings between locales, but a
289
- section's fields are one set for every locale).
321
+ marking, per-locale section visibility conditions (OQ #3 — each locale
322
+ now has its own tree, so a section's fields are already per locale; what
323
+ is open is hiding a section by a locale-dependent condition).
290
324
 
291
325
  ## Releasing
292
326
 
@@ -302,6 +336,79 @@ Both packages move together. From the repo root:
302
336
  `examples/site` in that build, so the template always matches the example
303
337
  site at the tagged commit.
304
338
 
339
+ ## Follow-ups (logged 2026-09-07, shared content spec)
340
+
341
+ - `f.shared(() => item)` — a ref field on a section that renders a shared
342
+ item inside the section's own markup (spec 2026-09-07 decision 3).
343
+ - Instance mode: a shared *type* with many editor-created items; today
344
+ the answer is a collection plus a section with `f.ref` (decision 1).
345
+ - Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
346
+ - Standalone live preview on a visual item's admin page — needs a
347
+ preview route in the glue; the page canvas shows placements today (§6).
348
+ - Built-in meta on shared items and on placements (anchor per placement
349
+ is the likely shape).
350
+ - Per-placement overrides (a shared band with a per-page heading) — out
351
+ of scope; use a normal section with `f.ref` fields.
352
+ - `fillLockedZones` for a zone bound to a shared item waits for
353
+ `ops.shared.list()`; a page opened before that resolves fills on the
354
+ next effect run. Not seen in the walk — the example binds no zone, so
355
+ the path was never exercised — but a double fill is unreachable by
356
+ construction: the effect depends on `sharedIds`, `record`, `mutate` and
357
+ `tree === null`, and `fillLockedZones` only touches EMPTY zones.
358
+ `z.locked(item)` still has no example usage.
359
+ - `SharedForm` lists linking pages by id; a title needs `ops.pages.get`
360
+ per page (or a usage op returning titles).
361
+ - `getSmoodlyShared` reads the store per request in draft mode and once
362
+ per (item, locale) otherwise; a layout calling it for several items
363
+ makes one round trip each — batch if it shows.
364
+ - The Supabase entry store's singleton check is SELECT-then-INSERT with
365
+ no unique index; two admin loads racing on an item's first
366
+ materialization can create two rows. `ensureSharedItems` reads the
367
+ first row either way, and the duplicate cannot be removed through the
368
+ API — `delete` refuses every shared row. Two ways to make the race
369
+ impossible: a deterministic row id (uuid v5 of the item name, so the
370
+ second insert fails on the primary key — no schema change), or a
371
+ per-item partial unique index emitted by the SQL generator the way
372
+ `collectionSQL` emits the rest.
373
+ - `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
374
+ (`localeFromUrl`, `writeLocaleToUrl`, `applyResult`, `InfoRow`, the
375
+ ref-options effect) and `SharedList.tsx` is the third copy of the
376
+ remembered-locale block (`EntriesList`, `PagesList`); extract
377
+ `shell/locale-url.ts` and a `useFormLocale` hook when the next form
378
+ arrives.
379
+ - A placement selected in a bound locked zone shows the canvas toolbar's
380
+ "fields only" label though a placement has no page-level fields; the
381
+ inspector notice is the real message.
382
+ - A zone whose policy allows a shared item's section also offers that
383
+ section as an ordinary block, so the picker lists e.g. Testimonials
384
+ twice (once under Shared); decide whether a section used by a shared
385
+ item should be hidden from the ordinary list.
386
+ - A placement of an object item or an unregistered item gates the page's
387
+ publish (the refs edge is emitted without a registry lookup) yet never
388
+ renders; only a hand-edited tree can produce one.
389
+ - `ops.shared.get` runs `ensureSharedItems` and then lists the item
390
+ again; one round trip could go.
391
+ - The example's Testimonials `quotes` richtext is required, so the first
392
+ admin save of the seeded band asks for it; make it optional or seed it.
393
+ - `CollectionSchema.kind` was widened to carry `"shared"`, so a
394
+ `SharedSchema` typechecks where `registry.collections` is expected;
395
+ `defineConfig` should throw on a `kind === "shared"` entry listed there.
396
+ - `ensureSharedItems` runs on every `ops.shared.list()` — which is every
397
+ editor mount, not only the Shared content page — costing one
398
+ `entries.list` per registration per open.
399
+ - `fanOut` resolves every shared registration's row id (one
400
+ `entries.list` each) on every entry write, so it can expire
401
+ `shared:<name>` when something the item references changes; a row-id
402
+ cache would remove the reads but has to survive materialization.
403
+ - Because `fanOut` lists every shared registration, an entry store built
404
+ without `options.shared` now fails on every ordinary entry write with
405
+ "no collection named", not only on `ops.shared.*`; both scaffolds pass
406
+ the same `shared` array to the store and the config, so this bites only
407
+ a hand-wired app.
408
+ - `ops.entries.move` expires entry tags directly and does not `fanOut`,
409
+ so a reorder that changes what a shared item's `refList` renders does
410
+ not expire `shared:<name>`.
411
+
305
412
  ## Follow-ups (logged 2026-09-06)
306
413
 
307
414
  - Drag-and-drop in the Pages list — the ops it needs exist
@@ -310,17 +417,13 @@ site at the tagged commit.
310
417
  - Nested entries have no admin UI beyond `entries.move`: nothing creates
311
418
  a child entry or shows the entry tree, and the entry form's slug prefix
312
419
  is the collection's mount base only, not the parent chain.
313
- - A blank translated slug cannot be CLEARED from the settings panel: the
314
- panel skips an empty non-default value (a blank falls back to the
315
- default locale) and the store reads `slug: undefined` as "no change",
316
- so removing an existing override would need a `null` patch through
317
- `rename`.
318
420
  - SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
319
421
  locale shipped; nothing writes `PageTree.meta` yet.
320
- - `pages.get` makes three sequential store round trips (`getPage`,
321
- `getDraftTree`, `pathsOf`), and the editor calls it on load and after
322
- every rename one store call returning the three would halve the
323
- editor's latency.
422
+ - `pages.get` makes four sequential store round trips for a non-root page
423
+ (`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
424
+ `parentLocales`; three for a root, which has no parent to read), and the
425
+ editor calls it on load and after every rename — one store call
426
+ returning them all would cut the editor's latency.
324
427
  - `entries.move` expires the entry and its descendants but does not fan
325
428
  out to the pages referencing them, nor to the old and the new parent
326
429
  entries the way `pages.move` does — asymmetric with `entries.save` and
@@ -389,8 +492,23 @@ site at the tagged commit.
389
492
  menu — inside a `role="button"` row. It works, because both stop the
390
493
  click and the keydown, but it is an accessibility smell a real treegrid
391
494
  would not have.
392
- - `EditorView.tsx` is ~720 lines. The inspector and the canvas column are
393
- the extraction seam whenever it is next touched.
495
+ - `ensureFixedNodes` warns on every `pages.list` while a fixed node's
496
+ locale cannot land (an editor page holds its path); a one-time notice
497
+ in the admin would be kinder than a warning per listing.
498
+ - Cache tags are still `page:<id>`: publishing one locale expires every
499
+ locale's cached unit of that page (spec 2026-09-06 §4). A
500
+ `page:<id>:<locale>` tag would halve the revalidations on a bilingual
501
+ site.
502
+ - The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
503
+ `Delete … in every language`) are `window.confirm`, and `EntriesList`
504
+ reports a refused add or remove with `window.alert`; the design
505
+ system's dialog is the follow-up for both, together with the row menu
506
+ extraction already logged above.
507
+ - `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
508
+ add and remove flows — see the 2026-09-03 follow-up below for the seams
509
+ (that entry is the one to act on).
510
+ - The `LocaleSwitcher` is a native `<select>`; a `Segmented` with an
511
+ "+ add" affordance would match the Pages list.
394
512
  - `i18n/request.ts` returns `{ locale, messages: {} }`. next-intl 4.14.2
395
513
  throws `No messages found` when the provider inherits none, and the
396
514
  example has no UI strings of its own, so the object is empty; a
@@ -426,6 +544,134 @@ site at the tagged commit.
426
544
  (`/((?!admin|api|_next|_vercel|.*\..*).*)`). An app that mounts the
427
545
  admin somewhere else edits `proxy.ts` by hand; nothing derives the
428
546
  matcher from the config.
547
+ - `classify` in `ops-impl.ts` is ~20 order-sensitive substring checks over
548
+ store messages — `"already exists in locale"` must precede
549
+ `"already exists"`, or an addLocale conflict reads as a slug conflict. A
550
+ typed code on `pageErrors` (thrown and matched, not re-parsed) would
551
+ remove the ordering hazard before plan 2 adds the entry messages.
552
+ - `ops-impl.ts` is ~436 lines with a ~165-line `pages` group. The guard
553
+ helpers (`nameOf`, `isHomeNode`, `supportedLocale`, `mountGuard`,
554
+ `owned`) are the extraction seam — a `pages-ops.ts` taking them as
555
+ collaborators. The `entries` group now duplicates two of them outright:
556
+ the `supportedLocale` guard and the `parentLocales` computation exist
557
+ once per group.
558
+ - `fixedNodeSpecs(config)` rebuilds the whole spec list on every
559
+ `fixedNodeOf` call, and the ops call `fixedNodeOf` per guarded write. A
560
+ `WeakMap` memo keyed by the config object is the whole fix.
561
+ - `SupabasePageStore.updateLocale` re-reads the record (`must(id)`) after
562
+ every update, so `setTemplate` is N+1 in the page's locales: one update
563
+ and one full read each. Only the last read is used. The Supabase entry
564
+ adapter is the same shape: `create` is insert → insert → read →
565
+ version → read → paths → refs, and `removeLocale` re-fetches the entry
566
+ three times.
567
+ - The single-language branch — no locale selector in the Pages list, no
568
+ switcher, no add/remove — has no automated coverage; only three sites
569
+ branch on the locale count (`PagesList.tsx`, `EditorView.tsx` and
570
+ `page-renderer.tsx`'s alternates), so a regression there shows up only
571
+ in a scaffolded single-language app.
572
+ - No test pins that a published child's breadcrumb carries an UNPUBLISHED
573
+ ancestor's title with `hasPage: false`; `site.test.tsx` covers the
574
+ folder ancestor only, and the two paths share one `hasPage` predicate.
575
+ - A locale dropped from `locales.supported` leaves its `page_locales`,
576
+ `page_versions` and `paths` rows in place but unreachable — nothing
577
+ lists or serves them, and nothing cleans them up. It is the mirror of
578
+ the materializer's top-up, and wants the same deliberate answer (an
579
+ admin-visible orphan list, or a prune op).
580
+ - `LOCALE_CODE_RE` accepts any 2–4 letter subtag as script or region
581
+ (`en-ZZZZ` passes), and a duplicate entry in `supported` passes
582
+ silently — boot validation catches typos in shape, not in fact.
583
+ - `MemoryPageStore.publish` throws the "is a folder" message when a
584
+ templated row has no draft version. Unreachable through the ops (a
585
+ templated page always gets a first version), but the message would be
586
+ wrong if it ever were.
587
+ - The Pages list offers "Add <locale>" on a dimmed row whose parent lacks
588
+ that locale, and learns the refusal only after the round trip.
589
+ `pagesTreeRows` already carries every node's locales, so the parent's
590
+ presence could disable the item with its reason up front, exactly as
591
+ the editor's switcher does with `parentLocales`. `EntriesList` has the
592
+ same gap (and `entryRows` would have to carry the locales again — the
593
+ dead field was deleted rather than left unused); it is unreachable
594
+ today, since nothing in the admin creates a child entry.
595
+ - No version history UI for entries (nor pages): `listVersions` exists on
596
+ both stores; a history pane with restore is the follow-up. Restoring a
597
+ versioned entry's snapshot must also restore the shared fields (the
598
+ snapshot is merged).
599
+ - `entries.list` filters `{ locale, status }` in memory after fetching
600
+ every row (PostgREST cannot filter the parent by an embedded row
601
+ without `!inner`, which would drop the other rows); fine at collection
602
+ sizes today.
603
+ - The entry form's ref picker offers only targets present in the locale
604
+ being edited; a target present but unpublished there is offered marked
605
+ `(draft)` and blocks a page publish in that locale until published —
606
+ the picker could say so.
607
+ - A collection's `path` map that omits a supported locale falls back to
608
+ the default segment for entry paths (`collectionSegment`) while the
609
+ mount node is materialized map-keys-only; the two should agree as
610
+ fixed pages now do.
611
+ - `removeLocale` on the Supabase entry adapter is deliberately
612
+ non-atomic (row and versions go, then paths), the same trade-off as
613
+ pages.
614
+ - `EntryForm.tsx` grew the locale lifecycle beside the fields; the
615
+ `LocaleSwitcher` is shared with the editor now, the add/remove flows
616
+ are not (`doAddLocale`/`doRemoveLocale` exist twice).
617
+ - Turning `.localized()` off for a field with per-locale values leaves
618
+ those values in `entry_locales.fields` until the next save of each
619
+ locale (a save writes only the current split) — the drift/codemod
620
+ pipeline (Phase 2) is where "keep en, discard fi" belongs.
621
+ - The repo-root migration (`supabase/migrations/00000000000000_smoodly.sql`)
622
+ is generated by `packages/smoodly/scripts/emit-sql.ts` from that
623
+ script's OWN fixture collections, not from
624
+ `examples/site/smoodly/collections.ts`; the create-smoodly-app template
625
+ migration IS generated from the example. Since the example's `articles`
626
+ is now `versions: true`, the repo-root `articles` view reads the live
627
+ locale row while the example's model serves the published snapshot —
628
+ the two should share one source.
629
+ - In the entry form, a shared ref field (e.g. an article's author) shows
630
+ `—` in a locale where the target is absent, because the picker offers
631
+ only targets present in the edited locale; the value is kept on the
632
+ node and the other locale's byline is unchanged, but it looks empty
633
+ and cannot be re-set from that locale.
634
+ - `entryPathRows` relies on the stores validating locale keys on write
635
+ (no guard of its own); `list`/`getMany` accept an unsupported locale
636
+ silently and read as "no content".
637
+ - The Supabase entry adapter's `removeLocale` deletes the row and its
638
+ versions before the paths rewrite with no rollback; its residue
639
+ comment describes the opposite case (a surviving path row for a
640
+ removed locale, not a present node without its path row).
641
+ - The entry form's locale switch, add and remove confirm before
642
+ discarding unsaved edits (a `dirty` flag; the form has no autosave,
643
+ unlike the page editor's flush).
644
+ - `addLocale` does not bump the entry's `updatedAt` while `removeLocale`
645
+ does. Both adapters agree, so the contract is honest, but "adding a
646
+ language is not an edit and removing one is" is arbitrary — decide it
647
+ deliberately before someone sorts a list by Updated.
648
+ - The Supabase entry adapter's rollbacks are best-effort and can mask the
649
+ original throw: `create`'s catch runs `paths.remove` before the node
650
+ delete (a failing remove strands the node and replaces the real error),
651
+ `update` and `move` restore by writing, and those writes can throw in
652
+ place of the rewrite's rejection. `recordVersion` is two statements
653
+ (insert, then point at it), so a failure between them leaves an
654
+ unreferenced `entry_versions` row — invisible to every policy and
655
+ harmless, but it accumulates.
656
+ - `removeLocale`'s blocker list (`localeInUse`) is unordered in the
657
+ Supabase adapter — the children query has no `.order()` — so a
658
+ multi-blocker message can name the titles in a different order than the
659
+ memory adapter's. Message text only; the contract case has one blocker.
660
+ - `entry-store.ts` is ~624 lines. The pure helpers (`localizedKeys`,
661
+ `splitFields`, `entryFieldsIn`, `entryRefEdges`, `entryTitleIn`) are the
662
+ extraction seam from the memory adapter, the same shape as the
663
+ `EditorView.tsx` and `ops-impl.ts` entries above.
664
+ - In a single-language project the entry form renders `slug` last:
665
+ `localizedFieldKeys` puts it in the `own` group, which follows
666
+ `shared`, and no section labels are shown — so the field order the
667
+ schema declares is not the order on screen.
668
+ - `admin-entries-list.test.ts` has no case for a row with differing
669
+ pointers (the `draft edits` status), nor one asserting that an absent
670
+ row's `updatedAt` falls back to the node's. Two assertions.
671
+ - `assertPublishable` (`store.ts`) awaits one `entries.get` per
672
+ referenced entry, sequentially, on every publish — a `refList` of a
673
+ dozen articles is a dozen serial reads, now per locale. `getMany(ids,
674
+ locale)` takes exactly that shape and would make it one call.
429
675
 
430
676
  ## Follow-ups (logged 2026-09-05)
431
677
 
@@ -450,18 +696,18 @@ site at the tagged commit.
450
696
  - `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
451
697
  `createPageRenderer` (the component map) — and `elements` cannot be passed
452
698
  at all; the two lists can drift silently.
453
- - `getEntry` by bare slug matches `entrySegment` on the ALREADY-localized
454
- entry, so the overlay is applied twice; it diverges only for a non-string
455
- `i18n[locale].slug`. `getEntry` by id reads the whole collection to find
456
- one row.
699
+ - `getEntry` by bare slug matches `entrySegment` the locale row's own
700
+ `slug`, the same value `paths` is written from so the two cannot
701
+ diverge (the double-overlay hazard went with `EntryRecord.i18n`).
702
+ `getEntry` by id still reads the whole collection to find one row.
457
703
  - `locate` (one primary-key hit on `paths`) runs per request outside the
458
704
  cache, because the cache tag needs the id before the unit can be cached; a
459
705
  later `"use cache"` migration (OQ #19) can fold it in.
460
706
  - Entry-page metadata is alternates only — entries have no `meta`. A
461
707
  `titleField`-based title is a later nicety.
462
- - Localized tree content (per-locale section fields) is not rendered: there
463
- is no storage model for it yet (OQ #3). `title` and entry `fields` are
464
- localized.
708
+ - Per-locale section visibility conditions are not modelled (OQ #3). Tree
709
+ content itself is per locale now each `page_locales` row points at its
710
+ own version — so the section fields a locale sees are its own.
465
711
  - `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
466
712
  by path resolves them, `getEntry` by id or slug does not — document or
467
713
  unify when a site needs resolved lists.
@@ -478,7 +724,8 @@ site at the tagged commit.
478
724
  - `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
479
725
  call signature, so every app's glue casts it `as PageRegistration[]`.
480
726
  - The entry-page view receives the whole `EntryRecord`, every locale's
481
- `i18n` included; a client entry page would ship all of it to the browser.
727
+ row in `locales` included (fields, slug, status and version pointers); a
728
+ client entry page would ship all of it to the browser.
482
729
  - `move` is best-effort across two statements (reorder, then path rewrite):
483
730
  a failed rewrite restores each destination sibling's prior `sort` row by
484
731
  row, so a failure in the rollback itself still leaves the order changed.
@@ -527,9 +774,6 @@ site at the tagged commit.
527
774
  names no path.
528
775
  - `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
529
776
  forever. The stores prevent cycles on `move`, so it is unreachable today.
530
- - `EntryRecord.i18n` has no writer yet — the store reads and derives paths
531
- from it, but nothing sets it, so per-locale entry slugs are unreachable
532
- until the ops layer exposes them.
533
777
  - Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
534
778
  lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
535
779
  Deferred deliberately, because each is a feature rather than a restyle: the
@@ -582,4 +826,4 @@ site at the tagged commit.
582
826
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
583
827
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
584
828
  - The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics.
585
- - `EditorView` split, remaining seams (autosave extracted 2026-09-03): pull the canvas overlay and the insert picker into components when the dnd-kit outline work starts; the sidebar form stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar).
829
+ - `EditorView` split, remaining seams (autosave extracted 2026-09-03): the locale lifecycle — `localeFromUrl`, `LocaleSwitcher`, `switchLocale`, `doAddLocale`, `doRemoveLocale` — is the cleanest extraction available now (self-contained, no canvas geometry); pull the canvas overlay and the insert picker into components when the dnd-kit outline work starts; the sidebar form stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). (logged 2026-09-06)
@@ -1,6 +1,7 @@
1
1
  const GROUPS = {
2
- pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate"],
3
- entries: ["list", "get", "create", "save", "setStatus", "delete", "move"],
2
+ pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
3
+ entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
4
+ shared: ["list", "get", "save", "setStatus"],
4
5
  preview: ["enable", "disable"],
5
6
  };
6
7
  export function makeClientOps(action, token) {