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
@@ -1,24 +1,57 @@
1
1
  // Pure view-model for the Pages list — the tree of the design system's
2
2
  // TreeTable (indent per depth, chevron on parents, the derived path as
3
- // the slug column). The shell is not DOM-tested; every decision that
3
+ // the slug column) for ONE selected locale (spec 2026-09-06 §6). The
4
+ // structure is shared, so every node has a row; presence is per locale,
5
+ // so a node absent from the selected locale is dimmed with no path and
6
+ // an "Add" action. The shell is not DOM-tested; every decision that
4
7
  // matters lives here.
5
- import { titleFor } from "../../localize.js";
6
8
  import { buildPageTree } from "../../paths.js";
7
9
  /** Registrations editors may create pages against: fixed ones are
8
10
  * materialized by Smoodly, never offered. */
9
11
  export function openTemplates(templates) {
10
12
  return templates.filter((t) => t.slug === undefined);
11
13
  }
14
+ /** The default-locale slug that identifies a ROOT node to the registry;
15
+ * a root that exists in another locale only is never code-owned. */
16
+ const rootSlug = (record, registry) => record.parentId === null ? record.locales[registry.locales.default]?.slug : undefined;
12
17
  /** The collection a ROOT record is the mount of, if any — known from the
13
18
  * registry, never from a column (spec 2026-09-05 §4). */
14
19
  export function mountOf(record, registry) {
15
- if (record.parentId !== null)
20
+ const slug = rootSlug(record, registry);
21
+ if (slug === undefined)
16
22
  return null;
17
- return registry.collections.find((c) => c.path?.[registry.locales.default] === record.slug) ?? null;
23
+ return registry.collections.find((c) => c.path?.[registry.locales.default] === slug) ?? null;
18
24
  }
19
- /** Fixed pages and mounts: never renamed, moved or deleted by editors. */
25
+ /** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
20
26
  export function isFixedNode(record, registry) {
21
- return record.parentId === null && (registry.pages.some((t) => t.slug === record.slug) || mountOf(record, registry) !== null);
27
+ const slug = rootSlug(record, registry);
28
+ return slug !== undefined && (registry.pages.some((t) => t.slug === slug) || mountOf(record, registry) !== null);
29
+ }
30
+ /** A node's title for display in a locale it may not exist in: its own,
31
+ * else the default locale's, else any. Display only — never content. */
32
+ export function displayTitle(record, locale, registry) {
33
+ return (record.locales[locale]?.title ??
34
+ record.locales[registry.locales.default]?.title ??
35
+ Object.values(record.locales)[0]?.title ??
36
+ record.id);
37
+ }
38
+ /** The locale an "Add <locale>" copies from: the default when the node has it, else its first. */
39
+ export function sourceLocaleOf(record, registry) {
40
+ return record.locales[registry.locales.default] ? registry.locales.default : Object.keys(record.locales)[0];
41
+ }
42
+ /** Whether a locale's row is live, and if so whether the draft has moved
43
+ * on. `status` decides — spec 2026-09-06 §2 keeps it as a column so a
44
+ * future unpublish can clear it while the version pointers stay — and
45
+ * the pointers only tell "published" from "draft edits". The Pages list
46
+ * and the editor's badge both read it here so they cannot disagree. */
47
+ export function localeStatusOf(row) {
48
+ if (row.status !== "published")
49
+ return "draft";
50
+ // Pointers exist on pages and on versioned collections; a row without
51
+ // them is live as it stands.
52
+ if (row.publishedVersionId === null)
53
+ return "published";
54
+ return row.publishedVersionId === row.draftVersionId ? "published" : "draft edits";
22
55
  }
