smoodly 0.0.14 → 0.0.16

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 (67) hide show
  1. package/README.md +94 -15
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
  6. package/dist/admin/editor/CanvasOverlay.js +5 -5
  7. package/dist/admin/editor/EditorView.d.ts +2 -0
  8. package/dist/admin/editor/EditorView.js +251 -23
  9. package/dist/admin/editor/PageSettings.js +3 -2
  10. package/dist/admin/editor/SharedInspector.d.ts +23 -0
  11. package/dist/admin/editor/SharedInspector.js +13 -0
  12. package/dist/admin/editor/shared-edit.d.ts +39 -0
  13. package/dist/admin/editor/shared-edit.js +124 -0
  14. package/dist/admin/editor/useSharedEdit.d.ts +6 -0
  15. package/dist/admin/editor/useSharedEdit.js +18 -0
  16. package/dist/admin/forms/tabs.d.ts +9 -3
  17. package/dist/admin/next/op-handler.js +4 -3
  18. package/dist/admin/ops-impl.d.ts +3 -0
  19. package/dist/admin/ops-impl.js +198 -23
  20. package/dist/admin/ops.d.ts +32 -0
  21. package/dist/admin/serialize.d.ts +0 -1
  22. package/dist/admin/serialize.js +0 -1
  23. package/dist/admin/shared-items.js +14 -0
  24. package/dist/admin/shell/AdminApp.js +1 -1
  25. package/dist/admin/shell/EntriesList.js +19 -3
  26. package/dist/admin/shell/EntryForm.js +183 -77
  27. package/dist/admin/shell/IconRail.js +3 -3
  28. package/dist/admin/shell/PagesList.js +3 -2
  29. package/dist/admin/shell/SharedFields.d.ts +21 -0
  30. package/dist/admin/shell/SharedFields.js +15 -0
  31. package/dist/admin/shell/SharedForm.js +166 -52
  32. package/dist/admin/shell/SharedList.js +3 -2
  33. package/dist/admin/shell/entries-list.d.ts +2 -2
  34. package/dist/admin/shell/entries-list.js +3 -3
  35. package/dist/admin/supabase-auth.js +1 -1
  36. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  37. package/dist/admin/ui/ActionMenu.js +25 -0
  38. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  39. package/dist/admin/ui/HistoryModal.js +40 -0
  40. package/dist/admin/ui/Modal.d.ts +5 -1
  41. package/dist/admin/ui/Modal.js +3 -3
  42. package/dist/admin/ui/PublishControl.d.ts +31 -0
  43. package/dist/admin/ui/PublishControl.js +24 -0
  44. package/dist/admin/ui/format.d.ts +4 -0
  45. package/dist/admin/ui/format.js +9 -0
  46. package/dist/admin/ui/primitives.d.ts +13 -0
  47. package/dist/admin/ui/primitives.js +18 -2
  48. package/dist/admin/ui/status-label.d.ts +10 -0
  49. package/dist/admin/ui/status-label.js +14 -0
  50. package/dist/admin/ui/theme.js +46 -6
  51. package/dist/collections.d.ts +0 -6
  52. package/dist/collections.js +0 -1
  53. package/dist/entry-store.d.ts +38 -13
  54. package/dist/entry-store.js +90 -23
  55. package/dist/next/page-renderer.js +8 -4
  56. package/dist/shared.d.ts +8 -1
  57. package/dist/shared.js +10 -1
  58. package/dist/site.d.ts +2 -0
  59. package/dist/site.js +17 -7
  60. package/dist/sql.js +18 -15
  61. package/dist/store.d.ts +38 -13
  62. package/dist/store.js +82 -12
  63. package/dist/supabase-entry-store.d.ts +11 -6
  64. package/dist/supabase-entry-store.js +101 -25
  65. package/dist/supabase-store.d.ts +13 -6
  66. package/dist/supabase-store.js +88 -19
  67. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ import { setAtPath } from "../field-paths.js";
