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.
- package/README.md +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/package.json +1 -1
package/dist/supabase-store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import { type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
|
|
2
|
+
import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
|
|
3
3
|
import type { PageTree } from "./render.tsx";
|
|
4
4
|
import { type PathTarget } from "./paths.ts";
|
|
5
5
|
export declare class SupabasePageStore implements PageStore {
|
|
@@ -15,6 +15,7 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
15
15
|
/** A one-row existence check — the shape `deletePage` needs too. */
|
|
16
16
|
private hasChildren;
|
|
17
17
|
private siblings;
|
|
18
|
+
private assertSlugFree;
|
|
18
19
|
/** The node's ancestors (root first, node last), by walking parent_id. */
|
|
19
20
|
private chain;
|
|
20
21
|
/** The node and every descendant, breadth-first. */
|
|
@@ -24,19 +25,30 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
24
25
|
private writePaths;
|
|
25
26
|
/** `touch` defaults to true (the normal edit path bumps updated_at).
|
|
26
27
|
* A rollback restoring prior column values passes `{ touch: false }`,
|
|
27
|
-
* which omits updated_at entirely — undoing a write is not an edit
|
|
28
|
-
|
|
29
|
-
private
|
|
28
|
+
* which omits updated_at entirely — undoing a write is not an edit. */
|
|
29
|
+
private updateNode;
|
|
30
|
+
private updateLocale;
|
|
31
|
+
private insertLocale;
|
|
32
|
+
/** The locale's row and versions — the removeLocale and rollback shape.
|
|
33
|
+
* The row goes first: it is what makes the page exist in that locale,
|
|
34
|
+
* so a failure between the two statements leaves no locale row
|
|
35
|
+
* pointing at an already-deleted version. */
|
|
36
|
+
private deleteLocale;
|
|
30
37
|
private insertVersion;
|
|
31
38
|
private versionTree;
|
|
39
|
+
/** Every locale's draft and published trees in ONE query. */
|
|
40
|
+
private liveTrees;
|
|
32
41
|
private writeRefs;
|
|
33
42
|
private reorder;
|
|
43
|
+
/** page_locales and page_versions cascade from pages; refs do not. */
|
|
34
44
|
private hardDelete;
|
|
35
45
|
createPage(input: CreatePageInput): Promise<PageRecord>;
|
|
36
46
|
getPage(id: string): Promise<PageRecord | null>;
|
|
37
47
|
listPages(): Promise<PageRecord[]>;
|
|
38
48
|
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
39
49
|
pathsOf(id: string): Promise<{}>;
|
|
50
|
+
addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
|
|
51
|
+
removeLocale(id: string, locale: string): Promise<PageRecord>;
|
|
40
52
|
rename(id: string, patch: {
|
|
41
53
|
locale: string;
|
|
42
54
|
slug?: string;
|
|
@@ -47,10 +59,10 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
47
59
|
index?: number;
|
|
48
60
|
}): Promise<PageRecord>;
|
|
49
61
|
setTemplate(id: string, template: string): Promise<PageRecord>;
|
|
50
|
-
saveDraft(id: string, tree: PageTree): Promise<PageVersion>;
|
|
51
|
-
publish(id: string): Promise<PageRecord>;
|
|
52
|
-
getDraftTree(id: string): Promise<PageTree | null>;
|
|
53
|
-
getPublishedTree(id: string): Promise<PageTree | null>;
|
|
54
|
-
listVersions(id: string): Promise<PageVersion[]>;
|
|
62
|
+
saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
|
|
63
|
+
publish(id: string, locale: string): Promise<PageRecord>;
|
|
64
|
+
getDraftTree(id: string, locale: string): Promise<PageTree | null>;
|
|
65
|
+
getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
|
|
66
|
+
listVersions(id: string, locale: string): Promise<PageVersion[]>;
|
|
55
67
|
deletePage(id: string): Promise<void>;
|
|
56
68
|
}
|
package/dist/supabase-store.js
CHANGED
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
// The Supabase adapter for the PageStore contract, over pages +
|
|
2
|
-
// page_versions + paths. Server-side only (the service
|
|
3
|
-
// a write key in the cloud); writes flow through
|
|
4
|
-
// (DESIGN.md §1). No FK links
|
|
5
|
-
// (they'd be circular), so tree reads
|
|
6
|
-
// are
|
|
7
|
-
|
|
2
|
+
// page_locales + page_versions + paths. Server-side only (the service
|
|
3
|
+
// role in self-host, a write key in the cloud); writes flow through
|
|
4
|
+
// Smoodly's API by design (DESIGN.md §1). No FK links the locale rows'
|
|
5
|
+
// version pointers to page_versions (they'd be circular), so tree reads
|
|
6
|
+
// are two indexed lookups. Every read embeds the locale rows
|
|
7
|
+
// (`*, page_locales(*)`) so a record arrives whole. Path rows are
|
|
8
|
+
// rewritten through ONE RPC per write, so a collision rolls back.
|
|
9
|
+
import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, } from "./store.js";
|
|
8
10
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
9
|
-
import { assertDepth, assertSegment, pagePathRows } from "./paths.js";
|
|
11
|
+
import { assertDepth, assertSegment, isHomeNode, pagePathRows } from "./paths.js";
|
|
12
|
+
const SELECT = "*, page_locales(*)";
|
|
10
13
|
const UNIQUE_VIOLATION = "23505";
|
|
11
14
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
15
|
function toRecord(row) {
|
|
16
|
+
const locales = {};
|
|
17
|
+
for (const l of row.page_locales ?? []) {
|
|
18
|
+
locales[l.locale] = {
|
|
19
|
+
slug: l.slug,
|
|
20
|
+
title: l.title,
|
|
21
|
+
status: l.status,
|
|
22
|
+
draftVersionId: l.draft_version_id,
|
|
23
|
+
publishedVersionId: l.published_version_id,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
13
26
|
return {
|
|
14
27
|
id: row.id,
|
|
15
28
|
parentId: row.parent_id,
|
|
16
|
-
slug: row.slug,
|
|
17
|
-
title: row.title,
|
|
18
|
-
i18n: row.i18n,
|
|
19
29
|
template: row.template,
|
|
20
|
-
status: row.status,
|
|
21
30
|
sort: row.sort === null ? null : Number(row.sort),
|
|
22
|
-
locales
|
|
23
|
-
draftVersionId: row.draft_version_id,
|
|
24
|
-
publishedVersionId: row.published_version_id,
|
|
31
|
+
locales,
|
|
25
32
|
};
|
|
26
33
|
}
|
|
27
34
|
function toVersion(row) {
|
|
28
|
-
return { id: row.id, pageId: row.page_id, tree: row.tree, createdAt: Date.parse(row.created_at) };
|
|
35
|
+
return { id: row.id, pageId: row.page_id, locale: row.locale, tree: row.tree, createdAt: Date.parse(row.created_at) };
|
|
29
36
|
}
|
|
30
37
|
export class SupabasePageStore {
|
|
31
38
|
constructor(db, options = {}) {
|
|
@@ -37,15 +44,14 @@ export class SupabasePageStore {
|
|
|
37
44
|
fail(message) {
|
|
38
45
|
throw new Error(`smoodly: ${message}`);
|
|
39
46
|
}
|
|
40
|
-
// Callers always pass a PageRecord (camelCase), never a raw row.
|
|
41
47
|
isHome(page) {
|
|
42
|
-
return page
|
|
48
|
+
return isHomeNode(page, this.opts);
|
|
43
49
|
}
|
|
44
50
|
// ── rows ──
|
|
45
51
|
async row(id) {
|
|
46
52
|
if (!UUID_RE.test(id))
|
|
47
53
|
return null;
|
|
48
|
-
const res = await this.db.from("pages").select(
|
|
54
|
+
const res = await this.db.from("pages").select(SELECT).eq("id", id).maybeSingle();
|
|
49
55
|
if (res.error)
|
|
50
56
|
this.fail(res.error.message);
|
|
51
57
|
return res.data ? toRecord(res.data) : null;
|
|
@@ -64,7 +70,7 @@ export class SupabasePageStore {
|
|
|
64
70
|
return res.data.length > 0;
|
|
65
71
|
}
|
|
66
72
|
async siblings(parentId, except) {
|
|
67
|
-
let query = this.db.from("pages").select(
|
|
73
|
+
let query = this.db.from("pages").select(SELECT);
|
|
68
74
|
query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
|
|
69
75
|
const res = await query
|
|
70
76
|
.order("sort", { ascending: true, nullsFirst: false })
|
|
@@ -74,6 +80,10 @@ export class SupabasePageStore {
|
|
|
74
80
|
this.fail(res.error.message);
|
|
75
81
|
return res.data.map(toRecord).filter((p) => p.id !== except);
|
|
76
82
|
}
|
|
83
|
+
async assertSlugFree(parentId, locale, slug, except) {
|
|
84
|
+
if ((await this.siblings(parentId, except)).some((s) => s.locales[locale]?.slug === slug))
|
|
85
|
+
throw pageErrors.slugTaken(slug);
|
|
86
|
+
}
|
|
77
87
|
/** The node's ancestors (root first, node last), by walking parent_id. */
|
|
78
88
|
async chain(page) {
|
|
79
89
|
const chain = [page];
|
|
@@ -92,7 +102,7 @@ export class SupabasePageStore {
|
|
|
92
102
|
const out = [page];
|
|
93
103
|
let frontier = [page.id];
|
|
94
104
|
while (frontier.length > 0) {
|
|
95
|
-
const res = await this.db.from("pages").select(
|
|
105
|
+
const res = await this.db.from("pages").select(SELECT).in("parent_id", frontier);
|
|
96
106
|
if (res.error)
|
|
97
107
|
this.fail(res.error.message);
|
|
98
108
|
const rows = res.data.map(toRecord);
|
|
@@ -111,25 +121,43 @@ export class SupabasePageStore {
|
|
|
111
121
|
}
|
|
112
122
|
/** `touch` defaults to true (the normal edit path bumps updated_at).
|
|
113
123
|
* A rollback restoring prior column values passes `{ touch: false }`,
|
|
114
|
-
* which omits updated_at entirely — undoing a write is not an edit
|
|
115
|
-
|
|
116
|
-
async update(id, patch, options = {}) {
|
|
124
|
+
* which omits updated_at entirely — undoing a write is not an edit. */
|
|
125
|
+
async updateNode(id, patch, options = {}) {
|
|
117
126
|
const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
118
|
-
const res = await this.db
|
|
119
|
-
|
|
120
|
-
.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
const res = await this.db.from("pages").update(payload).eq("id", id).select(SELECT).single();
|
|
128
|
+
if (res.error)
|
|
129
|
+
this.fail(res.error.message);
|
|
130
|
+
return toRecord(res.data);
|
|
131
|
+
}
|
|
132
|
+
async updateLocale(id, locale, patch, options = {}) {
|
|
133
|
+
const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
134
|
+
const res = await this.db.from("page_locales").update(payload).eq("page_id", id).eq("locale", locale).select("page_id").single();
|
|
135
|
+
if (res.error)
|
|
136
|
+
this.fail(res.error.message);
|
|
137
|
+
return this.must(id);
|
|
138
|
+
}
|
|
139
|
+
async insertLocale(id, locale, values) {
|
|
140
|
+
const res = await this.db.from("page_locales").insert({ page_id: id, locale, ...values });
|
|
124
141
|
if (res.error) {
|
|
125
142
|
if (res.error.code === UNIQUE_VIOLATION)
|
|
126
|
-
throw pageErrors.
|
|
143
|
+
throw pageErrors.alreadyInLocale(id, locale);
|
|
127
144
|
this.fail(res.error.message);
|
|
128
145
|
}
|
|
129
|
-
return toRecord(res.data);
|
|
130
146
|
}
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
/** The locale's row and versions — the removeLocale and rollback shape.
|
|
148
|
+
* The row goes first: it is what makes the page exist in that locale,
|
|
149
|
+
* so a failure between the two statements leaves no locale row
|
|
150
|
+
* pointing at an already-deleted version. */
|
|
151
|
+
async deleteLocale(id, locale) {
|
|
152
|
+
const row = await this.db.from("page_locales").delete().eq("page_id", id).eq("locale", locale);
|
|
153
|
+
if (row.error)
|
|
154
|
+
this.fail(row.error.message);
|
|
155
|
+
const versions = await this.db.from("page_versions").delete().eq("page_id", id).eq("locale", locale);
|
|
156
|
+
if (versions.error)
|
|
157
|
+
this.fail(versions.error.message);
|
|
158
|
+
}
|
|
159
|
+
async insertVersion(pageId, locale, tree) {
|
|
160
|
+
const res = await this.db.from("page_versions").insert({ page_id: pageId, locale, tree }).select("*").single();
|
|
133
161
|
if (res.error)
|
|
134
162
|
this.fail(res.error.message);
|
|
135
163
|
return res.data;
|
|
@@ -142,14 +170,33 @@ export class SupabasePageStore {
|
|
|
142
170
|
this.fail(res.error.message);
|
|
143
171
|
return res.data?.tree ?? null;
|
|
144
172
|
}
|
|
145
|
-
|
|
146
|
-
|
|
173
|
+
/** Every locale's draft and published trees in ONE query. */
|
|
174
|
+
async liveTrees(page) {
|
|
175
|
+
const ids = new Set();
|
|
176
|
+
for (const row of Object.values(page.locales)) {
|
|
177
|
+
if (row.draftVersionId)
|
|
178
|
+
ids.add(row.draftVersionId);
|
|
179
|
+
if (row.publishedVersionId)
|
|
180
|
+
ids.add(row.publishedVersionId);
|
|
181
|
+
}
|
|
182
|
+
if (ids.size === 0)
|
|
183
|
+
return [];
|
|
184
|
+
const res = await this.db.from("page_versions").select("tree").in("id", [...ids]);
|
|
185
|
+
if (res.error)
|
|
186
|
+
this.fail(res.error.message);
|
|
187
|
+
return res.data.map((r) => r.tree);
|
|
188
|
+
}
|
|
189
|
+
async writeRefs(page) {
|
|
190
|
+
if (!this.options.sections)
|
|
191
|
+
return;
|
|
192
|
+
const edges = pageRefEdges(await this.liveTrees(page), this.options.sections);
|
|
193
|
+
const deleted = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", page.id);
|
|
147
194
|
if (deleted.error)
|
|
148
195
|
this.fail(deleted.error.message);
|
|
149
196
|
const rows = new Map();
|
|
150
197
|
for (const edge of edges) {
|
|
151
198
|
rows.set(`${edge.field} ${edge.targetId}`, {
|
|
152
|
-
source_kind: "page", source_id:
|
|
199
|
+
source_kind: "page", source_id: page.id, source_field: edge.field,
|
|
153
200
|
target_collection: edge.targetCollection, target_id: edge.targetId,
|
|
154
201
|
});
|
|
155
202
|
}
|
|
@@ -166,58 +213,52 @@ export class SupabasePageStore {
|
|
|
166
213
|
if (res.error)
|
|
167
214
|
this.fail(res.error.message);
|
|
168
215
|
}
|
|
216
|
+
/** page_locales and page_versions cascade from pages; refs do not. */
|
|
169
217
|
async hardDelete(id) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
218
|
+
const refs = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", id);
|
|
219
|
+
if (refs.error)
|
|
220
|
+
this.fail(refs.error.message);
|
|
221
|
+
const page = await this.db.from("pages").delete().eq("id", id);
|
|
222
|
+
if (page.error)
|
|
223
|
+
this.fail(page.error.message);
|
|
177
224
|
}
|
|
178
225
|
// ── contract ──
|
|
179
226
|
async createPage(input) {
|
|
227
|
+
assertSupportedLocale(input.locale, this.opts.locales);
|
|
180
228
|
assertSegment(input.slug, "page slug");
|
|
181
|
-
assertPageI18n(input.i18n);
|
|
182
229
|
const parentId = input.parentId ?? null;
|
|
183
230
|
if (parentId !== null) {
|
|
184
231
|
const parent = await this.must(parentId);
|
|
185
232
|
if (this.isHome(parent))
|
|
186
233
|
throw pageErrors.homeChildren();
|
|
234
|
+
if (!parent.locales[input.locale])
|
|
235
|
+
throw pageErrors.parentNotInLocale(input.locale);
|
|
187
236
|
assertDepth((await this.chain(parent)).length + 1, this.opts.depth, "the page");
|
|
188
237
|
}
|
|
189
238
|
const siblings = await this.siblings(parentId);
|
|
239
|
+
if (siblings.some((s) => s.locales[input.locale]?.slug === input.slug))
|
|
240
|
+
throw pageErrors.slugTaken(input.slug);
|
|
190
241
|
const inserted = await this.db
|
|
191
242
|
.from("pages")
|
|
192
|
-
.insert({
|
|
193
|
-
|
|
194
|
-
slug: input.slug,
|
|
195
|
-
title: input.title ?? input.slug,
|
|
196
|
-
i18n: input.i18n ?? null,
|
|
197
|
-
template: input.template,
|
|
198
|
-
sort: siblings.length + 1,
|
|
199
|
-
locales: input.locales ?? null,
|
|
200
|
-
})
|
|
201
|
-
.select("*")
|
|
243
|
+
.insert({ parent_id: parentId, template: input.template, sort: siblings.length + 1 })
|
|
244
|
+
.select("id")
|
|
202
245
|
.single();
|
|
203
|
-
if (inserted.error)
|
|
204
|
-
if (inserted.error.code === UNIQUE_VIOLATION)
|
|
205
|
-
throw pageErrors.slugTaken(input.slug);
|
|
246
|
+
if (inserted.error)
|
|
206
247
|
this.fail(inserted.error.message);
|
|
207
|
-
|
|
208
|
-
let page = toRecord(inserted.data);
|
|
248
|
+
const id = inserted.data.id;
|
|
209
249
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
250
|
+
const version = input.template === null ? null : await this.insertVersion(id, input.locale, { zones: {} });
|
|
251
|
+
await this.insertLocale(id, input.locale, {
|
|
252
|
+
slug: input.slug, title: input.title ?? input.slug, draft_version_id: version?.id ?? null,
|
|
253
|
+
});
|
|
254
|
+
const page = await this.must(id);
|
|
214
255
|
await this.writePaths(page);
|
|
256
|
+
return page;
|
|
215
257
|
}
|
|
216
258
|
catch (e) {
|
|
217
|
-
await this.hardDelete(
|
|
259
|
+
await this.hardDelete(id);
|
|
218
260
|
throw e;
|
|
219
261
|
}
|
|
220
|
-
return page;
|
|
221
262
|
}
|
|
222
263
|
getPage(id) {
|
|
223
264
|
return this.row(id);
|
|
@@ -225,7 +266,7 @@ export class SupabasePageStore {
|
|
|
225
266
|
async listPages() {
|
|
226
267
|
const res = await this.db
|
|
227
268
|
.from("pages")
|
|
228
|
-
.select(
|
|
269
|
+
.select(SELECT)
|
|
229
270
|
.order("sort", { ascending: true, nullsFirst: false })
|
|
230
271
|
.order("created_at", { ascending: true })
|
|
231
272
|
.order("id", { ascending: true });
|
|
@@ -239,8 +280,56 @@ export class SupabasePageStore {
|
|
|
239
280
|
pathsOf(id) {
|
|
240
281
|
return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
|
|
241
282
|
}
|
|
283
|
+
async addLocale(id, locale, options) {
|
|
284
|
+
assertSupportedLocale(locale, this.opts.locales);
|
|
285
|
+
const page = await this.must(id);
|
|
286
|
+
if (page.locales[locale])
|
|
287
|
+
throw pageErrors.alreadyInLocale(id, locale);
|
|
288
|
+
const source = localeRow(page, options.from);
|
|
289
|
+
const slug = options.slug ?? source.slug;
|
|
290
|
+
if (options.slug !== undefined)
|
|
291
|
+
assertSegment(slug, `page slug (${locale})`);
|
|
292
|
+
if (page.parentId !== null && !(await this.must(page.parentId)).locales[locale])
|
|
293
|
+
throw pageErrors.parentNotInLocale(locale);
|
|
294
|
+
await this.assertSlugFree(page.parentId, locale, slug, id);
|
|
295
|
+
let added;
|
|
296
|
+
try {
|
|
297
|
+
// The SOURCE'S DRAFT is the copy (spec §3); a folder has no tree.
|
|
298
|
+
const version = page.template === null
|
|
299
|
+
? null
|
|
300
|
+
: await this.insertVersion(id, locale, (await this.versionTree(source.draftVersionId)) ?? { zones: {} });
|
|
301
|
+
await this.insertLocale(id, locale, { slug, title: options.title ?? source.title, draft_version_id: version?.id ?? null });
|
|
302
|
+
added = await this.must(id);
|
|
303
|
+
await this.writePaths(added);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
await this.deleteLocale(id, locale);
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
await this.writeRefs(added);
|
|
310
|
+
return added;
|
|
311
|
+
}
|
|
312
|
+
async removeLocale(id, locale) {
|
|
313
|
+
const page = await this.must(id);
|
|
314
|
+
localeRow(page, locale);
|
|
315
|
+
if (Object.keys(page.locales).length === 1)
|
|
316
|
+
throw pageErrors.lastLocale(id);
|
|
317
|
+
const blockers = (await this.subtree(page)).filter((n) => n.id !== id && n.locales[locale]);
|
|
318
|
+
if (blockers.length > 0)
|
|
319
|
+
throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
|
|
320
|
+
// Fewer path rows can never collide, so the row goes first and a
|
|
321
|
+
// network failure on the rewrite leaves a stale path row that
|
|
322
|
+
// resolves to a page absent in that locale — a 404, never leaked
|
|
323
|
+
// content — until the next write of this node fixes it.
|
|
324
|
+
await this.deleteLocale(id, locale);
|
|
325
|
+
const remaining = await this.must(id);
|
|
326
|
+
await this.writePaths(remaining);
|
|
327
|
+
await this.writeRefs(remaining);
|
|
328
|
+
return remaining;
|
|
329
|
+
}
|
|
242
330
|
async rename(id, patch) {
|
|
243
331
|
const page = await this.must(id);
|
|
332
|
+
const row = localeRow(page, patch.locale);
|
|
244
333
|
if (patch.slug !== undefined)
|
|
245
334
|
assertSegment(patch.slug, `page slug (${patch.locale})`);
|
|
246
335
|
// A rename that turns this node INTO the home node (root, default-locale
|
|
@@ -253,28 +342,19 @@ export class SupabasePageStore {
|
|
|
253
342
|
(await this.hasChildren(id))) {
|
|
254
343
|
throw pageErrors.homeChildren();
|
|
255
344
|
}
|
|
345
|
+
if (patch.slug !== undefined)
|
|
346
|
+
await this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
|
|
256
347
|
const columns = {};
|
|
257
|
-
if (patch.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
const i18n = { ...(page.i18n ?? {}) };
|
|
265
|
-
i18n[patch.locale] = {
|
|
266
|
-
...(i18n[patch.locale] ?? {}),
|
|
267
|
-
...(patch.slug !== undefined ? { slug: patch.slug } : {}),
|
|
268
|
-
...(patch.title !== undefined ? { title: patch.title } : {}),
|
|
269
|
-
};
|
|
270
|
-
columns.i18n = i18n;
|
|
271
|
-
}
|
|
272
|
-
const renamed = await this.update(id, columns);
|
|
348
|
+
if (patch.slug !== undefined)
|
|
349
|
+
columns.slug = patch.slug;
|
|
350
|
+
if (patch.title !== undefined)
|
|
351
|
+
columns.title = patch.title;
|
|
352
|
+
const renamed = await this.updateLocale(id, patch.locale, columns);
|
|
273
353
|
try {
|
|
274
354
|
await this.writePaths(renamed);
|
|
275
355
|
}
|
|
276
356
|
catch (e) {
|
|
277
|
-
await this.
|
|
357
|
+
await this.updateLocale(id, patch.locale, { slug: row.slug, title: row.title }, { touch: false });
|
|
278
358
|
throw e;
|
|
279
359
|
}
|
|
280
360
|
return renamed;
|
|
@@ -288,6 +368,9 @@ export class SupabasePageStore {
|
|
|
288
368
|
const parent = await this.must(to.parentId);
|
|
289
369
|
if (this.isHome(parent))
|
|
290
370
|
throw pageErrors.homeChildren();
|
|
371
|
+
for (const locale of Object.keys(page.locales))
|
|
372
|
+
if (!parent.locales[locale])
|
|
373
|
+
throw pageErrors.moveLocales(locale);
|
|
291
374
|
const byId = new Map(subtree.map((n) => [n.id, n]));
|
|
292
375
|
// The subtree's own height: walk each node up to the moved node
|
|
293
376
|
// itself (not to the tree root — its parentId points OUTSIDE this
|
|
@@ -297,32 +380,36 @@ export class SupabasePageStore {
|
|
|
297
380
|
const height = Math.max(...subtree.map(heightOf));
|
|
298
381
|
assertDepth((await this.chain(parent)).length + height, this.opts.depth, "the page");
|
|
299
382
|
}
|
|
300
|
-
else if (page.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
|
|
383
|
+
else if (page.locales[this.opts.locales.default]?.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
|
|
301
384
|
// Moving a home-slugged node TO the root is the mirror case of the
|
|
302
385
|
// rename guard above: it would make it the home node while it still
|
|
303
386
|
// has children.
|
|
304
387
|
throw pageErrors.homeChildren();
|
|
305
388
|
}
|
|
306
389
|
const siblings = await this.siblings(to.parentId, id);
|
|
307
|
-
if (to.parentId !== page.parentId
|
|
308
|
-
|
|
390
|
+
if (to.parentId !== page.parentId) {
|
|
391
|
+
for (const [locale, row] of Object.entries(page.locales)) {
|
|
392
|
+
if (siblings.some((s) => s.locales[locale]?.slug === row.slug))
|
|
393
|
+
throw pageErrors.slugTaken(row.slug);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
309
396
|
// The destination siblings' sorts as they stood BEFORE the reorder.
|
|
310
397
|
// A rollback restores these exact values rather than re-running the
|
|
311
398
|
// reorder RPC, which renumbers 1..N and would silently close gaps
|
|
312
399
|
// left by earlier deletes and moves.
|
|
313
400
|
const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
|
|
314
|
-
const moved = await this.
|
|
401
|
+
const moved = await this.updateNode(id, { parent_id: to.parentId });
|
|
315
402
|
const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
|
|
316
403
|
const ordered = siblings.map((s) => s.id);
|
|
317
404
|
ordered.splice(index, 0, id);
|
|
318
|
-
await this.reorder(ordered);
|
|
319
405
|
try {
|
|
406
|
+
await this.reorder(ordered);
|
|
320
407
|
await this.writePaths(moved);
|
|
321
408
|
}
|
|
322
409
|
catch (e) {
|
|
323
|
-
await this.
|
|
410
|
+
await this.updateNode(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
|
|
324
411
|
for (const s of previousSorts)
|
|
325
|
-
await this.
|
|
412
|
+
await this.updateNode(s.id, { sort: s.sort }, { touch: false });
|
|
326
413
|
throw e;
|
|
327
414
|
}
|
|
328
415
|
return { ...moved, sort: index + 1 };
|
|
@@ -331,49 +418,53 @@ export class SupabasePageStore {
|
|
|
331
418
|
const page = await this.must(id);
|
|
332
419
|
if (page.template !== null)
|
|
333
420
|
throw pageErrors.hasTemplate(id);
|
|
334
|
-
const
|
|
335
|
-
|
|
421
|
+
for (const locale of Object.keys(page.locales)) {
|
|
422
|
+
const version = await this.insertVersion(id, locale, { zones: {} });
|
|
423
|
+
await this.updateLocale(id, locale, { draft_version_id: version.id });
|
|
424
|
+
}
|
|
425
|
+
return this.updateNode(id, { template });
|
|
336
426
|
}
|
|
337
|
-
async saveDraft(id, tree) {
|
|
427
|
+
async saveDraft(id, locale, tree) {
|
|
338
428
|
const page = await this.must(id);
|
|
339
429
|
if (page.template === null)
|
|
340
430
|
throw pageErrors.folder(id);
|
|
341
|
-
|
|
342
|
-
await this.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
await this.writeRefs(id, pageRefEdges(tree, published, this.options.sections));
|
|
346
|
-
}
|
|
431
|
+
localeRow(page, locale);
|
|
432
|
+
const version = await this.insertVersion(id, locale, tree);
|
|
433
|
+
const saved = await this.updateLocale(id, locale, { draft_version_id: version.id });
|
|
434
|
+
await this.writeRefs(saved);
|
|
347
435
|
return toVersion(version);
|
|
348
436
|
}
|
|
349
|
-
async publish(id) {
|
|
437
|
+
async publish(id, locale) {
|
|
350
438
|
const page = await this.must(id);
|
|
351
|
-
if (page.template === null
|
|
439
|
+
if (page.template === null)
|
|
440
|
+
throw pageErrors.folder(id);
|
|
441
|
+
const row = localeRow(page, locale);
|
|
442
|
+
if (!row.draftVersionId)
|
|
352
443
|
throw pageErrors.folder(id);
|
|
353
444
|
const { sections, entries } = this.options;
|
|
354
|
-
const draft = (await this.versionTree(
|
|
445
|
+
const draft = (await this.versionTree(row.draftVersionId)) ?? { zones: {} };
|
|
355
446
|
if (sections && entries)
|
|
356
|
-
await assertPublishable(
|
|
357
|
-
const record = await this.
|
|
358
|
-
|
|
359
|
-
await this.writeRefs(id, pageRefEdges(draft, null, sections));
|
|
447
|
+
await assertPublishable(row.slug, draft, sections, entries, locale);
|
|
448
|
+
const record = await this.updateLocale(id, locale, { published_version_id: row.draftVersionId, status: "published" });
|
|
449
|
+
await this.writeRefs(record);
|
|
360
450
|
return record;
|
|
361
451
|
}
|
|
362
|
-
async getDraftTree(id) {
|
|
452
|
+
async getDraftTree(id, locale) {
|
|
363
453
|
const page = await this.row(id);
|
|
364
|
-
return page ? this.versionTree(page.draftVersionId) : null;
|
|
454
|
+
return page ? this.versionTree(page.locales[locale]?.draftVersionId ?? null) : null;
|
|
365
455
|
}
|
|
366
|
-
async getPublishedTree(id) {
|
|
456
|
+
async getPublishedTree(id, locale) {
|
|
367
457
|
const page = await this.row(id);
|
|
368
|
-
return page ? this.versionTree(page.publishedVersionId) : null;
|
|
458
|
+
return page ? this.versionTree(page.locales[locale]?.publishedVersionId ?? null) : null;
|
|
369
459
|
}
|
|
370
|
-
async listVersions(id) {
|
|
460
|
+
async listVersions(id, locale) {
|
|
371
461
|
if (!UUID_RE.test(id))
|
|
372
462
|
return [];
|
|
373
463
|
const res = await this.db
|
|
374
464
|
.from("page_versions")
|
|
375
465
|
.select("*")
|
|
376
466
|
.eq("page_id", id)
|
|
467
|
+
.eq("locale", locale)
|
|
377
468
|
.order("created_at", { ascending: false })
|
|
378
469
|
.order("id", { ascending: false });
|
|
379
470
|
if (res.error)
|
package/package.json
CHANGED