smoodly 0.0.6 → 0.0.8
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 +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- 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/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- 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/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- 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/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
|
@@ -1,79 +1,81 @@
|
|
|
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, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
|
|
8
11
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
9
|
-
import { assertDepth, entryPathRows, slugOf } from "./paths.js";
|
|
12
|
+
import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } 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;
|
|
34
|
-
this.collections = collections;
|
|
35
47
|
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
36
48
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
49
|
+
this.schemas = [...collections, ...(options.shared ?? [])];
|
|
37
50
|
}
|
|
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.schemas, 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,105 @@ 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
|
+
if (isSharedSchema(schema)) {
|
|
244
|
+
const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
|
|
245
|
+
if (existing.error)
|
|
246
|
+
this.fail(existing.error.message);
|
|
247
|
+
if (existing.data.length > 0)
|
|
248
|
+
throw entryErrors.singleton(collection);
|
|
249
|
+
if (slugOf(fields) !== null)
|
|
250
|
+
throw entryErrors.noSlug(collection);
|
|
251
|
+
}
|
|
252
|
+
const parent = await this.assertParent(collection, parentId, 1);
|
|
253
|
+
this.assertParentHasLocale(parent, options.locale);
|
|
254
|
+
const { shared, localized, slug } = splitFields(fields, schema);
|
|
255
|
+
await this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
256
|
+
const inserted = await this.db.from("entries").insert({ collection, parent_id: parentId, fields: shared }).select("id").single();
|
|
257
|
+
if (inserted.error)
|
|
258
|
+
this.fail(inserted.error.message);
|
|
259
|
+
const id = inserted.data.id;
|
|
140
260
|
try {
|
|
141
|
-
await this.
|
|
261
|
+
await this.insertLocale(id, options.locale, { slug, fields: localized });
|
|
262
|
+
let record = await this.must(collection, id);
|
|
263
|
+
await this.recordVersion(record, options.locale);
|
|
264
|
+
record = await this.must(collection, id);
|
|
265
|
+
await this.writePaths(record);
|
|
266
|
+
await this.writeRefs(record);
|
|
267
|
+
return record;
|
|
142
268
|
}
|
|
143
269
|
catch (e) {
|
|
144
|
-
|
|
270
|
+
// Everything hangs off the node: locale rows and versions cascade.
|
|
271
|
+
// `paths` does not — it has no FK — and writeRefs runs AFTER the
|
|
272
|
+
// rewrite, so clear the rows first, the order `delete` uses.
|
|
273
|
+
await this.paths.remove([{ kind: "entry", id }]);
|
|
274
|
+
await this.db.from("entries").delete().eq("id", id);
|
|
145
275
|
throw e;
|
|
146
276
|
}
|
|
147
|
-
await this.writeRefs(collection, row.id, fields);
|
|
148
|
-
return row;
|
|
149
277
|
}
|
|
150
278
|
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;
|
|
279
|
+
return this.fetch(collection, id);
|
|
166
280
|
}
|
|
167
281
|
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);
|
|
282
|
+
const schema = this.schema(collection);
|
|
283
|
+
let query = this.db.from("entries").select(SELECT).eq("collection", collection);
|
|
172
284
|
if (options?.parent !== undefined) {
|
|
173
285
|
query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
|
|
174
286
|
}
|
|
@@ -177,133 +289,207 @@ export class SupabaseEntryStore {
|
|
|
177
289
|
query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
|
|
178
290
|
}
|
|
179
291
|
else {
|
|
180
|
-
// `fields->x`, not `->>x`: ordering by the jsonb VALUE
|
|
181
|
-
//
|
|
182
|
-
// included — alphabetically, matching the memory comparator.
|
|
292
|
+
// `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
|
|
293
|
+
// field (an order field is never localized), matching the memory comparator.
|
|
183
294
|
const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
|
|
184
295
|
query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
|
|
185
296
|
}
|
|
186
297
|
const res = await query;
|
|
187
298
|
if (res.error)
|
|
188
299
|
this.fail(res.error.message);
|
|
189
|
-
|
|
300
|
+
let rows = res.data.map(toRecord);
|
|
301
|
+
// The status filter reads the locale row; collections are small enough
|
|
302
|
+
// to filter here rather than lose the other rows to an !inner embed.
|
|
303
|
+
if (options?.status) {
|
|
304
|
+
const locale = options.locale ?? this.locales.default;
|
|
305
|
+
rows = rows.filter((e) => e.locales[locale]?.status === options.status);
|
|
306
|
+
}
|
|
307
|
+
return rows;
|
|
190
308
|
}
|
|
191
|
-
async getMany(collection, ids, options) {
|
|
192
|
-
|
|
309
|
+
async getMany(collection, ids, locale, options) {
|
|
310
|
+
this.schema(collection);
|
|
193
311
|
const uuidIds = ids.filter((id) => UUID_RE.test(id));
|
|
194
312
|
if (uuidIds.length === 0)
|
|
195
313
|
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;
|
|
314
|
+
const res = await this.db.from("entries").select(SELECT).eq("collection", collection).in("id", uuidIds);
|
|
204
315
|
if (res.error)
|
|
205
316
|
this.fail(res.error.message);
|
|
317
|
+
const records = res.data.map(toRecord);
|
|
206
318
|
const out = {};
|
|
207
|
-
|
|
208
|
-
|
|
319
|
+
if (!options?.status) {
|
|
320
|
+
for (const record of records)
|
|
321
|
+
if (record.locales[locale])
|
|
322
|
+
out[record.id] = entryFieldsIn(record, locale);
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
const published = records.filter((r) => r.locales[locale]?.status === options.status);
|
|
326
|
+
// A versioned collection serves what was published, not the live row.
|
|
327
|
+
const snapshots = await this.versionFields(published.map((r) => r.locales[locale].publishedVersionId).filter((v) => v !== null));
|
|
328
|
+
for (const record of published) {
|
|
329
|
+
const pointer = record.locales[locale].publishedVersionId;
|
|
330
|
+
out[record.id] = (pointer !== null ? snapshots.get(pointer) : undefined) ?? entryFieldsIn(record, locale);
|
|
209
331
|
}
|
|
210
332
|
return out;
|
|
211
333
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
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 });
|
|
334
|
+
async update(collection, id, locale, fields) {
|
|
335
|
+
const schema = this.schema(collection);
|
|
336
|
+
const before = await this.must(collection, id);
|
|
337
|
+
const row = entryLocaleRow(before, locale);
|
|
338
|
+
const { shared, localized, slug } = splitFields(fields, schema);
|
|
339
|
+
if (isSharedSchema(schema) && slug !== null)
|
|
340
|
+
throw entryErrors.noSlug(collection);
|
|
341
|
+
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
342
|
+
await this.updateNode(id, { fields: shared });
|
|
343
|
+
await this.updateLocale(id, locale, { slug, fields: localized });
|
|
344
|
+
const record = await this.must(collection, id);
|
|
241
345
|
try {
|
|
242
346
|
await this.writePaths(record);
|
|
243
347
|
}
|
|
244
348
|
catch (e) {
|
|
245
|
-
await this.
|
|
349
|
+
await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
|
|
350
|
+
await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString() }, { touch: false });
|
|
246
351
|
throw e;
|
|
247
352
|
}
|
|
248
|
-
await this.
|
|
249
|
-
|
|
353
|
+
await this.recordVersion(record, locale);
|
|
354
|
+
const final = await this.must(collection, id);
|
|
355
|
+
await this.writeRefs(final);
|
|
356
|
+
return final;
|
|
250
357
|
}
|
|
251
|
-
async setStatus(collection, id, status) {
|
|
252
|
-
|
|
358
|
+
async setStatus(collection, id, locale, status) {
|
|
359
|
+
let record = await this.must(collection, id);
|
|
360
|
+
let row = entryLocaleRow(record, locale);
|
|
361
|
+
if (status === "published") {
|
|
362
|
+
if (this.schema(collection).versions && !row.draftVersionId) {
|
|
363
|
+
await this.recordVersion(record, locale);
|
|
364
|
+
record = await this.must(collection, id);
|
|
365
|
+
row = entryLocaleRow(record, locale);
|
|
366
|
+
}
|
|
367
|
+
await this.updateLocale(id, locale, { status, published_version_id: row.draftVersionId });
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
// Unpublish keeps the pointer for a later republish (spec §2).
|
|
371
|
+
await this.updateLocale(id, locale, { status });
|
|
372
|
+
}
|
|
373
|
+
await this.updateNode(id, {});
|
|
374
|
+
const final = await this.must(collection, id);
|
|
375
|
+
await this.writeRefs(final);
|
|
376
|
+
return final;
|
|
377
|
+
}
|
|
378
|
+
async addLocale(collection, id, locale, options) {
|
|
379
|
+
assertSupportedLocale(locale, this.locales);
|
|
380
|
+
const entry = await this.must(collection, id);
|
|
381
|
+
// The source is read FIRST (the memory adapter's order): "copy from a
|
|
382
|
+
// locale that isn't there" is the more specific complaint, even when
|
|
383
|
+
// the target locale already exists.
|
|
384
|
+
const source = entryLocaleRow(entry, options.from);
|
|
385
|
+
if (entry.locales[locale])
|
|
386
|
+
throw entryErrors.alreadyInLocale(id, locale);
|
|
387
|
+
const parent = entry.parentId === null ? null : await this.fetch(collection, entry.parentId);
|
|
388
|
+
this.assertParentHasLocale(parent, locale);
|
|
389
|
+
await this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
|
|
390
|
+
await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields });
|
|
391
|
+
let added;
|
|
392
|
+
try {
|
|
393
|
+
added = await this.must(collection, id);
|
|
394
|
+
await this.recordVersion(added, locale);
|
|
395
|
+
added = await this.must(collection, id);
|
|
396
|
+
await this.writePaths(added);
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
await this.deleteLocale(id, locale);
|
|
400
|
+
throw e;
|
|
401
|
+
}
|
|
402
|
+
await this.writeRefs(added);
|
|
403
|
+
return added;
|
|
404
|
+
}
|
|
405
|
+
async removeLocale(collection, id, locale) {
|
|
406
|
+
const schema = this.schema(collection);
|
|
407
|
+
if (isSharedSchema(schema))
|
|
408
|
+
throw entryErrors.everyLocale(collection);
|
|
409
|
+
const entry = await this.must(collection, id);
|
|
410
|
+
entryLocaleRow(entry, locale);
|
|
411
|
+
if (Object.keys(entry.locales).length === 1)
|
|
412
|
+
throw entryErrors.lastLocale(id);
|
|
413
|
+
const children = await this.db.from("entries").select(SELECT).eq("parent_id", id);
|
|
414
|
+
if (children.error)
|
|
415
|
+
this.fail(children.error.message);
|
|
416
|
+
const blockers = children.data.map(toRecord).filter((c) => c.locales[locale] !== undefined).map((c) => entryTitleIn(c, locale, schema));
|
|
417
|
+
if (blockers.length > 0)
|
|
418
|
+
throw entryErrors.localeInUse(locale, blockers);
|
|
419
|
+
// Not atomic across the two statements: a failure between them leaves
|
|
420
|
+
// a present node without that locale's path row — a 404 for that
|
|
421
|
+
// locale until the next write, never leaked content.
|
|
422
|
+
await this.deleteLocale(id, locale);
|
|
423
|
+
const remaining = await this.must(collection, id);
|
|
424
|
+
await this.writePaths(remaining);
|
|
425
|
+
await this.updateNode(id, {});
|
|
426
|
+
const final = await this.must(collection, id);
|
|
427
|
+
await this.writeRefs(final);
|
|
428
|
+
return final;
|
|
429
|
+
}
|
|
430
|
+
async listVersions(collection, id, locale) {
|
|
431
|
+
await this.must(collection, id);
|
|
432
|
+
const res = await this.db
|
|
433
|
+
.from("entry_versions")
|
|
434
|
+
.select("*")
|
|
435
|
+
.eq("entry_id", id)
|
|
436
|
+
.eq("locale", locale)
|
|
437
|
+
.order("created_at", { ascending: false })
|
|
438
|
+
.order("id", { ascending: false });
|
|
439
|
+
if (res.error)
|
|
440
|
+
this.fail(res.error.message);
|
|
441
|
+
return res.data.map(toVersion);
|
|
253
442
|
}
|
|
254
443
|
async move(collection, id, to) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
444
|
+
if (isSharedSchema(this.schema(collection)))
|
|
445
|
+
throw entryErrors.owned(collection, "it has no tree position");
|
|
446
|
+
const entry = await this.must(collection, id);
|
|
258
447
|
const subtree = await this.subtree(entry);
|
|
259
448
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
260
449
|
throw entryErrors.cycle();
|
|
261
450
|
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.
|
|
451
|
+
// `entry`'s own parentId points OUTSIDE this map (its pre-move parent):
|
|
452
|
+
// stop at `entry` itself, the map's root for this computation.
|
|
267
453
|
const heightOf = (e) => (e.id === entry.id ? 1 : 1 + heightOf(byId(e.parentId)));
|
|
268
454
|
const height = Math.max(...subtree.map(heightOf));
|
|
269
|
-
await this.assertParent(collection, to.parentId, height);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
455
|
+
const parent = await this.assertParent(collection, to.parentId, height);
|
|
456
|
+
if (parent)
|
|
457
|
+
for (const locale of Object.keys(entry.locales))
|
|
458
|
+
if (!parent.locales[locale])
|
|
459
|
+
throw entryErrors.moveLocales(locale);
|
|
460
|
+
const siblings = await this.siblings(collection, to.parentId, id);
|
|
275
461
|
if (to.parentId !== entry.parentId) {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
462
|
+
for (const [locale, row] of Object.entries(entry.locales)) {
|
|
463
|
+
if (row.slug !== null && siblings.some((s) => s.locales[locale]?.slug === row.slug))
|
|
464
|
+
throw entryErrors.slugTaken(collection);
|
|
279
465
|
}
|
|
280
466
|
}
|
|
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.
|
|
467
|
+
// The destination siblings' sorts as they stood BEFORE the reorder: a
|
|
468
|
+
// rollback restores these exact values rather than renumbering 1..N.
|
|
285
469
|
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
470
|
try {
|
|
291
|
-
await this.
|
|
471
|
+
await this.updateNode(id, { parent_id: to.parentId }, { touch: false });
|
|
472
|
+
const ordered = siblings.map((s) => s.id);
|
|
473
|
+
ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
|
|
474
|
+
await this.reorder(collection, ordered);
|
|
475
|
+
await this.writePaths(await this.must(collection, id));
|
|
292
476
|
}
|
|
293
477
|
catch (e) {
|
|
294
|
-
await this.
|
|
478
|
+
await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
|
|
295
479
|
for (const s of previousSorts)
|
|
296
|
-
await this.
|
|
480
|
+
await this.updateNode(s.id, { sort: s.sort }, { touch: false });
|
|
297
481
|
throw e;
|
|
298
482
|
}
|
|
299
|
-
return
|
|
483
|
+
return this.must(collection, id);
|
|
300
484
|
}
|
|
301
485
|
async delete(collection, id) {
|
|
302
486
|
if (!UUID_RE.test(id))
|
|
303
487
|
return;
|
|
304
|
-
const entry = await this.
|
|
488
|
+
const entry = await this.fetch(collection, id);
|
|
305
489
|
if (!entry)
|
|
306
490
|
return;
|
|
491
|
+
if (isSharedSchema(this.schema(collection)))
|
|
492
|
+
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
307
493
|
const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
|
|
308
494
|
if (children.error)
|
|
309
495
|
this.fail(children.error.message);
|
|
@@ -317,6 +503,7 @@ export class SupabaseEntryStore {
|
|
|
317
503
|
const refs = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", id);
|
|
318
504
|
if (refs.error)
|
|
319
505
|
this.fail(refs.error.message);
|
|
506
|
+
// locale rows and versions cascade from the node
|
|
320
507
|
const res = await this.db.from("entries").delete().eq("collection", collection).eq("id", id);
|
|
321
508
|
if (res.error)
|
|
322
509
|
this.fail(res.error.message);
|
|
@@ -325,22 +512,17 @@ export class SupabaseEntryStore {
|
|
|
325
512
|
return this.paths.resolve(locale, path);
|
|
326
513
|
}
|
|
327
514
|
async pathsOf(collection, id) {
|
|
328
|
-
return (await this.
|
|
515
|
+
return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
|
|
329
516
|
}
|
|
330
517
|
async reorder(collection, orderedIds) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
p_ids: orderedIds,
|
|
335
|
-
});
|
|
518
|
+
if (isSharedSchema(this.schema(collection)))
|
|
519
|
+
throw entryErrors.owned(collection, "it has no order");
|
|
520
|
+
const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
|
|
336
521
|
if (res.error)
|
|
337
522
|
this.fail(res.error.message);
|
|
338
523
|
}
|
|
339
524
|
async incoming(targetId) {
|
|
340
|
-
const res = await this.db
|
|
341
|
-
.from("refs")
|
|
342
|
-
.select("source_kind, source_id")
|
|
343
|
-
.eq("target_id", targetId);
|
|
525
|
+
const res = await this.db.from("refs").select("source_kind, source_id").eq("target_id", targetId);
|
|
344
526
|
if (res.error)
|
|
345
527
|
this.fail(res.error.message);
|
|
346
528
|
const seen = new Set(); // one row per source (multiple fields collapse)
|