smoodly 0.0.6 → 0.0.7

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 (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  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/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. 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,8 +172,9 @@ 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
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, `global_sections`), not only through
162
178
  the generated views.
163
179
 
164
180
  - Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
@@ -195,6 +211,8 @@ Implemented:
195
211
  - The admin is the page TREE (spec 2026-09-05 §7): the ops seam addresses
196
212
  pages by id — `pages.list`/`get`/`create`/`saveDraft`/`publish`/
197
213
  `delete`/`rename`/`move`/`setTemplate`, plus `entries.move`.
214
+ `entries.get`/`create`/`save`/`setStatus` take a locale;
215
+ `entries.addLocale`/`removeLocale`.
198
216
  `pages.list` materializes the code-owned root nodes before an editor
199
217
  touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
200
218
  mounts from the registry, never from a column), and every guard asks
@@ -214,11 +232,12 @@ Implemented:
214
232
  `admin/shell/PagesList.tsx` with a row menu over the tree ops (New
215
233
  subpage, New subfolder, Add page here, Rename…, Move up / Move down,
216
234
  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
235
+ is keyed by id, with a locale switcher loading that locale's own tree
236
+ and pointing the canvas at `urls[locale]` and, while no block is
237
+ selected, page settings (`admin/editor/PageSettings.tsx`: the title and
238
+ slug of the ACTIVE locale through `pages.rename`; SEO/meta fields stay
239
+ OQ #17). The entry form prefixes its slug field with the collection's
240
+ mount path. The serialized
222
241
  registry carries `collections[].path`/`depth`, `pages[].slugs` and
223
242
  `pageDepth`, and `defineConfig` validates `homePageSlug` — a real
224
243
  segment, and never a collection's mount.
@@ -229,10 +248,12 @@ Implemented:
229
248
  reads, folders or fallback. `locate(locale, path)` is one primary-key
230
249
  hit on the `paths` index; `load(target, locale, path, { draft? })`
231
250
  fetches the record and its tree (draft or published), resolves refs,
232
- applies the `i18n` overlays, and returns a `SitePage` — `record`,
251
+ reads the locale's own rows, and returns a `SitePage` — `record`,
233
252
  `template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
234
253
  and `paths` (locale → path, for alternates) — or a `SiteEntry` with the
235
- entry's fields localized and resolved. `resolve(locale, path, { draft? })`
254
+ locale's merged fields (a versioned collection's published snapshot on
255
+ the public path) resolved, and `paths` for its published locales.
256
+ `resolve(locale, path, { draft? })`
236
257
  is the two in sequence — `locate` then `load` — for a caller that just
237
258
  wants the hit. A folder (null template), an
238
259
  unpublished record outside draft mode, or a missing one is `null`.
@@ -284,9 +305,9 @@ Not yet: richtext components (TipTap; `plainToRichtext`/
284
305
  `richtextToPlain` exist but no rich editor widget), drag-and-drop
285
306
  reordering (move-up/move-down buttons in the editor, Move up / Move down
286
307
  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).
308
+ marking, per-locale section visibility conditions (OQ #3 — each locale
309
+ now has its own tree, so a section's fields are already per locale; what
310
+ is open is hiding a section by a locale-dependent condition).
290
311
 
291
312
  ## Releasing
292
313
 
@@ -310,17 +331,13 @@ site at the tagged commit.
310
331
  - Nested entries have no admin UI beyond `entries.move`: nothing creates
311
332
  a child entry or shows the entry tree, and the entry form's slug prefix
312
333
  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
334
  - SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
319
335
  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.
336
+ - `pages.get` makes four sequential store round trips for a non-root page
337
+ (`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
338
+ `parentLocales`; three for a root, which has no parent to read), and the
339
+ editor calls it on load and after every rename — one store call
340
+ returning them all would cut the editor's latency.
324
341
  - `entries.move` expires the entry and its descendants but does not fan
325
342
  out to the pages referencing them, nor to the old and the new parent
326
343
  entries the way `pages.move` does — asymmetric with `entries.save` and
@@ -389,8 +406,23 @@ site at the tagged commit.
389
406
  menu — inside a `role="button"` row. It works, because both stop the
390
407
  click and the keydown, but it is an accessibility smell a real treegrid
391
408
  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.
409
+ - `ensureFixedNodes` warns on every `pages.list` while a fixed node's
410
+ locale cannot land (an editor page holds its path); a one-time notice
411
+ in the admin would be kinder than a warning per listing.
412
+ - Cache tags are still `page:<id>`: publishing one locale expires every
413
+ locale's cached unit of that page (spec 2026-09-06 §4). A
414
+ `page:<id>:<locale>` tag would halve the revalidations on a bilingual
415
+ site.
416
+ - The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
417
+ `Delete … in every language`) are `window.confirm`, and `EntriesList`
418
+ reports a refused add or remove with `window.alert`; the design
419
+ system's dialog is the follow-up for both, together with the row menu
420
+ extraction already logged above.
421
+ - `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
422
+ add and remove flows — see the 2026-09-03 follow-up below for the seams
423
+ (that entry is the one to act on).
424
+ - The `LocaleSwitcher` is a native `<select>`; a `Segmented` with an
425
+ "+ add" affordance would match the Pages list.
394
426
  - `i18n/request.ts` returns `{ locale, messages: {} }`. next-intl 4.14.2
395
427
  throws `No messages found` when the provider inherits none, and the
396
428
  example has no UI strings of its own, so the object is empty; a
@@ -426,6 +458,134 @@ site at the tagged commit.
426
458
  (`/((?!admin|api|_next|_vercel|.*\..*).*)`). An app that mounts the
427
459
  admin somewhere else edits `proxy.ts` by hand; nothing derives the
428
460
  matcher from the config.
461
+ - `classify` in `ops-impl.ts` is ~20 order-sensitive substring checks over
462
+ store messages — `"already exists in locale"` must precede
463
+ `"already exists"`, or an addLocale conflict reads as a slug conflict. A
464
+ typed code on `pageErrors` (thrown and matched, not re-parsed) would
465
+ remove the ordering hazard before plan 2 adds the entry messages.
466
+ - `ops-impl.ts` is ~436 lines with a ~165-line `pages` group. The guard
467
+ helpers (`nameOf`, `isHomeNode`, `supportedLocale`, `mountGuard`,
468
+ `owned`) are the extraction seam — a `pages-ops.ts` taking them as
469
+ collaborators. The `entries` group now duplicates two of them outright:
470
+ the `supportedLocale` guard and the `parentLocales` computation exist
471
+ once per group.
472
+ - `fixedNodeSpecs(config)` rebuilds the whole spec list on every
473
+ `fixedNodeOf` call, and the ops call `fixedNodeOf` per guarded write. A
474
+ `WeakMap` memo keyed by the config object is the whole fix.
475
+ - `SupabasePageStore.updateLocale` re-reads the record (`must(id)`) after
476
+ every update, so `setTemplate` is N+1 in the page's locales: one update
477
+ and one full read each. Only the last read is used. The Supabase entry
478
+ adapter is the same shape: `create` is insert → insert → read →
479
+ version → read → paths → refs, and `removeLocale` re-fetches the entry
480
+ three times.
481
+ - The single-language branch — no locale selector in the Pages list, no
482
+ switcher, no add/remove — has no automated coverage; only three sites
483
+ branch on the locale count (`PagesList.tsx`, `EditorView.tsx` and
484
+ `page-renderer.tsx`'s alternates), so a regression there shows up only
485
+ in a scaffolded single-language app.
486
+ - No test pins that a published child's breadcrumb carries an UNPUBLISHED
487
+ ancestor's title with `hasPage: false`; `site.test.tsx` covers the
488
+ folder ancestor only, and the two paths share one `hasPage` predicate.
489
+ - A locale dropped from `locales.supported` leaves its `page_locales`,
490
+ `page_versions` and `paths` rows in place but unreachable — nothing
491
+ lists or serves them, and nothing cleans them up. It is the mirror of
492
+ the materializer's top-up, and wants the same deliberate answer (an
493
+ admin-visible orphan list, or a prune op).
494
+ - `LOCALE_CODE_RE` accepts any 2–4 letter subtag as script or region
495
+ (`en-ZZZZ` passes), and a duplicate entry in `supported` passes
496
+ silently — boot validation catches typos in shape, not in fact.
497
+ - `MemoryPageStore.publish` throws the "is a folder" message when a
498
+ templated row has no draft version. Unreachable through the ops (a
499
+ templated page always gets a first version), but the message would be
500
+ wrong if it ever were.
501
+ - The Pages list offers "Add <locale>" on a dimmed row whose parent lacks
502
+ that locale, and learns the refusal only after the round trip.
503
+ `pagesTreeRows` already carries every node's locales, so the parent's
504
+ presence could disable the item with its reason up front, exactly as
505
+ the editor's switcher does with `parentLocales`. `EntriesList` has the
506
+ same gap (and `entryRows` would have to carry the locales again — the
507
+ dead field was deleted rather than left unused); it is unreachable
508
+ today, since nothing in the admin creates a child entry.
509
+ - No version history UI for entries (nor pages): `listVersions` exists on
510
+ both stores; a history pane with restore is the follow-up. Restoring a
511
+ versioned entry's snapshot must also restore the shared fields (the
512
+ snapshot is merged).
513
+ - `entries.list` filters `{ locale, status }` in memory after fetching
514
+ every row (PostgREST cannot filter the parent by an embedded row
515
+ without `!inner`, which would drop the other rows); fine at collection
516
+ sizes today.
517
+ - The entry form's ref picker offers only targets present in the locale
518
+ being edited; a target present but unpublished there is offered marked
519
+ `(draft)` and blocks a page publish in that locale until published —
520
+ the picker could say so.
521
+ - A collection's `path` map that omits a supported locale falls back to
522
+ the default segment for entry paths (`collectionSegment`) while the
523
+ mount node is materialized map-keys-only; the two should agree as
524
+ fixed pages now do.
525
+ - `removeLocale` on the Supabase entry adapter is deliberately
526
+ non-atomic (row and versions go, then paths), the same trade-off as
527
+ pages.
528
+ - `EntryForm.tsx` grew the locale lifecycle beside the fields; the
529
+ `LocaleSwitcher` is shared with the editor now, the add/remove flows
530
+ are not (`doAddLocale`/`doRemoveLocale` exist twice).
531
+ - Turning `.localized()` off for a field with per-locale values leaves
532
+ those values in `entry_locales.fields` until the next save of each
533
+ locale (a save writes only the current split) — the drift/codemod
534
+ pipeline (Phase 2) is where "keep en, discard fi" belongs.
535
+ - The repo-root migration (`supabase/migrations/00000000000000_smoodly.sql`)
536
+ is generated by `packages/smoodly/scripts/emit-sql.ts` from that
537
+ script's OWN fixture collections, not from
538
+ `examples/site/smoodly/collections.ts`; the create-smoodly-app template
539
+ migration IS generated from the example. Since the example's `articles`
540
+ is now `versions: true`, the repo-root `articles` view reads the live
541
+ locale row while the example's model serves the published snapshot —
542
+ the two should share one source.
543
+ - In the entry form, a shared ref field (e.g. an article's author) shows
544
+ `—` in a locale where the target is absent, because the picker offers
545
+ only targets present in the edited locale; the value is kept on the
546
+ node and the other locale's byline is unchanged, but it looks empty
547
+ and cannot be re-set from that locale.
548
+ - `entryPathRows` relies on the stores validating locale keys on write
549
+ (no guard of its own); `list`/`getMany` accept an unsupported locale
550
+ silently and read as "no content".
551
+ - The Supabase entry adapter's `removeLocale` deletes the row and its
552
+ versions before the paths rewrite with no rollback; its residue
553
+ comment describes the opposite case (a surviving path row for a
554
+ removed locale, not a present node without its path row).
555
+ - The entry form's locale switch, add and remove confirm before
556
+ discarding unsaved edits (a `dirty` flag; the form has no autosave,
557
+ unlike the page editor's flush).
558
+ - `addLocale` does not bump the entry's `updatedAt` while `removeLocale`
559
+ does. Both adapters agree, so the contract is honest, but "adding a
560
+ language is not an edit and removing one is" is arbitrary — decide it
561
+ deliberately before someone sorts a list by Updated.
562
+ - The Supabase entry adapter's rollbacks are best-effort and can mask the
563
+ original throw: `create`'s catch runs `paths.remove` before the node
564
+ delete (a failing remove strands the node and replaces the real error),
565
+ `update` and `move` restore by writing, and those writes can throw in
566
+ place of the rewrite's rejection. `recordVersion` is two statements
567
+ (insert, then point at it), so a failure between them leaves an
568
+ unreferenced `entry_versions` row — invisible to every policy and
569
+ harmless, but it accumulates.
570
+ - `removeLocale`'s blocker list (`localeInUse`) is unordered in the
571
+ Supabase adapter — the children query has no `.order()` — so a
572
+ multi-blocker message can name the titles in a different order than the
573
+ memory adapter's. Message text only; the contract case has one blocker.
574
+ - `entry-store.ts` is ~624 lines. The pure helpers (`localizedKeys`,
575
+ `splitFields`, `entryFieldsIn`, `entryRefEdges`, `entryTitleIn`) are the
576
+ extraction seam from the memory adapter, the same shape as the
577
+ `EditorView.tsx` and `ops-impl.ts` entries above.
578
+ - In a single-language project the entry form renders `slug` last:
579
+ `localizedFieldKeys` puts it in the `own` group, which follows
580
+ `shared`, and no section labels are shown — so the field order the
581
+ schema declares is not the order on screen.
582
+ - `admin-entries-list.test.ts` has no case for a row with differing
583
+ pointers (the `draft edits` status), nor one asserting that an absent
584
+ row's `updatedAt` falls back to the node's. Two assertions.
585
+ - `assertPublishable` (`store.ts`) awaits one `entries.get` per
586
+ referenced entry, sequentially, on every publish — a `refList` of a
587
+ dozen articles is a dozen serial reads, now per locale. `getMany(ids,
588
+ locale)` takes exactly that shape and would make it one call.
429
589
 
430
590
  ## Follow-ups (logged 2026-09-05)
431
591
 
@@ -450,18 +610,18 @@ site at the tagged commit.
450
610
  - `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