2
+ import { createDraftAutosave } from "./autosave.js";
3
+ export function createSharedEdit(opts) {
4
+ let items = {};
5
+ let loadError = null;
6
+ let generation = 0;
7
+ const loading = new Set();
8
+ const rev = new Map();
9
+ const savedRev = new Map();
10
+ const listeners = new Set();
11
+ let snapshot = null;
12
+ const emit = () => { snapshot = null; for (const l of listeners)
13
+ l(); };
14
+ const dirty = () => Object.values(items).filter((i) => (rev.get(i.name) ?? 0) > (savedRev.get(i.name) ?? 0));
15
+ const autosave = createDraftAutosave({
16
+ read: () => { const d = dirty(); return d.length > 0 ? d : null; },
17
+ persist: async (list) => {
18
+ let failed = null;
19
+ for (const item of list) {
20
+ const at = rev.get(item.name) ?? 0;
21
+ let r;
22
+ try {
23
+ r = await opts.ops.shared.save(item.name, item.locale, item.fields);
24
+ }
25
+ catch {
26
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
27
+ }
28
+ const current = items[item.name];
29
+ if (r.ok) {
30
+ savedRev.set(item.name, at);
31
+ if (current)
32
+ items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
33
+ }
34
+ else {
35
+ failed ?? (failed = r);
36
+ if (current) {
37
+ items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
38
+ }
39
+ }
40
+ }
41
+ emit();
42
+ return failed ?? { ok: true, data: null };
43
+ },
44
+ onSaved: opts.onSaved,
45
+ debounceMs: opts.debounceMs,
46
+ retryBaseMs: opts.retryBaseMs,
47
+ retryCapMs: opts.retryCapMs,
48
+ });
49
+ autosave.subscribe(emit);
50
+ const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
51
+ return {
52
+ getState,
53
+ subscribe(listener) {
54
+ listeners.add(listener);
55
+ return () => { listeners.delete(listener); };
56
+ },
57
+ async load(name, locale) {
58
+ const key = `${name}/${locale}`;
59
+ if (items[name]?.locale === locale || loading.has(key))
60
+ return;
61
+ loading.add(key);
62
+ const gen = generation;
63
+ let r;
64
+ try {
65
+ r = await opts.ops.shared.get(name, locale);
66
+ }
67
+ catch {
68
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
69
+ }
70
+ loading.delete(key);
71
+ if (gen !== generation)
72
+ return; // reset() ran meanwhile — this canvas is gone
73
+ if (!r.ok) {
74
+ loadError = r.message;
75
+ emit();
76
+ return;
77
+ }
78
+ items = { ...items, [name]: { name, locale, record: r.data.record, fields: r.data.fields, usage: r.data.usage, errors: {} } };
79
+ loadError = null;
80
+ emit();
81
+ },
82
+ patch(name, path, value) {
83
+ const item = items[name];
84
+ if (!item)
85
+ return;
86
+ items = { ...items, [name]: { ...item, fields: setAtPath(item.fields, path, value) } };
87
+ rev.set(name, (rev.get(name) ?? 0) + 1);
88
+ emit();
89
+ autosave.mark();
90
+ },
91
+ flush: autosave.flush,
92
+ async setStatus(name, status) {
93
+ const item = items[name];
94
+ if (!item)
95
+ return { ok: false, code: "not_found", message: "This item hasn't loaded yet." };
96
+ if (!(await autosave.flush())) {
97
+ return { ok: false, code: "internal", message: "Couldn't save your latest changes — publish cancelled." };
98
+ }
99
+ let r;
100
+ try {
101
+ r = await opts.ops.shared.setStatus(name, item.locale, status);
102
+ }
103
+ catch {
104
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
105
+ }
106
+ if (r.ok && items[name]) {
107
+ items = { ...items, [name]: { ...items[name], record: r.data } };
108
+ emit();
109
+ }
110
+ return r;
111
+ },
112
+ reset() {
113
+ generation += 1;
114
+ items = {};
115
+ rev.clear();
116
+ savedRev.clear();
117
+ loading.clear();
118
+ loadError = null;
119
+ autosave.dispose();
120
+ emit();
121
+ },
122
+ dispose: autosave.dispose,
123
+ };
124
+ }
@@ -0,0 +1,6 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import { type SharedEdit, type SharedEditState } from "./shared-edit.ts";
3
+ export declare function useSharedEdit({ ops, onSaved }: {
4
+ ops: Pick<AdminOps, "shared">;
5
+ onSaved?: () => void;
6
+ }): SharedEditState & Pick<SharedEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
@@ -0,0 +1,18 @@
1
+ // React glue for `createSharedEdit`: one controller per editor mount, the
2
+ // latest `onSaved` through a ref (it closes over the inline session), the
3
+ // state through useSyncExternalStore. On unmount a pending save is
4
+ // flushed first — the server call outlives the view — then the timers stop.
5
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
6
+ import { createSharedEdit } from "./shared-edit.js";
7
+ export function useSharedEdit({ ops, onSaved }) {
8
+ const latest = useRef(onSaved);
9
+ latest.current = onSaved;
10
+ const [controller] = useState(() => createSharedEdit({ ops, onSaved: () => latest.current?.() }));
11
+ useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
12
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
13
+ return {
14
+ ...state,
15
+ load: controller.load, patch: controller.patch, flush: controller.flush,
16
+ setStatus: controller.setStatus, reset: controller.reset,
17
+ };
18
+ }
@@ -1,14 +1,20 @@
1
- import type { AdminField, AdminSection } from "../serialize.ts";
1
+ import type { AdminField } from "../serialize.ts";
2
2
  export type TabName = "Content" | "Styles" | "Media" | "Meta";
