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
package/dist/shared.d.ts CHANGED
@@ -6,6 +6,14 @@ import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
6
6
  export declare const SHARED_NODE_TYPE = "shared";
7
7
  /** The reserved key a visual item's styles live under in the node's shared JSONB. */
8
8
  export declare const STYLES_KEY = "$styles";
9
+ /** The node id a layout-rendered visual item carries (spec 2026-09-13 §1):
10
+ * `getShared` stamps it into the styles, the section wrapper emits it,
11
+ * the canvas selects by it. A placement keeps its own tree node id
12
+ * (`shared-xxxxxxxx`, no colon). */
13
+ export declare const SHARED_NODE_PREFIX = "shared:";
14
+ export declare const sharedNodeId: (name: string) => string;
15
+ /** The item a layout node id names, or null for any other id. */
16
+ export declare function sharedNameOf(id: string): string | null;
9
17
  export type SharedSchema<V = Record<string, unknown>, N extends string = string> = CollectionSchema<any, N> & {
10
18
  kind: "shared";
11
19
  /** The registered section a visual item renders through; absent = an object item. */
@@ -31,7 +39,6 @@ export declare function sharedLink(node: {
31
39
  type SharedInput<N extends string = string> = {
32
40
  name: N;
33
41
  title: string;
34
- versions?: boolean;
35
42
  };
36
43
  export declare function shared<F extends BuilderMap, const N extends string = string>(input: SharedInput<N> & {
37
44
  fields: F;
package/dist/shared.js CHANGED
@@ -2,6 +2,16 @@
2
2
  export const SHARED_NODE_TYPE = "shared";
3
3
  /** The reserved key a visual item's styles live under in the node's shared JSONB. */
4
4
  export const STYLES_KEY = "$styles";
5
+ /** The node id a layout-rendered visual item carries (spec 2026-09-13 §1):
6
+ * `getShared` stamps it into the styles, the section wrapper emits it,
7
+ * the canvas selects by it. A placement keeps its own tree node id
8
+ * (`shared-xxxxxxxx`, no colon). */
9
+ export const SHARED_NODE_PREFIX = "shared:";
10
+ export const sharedNodeId = (name) => `${SHARED_NODE_PREFIX}${name}`;
11
+ /** The item a layout node id names, or null for any other id. */
12
+ export function sharedNameOf(id) {
13
+ return id.startsWith(SHARED_NODE_PREFIX) && id.length > SHARED_NODE_PREFIX.length ? id.slice(SHARED_NODE_PREFIX.length) : null;
14
+ }
5
15
  /** The placement a tree node is, or null for any other node. Accepts a
6
16
  * bare `{ type, fields }` shape or a full tree node — `id` (and any
7
17
  * other tree-node property) is ignored. */
@@ -31,6 +41,5 @@ export function shared(input) {
31
41
  styles: input.section ? input.section.schema.styles : {},
32
42
  ...(input.section ? { section: input.section.schema.name } : {}),
33
43
  ...(input.section?.schema.sample ? { sample: input.section.schema.sample } : {}),
34
- ...(input.versions ? { versions: true } : {}),
35
44
  };
36
45
  }
package/dist/site.d.ts CHANGED
@@ -82,8 +82,10 @@ export type EntryQuery = {
82
82
  limit?: number;
83
83
  draft?: boolean;
84
84
  };
85
+ /** `version`: a stored page version to render instead of the draft — History (spec 2026-09-13 §4), draft mode only. */
85
86
  export type LoadOptions = {
86
87
  draft?: boolean;
88
+ version?: string;
87
89
  };
88
90
  export interface SmoodlySite {
89
91
  readonly locales: LocaleSet;
package/dist/site.js CHANGED
@@ -2,7 +2,7 @@ import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./ent
2
2
  import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
3
3
  import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
4
4
  import { elementSchemas } from "./richtext-walk.js";
5
- import { STYLES_KEY } from "./shared.js";
5
+ import { sharedNodeId, STYLES_KEY } from "./shared.js";
6
6
  import { withDefaults } from "./field-defaults.js";
7
7
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
8
8
  * bound to the render locale; the default keeps CMS paths as URLs. */
@@ -86,7 +86,7 @@ export function createSmoodlySite(options) {
86
86
  hasPage: hasPage(node, locale, draft),
87
87
  fields: fields.get(node.id) ?? {},
88
88
  });
89
- async function loadPage(id, locale, path, draft) {
89
+ async function loadPage(id, locale, path, draft, version) {
90
90
  const { pages } = options.stores();
91
91
  const record = await pages.getPage(id);
92
92
  if (!record || record.template === null)
@@ -94,7 +94,15 @@ export function createSmoodlySite(options) {
94
94
  const row = record.locales[locale];
95
95
  if (!row)
96
96
  return null;
97
- const raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
97
+ let raw;
98
+ if (version !== undefined) {
99
+ // History: a stored version of THIS page in THIS locale, and only inside draft mode.
100
+ const stored = draft ? await pages.getVersion(version) : null;
101
+ raw = stored && stored.pageId === id && stored.locale === locale ? stored.tree : null;
102
+ }
103
+ else {
104
+ raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
105
+ }
98
106
  if (!raw)
99
107
  return null;
100
108
  const tree = await resolveTree(raw, {
@@ -279,9 +287,11 @@ export function createSmoodlySite(options) {
279
287
  return fields;
280
288
  const { [STYLES_KEY]: styles, ...rest } = fields;
281
289
  const fetch = assetFetcher();
282
- if (fetch && styles && typeof styles === "object")
283
- await resolveMedia(styles, sectionStyles(item.section), fetch, elements);
284
- return { ...rest, styles: styles !== null && typeof styles === "object" ? styles : {} };
290
+ const own = styles !== null && typeof styles === "object" ? styles : {};
291
+ if (fetch)
292
+ await resolveMedia(own, sectionStyles(item.section), fetch, elements);
293
+ // The layout node id rides the styles: the wrapper strips it and emits data-smd-node (spec 2026-09-13 §1).
294
+ return { ...rest, styles: { ...own, __smoodlyNode: sharedNodeId(item.name) } };
285
295
  },
286
296
  };
287
297
  const site = {
@@ -290,7 +300,7 @@ export function createSmoodlySite(options) {
290
300
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
291
301
  load(target, locale, path, opts = {}) {
292
302
  const draft = opts.draft ?? false;
293
- return target.kind === "page" ? loadPage(target.id, locale, path, draft) : loadEntry(target.id, locale, path, draft);
303
+ return target.kind === "page" ? loadPage(target.id, locale, path, draft, opts.version) : loadEntry(target.id, locale, path, draft);
294
304
  },
295
305
  async resolve(locale, path, opts) {
296
306
  const target = await site.locate(locale, path);
package/dist/sql.js CHANGED
@@ -36,16 +36,16 @@ function fieldExpr(key, d, source) {
36
36
  }
37
37
  export function collectionSQL(collection) {
38
38
  const name = collection.name;
39
- const versioned = collection.versions === true;
40
- // Where a field's value lives: a versioned collection's view reads the
41
- // published snapshot (merged at publish time); otherwise shared fields
42
- // come from the node and localized ones from the locale row.
43
- const sourceOf = (d) => (versioned ? 'v."fields"' : d.localized === true ? 'l."fields"' : 'e."fields"');
39
+ // Every collection is versioned (spec 2026-09-14 §1): a field's value is the published
40
+ // snapshot's, falling back to the live row (the node's shared fields under the locale
41
+ // row's localized ones) for a published row from before versioning that nobody has
42
+ // written since — the store gives it a snapshot on its first write.
43
+ const source = `coalesce(v."fields", e."fields" || l."fields")`;
44
44
  const columns = ['e."id"', 'l."locale"', 'l."slug"', 'e."sort"', 'e."created_at"', 'l."updated_at"'];
45
45
  for (const [key, d] of Object.entries(collection.fields)) {
46
46
  if (key === "slug")
47
47
  continue; // the segment is the row's column, never a field
48
- columns.push(`${fieldExpr(key, d, sourceOf(d))} as ${q(snake(key))}`);
48
+ columns.push(`${fieldExpr(key, d, source)} as ${q(snake(key))}`);
49
49
  }
50
50
  const statements = [
51
51
  [
@@ -54,7 +54,7 @@ export function collectionSQL(collection) {
54
54
  columns.map((c) => " " + c).join(",\n"),
55
55
  `from "entries" e`,
56
56
  `join "entry_locales" l on l."entry_id" = e."id"`,
57
- ...(versioned ? [`join "entry_versions" v on v."id" = l."published_version_id"`] : []),
57
+ `left join "entry_versions" v on v."id" = l."published_version_id"`,
58
58
  `where e."collection" = '${name}' and l."status" = 'published';`,
59
59
  ].join("\n"),
60
60
  ];
@@ -106,6 +106,7 @@ create table if not exists "page_locales" (
106
106
  "draft_version_id" uuid,
107
107
  "published_version_id" uuid,
108
108
  "updated_at" timestamptz not null default now(),
109
+ "updated_by" text,
109
110
  primary key ("page_id", "locale")
110
111
  );
111
112
 
@@ -118,19 +119,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
118
119
  $$;
119
120
 
120
121
  -- A version belongs to ONE locale of one page: each locale has its own
121
- -- draft, its own publish and its own history.
122
+ -- draft, its own publish and its own history. One row per publish plus
123
+ -- the live draft (spec 2026-09-13 history §1): the unpublished draft row
124
+ -- is rewritten in place and saved_at is when its content last changed.
122
125
  create table if not exists "page_versions" (
123
126
  "id" uuid primary key default gen_random_uuid(),
124
127
  ${SPACE_COL},
125
128
  "page_id" uuid not null references "pages" ("id") on delete cascade,
126
129
  "locale" text not null,
127
130
  "tree" jsonb not null,
128
- "created_by" text,
129
- "created_at" timestamptz not null default now()
131
+ "saved_at" timestamptz not null default now()
130
132
  );
131
133
 
132
134
  create index if not exists "page_versions_page_locale_idx"
133
- on "page_versions" ("page_id", "locale", "created_at");
135
+ on "page_versions" ("page_id", "locale", "saved_at");
134
136
 
135
137
  create table if not exists "entries" (
136
138
  "id" uuid primary key default gen_random_uuid(),
@@ -167,6 +169,7 @@ create table if not exists "entry_locales" (
167
169
  "draft_version_id" uuid,
168
170
  "published_version_id" uuid,
169
171
  "updated_at" timestamptz not null default now(),
172
+ "updated_by" text,
170
173
  primary key ("entry_id", "locale")
171
174
  );
172
175
 
@@ -176,19 +179,19 @@ create index if not exists "entry_locales_status_idx"
176
179
  -- A version belongs to ONE locale of one entry and stores the MERGED
177
180
  -- fields (shared + localized + slug) as they were at that save, so
178
181
  -- restoring an old Finnish version restores the shared fields too.
179
- -- Rows exist only for collections registered with \`versions: true\`.
182
+ -- Rows for every collection and every shared item (spec 2026-09-14 §1).
183
+ -- One row per publish plus the live draft (spec 2026-09-13 history §1).
180
184
  create table if not exists "entry_versions" (
181
185
  "id" uuid primary key default gen_random_uuid(),
182
186
  ${SPACE_COL},
183
187
  "entry_id" uuid not null references "entries" ("id") on delete cascade,
184
188
  "locale" text not null,
185
189
  "fields" jsonb not null,
186
- "created_by" text,
187
- "created_at" timestamptz not null default now()
190
+ "saved_at" timestamptz not null default now()
188
191
  );
189
192
 
190
193
  create index if not exists "entry_versions_entry_locale_idx"
191
- on "entry_versions" ("entry_id", "locale", "created_at");
194
+ on "entry_versions" ("entry_id", "locale", "saved_at");
192
195
 
193
196
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
194
197
  -- statement. Deliberately does not touch updated_at — a drag must not
package/dist/store.d.ts CHANGED
@@ -11,6 +11,9 @@ export type PageLocale = {
11
11
  status: "draft" | "published";
12
12
  draftVersionId: string | null;
13
13
  publishedVersionId: string | null;
14
+ updatedAt: number;
15
+ /** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
16
+ updatedBy: string | null;
14
17
  };
15
18
  export type PageRecord = {
16
19
  id: string;
@@ -37,22 +40,23 @@ export type AddLocaleOptions = {
37
40
  slug?: string;
38
41
  title?: string;
39
42
  };
43
+ /** `savedAt` is when the content last changed: the unpublished draft row is rewritten in place (spec 2026-09-13 history §1). */
40
44
  export type PageVersion = {
41
45
  id: string;
42
46
  pageId: string;
43
47
  locale: string;
44
48
  tree: PageTree;
45
- createdAt: number;
49
+ savedAt: number;
46
50
  };
47
51
  export interface PageStore {
48
- createPage(input: CreatePageInput): Promise<PageRecord>;
52
+ createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
49
53
  getPage(id: string): Promise<PageRecord | null>;
50
54
  listPages(): Promise<PageRecord[]>;
51
55
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
52
56
  /** locale → path, for the locales the page exists in. */
53
57
  pathsOf(id: string): Promise<Record<string, string>>;
54
58
  /** Refused when the page already has the locale or its parent lacks it (the parent rule). */
55
- addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
59
+ addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
56
60
  /** Deletes the row, its versions and its paths. Refused while a
57
61
  * descendant has the locale, and for the page's last locale. */
58
62
  removeLocale(id: string, locale: string): Promise<PageRecord>;
@@ -60,7 +64,7 @@ export interface PageStore {
60
64
  locale: string;
61
65
  slug?: string;
62
66
  title?: string;
63
- }): Promise<PageRecord>;
67
+ }, by?: string): Promise<PageRecord>;
64
68
  /**
65
69
  * Reparents `id` under `to.parentId`. `to.index` is the moved node's
66
70
  * final 0-based position among the destination siblings: the node is
@@ -73,9 +77,21 @@ export interface PageStore {
73
77
  index?: number;
74
78
  }): Promise<PageRecord>;
75
79
  /** A folder becomes a page: an empty draft version per present locale. */
76
- setTemplate(id: string, template: string): Promise<PageRecord>;
77
- saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
78
- publish(id: string, locale: string): Promise<PageRecord>;
80
+ setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
81
+ saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
82
+ publish(id: string, locale: string, by?: string): Promise<PageRecord>;
83
+ /** The draft goes back to what is live: the locale's draft pointer set to its
84
+ * published pointer and the abandoned draft row deleted (spec 2026-09-13 history §1).
85
+ * Refused when nothing is published in the locale (spec 2026-09-13 publish flow §4). */
86
+ discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
87
+ /** Takes ONE locale off the site: status draft, both pointers kept so a later
88
+ * publish republishes the current draft (spec §5). */
89
+ unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
90
+ /** The version's tree becomes the draft through the draft rule (spec 2026-09-13 history §6); the
91
+ * published version restores as a discard. Refused when the version is not this page's in this locale. */
92
+ restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
93
+ /** One stored version by id, whoever it belongs to — callers check `pageId` and `locale`. */
94
+ getVersion(versionId: string): Promise<PageVersion | null>;
79
95
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
80
96
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
81
97
  /** The page fields of many versions in ONE read (spec 2026-09-10 page fields §3):
@@ -110,6 +126,7 @@ export declare const pageErrors: {
110
126
  notFound: (id: string) => Error;
111
127
  slugTaken: (slug: string) => Error;
112
128
  folder: (id: string) => Error;
129
+ nothingPublished: (id: string, locale: string) => Error;
113
130
  hasTemplate: (id: string) => Error;
114
131
  homeChildren: () => Error;
115
132
  cycle: () => Error;
@@ -121,6 +138,7 @@ export declare const pageErrors: {
121
138
  lastLocale: (id: string) => Error;
122
139
  localeInUse: (locale: string, titles: string[]) => Error;
123
140
  moveLocales: (locale: string) => Error;
141
+ foreignVersion: (id: string, locale: string, versionId: string) => Error;
124
142
  };
125
143
  export { assertSupportedLocale } from "./paths.ts";
126
144
  /** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
@@ -161,26 +179,33 @@ export declare class MemoryPageStore implements PageStore {
161
179
  private liveTrees;
162
180
  private writeRefs;
163
181
  private newVersion;
182
+ /** The draft rule (spec 2026-09-13 history §1): the unpublished draft row is
183
+ * rewritten in place; a new row starts only when the draft IS the published one. */
184
+ private writeDraftVersion;
164
185
  private dropVersions;
165
- createPage(input: CreatePageInput): Promise<PageRecord>;
186
+ createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
166
187
  getPage(id: string): Promise<PageRecord | null>;
167
188
  listPages(): Promise<PageRecord[]>;
168
189
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
169
190
  pathsOf(id: string): Promise<Record<string, string>>;
170
- addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
191
+ addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
171
192
  removeLocale(id: string, locale: string): Promise<PageRecord>;
172
193
  rename(id: string, patch: {
173
194
  locale: string;
174
195
  slug?: string;
175
196
  title?: string;
176
- }): Promise<PageRecord>;
197
+ }, by?: string): Promise<PageRecord>;
177
198
  move(id: string, to: {
178
199
  parentId: string | null;
179
200
  index?: number;
180
201
  }): Promise<PageRecord>;
181
- setTemplate(id: string, template: string): Promise<PageRecord>;
182
- saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
183
- publish(id: string, locale: string): Promise<PageRecord>;
202
+ setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
203
+ saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
204
+ publish(id: string, locale: string, by?: string): Promise<PageRecord>;
205
+ discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
206
+ unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
207
+ restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
208
+ getVersion(versionId: string): Promise<PageVersion | null>;
184
209
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
185
210
  fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
186
211
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
package/dist/store.js CHANGED
@@ -27,6 +27,7 @@ export const pageErrors = {
27
27
  notFound: (id) => new Error(`smoodly: no page "${id}".`),
28
28
  slugTaken: (slug) => new Error(`smoodly: a page with slug "${slug}" already exists under this parent.`),
29
29
  folder: (id) => new Error(`smoodly: "${id}" is a folder — give it a template first.`),
30
+ nothingPublished: (id, locale) => new Error(`smoodly: page "${id}" has nothing published in locale "${locale}" — there is no version to restore.`),
30
31
  hasTemplate: (id) => new Error(`smoodly: "${id}" already has a template.`),
31
32
  homeChildren: () => new Error("smoodly: the home page cannot have child pages."),
32
33
  cycle: () => new Error("smoodly: a page cannot be moved under itself."),
@@ -38,6 +39,7 @@ export const pageErrors = {
38
39
  lastLocale: (id) => new Error(`smoodly: page "${id}" must exist in at least one locale — delete the page instead.`),
39
40
  localeInUse: (locale, titles) => new Error(`smoodly: child pages still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
40
41
  moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the page cannot move under it.`),
42
+ foreignVersion: (id, locale, versionId) => new Error(`smoodly: "${versionId}" is not a version of page "${id}" in locale "${locale}".`),
41
43
  };
42
44
  export { assertSupportedLocale } from "./paths.js";
43
45
  /** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
@@ -83,6 +85,11 @@ export async function assertPublishable(label, tree, sections, entries, locale)
83
85
  }
84
86
  let counter = 0;
85
87
  const uid = () => `id-${++counter}-${Date.now().toString(36)}`;
88
+ /** The row's audit pair: every write that touches a locale row sets both (spec 2026-09-13 history §2). */
89
+ function touchRow(row, by) {
90
+ row.updatedAt = Date.now();
91
+ row.updatedBy = by ?? null;
92
+ }
86
93
  export class MemoryPageStore {
87
94
  constructor(options = {}) {
88
95
  this.options = options;
@@ -165,17 +172,35 @@ export class MemoryPageStore {
165
172
  refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections, elements));
166
173
  }
167
174
  newVersion(pageId, locale, tree) {
168
- const version = { id: uid(), pageId, locale, tree: structuredClone(tree), createdAt: Date.now() };
175
+ const version = { id: uid(), pageId, locale, tree: structuredClone(tree), savedAt: Date.now() };
169
176
  this.versions.set(version.id, version);
170
177
  return version;
171
178
  }
179
+ /** The draft rule (spec 2026-09-13 history §1): the unpublished draft row is
180
+ * rewritten in place; a new row starts only when the draft IS the published one. */
181
+ writeDraftVersion(page, locale, tree, by) {
182
+ const row = localeRow(page, locale);
183
+ const current = row.draftVersionId ? this.versions.get(row.draftVersionId) : undefined;
184
+ let version;
185
+ if (current && row.draftVersionId !== row.publishedVersionId) {
186
+ current.tree = structuredClone(tree);
187
+ current.savedAt = Date.now();
188
+ version = current;
189
+ }
190
+ else {
191
+ version = this.newVersion(page.id, locale, tree);
192
+ row.draftVersionId = version.id;
193
+ }
194
+ touchRow(row, by);
195
+ return version;
196
+ }
172
197
  dropVersions(pageId, locale) {
173
198
  for (const [vid, v] of this.versions) {
174
199
  if (v.pageId === pageId && (locale === undefined || v.locale === locale))
175
200
  this.versions.delete(vid);
176
201
  }
177
202
  }
178
- async createPage(input) {
203
+ async createPage(input, by) {
179
204
  assertSupportedLocale(input.locale, this.opts.locales);
180
205
  assertSegment(input.slug, "page slug");
181
206
  const parentId = input.parentId ?? null;
@@ -204,6 +229,8 @@ export class MemoryPageStore {
204
229
  status: "draft",
205
230
  draftVersionId: version?.id ?? null,
206
231
  publishedVersionId: null,
232
+ updatedAt: Date.now(),
233
+ updatedBy: by ?? null,
207
234
  },
208
235
  },
209
236
  };
@@ -236,7 +263,7 @@ export class MemoryPageStore {
236
263
  pathsOf(id) {
237
264
  return this.paths.pathsOf({ kind: "page", id });
238
265
  }
239
- async addLocale(id, locale, options) {
266
+ async addLocale(id, locale, options, by) {
240
267
  assertSupportedLocale(locale, this.opts.locales);
241
268
  const page = this.must(id);
242
269
  if (page.locales[locale])
@@ -259,6 +286,8 @@ export class MemoryPageStore {
259
286
  status: "draft",
260
287
  draftVersionId: version?.id ?? null,
261
288
  publishedVersionId: null,
289
+ updatedAt: Date.now(),
290
+ updatedBy: by ?? null,
262
291
  };
263
292
  try {
264
293
  await this.writePaths([page]);
@@ -294,7 +323,7 @@ export class MemoryPageStore {
294
323
  this.writeRefs(page);
295
324
  return this.snapshot(page);
296
325
  }
297
- async rename(id, patch) {
326
+ async rename(id, patch, by) {
298
327
  const page = this.must(id);
299
328
  const row = localeRow(page, patch.locale);
300
329
  if (patch.slug !== undefined)
@@ -323,6 +352,7 @@ export class MemoryPageStore {
323
352
  Object.assign(row, before);
324
353
  throw e;
325
354
  }
355
+ touchRow(row, by);
326
356
  return this.snapshot(page);
327
357
  }
328
358
  async move(id, to) {
@@ -369,26 +399,26 @@ export class MemoryPageStore {
369
399
  }
370
400
  return this.snapshot(page);
371
401
  }
372
- async setTemplate(id, template) {
402
+ async setTemplate(id, template, by) {
373
403
  const page = this.must(id);
374
404
  if (page.template !== null)
375
405
  throw pageErrors.hasTemplate(id);
376
406
  page.template = template;
377
- for (const [locale, row] of Object.entries(page.locales))
407
+ for (const [locale, row] of Object.entries(page.locales)) {
378
408
  row.draftVersionId = this.newVersion(id, locale, { zones: {} }).id;
409
+ touchRow(row, by);
410
+ }
379
411
  return this.snapshot(page);
380
412
  }
381
- async saveDraft(id, locale, tree) {
413
+ async saveDraft(id, locale, tree, by) {
382
414
  const page = this.must(id);
383
415
  if (page.template === null)
384
416
  throw pageErrors.folder(id);
385
- const row = localeRow(page, locale);
386
- const version = this.newVersion(id, locale, tree);
387
- row.draftVersionId = version.id;
417
+ const version = this.writeDraftVersion(page, locale, tree, by);
388
418
  this.writeRefs(page);
389
419
  return { ...version, tree: structuredClone(version.tree) };
390
420
  }
391
- async publish(id, locale) {
421
+ async publish(id, locale, by) {
392
422
  const page = this.must(id);
393
423
  if (page.template === null)
394
424
  throw pageErrors.folder(id);
@@ -401,9 +431,49 @@ export class MemoryPageStore {
401
431
  await assertPublishable(row.slug, draft, sections, entries, locale);
402
432
  row.publishedVersionId = row.draftVersionId;
403
433
  row.status = "published";
434
+ touchRow(row, by);
404
435
  this.writeRefs(page);
405
436
  return this.snapshot(page);
406
437
  }
438
+ async discardDraft(id, locale, by) {
439
+ const page = this.must(id);
440
+ const row = localeRow(page, locale);
441
+ if (!row.publishedVersionId)
442
+ throw pageErrors.nothingPublished(id, locale);
443
+ const abandoned = row.draftVersionId;
444
+ row.draftVersionId = row.publishedVersionId;
445
+ if (abandoned && abandoned !== row.publishedVersionId)
446
+ this.versions.delete(abandoned); // never shown as a stray draft
447
+ touchRow(row, by);
448
+ this.writeRefs(page); // the refs union (draft ∪ published) just shrank to one tree
449
+ return this.snapshot(page);
450
+ }
451
+ async unpublish(id, locale, by) {
452
+ const page = this.must(id);
453
+ const row = localeRow(page, locale);
454
+ row.status = "draft";
455
+ touchRow(row, by);
456
+ this.writeRefs(page);
457
+ return this.snapshot(page);
458
+ }
459
+ async restore(id, locale, versionId, by) {
460
+ const page = this.must(id);
461
+ if (page.template === null)
462
+ throw pageErrors.folder(id);
463
+ const row = localeRow(page, locale);
464
+ const version = this.versions.get(versionId);
465
+ if (!version || version.pageId !== id || version.locale !== locale)
466
+ throw pageErrors.foreignVersion(id, locale, versionId);
467
+ if (versionId === row.publishedVersionId)
468
+ return this.discardDraft(id, locale, by);
469
+ this.writeDraftVersion(page, locale, version.tree, by);
470
+ this.writeRefs(page);
471
+ return this.snapshot(page);
472
+ }
473
+ async getVersion(versionId) {
474
+ const v = this.versions.get(versionId);
475
+ return v ? { ...v, tree: structuredClone(v.tree) } : null;
476
+ }
407
477
  async getDraftTree(id, locale) {
408
478
  const row = this.pages.get(id)?.locales[locale];
409
479
  if (!row?.draftVersionId)
@@ -428,7 +498,7 @@ export class MemoryPageStore {
428
498
  async listVersions(id, locale) {
429
499
  return [...this.versions.values()]
430
500
  .filter((v) => v.pageId === id && v.locale === locale)
431
- .sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id))
501
+ .sort((a, b) => b.savedAt - a.savedAt || b.id.localeCompare(a.id))
432
502
  .map((v) => ({ ...v, tree: structuredClone(v.tree) }));
433
503
  }
434
504
  async deletePage(id) {
@@ -37,23 +37,28 @@ export declare class SupabaseEntryStore implements EntryStore {
37
37
  private assertParentHasLocale;
38
38
  private updateNode;
39
39
  private insertLocale;
40
+ /** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
40
41
  private updateLocale;
41
42
  /** The row goes first (it is what makes the entry exist in the locale), then its versions. */
42
43
  private deleteLocale;
43
- /** A versioned collection snapshots the merged fields and points the
44
- * locale's draft at the new row; others do nothing here. */
44
+ /** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion. */
45
45
  private recordVersion;
46
- create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
46
+ /** See MemoryEntryStore.ensureLiveSnapshot: the snapshot of what is live, both pointers on it. */
47
+ private ensureLiveSnapshot;
48
+ create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
47
49
  get(collection: string, id: string): Promise<EntryRecord | null>;
48
50
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
49
51
  getMany(collection: string, ids: string[], locale: string, options?: {
50
52
  status?: "published";
51
53
  }): Promise<Record<string, Record<string, unknown>>>;
52
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
53
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
54
- addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
54
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
55
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
56
+ discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
57
+ addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
55
58
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
56
59
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
60
+ restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
61
+ getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
57
62
  move(collection: string, id: string, to: {
58
63
  parentId: string | null;
59
64
  index?: number;