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,16 +1,19 @@
1
1
  // The collection-entry persistence contract — the entries counterpart
2
- // to PageStore. Writes always flow through this API (never raw table
3
- // access), which is what lets the refs index stay correct: every save
4
- // rewrites the entry's outgoing edges (collectEntryRefs), so
5
- // referencesTo/safe-delete answer from the index alone. Status-only
6
- // versioning in v1 (DESIGN.md §3). Reorder rewrites sort 1..N in one
7
- // pass and NEVER touches updatedAt order is a property of the
8
- // collection, not an edit to its entries.
2
+ // to PageStore. An entry is ONE node (identity, tree position, the
3
+ // SHARED fields) with one row per locale it exists in (spec 2026-09-06
4
+ // §7): the locale row owns its slug, its publish state and the
5
+ // .localized() fields, and for a collection registered with
6
+ // `versions: true` a history of merged snapshots. Nothing falls back
7
+ // to another locale. Writes always flow through this API (never raw
8
+ // table access), which is what lets the refs index stay correct: every
9
+ // save rewrites the entry's outgoing edges over EVERY locale's live
10
+ // field set. Reorder rewrites sort 1..N in one pass and NEVER touches
11
+ // updatedAt — order is a property of the collection, not an edit.
9
12
  import { collectionDepth, collectionOrder } from "./collections.js";
10
13
  import { collectEntryRefs } from "./refs.js";
11
14
  import { MemoryRefIndex } from "./ref-index.js";
12
15
  import { MemoryPathIndex } from "./path-index.js";