3
3
  export type TabEntry = {
4
4
  key: string;
5
5
  ns: "fields" | "styles" | "meta";
6
6
  descriptor: AdminField;
7
7
  };
8
- export declare function tabsFor(section: AdminSection): {
8
+ /** Anything with namespaced descriptors: a section, or a shared item (fields + styles, no meta). */
9
+ export type FieldsHolder = {
10
+ fields: Record<string, AdminField>;
11
+ styles?: Record<string, AdminField>;
12
+ meta?: Record<string, AdminField>;
13
+ };
14
+ export declare function tabsFor(section: FieldsHolder): {
9
15
  tab: TabName;
10
16
  entries: TabEntry[];
11
17
  }[];
12
18
  /** The tab holding a content field, by its dotted path's top-level key — a canvas
13
19
  * click on a marked image reveals it on Media, where the projection put it. */
14
- export declare function tabOf(section: AdminSection, path: string): TabName | undefined;
20
+ export declare function tabOf(section: FieldsHolder, path: string): TabName | undefined;
@@ -7,7 +7,7 @@ import { draftMode } from "next/headers";
7
7
  import { gateAdminOp } from "../auth.js";
8
8
  import { createAdminOps, runAdminOp } from "../ops-impl.js";
9
9
  export function createAdminOpHandler(deps) {
10
- const ops = createAdminOps({
10
+ const base = {
11
11
  config: deps.config,
12
12
  pages: deps.pages,
13
13
  entries: deps.entries,
@@ -24,6 +24,7 @@ export function createAdminOpHandler(deps) {
24
24
  disable: async () => (await draftMode()).disable(),
25
25
  },
26
26
  },
27
- });
28
- return (call) => gateAdminOp(deps.auth, call, (c) => runAdminOp(ops, c));
27
+ };
28
+ // Ops are built per call so the verified session rides into every store write as `by` (spec 2026-09-13 history §2).
29
+ return (call) => gateAdminOp(deps.auth, call, (c, session) => runAdminOp(createAdminOps({ ...base, actor: session }), c));
29
30
  }
@@ -3,6 +3,7 @@ import type { EntryStore } from "../entry-store.ts";
3
3
  import type { AssetStore } from "../asset-store.ts";
4
4
  import type { PageStore } from "../store.ts";
5
5
  import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