451
611
  `createPageRenderer` (the component map) — and `elements` cannot be passed
452
612
  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.
613
+ - `getEntry` by bare slug matches `entrySegment` the locale row's own
614
+ `slug`, the same value `paths` is written from so the two cannot
615
+ diverge (the double-overlay hazard went with `EntryRecord.i18n`).
616
+ `getEntry` by id still reads the whole collection to find one row.
457
617
  - `locate` (one primary-key hit on `paths`) runs per request outside the
458
618
  cache, because the cache tag needs the id before the unit can be cached; a
459
619
  later `"use cache"` migration (OQ #19) can fold it in.
460
620
  - Entry-page metadata is alternates only — entries have no `meta`. A
461
621
  `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.
622
+ - Per-locale section visibility conditions are not modelled (OQ #3). Tree
623
+ content itself is per locale now each `page_locales` row points at its
624
+ own version — so the section fields a locale sees are its own.
465
625
  - `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
466
626
  by path resolves them, `getEntry` by id or slug does not — document or
467
627
  unify when a site needs resolved lists.
@@ -478,7 +638,8 @@ site at the tagged commit.
478
638
  - `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
479
639
  call signature, so every app's glue casts it `as PageRegistration[]`.
480
640
  - 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.
641
+ row in `locales` included (fields, slug, status and version pointers); a
642
+ client entry page would ship all of it to the browser.
482
643
  - `move` is best-effort across two statements (reorder, then path rewrite):
483
644
  a failed rewrite restores each destination sibling's prior `sort` row by
484
645
  row, so a failure in the rollback itself still leaves the order changed.
@@ -527,9 +688,6 @@ site at the tagged commit.
527
688
  names no path.
528
689
  - `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
529
690
  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
691
  - Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
534
692
  lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
535
693
  Deferred deliberately, because each is a feature rather than a restyle: the
@@ -582,4 +740,4 @@ site at the tagged commit.
582
740
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
583
741
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
584
742
  - 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).
743
+ - `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,6 @@
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
4
  preview: ["enable", "disable"],
5
5
  };
6
6
  export function makeClientOps(action, token) {