23
56
  export function pagesTreeRows(records, registry, locale, collapsed) {
24
57
  const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
@@ -27,8 +60,10 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
27
60
  const walk = (nodes, depth, parentId) => {
28
61
  nodes.forEach((node, index) => {
29
62
  const r = node.record;
63
+ const row = r.locales[locale];
64
+ const present = row !== undefined;
30
65
  const mount = mountOf(r, registry);
31
- const isHome = r.parentId === null && r.slug === registry.homePageSlug;
66
+ const isHome = r.parentId === null && r.locales[registry.locales.default]?.slug === registry.homePageSlug;
32
67
  const expanded = !collapsed.has(r.id);
33
68
  rows.push({
34
69
  id: r.id,
@@ -38,8 +73,10 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
38
73
  siblingCount: nodes.length,
39
74
  hasChildren: node.children.length > 0,
40
75
  expanded,
41
- title: titleFor(r, locale, registry.locales),
76
+ title: displayTitle(r, locale, registry),
42
77
  path: node.path,
78
+ present,
79
+ locales: Object.keys(r.locales),
43
80
  kind: mount ? "mount" : r.template === null ? "folder" : "page",
44
81
  collection: mount?.name ?? null,
45
82
  fixed: isFixedNode(r, registry),
@@ -48,12 +85,9 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
48
85
  templateTitle: mount
49
86
  ? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
50
87
  : r.template === null ? "Folder" : templateTitle(r.template),
51
- status: r.template === null
52
- ? null
53
- : r.publishedVersionId
54
- ? r.publishedVersionId === r.draftVersionId ? "published" : "draft edits"
55
- : "draft",
56
- canHaveChildren: !mount && !isHome && depth + 1 < registry.pageDepth,
88
+ status: !row || r.template === null ? null : localeStatusOf(row),
89
+ // A child needs its parent in the locale (the parent rule), so an absent node offers none.
90
+ canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
57
91
  record: r,
58
92
  });
59
93
  if (expanded)
@@ -0,0 +1,11 @@
1
+ import type { AdminRegistry } from "../serialize.ts";
2
+ export declare function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }: {
3
+ registry: AdminRegistry;
4
+ /** The locales the record has; `[value]` while nothing is loaded. */
5
+ present: string[];
6
+ parentLocales: string[];
7
+ value: string;
8
+ adding: boolean;
9
+ onSwitch(locale: string): void;
10
+ onAdd(locale: string): void;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ export function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }) {
4
+ const missing = registry.locales.supported.filter((l) => !present.includes(l) && l !== value);
5
+ return (_jsxs("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => { const v = e.target.value; if (v.startsWith("+"))
6
+ onAdd(v.slice(1));
7
+ else
8
+ onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: l }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [value, " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) => {
9
+ const blocked = !parentLocales.includes(l);
10
+ const reason = `add ${l} to the parent first`;
11
+ return blocked
12
+ ? _jsxs("option", { value: `+${l}`, disabled: true, title: reason, children: ["+ ", l, " \u2014 ", reason] }, l)
13
+ : _jsxs("option", { value: `+${l}`, children: ["+ ", l] }, l);
14
+ }) }))] }));
15
+ }
@@ -25,6 +25,11 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
25
25
  admin?: {
26
26
  listColumns?: string[];
27
27
  };
28
+ /** Per-locale version history for this collection's entries (spec
29
+ * 2026-09-06 §7): every save becomes an `entry_versions` snapshot and
30
+ * publish moves a pointer, as on pages. Off by default — a People list
31
+ * does not want history; articles do. */
32
+ versions?: boolean;
28
33
  /** phantom: the entry value shape, used by f.ref target inference */
29
34
  readonly __entry?: NamespaceValues<F>;
30
35
  };
@@ -43,6 +48,7 @@ export declare function collection<F extends BuilderMap>(input: {
43
48
  admin?: {
44
49
  listColumns?: string[];
45
50
  };
51
+ versions?: boolean;
46
52
  }): CollectionSchema<F>;