6
+ import type { AdminSession } from "./auth.ts";
6
7
  export type AdminEffects = {
7
8
  /** Expire one cache tag (`pageTag(id)` / `entryTag(id)`). */
8
9
  revalidate?(tag: string): void;
@@ -20,6 +21,8 @@ export declare function createAdminOps(deps: {
20
21
  href?: (path: string, locale: string) => string;
21
22
  /** The media library; absent = every assets op answers "no media store". */
22
23
  assets?: AssetStore;
24
+ /** The verified editor; their email is every write's `by`. Absent for the seed and tests (spec 2026-09-13 history §2). */
25
+ actor?: AdminSession;
23
26
  }): AdminOps;
24
27
  /** Path-dispatch for the one-server-action seam. Own-property checks only —
25
28
  * "constructor.constructor" and prototype paths must not resolve. */
@@ -5,8 +5,11 @@ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, shared
5
5
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
6
  import { ensureSharedItems } from "./shared-items.js";
7
7
  import { validateFields } from "./validate.js";
8
+ import { withDefaults } from "../field-defaults.js";
8
9
  import { elementSchemas } from "../richtext-walk.js";
9
10
  const ok = (data) => ({ ok: true, data });
11
+ /** A history row without its content (spec 2026-09-13 history §6). */
12
+ const summary = ({ id, savedAt }) => ({ id, savedAt });
10
13
  const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
11
14
  /** Own-property, non-array plain object — same shape guard the tree checks
12
15
  * below and `runAdminOp`'s envelope check both lean on. */
@@ -62,6 +65,8 @@ const classify = (e) => {
62
65
  return err("blocked", message);
63
66
  if (message.includes("cannot publish"))
64
67
  return err("blocked", message);
68
+ if (message.includes("nothing published"))
69
+ return err("blocked", message);
65
70
  if (message.includes("already taken"))
66
71
  return err("conflict", message);
67
72
  if (message.includes("already has a template"))
@@ -88,6 +93,7 @@ const classify = (e) => {
88
93
  export function createAdminOps(deps) {
89
94
  const { config, pages, entries, effects } = deps;
90
95
  const href = deps.href ?? ((path) => path);
96
+ const by = deps.actor?.email ?? undefined;
91
97
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
92
98
  // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
93
99
  const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, styles: e.schema.styles })));
@@ -281,7 +287,7 @@ export function createAdminOps(deps) {
281
287
  const created = await pages.createPage({
282
288
  locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
283
289
  ...(input.title !== undefined ? { title: input.title } : {}),
284
- });
290
+ }, by);
285
291
  expireTree();
286
292
  return ok(created);
287
293
  }),
@@ -293,7 +299,7 @@ export function createAdminOps(deps) {
293
299
  return err("validation", "smoodly: malformed page tree.");
294
300
  if (!(await pages.getPage(id)))
295
301
  return err("not_found", `smoodly: no page "${id}".`);
296
- return ok({ versionId: (await pages.saveDraft(id, locale, tree)).id });
302
+ return ok({ versionId: (await pages.saveDraft(id, locale, tree, by)).id });
297
303
  }),
