smoodly 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. package/package.json +1 -1
package/dist/sql.js CHANGED
@@ -5,9 +5,11 @@
5
5
  // security_invoker so it runs under the caller's own RLS policies
6
6
  // rather than the view owner's privileges (PostgREST-native reads,
7
7
  // SQL-queryable eject story) — and a partial expression index for a
8
- // declared order field, on the jsonb VALUE (slug uniqueness is a core
9
- // index, scoped to siblings, not per collection). Views project the
10
- // default locale; the `i18n` overlay is the resolver/API's business.
8
+ // declared order field, on the jsonb VALUE (sibling slug uniqueness
9
+ // is the `paths` primary key plus the stores' per-locale pre-check,
10
+ // not an index). A view joins the locale rows, one row per published
11
+ // locale (spec 2026-09-06 §7); a versioned collection reads its
12
+ // fields from the published snapshot.
11
13
  import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
12
14
  import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
13
15
  const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
@@ -16,42 +18,50 @@ const q = (s) => `"${s}"`;
16
18
  // immutable — the order field's index stays on the raw jsonb
17
19
  // expression: jsonb ordering compares numbers numerically and strings,
18
20
  // ISO dates included, alphabetically, matching the memory comparator).
19
- function fieldExpr(key, d) {
21
+ function fieldExpr(key, d, source) {
20
22
  switch (d.type) {
21
23
  case "text":
22
24
  case "link":
23
25
  case "slug":
24
26
  case "select":
25
27
  case "ref": // stored as the target entry's id string
26
- return `"fields"->>'${key}'`;
28
+ return `${source}->>'${key}'`;
27
29
  case "number":
28
- return `("fields"->>'${key}')::numeric`;
30
+ return `(${source}->>'${key}')::numeric`;
29
31
  case "date":
30
- return `("fields"->>'${key}')::timestamptz`;
32
+ return `(${source}->>'${key}')::timestamptz`;
31
33
  default:
32
- return `"fields"->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
34
+ return `${source}->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
33
35
  }
34
36
  }
35
37
  export function collectionSQL(collection) {
36
38
  const name = collection.name;
37
- const columns = ['"id"', '"sort"', '"locales"', '"created_at"', '"updated_at"'];
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"');
44
+ const columns = ['e."id"', 'l."locale"', 'l."slug"', 'e."sort"', 'e."created_at"', 'l."updated_at"'];
38
45
  for (const [key, d] of Object.entries(collection.fields)) {
39
- columns.push(`${fieldExpr(key, d)} as ${q(snake(key))}`);
46
+ if (key === "slug")
47
+ continue; // the segment is the row's column, never a field
48
+ columns.push(`${fieldExpr(key, d, sourceOf(d))} as ${q(snake(key))}`);
40
49
  }
41
50
  const statements = [
42
51
  [
43
52
  `create or replace view ${q(name)} with (security_invoker = true) as`,
44
53
  `select`,
45
54
  columns.map((c) => " " + c).join(",\n"),
46
- `from "entries"`,
47
- `where "collection" = '${name}' and "status" = 'published';`,
55
+ `from "entries" e`,
56
+ `join "entry_locales" l on l."entry_id" = e."id"`,
57
+ ...(versioned ? [`join "entry_versions" v on v."id" = l."published_version_id"`] : []),
58
+ `where e."collection" = '${name}' and l."status" = 'published';`,
48
59
  ].join("\n"),
49
60
  ];
50
- // slug uniqueness is the generic core index, scoped to siblings
51
- // (space_id, collection, parent_id, slug); only a declared order field
52
- // needs a per-collection expression index the built-in timestamps are
53
- // real columns already. The expression is the jsonb value (`->`), the
54
- // same one the adapter orders by, so the index actually serves the sort.
61
+ // Only a declared order field needs a per-collection expression index —
62
+ // on the node's shared fields (an order field can be neither localized
63
+ // nor `slug`, which collection() refuses), the jsonb value (`->`) the
64
+ // adapter orders by, so the index serves the sort.
55
65
  const order = collection.order;
56
66
  if (order !== undefined && order !== "manual" && !BUILT_IN_ORDER_FIELDS.includes(order.by)) {
57
67
  statements.push(`create index if not exists ${q(`entries_${name}_${snake(order.by)}_idx`)}\n` +
@@ -71,28 +81,34 @@ create table if not exists "pages" (
71
81
  "id" uuid primary key default gen_random_uuid(),
72
82
  ${SPACE_COL},
73
83
  "parent_id" uuid references "pages" ("id") on delete restrict,
74
- "slug" text not null,
75
- "title" text not null,
76
- "i18n" jsonb,
77
84
  "template" text,
78
- "status" text not null default 'draft',
79
85
  "sort" numeric,
80
- "locales" text[],
81
- "draft_version_id" uuid,
82
- "published_version_id" uuid,
83
86
  "created_at" timestamptz not null default now(),
84
87
  "updated_at" timestamptz not null default now()
85
88
  );
86
89
 
87
- -- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
88
- -- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
89
- -- two root nodes cannot share one either. template null = folder.
90
- create unique index if not exists "pages_sibling_slug_key"
91
- on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
92
-
90
+ -- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
91
+ -- its position; template null = folder. Everything a locale owns — the
92
+ -- node's own segment, title, publish state and version pointers lives
93
+ -- in page_locales, one row per locale the page exists in (spec 2026-09-06
94
+ -- §2). There is no sibling-slug index: URL uniqueness per locale is the
95
+ -- primary key of "paths", which the stores write inside the same operation.
93
96
  create index if not exists "pages_parent_idx"
94
97
  on "pages" ("space_id", "parent_id", "sort");
95
98
 
99
+ create table if not exists "page_locales" (
100
+ ${SPACE_COL},
101
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
102
+ "locale" text not null,
103
+ "slug" text not null,
104
+ "title" text not null,
105
+ "status" text not null default 'draft',
106
+ "draft_version_id" uuid,
107
+ "published_version_id" uuid,
108
+ "updated_at" timestamptz not null default now(),
109
+ primary key ("page_id", "locale")
110
+ );
111
+
96
112
  -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
97
113
  create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
98
114
  update "pages" pg set "sort" = u.ord
@@ -101,15 +117,21 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
101
117
  and pg."space_id" = "smoodly_current_space"();
102
118
  $$;
103
119
 
120
+ -- A version belongs to ONE locale of one page: each locale has its own
121
+ -- draft, its own publish and its own history.
104
122
  create table if not exists "page_versions" (
105
123
  "id" uuid primary key default gen_random_uuid(),
106
124
  ${SPACE_COL},
107
125
  "page_id" uuid not null references "pages" ("id") on delete cascade,
126
+ "locale" text not null,
108
127
  "tree" jsonb not null,
109
128
  "created_by" text,
110
129
  "created_at" timestamptz not null default now()
111
130
  );
112
131
 
132
+ create index if not exists "page_versions_page_locale_idx"
133
+ on "page_versions" ("page_id", "locale", "created_at");
134
+
113
135
  create table if not exists "saved_sections" (
114
136
  "id" uuid primary key default gen_random_uuid(),
115
137
  ${SPACE_COL},
@@ -132,40 +154,58 @@ create table if not exists "entries" (
132
154
  "collection" text not null,
133
155
  "parent_id" uuid references "entries" ("id") on delete restrict,
134
156
  "fields" jsonb not null default '{}'::jsonb,
135
- "i18n" jsonb,
136
- "status" text not null default 'draft',
137
157
  "sort" numeric,
138
- "locales" text[],
139
158
  "created_at" timestamptz not null default now(),
140
159
  "updated_at" timestamptz not null default now()
141
160
  );
142
161
 
143
- -- Entry slugs are unique among SIBLINGS, like pages: one rule for every
144
- -- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
145
- -- ROOT entries (parent_id null) cannot share a slug either. The partial
146
- -- predicate keeps absent, null, empty or non-string slugs out of the
147
- -- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
148
- -- collide with the next null slug, and a collection with no slug field
149
- -- at all could hold only one entry per parent. jsonb_typeof(...) =
150
- -- 'string' is required because ->> renders ANY JSON scalar as text —
151
- -- a number, boolean or null slug would otherwise print as non-empty
152
- -- text and claim the index; only a non-empty string slug claims a URL,
153
- -- absent, null, empty or non-string slugs never conflict — the same
154
- -- rule as slugOf in paths.ts. A flat collection (every entry a root)
155
- -- therefore behaves exactly as per-collection uniqueness did.
156
- create unique index if not exists "entries_sibling_slug_key"
157
- on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
158
- where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
159
-
160
- create index if not exists "entries_collection_status_idx"
161
- on "entries" ("space_id", "collection", "status");
162
-
162
+ -- The node holds identity, tree position and the SHARED fields every
163
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
164
+ -- the same in every language. Everything a locale owns its segment,
165
+ -- publish state, the .localized() fields and the version pointers
166
+ -- lives in entry_locales, one row per locale the entry exists in. There
167
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
168
+ -- primary key of "paths", which the stores write inside the same
169
+ -- operation, and both adapters pre-check siblings per locale in code.
163
170
  create index if not exists "entries_collection_sort_idx"
164
171
  on "entries" ("space_id", "collection", "sort");
165
172
 
166
173
  create index if not exists "entries_parent_idx"
167
174
  on "entries" ("space_id", "collection", "parent_id");
168
175
 
176
+ create table if not exists "entry_locales" (
177
+ ${SPACE_COL},
178
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
179
+ "locale" text not null,
180
+ "slug" text,
181
+ "status" text not null default 'draft',
182
+ "fields" jsonb not null default '{}'::jsonb,
183
+ "draft_version_id" uuid,
184
+ "published_version_id" uuid,
185
+ "updated_at" timestamptz not null default now(),
186
+ primary key ("entry_id", "locale")
187
+ );
188
+
189
+ create index if not exists "entry_locales_status_idx"
190
+ on "entry_locales" ("space_id", "locale", "status");
191
+
192
+ -- A version belongs to ONE locale of one entry and stores the MERGED
193
+ -- fields (shared + localized + slug) as they were at that save, so
194
+ -- restoring an old Finnish version restores the shared fields too.
195
+ -- Rows exist only for collections registered with \`versions: true\`.
196
+ create table if not exists "entry_versions" (
197
+ "id" uuid primary key default gen_random_uuid(),
198
+ ${SPACE_COL},
199
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
200
+ "locale" text not null,
201
+ "fields" jsonb not null,
202
+ "created_by" text,
203
+ "created_at" timestamptz not null default now()
204
+ );
205
+
206
+ create index if not exists "entry_versions_entry_locale_idx"
207
+ on "entry_versions" ("entry_id", "locale", "created_at");
208
+
169
209
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
170
210
  -- statement. Deliberately does not touch updated_at — a drag must not
171
211
  -- make the whole collection look freshly edited. Scoped to the current
@@ -231,10 +271,13 @@ $$;
231
271
  -- (collectionSQL) run with security_invoker = true, so they are subject
232
272
  -- to this same smoodly_read policy rather than their owner's privileges.
233
273
  alter table "pages" enable row level security;
274
+ alter table "page_locales" enable row level security;
234
275
  alter table "page_versions" enable row level security;
235
276
  alter table "saved_sections" enable row level security;
236
277
  alter table "global_sections" enable row level security;
237
278
  alter table "entries" enable row level security;
279
+ alter table "entry_locales" enable row level security;
280
+ alter table "entry_versions" enable row level security;
238
281
  alter table "refs" enable row level security;
239
282
  alter table "paths" enable row level security;
240
283
  `.trim(),
package/dist/store.d.ts CHANGED
@@ -4,36 +4,43 @@ import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import type { MemoryRefIndex } from "./ref-index.ts";
5
5
  import { type PathIndex } from "./path-index.ts";
6
6
  import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
7
- export type PageI18n = Record<string, {
8
- slug?: string;
9
- title?: string;
10
- }>;
7
+ export type PageLocale = {
8
+ /** The node's OWN segment in this locale; the URL is derived (paths.ts). */
9
+ slug: string;
10
+ title: string;
11
+ status: "draft" | "published";
12
+ draftVersionId: string | null;
13
+ publishedVersionId: string | null;
14
+ };
11
15
  export type PageRecord = {
12
16
  id: string;
13
17
  parentId: string | null;
14
- /** The node's OWN segment in the default locale; the URL is derived (paths.ts). */
15
- slug: string;
16
- title: string;
17
- i18n: PageI18n | null;
18
18
  /** null = folder: a node with a title and a position but no page behind it. */
19
19
  template: string | null;
20
- status: "draft" | "published";
21
20
  sort: number | null;
22
- locales: string[] | null;
23
- draftVersionId: string | null;
24
- publishedVersionId: string | null;
21
+ /** One entry per locale the page exists in. */
22
+ locales: Record<string, PageLocale>;
25
23
  };
26
24
  export type CreatePageInput = {
25
+ /** The one locale the page is created in; others are added with addLocale. */
26
+ locale: string;
27
27
  slug: string;
28
28
  template: string | null;
29
29
  title?: string;
30
30
  parentId?: string | null;
31
- i18n?: PageI18n | null;
32
- locales?: string[] | null;
31
+ };
32
+ /** `from` names the locale whose draft tree, title and slug are copied;
33
+ * `slug` and `title` override the copies (the materializer's per-locale
34
+ * segments for fixed pages). */
35
+ export type AddLocaleOptions = {
36
+ from: string;
37
+ slug?: string;
38
+ title?: string;
33
39
  };
34
40
  export type PageVersion = {
35
41
  id: string;
36
42
  pageId: string;
43
+ locale: string;
37
44
  tree: PageTree;
38
45
  createdAt: number;
39
46
  };
@@ -42,7 +49,13 @@ export interface PageStore {
42
49
  getPage(id: string): Promise<PageRecord | null>;
43
50
  listPages(): Promise<PageRecord[]>;
44
51
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
52
+ /** locale → path, for the locales the page exists in. */
45
53
  pathsOf(id: string): Promise<Record<string, string>>;
54
+ /** 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>;
56
+ /** Deletes the row, its versions and its paths. Refused while a
57
+ * descendant has the locale, and for the page's last locale. */
58
+ removeLocale(id: string, locale: string): Promise<PageRecord>;
46
59
  rename(id: string, patch: {
47
60
  locale: string;
48
61
  slug?: string;
@@ -53,17 +66,19 @@ export interface PageStore {
53
66
  * final 0-based position among the destination siblings: the node is
54
67
  * removed from the sibling list first, then spliced back in at `index`.
55
68
  * Omitted, it appends; out-of-range values clamp to [0, siblings.length].
69
+ * Refused when the destination lacks one of the page's locales.
56
70
  */
57
71
  move(id: string, to: {
58
72
  parentId: string | null;
59
73
  index?: number;
60
74
  }): Promise<PageRecord>;
75
+ /** A folder becomes a page: an empty draft version per present locale. */
61
76
  setTemplate(id: string, template: string): Promise<PageRecord>;
62
- saveDraft(id: string, tree: PageTree): Promise<PageVersion>;
63
- publish(id: string): Promise<PageRecord>;
64
- getDraftTree(id: string): Promise<PageTree | null>;
65
- getPublishedTree(id: string): Promise<PageTree | null>;
66
- listVersions(id: string): Promise<PageVersion[]>;
77
+ saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
78
+ publish(id: string, locale: string): Promise<PageRecord>;
79
+ getDraftTree(id: string, locale: string): Promise<PageTree | null>;
80
+ getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
81
+ listVersions(id: string, locale: string): Promise<PageVersion[]>;
67
82
  deletePage(id: string): Promise<void>;
68
83
  }
69
84
  /** What a PageStore needs beyond its tables to maintain the refs index
@@ -94,16 +109,26 @@ export declare const pageErrors: {
94
109
  homeChildren: () => Error;
95
110
  cycle: () => Error;
96
111
  hasChildren: (id: string) => Error;
112
+ unsupportedLocale: (locale: string) => Error;
113
+ notInLocale: (id: string, locale: string) => Error;
114
+ alreadyInLocale: (id: string, locale: string) => Error;
115
+ parentNotInLocale: (locale: string) => Error;
116
+ lastLocale: (id: string) => Error;
117
+ localeInUse: (locale: string, titles: string[]) => Error;
118
+ moveLocales: (locale: string) => Error;
97
119
  };
98
- export declare function assertPageI18n(i18n: PageI18n | null | undefined): void;
99
- /** A page's refs rows cover BOTH live trees edges(draft)
100
- * edges(published) so an entry stays delete-guarded while either tree
120
+ export { assertSupportedLocale } from "./paths.ts";
121
+ /** The page's row for a locale the "does not exist in locale" guard every per-locale write runs. */
122
+ export declare function localeRow(page: PageRecord, locale: string): PageLocale;
123
+ /** A page's refs rows cover EVERY live tree — each locale's draft and
124
+ * published trees — so an entry stays delete-guarded while any of them
101
125
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
102
- export declare function pageRefEdges(draft: PageTree, published: PageTree | null, sections: SectionLike[]): RefEdge[];
103
- /** The publish-integrity gate (DESIGN.md §3): a page must not go live
104
- * showing entries the published site can't resolve — every referenced
105
- * entry must exist and be published. Throws naming the offenders. */
106
- export declare function assertPublishable(label: string, tree: PageTree, sections: SectionLike[], entries: EntryStore): Promise<void>;
126
+ export declare function pageRefEdges(trees: PageTree[], sections: SectionLike[]): RefEdge[];
127
+ /** The publish-integrity gate (DESIGN.md §3), per locale: a page must not
128
+ * go live in a locale showing entries the published site cannot resolve
129
+ * THERE — every referenced entry must exist, have a row in that locale,
130
+ * and be published in it. Throws naming the offenders. */
131
+ export declare function assertPublishable(label: string, tree: PageTree, sections: SectionLike[], entries: EntryStore, locale: string): Promise<void>;
107
132
  export declare class MemoryPageStore implements PageStore {
108
133
  private options;
109
134
  private pages;
@@ -118,18 +143,27 @@ export declare class MemoryPageStore implements PageStore {
118
143
  private byId;
119
144
  private must;
120
145
  private isHome;
146
+ private snapshot;
147
+ private children;
121
148
  private hasChildren;
122
149
  private siblings;
150
+ /** Sibling slugs are unique per parent PER LOCALE — a friendlier error
151
+ * than the path index's, which stays the real invariant. */
123
152
  private assertSlugFree;
124
153
  private subtree;
125
154
  private writePaths;
155
+ /** Every locale's draft and published trees, deduped by version id. */
156
+ private liveTrees;
126
157
  private writeRefs;
127
158
  private newVersion;
159
+ private dropVersions;
128
160
  createPage(input: CreatePageInput): Promise<PageRecord>;
129
161
  getPage(id: string): Promise<PageRecord | null>;
130
162
  listPages(): Promise<PageRecord[]>;
131
163
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
132
164
  pathsOf(id: string): Promise<Record<string, string>>;
165
+ addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
166
+ removeLocale(id: string, locale: string): Promise<PageRecord>;
133
167
  rename(id: string, patch: {
134
168
  locale: string;
135
169
  slug?: string;
@@ -140,10 +174,10 @@ export declare class MemoryPageStore implements PageStore {
140
174
  index?: number;
141
175
  }): Promise<PageRecord>;
142
176
  setTemplate(id: string, template: string): Promise<PageRecord>;
143
- saveDraft(id: string, tree: PageTree): Promise<PageVersion>;
144
- publish(id: string): Promise<PageRecord>;
145
- getDraftTree(id: string): Promise<PageTree | null>;
146
- getPublishedTree(id: string): Promise<PageTree | null>;
147
- listVersions(id: string): Promise<PageVersion[]>;
177
+ saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
178
+ publish(id: string, locale: string): Promise<PageRecord>;
179
+ getDraftTree(id: string, locale: string): Promise<PageTree | null>;
180
+ getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
181
+ listVersions(id: string, locale: string): Promise<PageVersion[]>;
148
182
  deletePage(id: string): Promise<void>;
149
183
  }