47
53
  export type Toolset = {
48
54
  marks?: string[];
@@ -8,6 +8,15 @@ export function collection(input) {
8
8
  if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
9
9
  throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
10
10
  }
11
+ // `slug` is not a field on the node at all: splitFields drops it and the
12
+ // locale row owns it (spec 2026-09-06 §12), so ordering by it would be
13
+ // silently creation-ordered. Checked before the unknown-field check below
14
+ // so a collection with no declared `slug` field still gets this message,
15
+ // not "orders by an unknown field" — `slug` routes to the locale row
16
+ // whether or not it is declared.
17
+ if (input.order !== undefined && input.order !== "manual" && input.order.by === "slug") {
18
+ throw new Error(`smoodly: collection "${input.name}" orders by "slug", which is per locale — order fields are shared across locales.`);
19
+ }
11
20
  if (input.order !== undefined &&
12
21
  input.order !== "manual" &&
13
22
  !BUILT_IN_ORDER_FIELDS.includes(input.order.by) &&
@@ -17,6 +26,12 @@ export function collection(input) {
17
26
  !Object.prototype.hasOwnProperty.call(input.fields, input.order.by)) {
18
27
  throw new Error(`smoodly: collection "${input.name}" orders by an unknown field "${input.order.by}".`);
19
28
  }
29
+ if (input.order !== undefined &&
30
+ input.order !== "manual" &&
31
+ !BUILT_IN_ORDER_FIELDS.includes(input.order.by) &&
32
+ input.fields[input.order.by]?.descriptor.localized === true) {
33
+ throw new Error(`smoodly: collection "${input.name}" orders by "${input.order.by}", which is localized — order fields are shared across locales.`);
34
+ }
20
35
  return {
21
36
  kind: "collection",
22
37
  name: input.name,
@@ -26,6 +41,7 @@ export function collection(input) {
26
41
  ...(input.path !== undefined ? { path: input.path } : {}),
27
42
  ...(input.tree !== undefined ? { tree: input.tree } : {}),
28
43
  ...(input.order !== undefined ? { order: input.order } : {}),
44
+ ...(input.versions ? { versions: true } : {}),
29
45
  admin: input.admin,
30
46
  };
31
47
  }
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertSegment, perLocaleSegments } from "./paths.js";
1
+ import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
2
2
  function assertUniqueNames(config) {
3
3
  const seen = new Set();
4
4
  const all = [
@@ -19,7 +19,11 @@ function assertUniqueNames(config) {
19
19
  * EVERY locale of that path, which is the collection's index page. */
20
20
  function assertRootSegments(config) {
21
21
  const { default: def, supported } = config.locales;
22
+ // Mounts claim exactly the locales their path names; fixed pages fill an
23
+ // omitted locale from the default one, so the check must fill the same
24
+ // way the materializer does or the clash only shows up at materialization.
22
25
  const perLocale = (v) => perLocaleSegments(v, supported);
26
+ const pageSegments = (v) => fixedPageSegments(v, config.locales);
23
27
  const pages = (config.registry.pages ?? []).filter((p) => p.schema.kind === "page" && p.schema.slug !== undefined);
24
28
  const owners = new Map(); // "<locale> <segment>" → page name
25
29
  for (const p of pages) {
@@ -27,7 +31,7 @@ function assertRootSegments(config) {
27
31
  if (typeof slug !== "string" && slug[def] === undefined) {
28
32
  throw new Error(`smoodly: page "${p.schema.name}" declares a per-locale slug without the default locale "${def}".`);
29
33
  }
30
- for (const [locale, segment] of Object.entries(perLocale(slug))) {
34
+ for (const [locale, segment] of Object.entries(pageSegments(slug))) {
31
35
  assertSegment(segment, `page "${p.schema.name}" fixed slug`);
32
36
  const owner = owners.get(`${locale} ${segment}`);
33
37
  if (owner) {
@@ -62,14 +66,30 @@ function assertRootSegments(config) {
62
66
  if (!pageName)
63
67
  continue;
64
68
  const page = pages.find((p) => p.schema.name === pageName);
65
- const everywhere = Object.entries(segments).every(([l, s]) => perLocale(page.schema.slug)[l] === s);
69
+ const everywhere = Object.entries(segments).every(([l, s]) => pageSegments(page.schema.slug)[l] === s);
66
70
  if (!everywhere) {
67
71
  throw new Error(`smoodly: collection "${c.name}" and page registration "${pageName}" both claim the root segment "${segment}" (${locale}).`);
68
72
  }
69
73
  }
70
74
  }
71
75
  }
76
+ /** A language subtag, optionally a script or region subtag (`en`, `fi`,
77
+ * `en-US`, `zh-Hant`). Codes are otherwise opaque strings all the way
78
+ * down — the paths key, the page_locales key — so the only place a
79
+ * typo can be caught is here, at boot (spec 2026-09-06 §5). */
80
+ const LOCALE_CODE_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
81
+ function assertLocales(config) {
82
+ const { default: def, supported } = config.locales;
83
+ for (const code of supported) {
84
+ if (!LOCALE_CODE_RE.test(code)) {
85
+ throw new Error(`smoodly: "${code}" is not a valid locale code — use a language tag like "en", "fi", "en-US" or "zh-Hant".`);
86
+ }
87
+ }
88
+ if (!supported.includes(def))
89
+ throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
90
+ }
72
91
  export function defineConfig(config) {
92
+ assertLocales(config);
73
93
  assertUniqueNames(config);
74
94
  assertRootSegments(config);
75
95
  const depth = config.pages?.tree?.depth ?? 1;
@@ -1,19 +1,38 @@
1
1
  import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
+ import { type RefEdge } from "./refs.ts";
2
3
  import { MemoryRefIndex } from "./ref-index.ts";
3
4
  import { type PathIndex } from "./path-index.ts";
4
5
  import { type LocaleSet, type PathTarget } from "./paths.ts";
6
+ export type EntryLocale = {
7
+ /** The entry's segment in this locale; null claims no URL. */
8
+ slug: string | null;
9
+ status: "draft" | "published";
10
+ /** ONLY the .localized() fields, for this locale. */
11
+ fields: Record<string, unknown>;
12
+ draftVersionId: string | null;
13
+ publishedVersionId: string | null;
14
+ updatedAt: number;
15
+ };
5
16
  export type EntryRecord = {
6
17
  id: string;
7
18
  collection: string;
8
19
  parentId: string | null;
20
+ /** The SHARED fields — every field without .localized(). */
9
21
  fields: Record<string, unknown>;
10
- i18n: Record<string, Record<string, unknown>> | null;
11
- status: "draft" | "published";
22
+ /** Only the locales present. Presence IS the row. */
23
+ locales: Record<string, EntryLocale>;
12
24
  sort: number | null;
13
- locales: string[] | null;
14
25
  createdAt: number;
15
26
  updatedAt: number;
16
27
  };
28
+ export type EntryVersion = {
29
+ id: string;
30
+ entryId: string;
31
+ locale: string;
32
+ /** The MERGED fields (shared + localized + slug) at that save. */
33
+ fields: Record<string, unknown>;
34
+ createdAt: number;
35
+ };
17
36
  export type EntryUsage = {
18
37
  sourceKind: string;
19
38
  sourceId: string;
@@ -22,47 +41,65 @@ export type EntryUsage = {
22
41
  export type ListOptions = {
23
42
  /** Absent = the collection's configured order (config); `"manual"` sorts by `sort` asc. */
24
43
  order?: EntryOrder;
44
+ /** With `status`: the locale whose row is inspected; the default locale when omitted. */
45
+ locale?: string;
46
+ /** Only entries whose row in `locale` is published. */
25
47
  status?: "published";
26
48
  /**
27
49
  * `null` lists roots; a string id lists that parent's children; omitted
28
- * (or explicitly `undefined`) applies no parent filter — everything in
29
- * the collection, at every depth.
50
+ * (or explicitly `undefined`) applies no parent filter.
30
51
  */
31
52
  parent?: string | null;
32
53
  };
33
54
  export type CreateEntryOptions = {
55
+ locale: string;
34
56
  parentId?: string | null;
35
- locales?: string[] | null;
57
+ };
58
+ export type AddEntryLocaleOptions = {
59
+ from: string;
36
60
  };
37
61
  export type EntryStoreOptions = {
38
62
  paths?: PathIndex;
39
63
  locales?: LocaleSet;
40
64
  };
41
65
  export interface EntryStore {
42
- create(collection: string, fields: Record<string, unknown>, options?: CreateEntryOptions): Promise<EntryRecord>;
66
+ /** Creates the node and ONE locale row from the merged `fields`: shared
67
+ * keys go to the node, .localized() keys and `slug` to the row. The
68
+ * parent (if any) must exist in that locale. */
69
+ create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
43
70
  get(collection: string, id: string): Promise<EntryRecord | null>;
44
71
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
45
- /** Batched read for the resolver: fields keyed by id, missing ids omitted.
46
- * The signature IS resolveTree's EntryFetcher. The published-route
47
- * resolver passes { status: "published" } so an entry demoted to draft
48
- * after a referencing page was published drops out on the next
49
- * revalidation instead of hydrating draft fields (DESIGN.md OQ #16). */
50
- getMany(collection: string, ids: string[], options?: {
72
+ /** Batched read for the resolver: the MERGED fields in `locale`, keyed
73
+ * by id. An id that is missing, absent in the locale, or (with
74
+ * `status`) unpublished there is omitted. With `{ status: "published" }`
75
+ * a versioned collection answers from the published snapshot, so a
76
+ * post-publish edit never leaks (DESIGN.md OQ #16). The signature,
77
+ * with the locale bound, IS resolveTree's EntryFetcher. */
78
+ getMany(collection: string, ids: string[], locale: string, options?: {
51
79
  status?: "published";
52
80
  }): Promise<Record<string, Record<string, unknown>>>;
53
- update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
54
- setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
81
+ /** Replaces the merged fields for `locale`: shared keys on the node
82
+ * (every locale sees them), localized keys and the slug on the row. */
83
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
84
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
85
+ /** Copies `from`'s localized fields and slug as the new row (shared
86
+ * fields need no copy); refused when the entry has the locale or its
87
+ * parent lacks it. */
88
+ addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
89
+ /** Deletes the row, its versions and its paths rows; refused for the
90
+ * last locale and while a child has it. */
91
+ removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
92
+ /** Newest first; empty for a collection without `versions`. */
93
+ listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
55
94
  /** Blocked while anything references the entry (the safe-delete guard),
56
- * and while it still has child entries. */
95
+ * and while it still has child entries. Removes every locale. */
57
96
  delete(collection: string, id: string): Promise<void>;
58
97
  /**
59
- * Reparents `id` under `to.parentId`. `to.index` is the moved entry's
60
- * final 0-based position among the destination siblings: the entry is
61
- * removed from the sibling list first, then spliced back in at `index`.
62
- * Omitted, it appends; out-of-range values clamp to [0, siblings.length].
63
- * Destination siblings are renumbered 1..N afterward. Like `reorder`,
64
- * `move` does NOT bump `updatedAt` — reparenting/resequencing is a
65
- * structure change, not an edit to the entry's content.
98
+ * Reparents `id` under `to.parentId`, which must exist in every locale
99
+ * the entry has. `to.index` is the moved entry's final 0-based position
100
+ * among the destination siblings; omitted, it appends; out-of-range
101
+ * values clamp. Destination siblings are renumbered 1..N afterward.
102
+ * Like `reorder`, `move` does NOT bump `updatedAt`.
66
103
  */
67
104
  move(collection: string, id: string, to: {
68
105
  parentId: string | null;
@@ -72,8 +109,7 @@ export interface EntryStore {
72
109
  reorder(collection: string, orderedIds: string[]): Promise<void>;
73
110
  /** The refs index's reverse question: who points at this entry? */
74
111
  referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
75
- /** Reverse lookup by id alone (ids are globally unique) — what the
76
- * fan-out walk queries, since it can't know a source's collection. */
112
+ /** Reverse lookup by id alone (ids are globally unique) — the fan-out walk. */
77
113
  incoming(targetId: string): Promise<{
78
114
  sourceKind: string;
79
115
  sourceId: string;
@@ -88,63 +124,105 @@ export declare const entryErrors: {
88
124
  hasChildren: (collection: string, id: string) => Error;
89
125
  cycle: () => Error;
90
126
  foreignParent: () => Error;
91
- /** Entry slugs are unique among SIBLINGS (DESIGN.md §3, decided
92
- * 2026-09-05): the same rule pages have, since the URL is the tree.
93
- * ONE source for the text the Supabase adapter maps its
94
- * entries_sibling_slug_key 23505 onto this same error. */
127
+ /** Entry slugs are unique among SIBLINGS per locale — the rule pages
128
+ * have (spec 2026-09-06 §11): both adapters pre-check in code and the
129
+ * paths primary key is the invariant. ONE source for the text. */
95
130
  slugTaken: (collection: string) => Error;
131
+ unsupportedLocale: (locale: string) => Error;
132
+ notInLocale: (id: string, locale: string) => Error;
133
+ alreadyInLocale: (id: string, locale: string) => Error;
134
+ parentNotInLocale: (locale: string) => Error;
135
+ lastLocale: (id: string) => Error;
136
+ localeInUse: (locale: string, titles: string[]) => Error;
137
+ moveLocales: (locale: string) => Error;
138
+ };
139
+ /** The keys a locale row owns: every field with .localized(), except
140
+ * `slug`, which is the row's own column (a segment is an address, per
141
+ * locale by nature). */
142
+ export declare function localizedKeys(schema: CollectionSchema<any>): string[];
143
+ export type SplitFields = {
144
+ shared: Record<string, unknown>;
145
+ localized: Record<string, unknown>;
146
+ slug: string | null;
96
147
  };
148
+ /** The merged form an editor submits, split the way the tables store it.
149
+ * Every key that is neither localized nor `slug` is shared — declared or
150
+ * not — so a stray key never disappears into a locale row. */
151
+ export declare function splitFields(fields: Record<string, unknown>, schema: CollectionSchema<any>): SplitFields;
152
+ export declare function entryLocaleRow(record: EntryRecord, locale: string): EntryLocale;
153
+ /** The merged view of one locale: shared + that locale's fields + its
154
+ * slug. A fresh object; throws for an absent locale. */
155
+ export declare function entryFieldsIn(record: EntryRecord, locale: string): Record<string, unknown>;
156
+ /** The refs index rows for an entry: the union over every live field set
157
+ * — one per present locale, plus each published snapshot the draft has
158
+ * moved past. Safe-delete must hold while ANY of them shows the target,
159
+ * since the published site still renders it (DESIGN.md §3 "Page edges
160
+ * in refs", the same stance). */
161
+ export declare function entryRefEdges(fieldSets: Record<string, unknown>[], schema: CollectionSchema<any>): RefEdge[];
162
+ /** What a message calls an entry: its title field in that locale, else its id. */
163
+ export declare function entryTitleIn(record: EntryRecord, locale: string, schema: CollectionSchema<any>): string;
97
164
  /**
98
- * Sort comparator for one order rule; `seq` breaks ties by creation.
99
- *
100
- * Nulls sort last in BOTH directions (the Supabase adapter must use
101
- * `nullsFirst: false` to match). The creation tiebreak follows `direction`
102
- * for non-manual orders — two entries created in the same millisecond
103
- * must still land newest-first under the default order, not oldest-first
104
- * — while the manual branch's tiebreak stays ascending (creation order
105
- * is the natural fallback for otherwise-unordered `sort` values).
165
+ * Sort comparator for one order rule; `seq` breaks ties by creation. A
166
+ * declared order field is SHARED (collection() refuses a localized one),
167
+ * so `e.fields[order.by]` reads the node. Nulls sort last in BOTH
168
+ * directions (the Supabase adapter uses `nullsFirst: false` to match);
169
+ * the creation tiebreak follows `direction` for non-manual orders.
106
170
  * Declared fields compare numerically when both values are numbers,
107
- * otherwise as strings via `localeCompare`. The Supabase adapter orders
108
- * by the jsonb VALUE (`fields->x`, decided 2026-09-05), so the two agree
109
- * for a field holding numbers and for one holding strings (ISO dates
110
- * included). Two edges still differ: MIXED types in one field — Postgres
111
- * orders jsonb by type first (null < string < number < boolean < array <
112
- * object) rather than by `String(...)` — and an explicit JSON `null`
113
- * VALUE, which is a jsonb null and sorts FIRST in Postgres but last here
114
- * (a MISSING key is SQL NULL and does sort last, matching). See the
115
- * package README's follow-ups.
171
+ * otherwise as strings via `localeCompare` the same order Postgres
172
+ * gives the jsonb VALUE (`fields->x`) for one type; see the README's
173
+ * follow-ups for the mixed-type and JSON-null edges.
116
174
  */
117
175
  export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
118
176
  export declare class MemoryEntryStore implements EntryStore {
119
177
  private collections;
120
178
  private refs;
121
179
  private entries;
180
+ private versions;
181
+ /** Insertion order per version — `createdAt` alone can tie within a millisecond. */
182
+ private versionSeq;
122
183
  private seq;
123
184
  private order;
124
185
  private paths;
125
186
  private locales;
126
187
  constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
188
+ private schema;
127
189
  private row;
128
190
  private must;
129
191
  private byId;
192
+ private snapshot;
130
193
  private subtree;
194
+ /** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
195
+ private bump;
196
+ /** A versioned collection records every save as a merged snapshot and
197
+ * points the locale's draft at it; others keep the row as the live content. */
198
+ private recordVersion;
199
+ private dropVersions;
200
+ /** Every field set the site can render for this entry: each present
201
+ * locale's live view, plus a published snapshot the draft has moved
202
+ * past (still rendered by the published site). */
203
+ private liveFieldSets;
131
204
  private writePaths;
132
205
  private writeRefs;
133
206
  private siblings;
134
- /** The memory mirror of entries_sibling_slug_key: a string slug must be
135
- * unique among the entries sharing a parent. An absent, null or empty
136
- * slug claims no URL and never conflicts (the index's partial
137
- * predicate folds "" to null too via nullif). */
207
+ /** The friendly pre-check (spec 2026-09-06 §11): a string slug must be
208
+ * unique among the siblings PRESENT in that locale; null never
209
+ * conflicts. The paths primary key remains the invariant. */
138
210
  private assertSiblingSlug;
211
+ /** Same collection, within depth. Returns the parent (null at the root). */
139
212
  private assertParent;
140
- create(collection: string, fields: Record<string, unknown>, options?: CreateEntryOptions): Promise<EntryRecord>;
213
+ /** The parent rule: a locale can only be added where the parent has it. */
214
+ private assertParentHasLocale;
215
+ create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
141
216
  get(collection: string, id: string): Promise<EntryRecord | null>;
142
217
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
143
- getMany(collection: string, ids: string[], options?: {
218
+ getMany(collection: string, ids: string[], locale: string, options?: {
144
219
  status?: "published";
145
220
  }): Promise<Record<string, Record<string, unknown>>>;
146
- update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
147
- setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
221
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
222
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
223
+ addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
224
+ removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
225
+ listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
148
226
  move(collection: string, id: string, to: {
149
227
  parentId: string | null;
150
228
  index?: number;