298
304
  publish: (id, locale) => guard(async () => {
299
305
  const unsupported = supportedLocale(locale);
@@ -330,12 +336,75 @@ export function createAdminOps(deps) {
330
336
  if (fieldErrors.length > 0) {
331
337
  return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
332
338
  }
333
- const published = await pages.publish(id, locale);
339
+ const published = await pages.publish(id, locale, by);
334
340
  // The parent's cached unit lists its published children.
335
341
  expirePages([published.id, published.parentId]);
336
342
  expireTree();
337
343
  return ok(published);
338
344
  }),
345
+ unpublish: (id, locale) => guard(async () => {
346
+ const unsupported = supportedLocale(locale);
347
+ if (unsupported)
348
+ return unsupported;
349
+ const record = await pages.getPage(id);
350
+ if (!record)
351
+ return err("not_found", `smoodly: no page "${id}".`);
352
+ if (!record.locales[locale])
353
+ return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
354
+ if (isHomeNode(record))
355
+ return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
356
+ if (fixedNodeOf(record, config))
357
+ return owned(record, "it stays published");
358
+ const off = await pages.unpublish(id, locale, by);
359
+ // The parent's cached unit lists its published children; the tree listing shows status.
360
+ expirePages([off.id, off.parentId]);
361
+ expireTree();
362
+ return ok(off);
363
+ }),
364
+ discardDraft: (id, locale) => guard(async () => {
365
+ const unsupported = supportedLocale(locale);
366
+ if (unsupported)
367
+ return unsupported;
368
+ const record = await pages.getPage(id);
369
+ if (!record)
370
+ return err("not_found", `smoodly: no page "${id}".`);
371
+ const row = record.locales[locale];
372
+ if (!row)
373
+ return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
374
+ if (!row.publishedVersionId) {
375
+ return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
376
+ }
377
+ const restored = await pages.discardDraft(id, locale, by);
378
+ expirePages([restored.id]); // the draft-mode render of this page changed
379
+ return ok(restored);
380
+ }),
381
+ listVersions: (id, locale) => guard(async () => {
382
+ const unsupported = supportedLocale(locale);
383
+ if (unsupported)
384
+ return unsupported;
385
+ if (!(await pages.getPage(id)))
386
+ return err("not_found", `smoodly: no page "${id}".`);
387
+ return ok((await pages.listVersions(id, locale)).map(summary));
388
+ }),
389
+ restore: (id, locale, versionId) => guard(async () => {
390
+ const unsupported = supportedLocale(locale);
391
+ if (unsupported)
392
+ return unsupported;
393
+ const record = await pages.getPage(id);
394
+ if (!record)
395
+ return err("not_found", `smoodly: no page "${id}".`);
396
+ if (!record.locales[locale])
397
+ return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
398
+ if (record.template === null)
399
+ return err("blocked", `smoodly: "${nameOf(record)}" is a folder — it has no versions.`);
400
+ const version = typeof versionId === "string" ? await pages.getVersion(versionId) : null;
401
+ if (!version || version.pageId !== id || version.locale !== locale) {
402
+ return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
403
+ }
404
+ const restored = await pages.restore(id, locale, versionId, by);
405
+ expirePages([restored.id]); // the draft-mode render of this page changed
406
+ return ok(restored);
407
+ }),
339
408
  delete: (id) => guard(async () => {
340
409
  const record = await pages.getPage(id);
341
410
  if (!record)
@@ -364,7 +433,7 @@ export function createAdminOps(deps) {
364
433
  const unsupported = supportedLocale(patch.locale);
365
434
  if (unsupported)
366
435
  return unsupported;
367
- const renamed = await pages.rename(id, patch);
436
+ const renamed = await pages.rename(id, patch, by);
368
437
  // Every descendant's path changed with this segment; the parent
369
438
  // lists this node's title and path.
370
439
  expirePages([...(await subtreeIds(id)), record.parentId]);
@@ -408,7 +477,7 @@ export function createAdminOps(deps) {
408
477
  if (registration.schema.slug !== undefined) {
409
478
  return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
410
479
  }
411
- const withTemplate = await pages.setTemplate(id, template);
480
+ const withTemplate = await pages.setTemplate(id, template, by);
412
481
  expireTree();
413
482
  return ok(withTemplate);
414
483
  }),
@@ -419,7 +488,7 @@ export function createAdminOps(deps) {
419
488
  if (!(await pages.getPage(id)))
420
489
  return err("not_found", `smoodly: no page "${id}".`);
421
490
  // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
422
- const added = await pages.addLocale(id, locale, { from: options.from });
491
+ const added = await pages.addLocale(id, locale, { from: options.from }, by);
423
492
  expireTree();
424
493
  return ok(added);
425
494
  }),
@@ -455,7 +524,9 @@ export function createAdminOps(deps) {
455
524
  const parentLocales = record.parentId === null
456
525
  ? config.locales.supported
457
526
  : Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
458
- return ok({ record, locale, fields, parentLocales });
527
+ const paths = await entries.pathsOf(collection, id);
528
+ const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
529
+ return ok({ record, locale, fields, parentLocales, urls });
459
530
  }),
460
531
  create: (collection, input) => guard(async () => {
461
532
  const unsupported = supportedLocale(input.locale);
@@ -464,10 +535,7 @@ export function createAdminOps(deps) {
464
535
  const descriptors = collectionFields(collection);
465
536
  if (!descriptors)
466
537
  return err("not_found", `smoodly: no collection named "${collection}".`);
467
- const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
468
- if (fieldErrors.length > 0)
469
- return err("validation", "Some fields need attention.", fieldErrors);
470
- const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
538
+ const created = await entries.create(collection, withDefaults(input.fields, descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
471
539
  expireCollection(collection);
472
540
  return ok(created);
473
541
  }),
@@ -478,10 +546,7 @@ export function createAdminOps(deps) {
478
546
  const descriptors = collectionFields(collection);
479
547
  if (!descriptors)
480
548
  return err("not_found", `smoodly: no collection named "${collection}".`);
481
- const fieldErrors = validateFields(fields, descriptors, elementDescs);
482
- if (fieldErrors.length > 0)
483
- return err("validation", "Some fields need attention.", fieldErrors);
484
- const entry = await entries.update(collection, id, locale, fields);
549
+ const entry = await entries.update(collection, id, locale, fields, by);
485
550
  await fanOut(id);
486
551
  expireCollection(collection);
487
552
  return ok(entry);
@@ -490,11 +555,62 @@ export function createAdminOps(deps) {
490
555
  const unsupported = supportedLocale(locale);
491
556
  if (unsupported)
492
557
  return unsupported;
493
- const entry = await entries.setStatus(collection, id, locale, status);
558
+ if (status === "published") {
559
+ // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
560
+ const descriptors = collectionFields(collection);
561
+ if (!descriptors)
562
+ return err("not_found", `smoodly: no collection named "${collection}".`);
563
+ const record = await entries.get(collection, id);
564
+ if (!record)
565
+ return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
566
+ if (!record.locales[locale])
567
+ return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
568
+ const fieldErrors = validateFields(entryFieldsIn(record, locale), descriptors, elementDescs);
569
+ if (fieldErrors.length > 0)
570
+ return err("validation", "Some fields need attention.", fieldErrors);
571
+ }
572
+ const entry = await entries.setStatus(collection, id, locale, status, by);
494
573
  await fanOut(id);
495
574
  expireCollection(collection);
496
575
  return ok(entry);
497
576
  }),
577
+ discardDraft: (collection, id, locale) => guard(async () => {
578
+ const unsupported = supportedLocale(locale);
579
+ if (unsupported)
580
+ return unsupported;
581
+ const restored = await entries.discardDraft(collection, id, locale, by);
582
+ await fanOut(id);
583
+ expireCollection(collection);
584
+ return ok(restored);
585
+ }),
586
+ listVersions: (collection, id, locale) => guard(async () => {
587
+ const unsupported = supportedLocale(locale);
588
+ if (unsupported)
589
+ return unsupported;
590
+ if (!(await entries.get(collection, id)))
591
+ return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
592
+ return ok((await entries.listVersions(collection, id, locale)).map(summary));
593
+ }),
594
+ getVersion: (collection, id, versionId) => guard(async () => {
595
+ const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
596
+ if (!version || version.entryId !== id) {
597
+ return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}".`);
598
+ }
599
+ return ok({ ...summary(version), fields: version.fields });
600
+ }),
601
+ restore: (collection, id, locale, versionId) => guard(async () => {
602
+ const unsupported = supportedLocale(locale);
603
+ if (unsupported)
604
+ return unsupported;
605
+ const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
606
+ if (!version || version.entryId !== id || version.locale !== locale) {
607
+ return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
608
+ }
609
+ const restored = await entries.restore(collection, id, locale, versionId, by);
610
+ await fanOut(id);
611
+ expireCollection(collection);
612
+ return ok(restored);
613
+ }),
498
614
  // The tag expires only after the delete succeeded; a blocked delete expires nothing.
499
615
  delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
500
616
  move: (collection, id, to) => guard(async () => {
@@ -512,7 +628,7 @@ export function createAdminOps(deps) {
512
628
  const unsupported = supportedLocale(locale);
513
629
  if (unsupported)
514
630
  return unsupported;
515
- const added = await entries.addLocale(collection, id, locale, { from: options.from });
631
+ const added = await entries.addLocale(collection, id, locale, { from: options.from }, by);
516
632
  expireCollection(collection);
517
633
  return ok(added);
518
634
  }),
@@ -552,13 +668,10 @@ export function createAdminOps(deps) {
552
668
  const item = sharedItem(name);
553
669
  if (!item)
554
670
  return err("not_found", `smoodly: no shared item named "${name}".`);
555
- const fieldErrors = validateFields(fields, item.fields, elementDescs);
556
- if (fieldErrors.length > 0)
557
- return err("validation", "Some fields need attention.", fieldErrors);
558
671
  const record = await sharedRecord(name);
559
672
  if (!record)
560
673
  return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
561
- const saved = await entries.update(name, record.id, locale, fields);
674
+ const saved = await entries.update(name, record.id, locale, fields, by);
562
675
  // fanOut expires `shared:<name>` itself — this row IS the item's.
563
676
  await fanOut(record.id);
564
677
  return ok(saved);
@@ -567,16 +680,78 @@ export function createAdminOps(deps) {
567
680
  const unsupported = supportedLocale(locale);
568
681
  if (unsupported)
569
682
  return unsupported;
570
- if (!sharedItem(name))
683
+ const item = sharedItem(name);
684
+ if (!item)
571
685
  return err("not_found", `smoodly: no shared item named "${name}".`);
572
686
  const record = await sharedRecord(name);
573
687
  if (!record)
574
688
  return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
575
- const changed = await entries.setStatus(name, record.id, locale, status);
689
+ if (status === "published") {
690
+ // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
691
+ if (!record.locales[locale])
692
+ return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
693
+ const fieldErrors = validateFields(entryFieldsIn(record, locale), item.fields, elementDescs);
694
+ if (fieldErrors.length > 0)
695
+ return err("validation", "Some fields need attention.", fieldErrors);
696
+ }
697
+ const changed = await entries.setStatus(name, record.id, locale, status, by);
576
698
  // see save: the fan-out carries the item's own tag.
577
699
  await fanOut(record.id);
578
700
  return ok(changed);
579
701
  }),
702
+ discardDraft: (name, locale) => guard(async () => {
703
+ const unsupported = supportedLocale(locale);
704
+ if (unsupported)
705
+ return unsupported;
706
+ if (!sharedItem(name))
707
+ return err("not_found", `smoodly: no shared item named "${name}".`);
708
+ const record = await sharedRecord(name);
709
+ if (!record)
710
+ return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
711
+ const restored = await entries.discardDraft(name, record.id, locale, by);
712
+ await fanOut(record.id);
713
+ return ok(restored);
714
+ }),
715
+ listVersions: (name, locale) => guard(async () => {
716
+ const unsupported = supportedLocale(locale);
717
+ if (unsupported)
718
+ return unsupported;
719
+ if (!sharedItem(name))
720
+ return err("not_found", `smoodly: no shared item named "${name}".`);
721
+ const record = await sharedRecord(name);
722
+ if (!record)
723
+ return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
724
+ return ok((await entries.listVersions(name, record.id, locale)).map(summary));
725
+ }),
726
+ getVersion: (name, versionId) => guard(async () => {
727
+ if (!sharedItem(name))
728
+ return err("not_found", `smoodly: no shared item named "${name}".`);
729
+ const record = await sharedRecord(name);
730
+ if (!record)
731
+ return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
732
+ const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
733
+ if (!version || version.entryId !== record.id) {
734
+ return err("not_found", `smoodly: no version "${String(versionId)}" of shared item "${name}".`);
735
+ }
736
+ return ok({ ...summary(version), fields: version.fields });
737
+ }),
738
+ restore: (name, locale, versionId) => guard(async () => {
739
+ const unsupported = supportedLocale(locale);
740
+ if (unsupported)
741
+ return unsupported;
742
+ if (!sharedItem(name))
743
+ return err("not_found", `smoodly: no shared item named "${name}".`);
744
+ const record = await sharedRecord(name);
745
+ if (!record)
746
+ return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
747
+ const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
748
+ if (!version || version.entryId !== record.id || version.locale !== locale) {
749
+ return err("not_found", `smoodly: no version "${String(versionId)}" of shared item "${name}" in ${locale}.`);
750
+ }
751
+ const restored = await entries.restore(name, record.id, locale, versionId, by);
752
+ await fanOut(record.id);
753
+ return ok(restored);
754
+ }),
580
755
  },
581
756
  assets: {
582
757
  list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
@@ -55,6 +55,8 @@ export type EntryGetResult = {
55
55
  locale: string;
56
56
  /** That locale's merged draft fields; null when the entry has no row in `locale`. */
57
57
  fields: Record<string, unknown> | null;
58
+ /** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
59
+ urls: Record<string, string>;
58
60
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
59
61
  parentLocales: string[];
60
62
  };
@@ -72,6 +74,15 @@ export type SharedGetResult = {
72
74
  /** Who links it: "used on N pages". */
73
75
  usage: EntryUsage[];
74
76
  };
77
+ /** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
78
+ export type VersionSummary = {
79
+ id: string;
80
+ savedAt: number;
81
+ };
82
+ /** A stored entry snapshot for the disabled form. */
83
+ export type EntryVersionResult = VersionSummary & {
84
+ fields: Record<string, unknown>;
85
+ };
75
86
  /** One place an asset is used: the source row, the field, a label and an admin link. */
76
87
  export type AssetUsageItem = {
77
88
  sourceKind: string;
@@ -91,6 +102,10 @@ export type AdminOps = {
91
102
  versionId: string;
92
103
  }>>;
93
104
  publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
105
+ /** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
106
+ unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
107
+ /** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
108
+ discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
94
109
  delete(id: string): Promise<OpResult<null>>;
95
110
  rename(id: string, patch: {
96
111
  locale: string;
@@ -109,6 +124,10 @@ export type AdminOps = {
109
124
  }): Promise<OpResult<PageRecord>>;
110
125
  /** Deletes the locale's row, versions and paths. Never on fixed nodes or the site root. */
111
126
  removeLocale(id: string, locale: string): Promise<OpResult<PageRecord>>;
127
+ /** The locale's versions newest first, without their trees. */
128
+ listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
129
+ /** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
130
+ restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
112
131
  };
113
132
  entries: {
114
133
  list(collection: string): Promise<OpResult<EntryRecord[]>>;
@@ -117,6 +136,8 @@ export type AdminOps = {
117
136
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
118
137
  save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
119
138
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
139
+ /** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
140
+ discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
120
141
  delete(collection: string, id: string): Promise<OpResult<null>>;
121
142
  move(collection: string, id: string, to: {
122
143
  parentId: string | null;
@@ -128,6 +149,12 @@ export type AdminOps = {
128
149
  }): Promise<OpResult<EntryRecord>>;
129
150
  /** Deletes the locale's row, versions and paths; fans out like a save. */
130
151
  removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
152
+ /** Newest first; empty for a collection without `versions`. */
153
+ listVersions(collection: string, id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
154
+ /** One snapshot's fields; `not_found` unless it belongs to `id`. */
155
+ getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
156
+ /** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
157
+ restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
131
158
  };
132
159
  shared: {
133
160
  /** Materializes every registered item in every supported locale, then lists them in registry order. */
@@ -135,6 +162,11 @@ export type AdminOps = {
135
162
  get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
136
163
  save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
137
164
  setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
165
+ /** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
166
+ discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
167
+ listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
168
+ getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
169
+ restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
138
170
  };
139
171
  assets: {
140
172
  /** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
@@ -48,7 +48,6 @@ export type AdminShared = {
48
48
  title: string;
49
49
  /** The section a visual item renders through; absent = an object item. */
50
50
  section?: string;
51
- versions: boolean;
52
51
  fields: Record<string, AdminField>;
53
52
  /** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
54
53
  styles: Record<string, AdminField>;