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
|
@@ -1,33 +1,46 @@
|
|
|
1
|
-
// The Supabase adapter for the EntryStore contract, over entries +
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The Supabase adapter for the EntryStore contract, over entries +
|
|
2
|
+
// entry_locales + entry_versions + refs + paths. Service-role client (or
|
|
3
|
+
// a cloud write key), server-side only. Every read embeds the locale
|
|
4
|
+
// rows (`*, entry_locales(*)`) so a record arrives whole; a versioned
|
|
5
|
+
// collection's published read goes through entry_versions. Path rows are
|
|
6
|
+
// rewritten through ONE RPC per write, so a collision rolls back. Reorder
|
|
7
|
+
// calls the smoodly_reorder SQL function: one statement, updated_at
|
|
8
|
+
// untouched.
|
|
5
9
|
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
6
|
-
import { DEFAULT_ENTRY_LOCALES, entryErrors, schemaOf } from "./entry-store.js";
|
|
7
|
-
import { collectEntryRefs } from "./refs.js";
|
|
10
|
+
import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, schemaOf, splitFields, } from "./entry-store.js";
|
|
8
11
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
9
|
-
import { assertDepth,
|
|
12
|
+
import { assertDepth, assertSupportedLocale, entryPathRows } from "./paths.js";
|
|
10
13
|
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
11
14
|
// throws invalid_text_representation. A non-UUID id can't exist in the
|
|
12
|
-
// table, so treat it the same as a missing one
|
|
13
|
-
// it, patch raises the contract's "no entry" error — rather than letting
|
|
14
|
-
// the cast error leak through.
|
|
15
|
+
// table, so treat it the same as a missing one.
|
|
15
16
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
17
|
const UNIQUE_VIOLATION = "23505";
|
|
18
|
+
const SELECT = "*, entry_locales(*)";
|
|
19
|
+
function toLocale(row) {
|
|
20
|
+
return {
|
|
21
|
+
slug: row.slug,
|
|
22
|
+
status: row.status,
|
|
23
|
+
fields: row.fields,
|
|
24
|
+
draftVersionId: row.draft_version_id,
|
|
25
|
+
publishedVersionId: row.published_version_id,
|
|
26
|
+
updatedAt: Date.parse(row.updated_at),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
17
29
|
function toRecord(row) {
|
|
18
30
|
return {
|
|
19
31
|
id: row.id,
|
|
20
32
|
collection: row.collection,
|
|
21
33
|
parentId: row.parent_id,
|
|
22
34
|
fields: row.fields,
|
|
23
|
-
|
|
24
|
-
status: row.status,
|
|
35
|
+
locales: Object.fromEntries((row.entry_locales ?? []).map((l) => [l.locale, toLocale(l)])),
|
|
25
36
|
sort: row.sort,
|
|
26
|
-
locales: row.locales,
|
|
27
37
|
createdAt: Date.parse(row.created_at),
|
|
28
38
|
updatedAt: Date.parse(row.updated_at),
|
|
29
39
|
};
|
|
30
40
|
}
|
|
41
|
+
function toVersion(row) {
|
|
42
|
+
return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, createdAt: Date.parse(row.created_at) };
|
|
43
|
+
}
|
|
31
44
|
export class SupabaseEntryStore {
|
|
32
45
|
constructor(db, collections, options = {}) {
|
|
33
46
|
this.db = db;
|
|
@@ -38,42 +51,31 @@ export class SupabaseEntryStore {
|
|
|
38
51
|
fail(message) {
|
|
39
52
|
throw new Error(`smoodly: ${message}`);
|
|
40
53
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
source_id: id,
|
|
67
|
-
source_field: edge.field,
|
|
68
|
-
target_collection: edge.targetCollection,
|
|
69
|
-
target_id: edge.targetId,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
if (rows.size > 0) {
|
|
73
|
-
const inserted = await this.db.from("refs").insert([...rows.values()]);
|
|
74
|
-
if (inserted.error)
|
|
75
|
-
this.fail(inserted.error.message);
|
|
76
|
-
}
|
|
54
|
+
schema(collection) {
|
|
55
|
+
return schemaOf(this.collections, collection);
|
|
56
|
+
}
|
|
57
|
+
// ── reads ──
|
|
58
|
+
async fetch(collection, id) {
|
|
59
|
+
if (!UUID_RE.test(id))
|
|
60
|
+
return null;
|
|
61
|
+
const res = await this.db.from("entries").select(SELECT).eq("collection", collection).eq("id", id).maybeSingle();
|
|
62
|
+
if (res.error)
|
|
63
|
+
this.fail(res.error.message);
|
|
64
|
+
return res.data ? toRecord(res.data) : null;
|
|
65
|
+
}
|
|
66
|
+
async must(collection, id) {
|
|
67
|
+
const entry = await this.fetch(collection, id);
|
|
68
|
+
if (!entry)
|
|
69
|
+
throw entryErrors.notFound(collection, id);
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
async siblings(collection, parentId, except) {
|
|
73
|
+
let query = this.db.from("entries").select(SELECT).eq("collection", collection);
|
|
74
|
+
query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
|
|
75
|
+
const res = await query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
|
|
76
|
+
if (res.error)
|
|
77
|
+
this.fail(res.error.message);
|
|
78
|
+
return res.data.map(toRecord).filter((e) => e.id !== except);
|
|
77
79
|
}
|
|
78
80
|
byIdMap(rows) {
|
|
79
81
|
const map = new Map(rows.map((r) => [r.id, r]));
|
|
@@ -83,7 +85,7 @@ export class SupabaseEntryStore {
|
|
|
83
85
|
const chain = [entry];
|
|
84
86
|
let current = entry;
|
|
85
87
|
while (current.parentId !== null) {
|
|
86
|
-
const parent = await this.
|
|
88
|
+
const parent = await this.fetch(current.collection, current.parentId);
|
|
87
89
|
if (!parent)
|
|
88
90
|
this.fail(`broken parent chain at "${entry.id}".`);
|
|
89
91
|
chain.unshift(parent);
|
|
@@ -95,7 +97,7 @@ export class SupabaseEntryStore {
|
|
|
95
97
|
const out = [entry];
|
|
96
98
|
let frontier = [entry.id];
|
|
97
99
|
while (frontier.length > 0) {
|
|
98
|
-
const res = await this.db.from("entries").select(
|
|
100
|
+
const res = await this.db.from("entries").select(SELECT).eq("collection", entry.collection).in("parent_id", frontier);
|
|
99
101
|
if (res.error)
|
|
100
102
|
this.fail(res.error.message);
|
|
101
103
|
const rows = res.data.map(toRecord);
|
|
@@ -104,18 +106,75 @@ export class SupabaseEntryStore {
|
|
|
104
106
|
}
|
|
105
107
|
return out;
|
|
106
108
|
}
|
|
109
|
+
async versionFields(ids) {
|
|
110
|
+
if (ids.length === 0)
|
|
111
|
+
return new Map();
|
|
112
|
+
const res = await this.db.from("entry_versions").select("id, fields").in("id", ids);
|
|
113
|
+
if (res.error)
|
|
114
|
+
this.fail(res.error.message);
|
|
115
|
+
return new Map(res.data.map((r) => [r.id, r.fields]));
|
|
116
|
+
}
|
|
117
|
+
// ── the derived indexes ──
|
|
107
118
|
async writePaths(top) {
|
|
108
|
-
const schema =
|
|
119
|
+
const schema = this.schema(top.collection);
|
|
109
120
|
const ancestors = await this.chain(top);
|
|
110
121
|
const nodes = await this.subtree(top);
|
|
111
122
|
await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema, this.locales));
|
|
112
123
|
}
|
|
124
|
+
/** Every field set the site can render: each locale's live view, plus a
|
|
125
|
+
* published snapshot the draft has moved past (see entryRefEdges). */
|
|
126
|
+
async liveFieldSets(entry) {
|
|
127
|
+
const sets = [];
|
|
128
|
+
const snapshotIds = [];
|
|
129
|
+
for (const [locale, row] of Object.entries(entry.locales)) {
|
|
130
|
+
sets.push(entryFieldsIn(entry, locale));
|
|
131
|
+
if (row.status === "published" && row.publishedVersionId && row.publishedVersionId !== row.draftVersionId) {
|
|
132
|
+
snapshotIds.push(row.publishedVersionId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const fields of (await this.versionFields(snapshotIds)).values())
|
|
136
|
+
sets.push(fields);
|
|
137
|
+
return sets;
|
|
138
|
+
}
|
|
139
|
+
async writeRefs(entry) {
|
|
140
|
+
const schema = this.schema(entry.collection);
|
|
141
|
+
const deleted = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", entry.id);
|
|
142
|
+
if (deleted.error)
|
|
143
|
+
this.fail(deleted.error.message);
|
|
144
|
+
// dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
|
|
145
|
+
const rows = new Map();
|
|
146
|
+
for (const edge of entryRefEdges(await this.liveFieldSets(entry), schema)) {
|
|
147
|
+
rows.set(`${edge.field}\x00${edge.targetId}`, {
|
|
148
|
+
source_kind: "entry",
|
|
149
|
+
source_id: entry.id,
|
|
150
|
+
source_field: edge.field,
|
|
151
|
+
target_collection: edge.targetCollection,
|
|
152
|
+
target_id: edge.targetId,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (rows.size > 0) {
|
|
156
|
+
const inserted = await this.db.from("refs").insert([...rows.values()]);
|
|
157
|
+
if (inserted.error)
|
|
158
|
+
this.fail(inserted.error.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ── guards ──
|
|
162
|
+
/** The friendly pre-check per locale (spec 2026-09-06 §11); the paths
|
|
163
|
+
* primary key is the invariant. */
|
|
164
|
+
async assertSiblingSlug(collection, parentId, locale, slug, except) {
|
|
165
|
+
if (slug === null)
|
|
166
|
+
return;
|
|
167
|
+
for (const s of await this.siblings(collection, parentId, except)) {
|
|
168
|
+
if (s.locales[locale]?.slug === slug)
|
|
169
|
+
throw entryErrors.slugTaken(collection);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
113
172
|
async assertParent(collection, parentId, subtreeHeight) {
|
|
114
173
|
if (parentId === null)
|
|
115
|
-
return;
|
|
174
|
+
return null;
|
|
116
175
|
if (!UUID_RE.test(parentId))
|
|
117
176
|
throw entryErrors.notFound(collection, parentId);
|
|
118
|
-
const res = await this.db.from("entries").select(
|
|
177
|
+
const res = await this.db.from("entries").select(SELECT).eq("id", parentId).maybeSingle();
|
|
119
178
|
if (res.error)
|
|
120
179
|
this.fail(res.error.message);
|
|
121
180
|
if (!res.data)
|
|
@@ -123,52 +182,96 @@ export class SupabaseEntryStore {
|
|
|
123
182
|
const parent = toRecord(res.data);
|
|
124
183
|
if (parent.collection !== collection)
|
|
125
184
|
throw entryErrors.foreignParent();
|
|
126
|
-
assertDepth((await this.chain(parent)).length + subtreeHeight, collectionDepth(
|
|
185
|
+
assertDepth((await this.chain(parent)).length + subtreeHeight, collectionDepth(this.schema(collection)), "the entry");
|
|
186
|
+
return parent;
|
|
127
187
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
188
|
+
assertParentHasLocale(parent, locale) {
|
|
189
|
+
if (parent && !parent.locales[locale])
|
|
190
|
+
throw entryErrors.parentNotInLocale(locale);
|
|
191
|
+
}
|
|
192
|
+
// ── row writers ──
|
|
193
|
+
// `touch` defaults to true (an edit bumps updated_at). A rollback or a
|
|
194
|
+
// structure change passes { touch: false } so the column is left alone.
|
|
195
|
+
async updateNode(id, patch, options = {}) {
|
|
196
|
+
const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
197
|
+
const res = await this.db.from("entries").update(payload).eq("id", id);
|
|
198
|
+
if (res.error)
|
|
199
|
+
this.fail(res.error.message);
|
|
200
|
+
}
|
|
201
|
+
async insertLocale(entryId, locale, row) {
|
|
202
|
+
const res = await this.db.from("entry_locales").insert({ entry_id: entryId, locale, slug: row.slug, fields: row.fields });
|
|
203
|
+
if (res.error) {
|
|
204
|
+
if (res.error.code === UNIQUE_VIOLATION)
|
|
205
|
+
throw entryErrors.alreadyInLocale(entryId, locale);
|
|
206
|
+
this.fail(res.error.message);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async updateLocale(entryId, locale, patch, options = {}) {
|
|
210
|
+
const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
211
|
+
const res = await this.db.from("entry_locales").update(payload).eq("entry_id", entryId).eq("locale", locale);
|
|
212
|
+
if (res.error)
|
|
213
|
+
this.fail(res.error.message);
|
|
214
|
+
}
|
|
215
|
+
/** The row goes first (it is what makes the entry exist in the locale), then its versions. */
|
|
216
|
+
async deleteLocale(entryId, locale) {
|
|
217
|
+
const row = await this.db.from("entry_locales").delete().eq("entry_id", entryId).eq("locale", locale);
|
|
218
|
+
if (row.error)
|
|
219
|
+
this.fail(row.error.message);
|
|
220
|
+
const versions = await this.db.from("entry_versions").delete().eq("entry_id", entryId).eq("locale", locale);
|
|
221
|
+
if (versions.error)
|
|
222
|
+
this.fail(versions.error.message);
|
|
223
|
+
}
|
|
224
|
+
/** A versioned collection snapshots the merged fields and points the
|
|
225
|
+
* locale's draft at the new row; others do nothing here. */
|
|
226
|
+
async recordVersion(entry, locale) {
|
|
227
|
+
if (!this.schema(entry.collection).versions)
|
|
228
|
+
return;
|
|
132
229
|
const res = await this.db
|
|
133
|
-
.from("
|
|
134
|
-
.insert({
|
|
135
|
-
.select("
|
|
230
|
+
.from("entry_versions")
|
|
231
|
+
.insert({ entry_id: entry.id, locale, fields: entryFieldsIn(entry, locale) })
|
|
232
|
+
.select("id")
|
|
136
233
|
.single();
|
|
137
234
|
if (res.error)
|
|
138
|
-
this.
|
|
139
|
-
|
|
235
|
+
this.fail(res.error.message);
|
|
236
|
+
await this.updateLocale(entry.id, locale, { draft_version_id: res.data.id }, { touch: false });
|
|
237
|
+
}
|
|
238
|
+
// ── the contract ──
|
|
239
|
+
async create(collection, fields, options) {
|
|
240
|
+
const schema = this.schema(collection);
|
|
241
|
+
assertSupportedLocale(options.locale, this.locales);
|
|
242
|
+
const parentId = options.parentId ?? null;
|
|
243
|
+
const parent = await this.assertParent(collection, parentId, 1);
|
|
244
|
+
this.assertParentHasLocale(parent, options.locale);
|
|
245
|
+
const { shared, localized, slug } = splitFields(fields, schema);
|
|
246
|
+
await this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
247
|
+
const inserted = await this.db.from("entries").insert({ collection, parent_id: parentId, fields: shared }).select("id").single();
|
|
248
|
+
if (inserted.error)
|
|
249
|
+
this.fail(inserted.error.message);
|
|
250
|
+
const id = inserted.data.id;
|
|
140
251
|
try {
|
|
141
|
-
await this.
|
|
252
|
+
await this.insertLocale(id, options.locale, { slug, fields: localized });
|
|
253
|
+
let record = await this.must(collection, id);
|
|
254
|
+
await this.recordVersion(record, options.locale);
|
|
255
|
+
record = await this.must(collection, id);
|
|
256
|
+
await this.writePaths(record);
|
|
257
|
+
await this.writeRefs(record);
|
|
258
|
+
return record;
|
|
142
259
|
}
|
|
143
260
|
catch (e) {
|
|
144
|
-
|
|
261
|
+
// Everything hangs off the node: locale rows and versions cascade.
|
|
262
|
+
// `paths` does not — it has no FK — and writeRefs runs AFTER the
|
|
263
|
+
// rewrite, so clear the rows first, the order `delete` uses.
|
|
264
|
+
await this.paths.remove([{ kind: "entry", id }]);
|
|
265
|
+
await this.db.from("entries").delete().eq("id", id);
|
|
145
266
|
throw e;
|
|
146
267
|
}
|
|
147
|
-
await this.writeRefs(collection, row.id, fields);
|
|
148
|
-
return row;
|
|
149
268
|
}
|
|
150
269
|
async get(collection, id) {
|
|
151
|
-
|
|
152
|
-
// in the table, and querying it as one throws invalid_text_representation
|
|
153
|
-
// instead of returning "not found" (which `update`'s rollback read and
|
|
154
|
-
// `chain`'s parent walk both rely on).
|
|
155
|
-
if (!UUID_RE.test(id))
|
|
156
|
-
return null;
|
|
157
|
-
const res = await this.db
|
|
158
|
-
.from("entries")
|
|
159
|
-
.select("*")
|
|
160
|
-
.eq("collection", collection)
|
|
161
|
-
.eq("id", id)
|
|
162
|
-
.maybeSingle();
|
|
163
|
-
if (res.error)
|
|
164
|
-
this.fail(res.error.message);
|
|
165
|
-
return res.data ? toRecord(res.data) : null;
|
|
270
|
+
return this.fetch(collection, id);
|
|
166
271
|
}
|
|
167
272
|
async list(collection, options) {
|
|
168
|
-
const schema =
|
|
169
|
-
let query = this.db.from("entries").select(
|
|
170
|
-
if (options?.status)
|
|
171
|
-
query = query.eq("status", options.status);
|
|
273
|
+
const schema = this.schema(collection);
|
|
274
|
+
let query = this.db.from("entries").select(SELECT).eq("collection", collection);
|
|
172
275
|
if (options?.parent !== undefined) {
|
|
173
276
|
query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
|
|
174
277
|
}
|
|
@@ -177,131 +280,197 @@ export class SupabaseEntryStore {
|
|
|
177
280
|
query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
|
|
178
281
|
}
|
|
179
282
|
else {
|
|
180
|
-
// `fields->x`, not `->>x`: ordering by the jsonb VALUE
|
|
181
|
-
//
|
|
182
|
-
// included — alphabetically, matching the memory comparator.
|
|
283
|
+
// `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
|
|
284
|
+
// field (an order field is never localized), matching the memory comparator.
|
|
183
285
|
const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
|
|
184
286
|
query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
|
|
185
287
|
}
|
|
186
288
|
const res = await query;
|
|
187
289
|
if (res.error)
|
|
188
290
|
this.fail(res.error.message);
|
|
189
|
-
|
|
291
|
+
let rows = res.data.map(toRecord);
|
|
292
|
+
// The status filter reads the locale row; collections are small enough
|
|
293
|
+
// to filter here rather than lose the other rows to an !inner embed.
|
|
294
|
+
if (options?.status) {
|
|
295
|
+
const locale = options.locale ?? this.locales.default;
|
|
296
|
+
rows = rows.filter((e) => e.locales[locale]?.status === options.status);
|
|
297
|
+
}
|
|
298
|
+
return rows;
|
|
190
299
|
}
|
|
191
|
-
async getMany(collection, ids, options) {
|
|
192
|
-
|
|
300
|
+
async getMany(collection, ids, locale, options) {
|
|
301
|
+
this.schema(collection);
|
|
193
302
|
const uuidIds = ids.filter((id) => UUID_RE.test(id));
|
|
194
303
|
if (uuidIds.length === 0)
|
|
195
304
|
return {};
|
|
196
|
-
|
|
197
|
-
.from("entries")
|
|
198
|
-
.select("id, fields")
|
|
199
|
-
.eq("collection", collection)
|
|
200
|
-
.in("id", uuidIds);
|
|
201
|
-
if (options?.status)
|
|
202
|
-
query = query.eq("status", options.status);
|
|
203
|
-
const res = await query;
|
|
305
|
+
const res = await this.db.from("entries").select(SELECT).eq("collection", collection).in("id", uuidIds);
|
|
204
306
|
if (res.error)
|
|
205
307
|
this.fail(res.error.message);
|
|
308
|
+
const records = res.data.map(toRecord);
|
|
206
309
|
const out = {};
|
|
207
|
-
|
|
208
|
-
|
|
310
|
+
if (!options?.status) {
|
|
311
|
+
for (const record of records)
|
|
312
|
+
if (record.locales[locale])
|
|
313
|
+
out[record.id] = entryFieldsIn(record, locale);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
const published = records.filter((r) => r.locales[locale]?.status === options.status);
|
|
317
|
+
// A versioned collection serves what was published, not the live row.
|
|
318
|
+
const snapshots = await this.versionFields(published.map((r) => r.locales[locale].publishedVersionId).filter((v) => v !== null));
|
|
319
|
+
for (const record of published) {
|
|
320
|
+
const pointer = record.locales[locale].publishedVersionId;
|
|
321
|
+
out[record.id] = (pointer !== null ? snapshots.get(pointer) : undefined) ?? entryFieldsIn(record, locale);
|
|
209
322
|
}
|
|
210
323
|
return out;
|
|
211
324
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const touch = options.touch ?? true;
|
|
222
|
-
const payload = touch ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
223
|
-
const res = await this.db
|
|
224
|
-
.from("entries")
|
|
225
|
-
.update(payload)
|
|
226
|
-
.eq("collection", collection)
|
|
227
|
-
.eq("id", id)
|
|
228
|
-
.select("*")
|
|
229
|
-
.maybeSingle();
|
|
230
|
-
if (res.error)
|
|
231
|
-
this.failWriteError(res.error, collection);
|
|
232
|
-
if (!res.data)
|
|
233
|
-
throw entryErrors.notFound(collection, id);
|
|
234
|
-
return toRecord(res.data);
|
|
235
|
-
}
|
|
236
|
-
async update(collection, id, fields) {
|
|
237
|
-
const before = await this.get(collection, id);
|
|
238
|
-
if (!before)
|
|
239
|
-
throw entryErrors.notFound(collection, id);
|
|
240
|
-
const record = await this.patch(collection, id, { fields });
|
|
325
|
+
async update(collection, id, locale, fields) {
|
|
326
|
+
const schema = this.schema(collection);
|
|
327
|
+
const before = await this.must(collection, id);
|
|
328
|
+
const row = entryLocaleRow(before, locale);
|
|
329
|
+
const { shared, localized, slug } = splitFields(fields, schema);
|
|
330
|
+
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
331
|
+
await this.updateNode(id, { fields: shared });
|
|
332
|
+
await this.updateLocale(id, locale, { slug, fields: localized });
|
|
333
|
+
const record = await this.must(collection, id);
|
|
241
334
|
try {
|
|
242
335
|
await this.writePaths(record);
|
|
243
336
|
}
|
|
244
337
|
catch (e) {
|
|
245
|
-
await this.
|
|
338
|
+
await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
|
|
339
|
+
await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString() }, { touch: false });
|
|
246
340
|
throw e;
|
|
247
341
|
}
|
|
248
|
-
await this.
|
|
249
|
-
|
|
342
|
+
await this.recordVersion(record, locale);
|
|
343
|
+
const final = await this.must(collection, id);
|
|
344
|
+
await this.writeRefs(final);
|
|
345
|
+
return final;
|
|
250
346
|
}
|
|
251
|
-
async setStatus(collection, id, status) {
|
|
252
|
-
|
|
347
|
+
async setStatus(collection, id, locale, status) {
|
|
348
|
+
let record = await this.must(collection, id);
|
|
349
|
+
let row = entryLocaleRow(record, locale);
|
|
350
|
+
if (status === "published") {
|
|
351
|
+
if (this.schema(collection).versions && !row.draftVersionId) {
|
|
352
|
+
await this.recordVersion(record, locale);
|
|
353
|
+
record = await this.must(collection, id);
|
|
354
|
+
row = entryLocaleRow(record, locale);
|
|
355
|
+
}
|
|
356
|
+
await this.updateLocale(id, locale, { status, published_version_id: row.draftVersionId });
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// Unpublish keeps the pointer for a later republish (spec §2).
|
|
360
|
+
await this.updateLocale(id, locale, { status });
|
|
361
|
+
}
|
|
362
|
+
await this.updateNode(id, {});
|
|
363
|
+
const final = await this.must(collection, id);
|
|
364
|
+
await this.writeRefs(final);
|
|
365
|
+
return final;
|
|
366
|
+
}
|
|
367
|
+
async addLocale(collection, id, locale, options) {
|
|
368
|
+
assertSupportedLocale(locale, this.locales);
|
|
369
|
+
const entry = await this.must(collection, id);
|
|
370
|
+
// The source is read FIRST (the memory adapter's order): "copy from a
|
|
371
|
+
// locale that isn't there" is the more specific complaint, even when
|
|
372
|
+
// the target locale already exists.
|
|
373
|
+
const source = entryLocaleRow(entry, options.from);
|
|
374
|
+
if (entry.locales[locale])
|
|
375
|
+
throw entryErrors.alreadyInLocale(id, locale);
|
|
376
|
+
const parent = entry.parentId === null ? null : await this.fetch(collection, entry.parentId);
|
|
377
|
+
this.assertParentHasLocale(parent, locale);
|
|
378
|
+
await this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
|
|
379
|
+
await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields });
|
|
380
|
+
let added;
|
|
381
|
+
try {
|
|
382
|
+
added = await this.must(collection, id);
|
|
383
|
+
await this.recordVersion(added, locale);
|
|
384
|
+
added = await this.must(collection, id);
|
|
385
|
+
await this.writePaths(added);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
await this.deleteLocale(id, locale);
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
await this.writeRefs(added);
|
|
392
|
+
return added;
|
|
393
|
+
}
|
|
394
|
+
async removeLocale(collection, id, locale) {
|
|
395
|
+
const schema = this.schema(collection);
|
|
396
|
+
const entry = await this.must(collection, id);
|
|
397
|
+
entryLocaleRow(entry, locale);
|
|
398
|
+
if (Object.keys(entry.locales).length === 1)
|
|
399
|
+
throw entryErrors.lastLocale(id);
|
|
400
|
+
const children = await this.db.from("entries").select(SELECT).eq("parent_id", id);
|
|
401
|
+
if (children.error)
|
|
402
|
+
this.fail(children.error.message);
|
|
403
|
+
const blockers = children.data.map(toRecord).filter((c) => c.locales[locale] !== undefined).map((c) => entryTitleIn(c, locale, schema));
|
|
404
|
+
if (blockers.length > 0)
|
|
405
|
+
throw entryErrors.localeInUse(locale, blockers);
|
|
406
|
+
// Not atomic across the two statements: a failure between them leaves
|
|
407
|
+
// a present node without that locale's path row — a 404 for that
|
|
408
|
+
// locale until the next write, never leaked content.
|
|
409
|
+
await this.deleteLocale(id, locale);
|
|
410
|
+
const remaining = await this.must(collection, id);
|
|
411
|
+
await this.writePaths(remaining);
|
|
412
|
+
await this.updateNode(id, {});
|
|
413
|
+
const final = await this.must(collection, id);
|
|
414
|
+
await this.writeRefs(final);
|
|
415
|
+
return final;
|
|
416
|
+
}
|
|
417
|
+
async listVersions(collection, id, locale) {
|
|
418
|
+
await this.must(collection, id);
|
|
419
|
+
const res = await this.db
|
|
420
|
+
.from("entry_versions")
|
|
421
|
+
.select("*")
|
|
422
|
+
.eq("entry_id", id)
|
|
423
|
+
.eq("locale", locale)
|
|
424
|
+
.order("created_at", { ascending: false })
|
|
425
|
+
.order("id", { ascending: false });
|
|
426
|
+
if (res.error)
|
|
427
|
+
this.fail(res.error.message);
|
|
428
|
+
return res.data.map(toVersion);
|
|
253
429
|
}
|
|
254
430
|
async move(collection, id, to) {
|
|
255
|
-
const entry = await this.
|
|
256
|
-
if (!entry)
|
|
257
|
-
throw entryErrors.notFound(collection, id);
|
|
431
|
+
const entry = await this.must(collection, id);
|
|
258
432
|
const subtree = await this.subtree(entry);
|
|
259
433
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
260
434
|
throw entryErrors.cycle();
|
|
261
435
|
const byId = this.byIdMap(subtree);
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// parent) — so it can't be used here directly (page store's `move`
|
|
265
|
-
// hits the same shape and solves it the same way): stop at `entry`
|
|
266
|
-
// itself instead, since that's the map's root for this computation.
|
|
436
|
+
// `entry`'s own parentId points OUTSIDE this map (its pre-move parent):
|
|
437
|
+
// stop at `entry` itself, the map's root for this computation.
|
|
267
438
|
const heightOf = (e) => (e.id === entry.id ? 1 : 1 + heightOf(byId(e.parentId)));
|
|
268
439
|
const height = Math.max(...subtree.map(heightOf));
|
|
269
|
-
await this.assertParent(collection, to.parentId, height);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
440
|
+
const parent = await this.assertParent(collection, to.parentId, height);
|
|
441
|
+
if (parent)
|
|
442
|
+
for (const locale of Object.keys(entry.locales))
|
|
443
|
+
if (!parent.locales[locale])
|
|
444
|
+
throw entryErrors.moveLocales(locale);
|
|
445
|
+
const siblings = await this.siblings(collection, to.parentId, id);
|
|
275
446
|
if (to.parentId !== entry.parentId) {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
447
|
+
for (const [locale, row] of Object.entries(entry.locales)) {
|
|
448
|
+
if (row.slug !== null && siblings.some((s) => s.locales[locale]?.slug === row.slug))
|
|
449
|
+
throw entryErrors.slugTaken(collection);
|
|
279
450
|
}
|
|
280
451
|
}
|
|
281
|
-
// The destination siblings' sorts as they stood BEFORE the reorder
|
|
282
|
-
//
|
|
283
|
-
// reorder RPC, which renumbers 1..N and would silently close gaps
|
|
284
|
-
// left by earlier deletes and moves.
|
|
452
|
+
// The destination siblings' sorts as they stood BEFORE the reorder: a
|
|
453
|
+
// rollback restores these exact values rather than renumbering 1..N.
|
|
285
454
|
const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
|
|
286
|
-
const moved = await this.patch(collection, id, { parent_id: to.parentId }, { touch: false });
|
|
287
|
-
const ordered = siblings.map((s) => s.id);
|
|
288
|
-
ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
|
|
289
|
-
await this.reorder(collection, ordered);
|
|
290
455
|
try {
|
|
291
|
-
await this.
|
|
456
|
+
await this.updateNode(id, { parent_id: to.parentId }, { touch: false });
|
|
457
|
+
const ordered = siblings.map((s) => s.id);
|
|
458
|
+
ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
|
|
459
|
+
await this.reorder(collection, ordered);
|
|
460
|
+
await this.writePaths(await this.must(collection, id));
|
|
292
461
|
}
|
|
293
462
|
catch (e) {
|
|
294
|
-
await this.
|
|
463
|
+
await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
|
|
295
464
|
for (const s of previousSorts)
|
|
296
|
-
await this.
|
|
465
|
+
await this.updateNode(s.id, { sort: s.sort }, { touch: false });
|
|
297
466
|
throw e;
|
|
298
467
|
}
|
|
299
|
-
return
|
|
468
|
+
return this.must(collection, id);
|
|
300
469
|
}
|
|
301
470
|
async delete(collection, id) {
|
|
302
471
|
if (!UUID_RE.test(id))
|
|
303
472
|
return;
|
|
304
|
-
const entry = await this.
|
|
473
|
+
const entry = await this.fetch(collection, id);
|
|
305
474
|
if (!entry)
|
|
306
475
|
return;
|
|
307
476
|
const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
|
|
@@ -317,6 +486,7 @@ export class SupabaseEntryStore {
|
|
|
317
486
|
const refs = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", id);
|
|
318
487
|
if (refs.error)
|
|
319
488
|
this.fail(refs.error.message);
|
|
489
|
+
// locale rows and versions cascade from the node
|
|
320
490
|
const res = await this.db.from("entries").delete().eq("collection", collection).eq("id", id);
|
|
321
491
|
if (res.error)
|
|
322
492
|
this.fail(res.error.message);
|
|
@@ -325,22 +495,16 @@ export class SupabaseEntryStore {
|
|
|
325
495
|
return this.paths.resolve(locale, path);
|
|
326
496
|
}
|
|
327
497
|
async pathsOf(collection, id) {
|
|
328
|
-
return (await this.
|
|
498
|
+
return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
|
|
329
499
|
}
|
|
330
500
|
async reorder(collection, orderedIds) {
|
|
331
|
-
|
|
332
|
-
const res = await this.db.rpc("smoodly_reorder", {
|
|
333
|
-
p_collection: collection,
|
|
334
|
-
p_ids: orderedIds,
|
|
335
|
-
});
|
|
501
|
+
this.schema(collection);
|
|
502
|
+
const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
|
|
336
503
|
if (res.error)
|
|
337
504
|
this.fail(res.error.message);
|
|
338
505
|
}
|
|
339
506
|
async incoming(targetId) {
|
|
340
|
-
const res = await this.db
|
|
341
|
-
.from("refs")
|
|
342
|
-
.select("source_kind, source_id")
|
|
343
|
-
.eq("target_id", targetId);
|
|
507
|
+
const res = await this.db.from("refs").select("source_kind, source_id").eq("target_id", targetId);
|
|
344
508
|
if (res.error)
|
|
345
509
|
this.fail(res.error.message);
|
|
346
510
|
const seen = new Set(); // one row per source (multiple fields collapse)
|