13
- import { assertDepth, chainOf, entryPathRows, slugOf } from "./paths.js";
16
+ import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
14
17
  export function schemaOf(collections, name) {
15
18
  const schema = collections.find((c) => c.name === name);
16
19
  if (!schema)
@@ -18,38 +21,89 @@ export function schemaOf(collections, name) {
18
21
  return schema;
19
22
  }
20
23
  let counter = 0;
21
- const uid = () => `entry-${++counter}-${Date.now().toString(36)}`;
24
+ const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
22
25
  export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
23
26
  export const entryErrors = {
24
27
  notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
25
28
  hasChildren: (collection, id) => new Error(`smoodly: "${collection}/${id}" has child entries — move or delete them first.`),
26
29
  cycle: () => new Error("smoodly: an entry cannot be moved under itself."),
27
30
  foreignParent: () => new Error("smoodly: the parent of an entry must be in the same collection."),
28
- /** Entry slugs are unique among SIBLINGS (DESIGN.md §3, decided
29
- * 2026-09-05): the same rule pages have, since the URL is the tree.
30
- * ONE source for the text the Supabase adapter maps its
31
- * entries_sibling_slug_key 23505 onto this same error. */
31
+ /** Entry slugs are unique among SIBLINGS per locale — the rule pages
32
+ * have (spec 2026-09-06 §11): both adapters pre-check in code and the
33
+ * paths primary key is the invariant. ONE source for the text. */
32
34
  slugTaken: (collection) => new Error(`smoodly: a "${collection}" entry with that slug already exists under this parent.`),
35
+ unsupportedLocale,
36
+ notInLocale: (id, locale) => new Error(`smoodly: entry "${id}" does not exist in locale "${locale}".`),
37
+ alreadyInLocale: (id, locale) => new Error(`smoodly: entry "${id}" already exists in locale "${locale}".`),
38
+ parentNotInLocale: (locale) => new Error(`smoodly: the parent entry does not exist in locale "${locale}" — add it there first.`),
39
+ lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
40
+ localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
41
+ moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
33
42
  };
43
+ /** The keys a locale row owns: every field with .localized(), except
44
+ * `slug`, which is the row's own column (a segment is an address, per
45
+ * locale by nature). */
46
+ export function localizedKeys(schema) {
47
+ return Object.entries(schema.fields)
48
+ .filter(([key, d]) => key !== "slug" && d.localized === true)
49
+ .map(([key]) => key);
50
+ }
51
+ /** The merged form an editor submits, split the way the tables store it.
52
+ * Every key that is neither localized nor `slug` is shared — declared or
53
+ * not — so a stray key never disappears into a locale row. */
54
+ export function splitFields(fields, schema) {
55
+ const keys = new Set(localizedKeys(schema));
56
+ const shared = {};
57
+ const localized = {};
58
+ for (const [key, value] of Object.entries(fields)) {
59
+ if (key === "slug")
60
+ continue;
61
+ (keys.has(key) ? localized : shared)[key] = value;
62
+ }
63
+ return { shared: structuredClone(shared), localized: structuredClone(localized), slug: slugOf(fields) };
64
+ }
65
+ export function entryLocaleRow(record, locale) {
66
+ const row = record.locales[locale];
67
+ if (!row)
68
+ throw entryErrors.notInLocale(record.id, locale);
69
+ return row;
70
+ }
71
+ /** The merged view of one locale: shared + that locale's fields + its
72
+ * slug. A fresh object; throws for an absent locale. */
73
+ export function entryFieldsIn(record, locale) {
74
+ const row = entryLocaleRow(record, locale);
75
+ return {
76
+ ...structuredClone(record.fields),
77
+ ...structuredClone(row.fields),
78
+ ...(row.slug !== null ? { slug: row.slug } : {}),
79
+ };
80
+ }
81
+ /** The refs index rows for an entry: the union over every live field set
82
+ * — one per present locale, plus each published snapshot the draft has
83
+ * moved past. Safe-delete must hold while ANY of them shows the target,
84
+ * since the published site still renders it (DESIGN.md §3 "Page edges
85
+ * in refs", the same stance). */
86
+ export function entryRefEdges(fieldSets, schema) {
87
+ const edges = [];
88
+ for (const fields of fieldSets)
89
+ edges.push(...collectEntryRefs(fields, schema));
90
+ return edges;
91
+ }
92
+ /** What a message calls an entry: its title field in that locale, else its id. */
93
+ export function entryTitleIn(record, locale, schema) {
94
+ const value = schema.titleField && record.locales[locale] ? entryFieldsIn(record, locale)[schema.titleField] : undefined;
95
+ return typeof value === "string" && value.length > 0 ? value : record.id;
96
+ }
34
97
  /**
35
- * Sort comparator for one order rule; `seq` breaks ties by creation.
36
- *
37
- * Nulls sort last in BOTH directions (the Supabase adapter must use
38
- * `nullsFirst: false` to match). The creation tiebreak follows `direction`
39
- * for non-manual orders — two entries created in the same millisecond
40
- * must still land newest-first under the default order, not oldest-first
41
- * — while the manual branch's tiebreak stays ascending (creation order
42
- * is the natural fallback for otherwise-unordered `sort` values).
98
+ * Sort comparator for one order rule; `seq` breaks ties by creation. A
99
+ * declared order field is SHARED (collection() refuses a localized one),
100
+ * so `e.fields[order.by]` reads the node. Nulls sort last in BOTH
101
+ * directions (the Supabase adapter uses `nullsFirst: false` to match);
102
+ * the creation tiebreak follows `direction` for non-manual orders.
43
103
  * Declared fields compare numerically when both values are numbers,
44
- * otherwise as strings via `localeCompare`. The Supabase adapter orders
45
- * by the jsonb VALUE (`fields->x`, decided 2026-09-05), so the two agree
46
- * for a field holding numbers and for one holding strings (ISO dates
47
- * included). Two edges still differ: MIXED types in one field — Postgres
48
- * orders jsonb by type first (null < string < number < boolean < array <
49
- * object) rather than by `String(...)` — and an explicit JSON `null`
50
- * VALUE, which is a jsonb null and sorts FIRST in Postgres but last here
51
- * (a MISSING key is SQL NULL and does sort last, matching). See the
52
- * package README's follow-ups.
104
+ * otherwise as strings via `localeCompare` the same order Postgres
105
+ * gives the jsonb VALUE (`fields->x`) for one type; see the README's
106
+ * follow-ups for the mixed-type and JSON-null edges.
53
107
  */
54
108
  export function compareEntries(order, seq) {
55
109
  return (a, b) => {
@@ -77,12 +131,18 @@ export class MemoryEntryStore {
77
131
  this.collections = collections;
78
132
  this.refs = refs;
79
133
  this.entries = new Map();
80
- this.seq = 0; // creation-order tiebreak (Date.now can tie)
134
+ this.versions = new Map();
135
+ /** Insertion order per version — `createdAt` alone can tie within a millisecond. */
136
+ this.versionSeq = new Map();
137
+ this.seq = 0;
81
138
  this.order = new Map();
82
139
  this.byId = (id) => this.entries.get(id);
83
140
  this.paths = options.paths ?? new MemoryPathIndex();
84
141
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
85
142
  }
143
+ schema(collection) {
144
+ return schemaOf(this.collections, collection);
145
+ }
86
146
  row(collection, id) {
87
147
  const entry = this.entries.get(id);
88
148
  return entry && entry.collection === collection ? entry : null;
@@ -93,6 +153,9 @@ export class MemoryEntryStore {
93
153
  throw entryErrors.notFound(collection, id);
94
154
  return entry;
95
155
  }
156
+ snapshot(entry) {
157
+ return structuredClone(entry);
158
+ }
96
159
  subtree(entry) {
97
160
  const out = [entry];
98
161
  const walk = (parentId) => {
@@ -105,54 +168,104 @@ export class MemoryEntryStore {
105
168
  walk(entry.id);
106
169
  return out;
107
170
  }
171
+ /** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
172
+ bump(entry, row) {
173
+ const now = Date.now();
174
+ entry.updatedAt = now > entry.updatedAt ? now : entry.updatedAt + 1;
175
+ if (row)
176
+ row.updatedAt = entry.updatedAt;
177
+ }
178
+ /** A versioned collection records every save as a merged snapshot and
179
+ * points the locale's draft at it; others keep the row as the live content. */
180
+ recordVersion(entry, locale) {
181
+ if (!this.schema(entry.collection).versions)
182
+ return;
183
+ const version = {
184
+ id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), createdAt: Date.now(),
185
+ };
186
+ this.versions.set(version.id, version);
187
+ this.versionSeq.set(version.id, ++this.seq);
188
+ entry.locales[locale].draftVersionId = version.id;
189
+ }
190
+ dropVersions(entryId, locale) {
191
+ for (const [id, v] of this.versions) {
192
+ if (v.entryId === entryId && (locale === undefined || v.locale === locale)) {
193
+ this.versions.delete(id);
194
+ this.versionSeq.delete(id);
195
+ }
196
+ }
197
+ }
198
+ /** Every field set the site can render for this entry: each present
199
+ * locale's live view, plus a published snapshot the draft has moved
200
+ * past (still rendered by the published site). */
201
+ liveFieldSets(entry) {
202
+ const sets = [];
203
+ for (const [locale, row] of Object.entries(entry.locales)) {
204
+ sets.push(entryFieldsIn(entry, locale));
205
+ if (row.status === "published" && row.publishedVersionId && row.publishedVersionId !== row.draftVersionId) {
206
+ const snapshot = this.versions.get(row.publishedVersionId);
207
+ if (snapshot)
208
+ sets.push(snapshot.fields);
209
+ }
210
+ }
211
+ return sets;
212
+ }
108
213
  async writePaths(collection, nodes) {
109
- const schema = schemaOf(this.collections, collection);
110
- await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, schema, this.locales));
214
+ await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
111
215
  }
112
- writeRefs(collection, id, fields) {
113
- const schema = schemaOf(this.collections, collection);
114
- this.refs.rewrite({ sourceKind: "entry", sourceId: id }, collectEntryRefs(fields, schema));
216
+ writeRefs(entry) {
217
+ this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection)));
115
218
  }
116
219
  siblings(collection, parentId, except) {
117
220
  return [...this.entries.values()]
118
221
  .filter((e) => e.collection === collection && e.parentId === parentId && e.id !== except)
119
222
  .sort(compareEntries("manual", (e) => this.order.get(e.id)));
120
223
  }
121
- /** The memory mirror of entries_sibling_slug_key: a string slug must be
122
- * unique among the entries sharing a parent. An absent, null or empty
123
- * slug claims no URL and never conflicts (the index's partial
124
- * predicate folds "" to null too via nullif). */
125
- assertSiblingSlug(collection, parentId, fields, except) {
126
- const slug = slugOf(fields);
224
+ /** The friendly pre-check (spec 2026-09-06 §11): a string slug must be
225
+ * unique among the siblings PRESENT in that locale; null never
226
+ * conflicts. The paths primary key remains the invariant. */
227
+ assertSiblingSlug(collection, parentId, locale, slug, except) {
127
228
  if (slug === null)
128
229
  return;
129
230
  for (const e of this.siblings(collection, parentId, except)) {
130
- if (slugOf(e.fields) === slug)
231
+ if (e.locales[locale]?.slug === slug)
131
232
  throw entryErrors.slugTaken(collection);
132
233
  }
133
234
  }
235
+ /** Same collection, within depth. Returns the parent (null at the root). */
134
236
  assertParent(collection, parentId, subtreeHeight) {
135
237
  if (parentId === null)
136
- return;
238
+ return null;
137
239
  const parent = this.entries.get(parentId);
138
240
  if (!parent)
139
241
  throw entryErrors.notFound(collection, parentId);
140
242
  if (parent.collection !== collection)
141
243
  throw entryErrors.foreignParent();
142
- assertDepth(chainOf(parent, this.byId).length + subtreeHeight, collectionDepth(schemaOf(this.collections, collection)), "the entry");
244
+ assertDepth(chainOf(parent, this.byId).length + subtreeHeight, collectionDepth(this.schema(collection)), "the entry");
245
+ return parent;
143
246
  }
144
- async create(collection, fields, options = {}) {
145
- schemaOf(this.collections, collection);
247
+ /** The parent rule: a locale can only be added where the parent has it. */
248
+ assertParentHasLocale(parent, locale) {
249
+ if (parent && !parent.locales[locale])
250
+ throw entryErrors.parentNotInLocale(locale);
251
+ }
252
+ async create(collection, fields, options) {
253
+ const schema = this.schema(collection);
254
+ assertSupportedLocale(options.locale, this.locales);
146
255
  const parentId = options.parentId ?? null;
147
- this.assertParent(collection, parentId, 1);
148
- this.assertSiblingSlug(collection, parentId, fields);
256
+ const parent = this.assertParent(collection, parentId, 1);
257
+ this.assertParentHasLocale(parent, options.locale);
258
+ const { shared, localized, slug } = splitFields(fields, schema);
259
+ this.assertSiblingSlug(collection, parentId, options.locale, slug);
149
260
  const now = Date.now();
150
261
  const entry = {
151
- id: uid(), collection, parentId,
152
- fields: structuredClone(fields), i18n: null,
153
- status: "draft", sort: null, locales: options.locales ?? null,
154
- createdAt: now, updatedAt: now,
262
+ id: uid("entry"), collection, parentId, fields: shared,
263
+ locales: {
264
+ [options.locale]: { slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now },
265
+ },
266
+ sort: null, createdAt: now, updatedAt: now,
155
267
  };
268
+ this.recordVersion(entry, options.locale);
156
269
  this.entries.set(entry.id, entry);
157
270
  this.order.set(entry.id, ++this.seq);
158
271
  try {
@@ -161,61 +274,147 @@ export class MemoryEntryStore {
161
274
  catch (e) {
162
275
  this.entries.delete(entry.id);
163
276
  this.order.delete(entry.id);
277
+ this.dropVersions(entry.id);
164
278
  throw e;
165
279
  }
166
- this.writeRefs(collection, entry.id, fields);
167
- return structuredClone(entry);
280
+ this.writeRefs(entry);
281
+ return this.snapshot(entry);
168
282
  }
169
283
  async get(collection, id) {
170
284
  const entry = this.row(collection, id);
171
- return entry ? structuredClone(entry) : null;
285
+ return entry ? this.snapshot(entry) : null;
172
286
  }
173
287
  async list(collection, options) {
174
- const schema = schemaOf(this.collections, collection);
288
+ const schema = this.schema(collection);
175
289
  let rows = [...this.entries.values()].filter((e) => e.collection === collection);
176
- if (options?.status)
177
- rows = rows.filter((e) => e.status === options.status);
290
+ if (options?.status) {
291
+ const locale = options.locale ?? this.locales.default;
292
+ rows = rows.filter((e) => e.locales[locale]?.status === options.status);
293
+ }
178
294
  if (options?.parent !== undefined)
179
295
  rows = rows.filter((e) => e.parentId === options.parent);
180
296
  rows.sort(compareEntries(options?.order ?? collectionOrder(schema), (e) => this.order.get(e.id)));
181
- return rows.map((e) => structuredClone(e));
297
+ return rows.map((e) => this.snapshot(e));
182
298
  }
183
- async getMany(collection, ids, options) {
184
- schemaOf(this.collections, collection);
299
+ async getMany(collection, ids, locale, options) {
300
+ this.schema(collection);
185
301
  const out = {};
186
302
  for (const id of ids) {
187
303
  const entry = this.row(collection, id);
188
- if (!entry)
304
+ const row = entry?.locales[locale];
305
+ if (!entry || !row)
189
306
  continue;
190
- if (options?.status && entry.status !== options.status)
191
- continue;
192
- out[id] = structuredClone(entry.fields);
307
+ if (options?.status) {
308
+ if (row.status !== options.status)
309
+ continue;
310
+ // A versioned collection serves what was published, not the live row.
311
+ const snapshot = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
312
+ out[id] = snapshot ? structuredClone(snapshot.fields) : entryFieldsIn(entry, locale);
313
+ }
314
+ else {
315
+ out[id] = entryFieldsIn(entry, locale);
316
+ }
193
317
  }
194
318
  return out;
195
319
  }
196
- async update(collection, id, fields) {
320
+ async update(collection, id, locale, fields) {
321
+ const schema = this.schema(collection);
197
322
  const entry = this.must(collection, id);
198
- this.assertSiblingSlug(collection, entry.parentId, fields, id);
199
- const before = entry.fields;
200
- entry.fields = structuredClone(fields);
323
+ const row = entryLocaleRow(entry, locale);
324
+ const { shared, localized, slug } = splitFields(fields, schema);
325
+ this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
326
+ const before = { fields: entry.fields, row: { ...row } };
327
+ entry.fields = shared;
328
+ row.fields = localized;
329
+ row.slug = slug;
201
330
  try {
202
331
  await this.writePaths(collection, this.subtree(entry));
203
332
  }
204
333
  catch (e) {
205
- entry.fields = before;
334
+ entry.fields = before.fields;
335
+ Object.assign(row, before.row);
206
336
  throw e;
207
337
  }
208
- entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
209
- this.writeRefs(collection, id, fields);
210
- return structuredClone(entry);
338
+ this.recordVersion(entry, locale);
339
+ this.bump(entry, row);
340
+ this.writeRefs(entry);
341
+ return this.snapshot(entry);
211
342
  }
212
- async setStatus(collection, id, status) {
213
- const entry = this.row(collection, id);
214
- if (!entry)
215
- throw entryErrors.notFound(collection, id);
216
- entry.status = status;
217
- entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
218
- return structuredClone(entry);
343
+ async setStatus(collection, id, locale, status) {
344
+ const entry = this.must(collection, id);
345
+ const row = entryLocaleRow(entry, locale);
346
+ if (status === "published") {
347
+ // Publish points at the current draft; a versioned row always has one
348
+ // (create and update record it), but never trust that from here.
349
+ if (this.schema(collection).versions && !row.draftVersionId)
350
+ this.recordVersion(entry, locale);
351
+ row.publishedVersionId = row.draftVersionId;
352
+ }
353
+ // Unpublish keeps the pointer for a later republish (spec §2).
354
+ row.status = status;
355
+ this.bump(entry, row);
356
+ this.writeRefs(entry); // the published snapshot just became, or stopped being, a live set
357
+ return this.snapshot(entry);
358
+ }
359
+ async addLocale(collection, id, locale, options) {
360
+ assertSupportedLocale(locale, this.locales);
361
+ const entry = this.must(collection, id);
362
+ // The source is read FIRST: "copy from a locale that isn't there" is
363
+ // the more specific complaint, even when the target locale also exists.
364
+ const source = entryLocaleRow(entry, options.from);
365
+ if (entry.locales[locale])
366
+ throw entryErrors.alreadyInLocale(id, locale);
367
+ const parent = entry.parentId === null ? null : (this.entries.get(entry.parentId) ?? null);
368
+ this.assertParentHasLocale(parent, locale);
369
+ this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
370
+ const now = Date.now();
371
+ entry.locales[locale] = {
372
+ slug: source.slug, status: "draft", fields: structuredClone(source.fields),
373
+ draftVersionId: null, publishedVersionId: null, updatedAt: now,
374
+ };
375
+ this.recordVersion(entry, locale);
376
+ // Only this node's rows change: a child cannot have a locale its parent lacked.
377
+ try {
378
+ await this.writePaths(collection, [entry]);
379
+ }
380
+ catch (e) {
381
+ delete entry.locales[locale];
382
+ this.dropVersions(id, locale);
383
+ throw e;
384
+ }
385
+ this.writeRefs(entry);
386
+ return this.snapshot(entry);
387
+ }
388
+ async removeLocale(collection, id, locale) {
389
+ const schema = this.schema(collection);
390
+ const entry = this.must(collection, id);
391
+ const row = entryLocaleRow(entry, locale);
392
+ if (Object.keys(entry.locales).length === 1)
393
+ throw entryErrors.lastLocale(id);
394
+ const blockers = [...this.entries.values()]
395
+ .filter((e) => e.parentId === id && e.locales[locale] !== undefined)
396
+ .map((e) => entryTitleIn(e, locale, schema));
397
+ if (blockers.length > 0)
398
+ throw entryErrors.localeInUse(locale, blockers);
399
+ delete entry.locales[locale];
400
+ try {
401
+ await this.writePaths(collection, [entry]);
402
+ }
403
+ catch (e) {
404
+ entry.locales[locale] = row;
405
+ throw e;
406
+ }
407
+ this.dropVersions(id, locale);
408
+ this.bump(entry);
409
+ this.writeRefs(entry);
410
+ return this.snapshot(entry);
411
+ }
412
+ async listVersions(collection, id, locale) {
413
+ this.must(collection, id);
414
+ return [...this.versions.values()]
415
+ .filter((v) => v.entryId === id && v.locale === locale)
416
+ .sort((a, b) => b.createdAt - a.createdAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
417
+ .map((v) => structuredClone(v));
219
418
  }
220
419
  async move(collection, id, to) {
221
420
  const entry = this.must(collection, id);
@@ -223,9 +422,16 @@ export class MemoryEntryStore {
223
422
  if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
224
423
  throw entryErrors.cycle();
225
424
  const height = Math.max(...subtree.map((e) => chainOf(e, this.byId).length)) - chainOf(entry, this.byId).length + 1;
226
- this.assertParent(collection, to.parentId, height);
227
- if (to.parentId !== entry.parentId)
228
- this.assertSiblingSlug(collection, to.parentId, entry.fields, id);
425
+ const parent = this.assertParent(collection, to.parentId, height);
426
+ // The destination must have every locale the entry has, or a chain breaks there.
427
+ if (parent)
428
+ for (const locale of Object.keys(entry.locales))
429
+ if (!parent.locales[locale])
430
+ throw entryErrors.moveLocales(locale);
431
+ if (to.parentId !== entry.parentId) {
432
+ for (const [locale, row] of Object.entries(entry.locales))
433
+ this.assertSiblingSlug(collection, to.parentId, locale, row.slug, id);
434
+ }
229
435
  const before = { parentId: entry.parentId, sort: entry.sort };
230
436
  entry.parentId = to.parentId;
231
437
  const siblings = this.siblings(collection, to.parentId, id);
@@ -242,7 +448,7 @@ export class MemoryEntryStore {
242
448
  s.sort = previous.get(s.id) ?? null;
243
449
  throw e;
244
450
  }
245
- return structuredClone(entry);
451
+ return this.snapshot(entry);
246
452
  }
247
453
  async delete(collection, id) {
248
454
  const entry = this.row(collection, id);
@@ -256,10 +462,12 @@ export class MemoryEntryStore {
256
462
  }
257
463
  await this.paths.remove([{ kind: "entry", id }]);
258
464
  this.entries.delete(id);
465
+ this.order.delete(id);
466
+ this.dropVersions(id);
259
467
  this.refs.remove({ sourceKind: "entry", sourceId: id });
260
468
  }
261
469
  async reorder(collection, orderedIds) {
262
- schemaOf(this.collections, collection);
470
+ this.schema(collection);
263
471
  orderedIds.forEach((id, i) => {
264
472
  const entry = this.row(collection, id);
265
473
  if (entry)
package/dist/index.d.ts CHANGED
@@ -22,17 +22,17 @@ export type { PathIndex } from "./path-index.ts";
22
22
  export { MemoryEntryStore } from "./entry-store.ts";
23
23
  export { SupabaseEntryStore } from "./supabase-entry-store.ts";
24
24
  export { SupabasePathIndex } from "./supabase-path-index.ts";
25
- export type { EntryStore, EntryRecord, EntryUsage, ListOptions, CreateEntryOptions, EntryStoreOptions } from "./entry-store.ts";
26
- export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES } from "./entry-store.ts";
25
+ export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, } from "./entry-store.ts";
26
+ export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
27
27
  export { affectedTargets, pageTag, entryTag } from "./revalidate.ts";
28
28
  export type { AffectedTargets } from "./revalidate.ts";
29
29
  export { resolveTree, resolveFields } from "./resolve.ts";
30
30
  export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
31
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, localesOf, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, } from "./paths.ts";
31
+ export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.ts";
32
32
  export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions } from "./paths.ts";
33
- export { titleFor, localizeFields, fixedPathFor } from "./localize.ts";
34
- export type { PageStore, PageRecord, PageVersion, PageStoreOptions, PageI18n, CreatePageInput } from "./store.ts";
35
- export { pathOptions, pageErrors, assertPageI18n, DEFAULT_LOCALES } from "./store.ts";
33
+ export { fixedPathFor } from "./localize.ts";
34
+ export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
35
+ export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
36
36
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
37
37
  export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
38
38
  export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
package/dist/index.js CHANGED
@@ -18,12 +18,12 @@ export { MemoryPathIndex } from "./path-index.js";
18
18
  export { MemoryEntryStore } from "./entry-store.js";
19
19
  export { SupabaseEntryStore } from "./supabase-entry-store.js";
20
20
  export { SupabasePathIndex } from "./supabase-path-index.js";
21
- export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES } from "./entry-store.js";
21
+ export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
22
22
  export { affectedTargets, pageTag, entryTag } from "./revalidate.js";
23
23
  export { resolveTree, resolveFields } from "./resolve.js";
24
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, localesOf, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, } from "./paths.js";
25
- export { titleFor, localizeFields, fixedPathFor } from "./localize.js";
26
- export { pathOptions, pageErrors, assertPageI18n, DEFAULT_LOCALES } from "./store.js";
24
+ export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
25
+ export { fixedPathFor } from "./localize.js";
26
+ export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
27
27
  export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
28
28
  export const smoodly = {
29
29
  schema,
@@ -1,15 +1,4 @@
1
1
  import { type LocaleSet } from "./paths.ts";
2
- /** The locale's title, or the default locale's. */
3
- export declare function titleFor(node: {
4
- title: string;
5
- i18n?: Record<string, {
6
- title?: string;
7
- }> | null;
8
- }, locale: string, set: LocaleSet): string;
9
- /** A fresh object: the default locale's fields, overlaid per field with
10
- * the locale's values. An overlay value that is undefined, null or ""
11
- * falls back — a translator may leave a field blank. */
12
- export declare function localizeFields(fields: Record<string, unknown>, i18n: Record<string, Record<string, unknown>> | null | undefined, locale: string, set: LocaleSet): Record<string, unknown>;
13
2
  /** The path a FIXED page registration is served at in one locale — the
14
3
  * escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
15
4
  * by the default-locale segment, exactly as the stores judge the record. */
package/dist/localize.js CHANGED
@@ -1,29 +1,8 @@
1
- // Per-field locale fallback for the two overlays the stores carry
2
- // (spec 2026-09-05 §2): `pages.i18n[locale].title` and
3
- // `entries.i18n[locale]`. Slugs are paths.ts's business (segmentFor);
4
- // this module owns titles and entry fields. Pure and framework-free.
5
- import { segmentFor } from "./paths.js";
6
- const present = (v) => v !== undefined && v !== null && v !== "";
7
- /** The locale's title, or the default locale's. */
8
- export function titleFor(node, locale, set) {
9
- if (locale === set.default)
10
- return node.title;
11
- const own = node.i18n?.[locale]?.title;
12
- return typeof own === "string" && own.length > 0 ? own : node.title;
13
- }
14
- /** A fresh object: the default locale's fields, overlaid per field with
15
- * the locale's values. An overlay value that is undefined, null or ""
16
- * falls back — a translator may leave a field blank. */
17
- export function localizeFields(fields, i18n, locale, set) {
18
- const out = structuredClone(fields);
19
- if (locale === set.default)
20
- return out;
21
- for (const [key, value] of Object.entries(i18n?.[locale] ?? {})) {
22
- if (present(value))
23
- out[key] = structuredClone(value);
24
- }
25
- return out;
26
- }
1
+ // The fixed-page path derivation the escape hatch's addressing (spec
2
+ // 2026-09-05 §5). Entries and pages alike have no field fallback: their
3
+ // locale rows own their fields, titles and slugs (spec 2026-09-06). This
4
+ // module owns nothing but fixedPathFor now.
5
+ import { fixedPageSegments } from "./paths.js";
27
6
  /** The path a FIXED page registration is served at in one locale — the
28
7
  * escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
29
8
  * by the default-locale segment, exactly as the stores judge the record. */
@@ -31,12 +10,15 @@ export function fixedPathFor(schema, locale, set, homePageSlug) {
31
10
  if (schema.slug === undefined) {
32
11
  throw new Error(`smoodly: page registration "${schema.name}" has no fixed slug — open pages are served by renderSmoodlyPath.`);
33
12
  }
34
- const slug = typeof schema.slug === "string" ? schema.slug : schema.slug[set.default];
13
+ // A registration declares its slugs in code, so a locale it omits means
14
+ // "the same segment", not "absent" — paths.ts's fixedPageSegments fills
15
+ // it, the same fill the boot check and the materializer use.
16
+ const segments = fixedPageSegments(schema.slug, set);
17
+ const slug = segments[set.default];
35
18
  if (slug === undefined) {
36
19
  throw new Error(`smoodly: page registration "${schema.name}" declares a per-locale slug without the default locale "${set.default}".`);
37
20
  }
38
21
  if (slug === homePageSlug)
39
22
  return "/";
40
- const i18n = typeof schema.slug === "string" ? null : Object.fromEntries(Object.entries(schema.slug).map(([l, s]) => [l, { slug: s }]));
41
- return `/${segmentFor({ slug, i18n }, locale, set)}`;
23
+ return `/${segments[locale] ?? slug}`;
42
24
  }