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.
- package/README.md +94 -15
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
- package/dist/admin/editor/CanvasOverlay.js +5 -5
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +251 -23
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/SharedInspector.d.ts +23 -0
- package/dist/admin/editor/SharedInspector.js +13 -0
- package/dist/admin/editor/shared-edit.d.ts +39 -0
- package/dist/admin/editor/shared-edit.js +124 -0
- package/dist/admin/editor/useSharedEdit.d.ts +6 -0
- package/dist/admin/editor/useSharedEdit.js +18 -0
- package/dist/admin/forms/tabs.d.ts +9 -3
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +198 -23
- package/dist/admin/ops.d.ts +32 -0
- package/dist/admin/serialize.d.ts +0 -1
- package/dist/admin/serialize.js +0 -1
- package/dist/admin/shared-items.js +14 -0
- package/dist/admin/shell/AdminApp.js +1 -1
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- package/dist/admin/shell/IconRail.js +3 -3
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/SharedFields.d.ts +21 -0
- package/dist/admin/shell/SharedFields.js +15 -0
- package/dist/admin/shell/SharedForm.js +166 -52
- package/dist/admin/shell/SharedList.js +3 -2
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +13 -0
- package/dist/admin/ui/primitives.js +18 -2
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +46 -6
- package/dist/collections.d.ts +0 -6
- package/dist/collections.js +0 -1
- package/dist/entry-store.d.ts +38 -13
- package/dist/entry-store.js +90 -23
- package/dist/next/page-renderer.js +8 -4
- package/dist/shared.d.ts +8 -1
- package/dist/shared.js +10 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +17 -7
- package/dist/sql.js +18 -15
- package/dist/store.d.ts +38 -13
- package/dist/store.js +82 -12
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +101 -25
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +88 -19
- 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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
40
|
-
//
|
|
41
|
-
// published
|
|
42
|
-
//
|
|
43
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
"
|
|
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", "
|
|
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
|
|
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
|
-
"
|
|
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", "
|
|
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
|
-
|
|
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),
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
53
|
-
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
54
|
-
|
|
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;
|