smoodly 0.0.33 → 0.0.35
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 +845 -1301
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +14 -5
- package/dist/admin/editor/PageSettings.js +2 -1
- package/dist/admin/editor/autosave.d.ts +6 -1
- package/dist/admin/editor/autosave.js +43 -3
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/forms/list.js +10 -1
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +121 -12
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/EntryForm.js +3 -2
- package/dist/admin/shell/GlobalForm.js +3 -2
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/shell/router.d.ts +5 -0
- package/dist/admin/shell/router.js +13 -0
- package/dist/admin/ui/theme.js +1 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/next/canvas-slot.d.ts +7 -0
- package/dist/next/canvas-slot.js +9 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/page-renderer.d.ts +2 -0
- package/dist/next/page-renderer.js +14 -10
- package/dist/next/preview-pill.d.ts +9 -0
- package/dist/next/preview-pill.js +56 -0
- package/dist/next/preview-route.d.ts +1 -0
- package/dist/next/preview-route.js +30 -0
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +25 -12
- package/dist/paths.js +59 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql-space.d.ts +14 -0
- package/dist/sql-space.js +51 -23
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +51 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/migrations/20260929135523_smoodly_v6.sql +200 -0
- package/package.json +1 -5
package/dist/store.js
CHANGED
|
@@ -31,12 +31,17 @@ export function groupRedirects(rows) {
|
|
|
31
31
|
export function refSections(options) {
|
|
32
32
|
return options.sections ? withCustomSchema(options.sections, customToolset(options.custom?.toolset)) : undefined;
|
|
33
33
|
}
|
|
34
|
+
/** The anchors a store's own index reads when none is shared (spec 2026-09-29 anchored paths §2). */
|
|
35
|
+
export function storeAnchors(options) {
|
|
36
|
+
return { addresses: options.addresses ?? {}, homePageSlug: options.homePageSlug ?? "home", defaultLocale: (options.locales ?? DEFAULT_LOCALES).default };
|
|
37
|
+
}
|
|
34
38
|
/** The options every adapter resolves the same way. */
|
|
35
39
|
export function pathOptions(options) {
|
|
36
40
|
return {
|
|
37
41
|
locales: options.locales ?? DEFAULT_LOCALES,
|
|
38
42
|
homePageSlug: options.homePageSlug ?? "home",
|
|
39
43
|
depth: options.tree?.depth ?? 1,
|
|
44
|
+
addresses: options.addresses,
|
|
40
45
|
};
|
|
41
46
|
}
|
|
42
47
|
/** Shared write-side rules, so both adapters throw the same messages. */
|
|
@@ -57,6 +62,8 @@ export const pageErrors = {
|
|
|
57
62
|
localeInUse: (locale, titles) => new Error(`smoodly: child pages still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
|
|
58
63
|
moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the page cannot move under it.`),
|
|
59
64
|
foreignVersion: (id, locale, versionId) => new Error(`smoodly: "${versionId}" is not a version of page "${id}" in locale "${locale}".`),
|
|
65
|
+
fixedTaken: (key) => new Error(`smoodly: a page owned by "${key}" already exists.`),
|
|
66
|
+
fixedAtRoot: () => new Error("smoodly: a code-owned page is a root record."),
|
|
60
67
|
};
|
|
61
68
|
export { assertSupportedLocale } from "./paths.js";
|
|
62
69
|
/** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
|
|
@@ -121,8 +128,9 @@ export class MemoryPageStore {
|
|
|
121
128
|
this.seq = new Map();
|
|
122
129
|
this.byId = (id) => this.pages.get(id);
|
|
123
130
|
this.options = { ...options, sections: refSections(options) };
|
|
124
|
-
this.paths = options.paths ?? new MemoryPathIndex();
|
|
125
131
|
this.opts = pathOptions(options);
|
|
132
|
+
// Alone, the store's own index reads the addresses it was given (spec 2026-09-29 anchored paths §2).
|
|
133
|
+
this.paths = options.paths ?? new MemoryPathIndex(storeAnchors(options));
|
|
126
134
|
}
|
|
127
135
|
must(id) {
|
|
128
136
|
const page = this.pages.get(id);
|
|
@@ -149,9 +157,11 @@ export class MemoryPageStore {
|
|
|
149
157
|
.sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id));
|
|
150
158
|
}
|
|
151
159
|
/** Sibling slugs are unique per parent PER LOCALE — a friendlier error
|
|
152
|
-
* than the path index's, which stays the real invariant.
|
|
160
|
+
* than the path index's, which stays the real invariant. Code-owned
|
|
161
|
+
* roots are left out: their path is their address, and the path index
|
|
162
|
+
* guards it (spec 2026-09-29 nested paths §3). */
|
|
153
163
|
assertSlugFree(parentId, locale, slug, except) {
|
|
154
|
-
if (this.siblings(parentId, except).some((p) => p.locales[locale]?.slug === slug))
|
|
164
|
+
if (this.siblings(parentId, except).some((p) => !p.fixed && p.locales[locale]?.slug === slug))
|
|
155
165
|
throw pageErrors.slugTaken(slug);
|
|
156
166
|
}
|
|
157
167
|
subtree(id) {
|
|
@@ -229,6 +239,12 @@ export class MemoryPageStore {
|
|
|
229
239
|
assertSupportedLocale(input.locale, this.opts.locales);
|
|
230
240
|
assertSegment(input.slug, "page slug");
|
|
231
241
|
const parentId = input.parentId ?? null;
|
|
242
|
+
if (input.fixed !== undefined) {
|
|
243
|
+
if (parentId !== null)
|
|
244
|
+
throw pageErrors.fixedAtRoot();
|
|
245
|
+
if ([...this.pages.values()].some((p) => p.fixed === input.fixed))
|
|
246
|
+
throw pageErrors.fixedTaken(input.fixed);
|
|
247
|
+
}
|
|
232
248
|
if (parentId !== null) {
|
|
233
249
|
const parent = this.must(parentId);
|
|
234
250
|
if (this.isHome(parent))
|
|
@@ -237,7 +253,8 @@ export class MemoryPageStore {
|
|
|
237
253
|
throw pageErrors.parentNotInLocale(input.locale);
|
|
238
254
|
assertDepth(chainOf(parent, this.byId).length + 1, this.opts.depth, "the page");
|
|
239
255
|
}
|
|
240
|
-
|
|
256
|
+
if (input.fixed === undefined)
|
|
257
|
+
this.assertSlugFree(parentId, input.locale, input.slug);
|
|
241
258
|
const id = uid();
|
|
242
259
|
const version = input.template === null ? null : this.newVersion(id, input.locale, { zones: {} });
|
|
243
260
|
const page = {
|
|
@@ -247,6 +264,7 @@ export class MemoryPageStore {
|
|
|
247
264
|
// An ordering hint, not a unique key: duplicates are possible after
|
|
248
265
|
// moves/deletes, and the creation-order tie-break settles them.
|
|
249
266
|
sort: this.siblings(parentId).length + 1,
|
|
267
|
+
fixed: input.fixed ?? null,
|
|
250
268
|
locales: {
|
|
251
269
|
[input.locale]: {
|
|
252
270
|
slug: input.slug,
|
|
@@ -317,7 +335,8 @@ export class MemoryPageStore {
|
|
|
317
335
|
assertSegment(slug, `page slug (${locale})`);
|
|
318
336
|
if (page.parentId !== null && !this.must(page.parentId).locales[locale])
|
|
319
337
|
throw pageErrors.parentNotInLocale(locale);
|
|
320
|
-
|
|
338
|
+
if (!page.fixed)
|
|
339
|
+
this.assertSlugFree(page.parentId, locale, slug, id);
|
|
321
340
|
// The SOURCE'S DRAFT is the copy: what the editor sees on screen when
|
|
322
341
|
// they add the locale (spec §3). A folder has no tree to copy.
|
|
323
342
|
const version = page.template === null
|
|
@@ -382,7 +401,7 @@ export class MemoryPageStore {
|
|
|
382
401
|
this.hasChildren(id)) {
|
|
383
402
|
throw pageErrors.homeChildren();
|
|
384
403
|
}
|
|
385
|
-
if (patch.slug !== undefined)
|
|
404
|
+
if (patch.slug !== undefined && !page.fixed)
|
|
386
405
|
this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
|
|
387
406
|
const before = { ...row };
|
|
388
407
|
if (patch.slug !== undefined)
|
|
@@ -401,6 +420,8 @@ export class MemoryPageStore {
|
|
|
401
420
|
}
|
|
402
421
|
async move(id, to, options) {
|
|
403
422
|
const page = this.must(id);
|
|
423
|
+
if (page.fixed && to.parentId !== null)
|
|
424
|
+
throw pageErrors.fixedAtRoot();
|
|
404
425
|
if (to.parentId !== null) {
|
|
405
426
|
const parent = this.must(to.parentId);
|
|
406
427
|
if (to.parentId === id || chainOf(parent, this.byId).some((n) => n.id === id))
|
|
@@ -18,7 +18,6 @@ export declare class SupabaseEntryStore implements EntryStore {
|
|
|
18
18
|
* life of the client, so the PROMISE is cached and concurrent creates
|
|
19
19
|
* share one round trip; a failed lookup is not cached. */
|
|
20
20
|
private space;
|
|
21
|
-
private fetchSpace;
|
|
22
21
|
private fetch;
|
|
23
22
|
private must;
|
|
24
23
|
private siblings;
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
10
10
|
import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, keepLocalizedShared, schemaOf, splitFields, } from "./entry-store.js";
|
|
11
11
|
import { globalEntryId } from "./global-id.js";
|
|
12
|
+
import { readCurrentSpace } from "./current-space.js";
|
|
12
13
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
14
|
+
import { anchorsFor } from "./anchors.js";
|
|
13
15
|
import { groupRedirects } from "./store.js";
|
|
14
16
|
import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
|
|
15
17
|
import { assertDepth, assertSupportedLocale, entryPathRows, rewriteTargets, slugOf } from "./paths.js";
|
|
@@ -49,9 +51,10 @@ function toVersion(row) {
|
|
|
49
51
|
export class SupabaseEntryStore {
|
|
50
52
|
constructor(db, collections, options = {}) {
|
|
51
53
|
this.db = db;
|
|
52
|
-
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
53
54
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
54
55
|
this.schemas = [...collections, ...(options.globals ?? [])];
|
|
56
|
+
// Alone, the store's own index reads its collections' addresses (spec 2026-09-29 anchored paths §2).
|
|
57
|
+
this.paths = options.paths ?? new SupabasePathIndex(db, anchorsFor({ locales: this.locales, collections }));
|
|
55
58
|
this.elements = options.elements;
|
|
56
59
|
}
|
|
57
60
|
fail(message) {
|
|
@@ -67,7 +70,7 @@ export class SupabaseEntryStore {
|
|
|
67
70
|
* share one round trip; a failed lookup is not cached. */
|
|
68
71
|
space() {
|
|
69
72
|
if (!this.spaceId) {
|
|
70
|
-
this.spaceId = this.
|
|
73
|
+
this.spaceId = readCurrentSpace(this.db).catch((e) => {
|
|
71
74
|
// A failed lookup is not cached: the next write retries it.
|
|
72
75
|
this.spaceId = undefined;
|
|
73
76
|
throw e;
|
|
@@ -75,14 +78,6 @@ export class SupabaseEntryStore {
|
|
|
75
78
|
}
|
|
76
79
|
return this.spaceId;
|
|
77
80
|
}
|
|
78
|
-
async fetchSpace() {
|
|
79
|
-
const res = await this.db.rpc("smoodly_current_space");
|
|
80
|
-
if (res.error)
|
|
81
|
-
this.fail(res.error.message);
|
|
82
|
-
if (typeof res.data !== "string")
|
|
83
|
-
this.fail("smoodly_current_space() returned no space — is the schema up to date?");
|
|
84
|
-
return res.data;
|
|
85
|
-
}
|
|
86
81
|
// ── reads ──
|
|
87
82
|
async fetch(collection, id) {
|
|
88
83
|
if (!UUID_RE.test(id))
|
|
@@ -156,7 +151,7 @@ export class SupabaseEntryStore {
|
|
|
156
151
|
const schema = this.schema(top.collection);
|
|
157
152
|
const ancestors = await this.chain(top);
|
|
158
153
|
const nodes = await this.subtree(top);
|
|
159
|
-
await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema
|
|
154
|
+
await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema.name));
|
|
160
155
|
}
|
|
161
156
|
/** Every field set the site can render: each locale's live view, plus a
|
|
162
157
|
* published snapshot the draft has moved past (see entryRefEdges). */
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import { type Anchors } from "./anchors.ts";
|
|
2
3
|
import { type PathHit, type PathIndex, type PathRow, type PathTarget, type RewriteTarget } from "./path-index.ts";
|
|
3
4
|
export declare class SupabasePathIndex implements PathIndex {
|
|
4
5
|
private db;
|
|
5
|
-
|
|
6
|
+
private anchors;
|
|
7
|
+
/** Held by reference: the config's addresses (spec 2026-09-29 anchored paths §2). */
|
|
8
|
+
constructor(db: SupabaseClient, anchors?: Anchors);
|
|
6
9
|
private fail;
|
|
10
|
+
/** Stored rows as callers see them: full URLs; a row whose anchor has no address is left out. */
|
|
11
|
+
private shown;
|
|
7
12
|
rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
|
|
8
13
|
remove(targets: PathTarget[]): Promise<void>;
|
|
14
|
+
/** Every row at a request URL's candidate keys, in one call, and the winner by precedence (spec §2). */
|
|
15
|
+
private lookup;
|
|
16
|
+
private hit;
|
|
9
17
|
resolve(locale: string, path: string): Promise<PathHit | null>;
|
|
10
18
|
private rowsOf;
|
|
19
|
+
/** A locale's rows, sorted by URL in JS: the stored path is not the URL. */
|
|
11
20
|
private rowsIn;
|
|
12
21
|
pathsOf(target: PathTarget): Promise<Record<string, string>>;
|
|
13
22
|
redirectsOf(target: PathTarget): Promise<PathRow[]>;
|
|
14
23
|
listLive(locale: string): Promise<PathRow[]>;
|
|
15
24
|
listRedirects(locale: string): Promise<PathRow[]>;
|
|
25
|
+
/** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
|
|
16
26
|
addRedirect(row: PathRow): Promise<void>;
|
|
27
|
+
/** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
|
|
17
28
|
removeRedirect(locale: string, path: string): Promise<void>;
|
|
18
29
|
}
|
|
@@ -1,23 +1,40 @@
|
|
|
1
|
+
import { candidates, fullPath, pickHit, sameUrlKeys } from "./anchors.js";
|
|
1
2
|
import { pathLiveError, pathTakenError } from "./path-index.js";
|
|
2
3
|
const UNIQUE_VIOLATION = "23505";
|
|
3
|
-
const
|
|
4
|
+
const COLS = "locale, anchor, path, kind, target_id, redirect";
|
|
4
5
|
export class SupabasePathIndex {
|
|
5
|
-
|
|
6
|
+
/** Held by reference: the config's addresses (spec 2026-09-29 anchored paths §2). */
|
|
7
|
+
constructor(db, anchors = { addresses: {}, homePageSlug: "home", defaultLocale: "en" }) {
|
|
6
8
|
this.db = db;
|
|
9
|
+
this.anchors = anchors;
|
|
7
10
|
}
|
|
8
11
|
fail(message) {
|
|
9
12
|
throw new Error(`smoodly: ${message}`);
|
|
10
13
|
}
|
|
14
|
+
/** Stored rows as callers see them: full URLs; a row whose anchor has no address is left out. */
|
|
15
|
+
shown(rows) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const r of rows) {
|
|
18
|
+
const path = fullPath(this.anchors, r.anchor, r.path, r.locale);
|
|
19
|
+
if (path !== null)
|
|
20
|
+
out.push({ kind: r.kind, id: r.target_id, locale: r.locale, path });
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
11
24
|
async rewrite(targets, rows) {
|
|
12
25
|
const res = await this.db.rpc("smoodly_rewrite_paths", {
|
|
13
26
|
p_targets: targets.map((t) => ({ kind: t.kind, id: t.id, ...(t.redirectLocales ? { redirect_locales: [...t.redirectLocales] } : {}) })),
|
|
14
|
-
|
|
27
|
+
// `keys`: every stored key that yields the row's full URL (spec §2) — the SQL cannot read the config.
|
|
28
|
+
p_rows: rows.map((r) => ({
|
|
29
|
+
locale: r.locale, anchor: r.anchor ?? "", path: r.path, kind: r.kind, id: r.id,
|
|
30
|
+
keys: sameUrlKeys(this.anchors, r.locale, r.anchor ?? "", r.path),
|
|
31
|
+
})),
|
|
15
32
|
});
|
|
16
33
|
if (res.error) {
|
|
17
34
|
if (res.error.code === UNIQUE_VIOLATION) {
|
|
18
|
-
// details: Key (space_id, locale, path)=(…,
|
|
19
|
-
const m = /\(space_id, locale, path\)=\([^,]+, ([^,]+), (
|
|
20
|
-
throw m ? pathTakenError(m[1], m[2]) : pathTakenError("?", "?");
|
|
35
|
+
// details: Key (space_id, locale, anchor, path)=(…, en, collection:news, /hello) already exists.
|
|
36
|
+
const m = /\(space_id, locale, anchor, path\)=\([^,]+, ([^,]+), ([^,]*), (.*)\) already exists/.exec(res.error.details ?? "");
|
|
37
|
+
throw m ? pathTakenError(m[1], fullPath(this.anchors, m[2], m[3], m[1]) ?? m[3]) : pathTakenError("?", "?");
|
|
21
38
|
}
|
|
22
39
|
this.fail(res.error.message);
|
|
23
40
|
}
|
|
@@ -30,24 +47,36 @@ export class SupabasePathIndex {
|
|
|
30
47
|
this.fail(res.error.message);
|
|
31
48
|
}
|
|
32
49
|
}
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
/** Every row at a request URL's candidate keys, in one call, and the winner by precedence (spec §2). */
|
|
51
|
+
async lookup(locale, url) {
|
|
52
|
+
const { keys, coded } = candidates(this.anchors, locale, url);
|
|
53
|
+
const res = await this.db.rpc("smoodly_resolve_path", { p_locale: locale, p_keys: keys });
|
|
35
54
|
if (res.error)
|
|
36
55
|
this.fail(res.error.message);
|
|
37
|
-
const
|
|
56
|
+
const rows = res.data;
|
|
57
|
+
return { rows, hit: pickHit(rows, keys, coded) };
|
|
58
|
+
}
|
|
59
|
+
async hit(locale, url) {
|
|
60
|
+
return (await this.lookup(locale, url)).hit;
|
|
61
|
+
}
|
|
62
|
+
async resolve(locale, path) {
|
|
63
|
+
const row = await this.hit(locale, path);
|
|
38
64
|
return row ? { kind: row.kind, id: row.target_id, redirect: row.redirect } : null;
|
|
39
65
|
}
|
|
40
66
|
async rowsOf(target, redirect) {
|
|
41
|
-
const res = await this.db.from("paths").select(
|
|
67
|
+
const res = await this.db.from("paths").select(COLS).eq("kind", target.kind).eq("target_id", target.id).eq("redirect", redirect);
|
|
42
68
|
if (res.error)
|
|
43
69
|
this.fail(res.error.message);
|
|
44
|
-
return res.data
|
|
70
|
+
return this.shown(res.data);
|
|
45
71
|
}
|
|
72
|
+
/** A locale's rows, sorted by URL in JS: the stored path is not the URL. */
|
|
46
73
|
async rowsIn(locale, redirect) {
|
|
47
|
-
const res = await this.db.from("paths").select(
|
|
74
|
+
const res = await this.db.from("paths").select(COLS).eq("locale", locale).eq("redirect", redirect).order("anchor").order("path");
|
|
48
75
|
if (res.error)
|
|
49
76
|
this.fail(res.error.message);
|
|
50
|
-
|
|
77
|
+
// An editor's live row under a code address is no live URL: visitors never reach it (spec §4).
|
|
78
|
+
const rows = res.data.filter((r) => redirect || !(r.anchor === "" && candidates(this.anchors, locale, r.path).coded));
|
|
79
|
+
return this.shown(rows).sort((a, b) => a.path.localeCompare(b.path));
|
|
51
80
|
}
|
|
52
81
|
async pathsOf(target) {
|
|
53
82
|
const out = {};
|
|
@@ -64,22 +93,30 @@ export class SupabasePathIndex {
|
|
|
64
93
|
listRedirects(locale) {
|
|
65
94
|
return this.rowsIn(locale, true);
|
|
66
95
|
}
|
|
96
|
+
/** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
|
|
67
97
|
async addRedirect(row) {
|
|
68
|
-
const existing = await this.
|
|
98
|
+
const { rows, hit: existing } = await this.lookup(row.locale, row.path);
|
|
69
99
|
if (existing && !existing.redirect)
|
|
70
100
|
throw pathLiveError(row.locale, row.path);
|
|
71
|
-
|
|
101
|
+
// An editor's page hidden under a code address still holds its row: a redirect never stacks on it.
|
|
102
|
+
if (rows.some((r) => !r.redirect && r.anchor === "" && r.path === row.path))
|
|
103
|
+
throw pathLiveError(row.locale, row.path);
|
|
104
|
+
// Every redirect the URL reaches goes, under any anchor: the new one replaces them all.
|
|
105
|
+
if (rows.some((r) => r.redirect))
|
|
72
106
|
await this.removeRedirect(row.locale, row.path);
|
|
73
|
-
const res = await this.db.from("paths").insert({ locale: row.locale, path: row.path, kind: row.kind, target_id: row.id, redirect: true });
|
|
107
|
+
const res = await this.db.from("paths").insert({ locale: row.locale, anchor: "", path: row.path, kind: row.kind, target_id: row.id, redirect: true });
|
|
74
108
|
if (res.error) {
|
|
75
109
|
if (res.error.code === UNIQUE_VIOLATION)
|
|
76
110
|
throw pathLiveError(row.locale, row.path);
|
|
77
111
|
this.fail(res.error.message);
|
|
78
112
|
}
|
|
79
113
|
}
|
|
114
|
+
/** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
|
|
80
115
|
async removeRedirect(locale, path) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
for (const k of candidates(this.anchors, locale, path).keys) {
|
|
117
|
+
const res = await this.db.from("paths").delete().eq("locale", locale).eq("anchor", k.anchor).eq("path", k.path).eq("redirect", true);
|
|
118
|
+
if (res.error)
|
|
119
|
+
this.fail(res.error.message);
|
|
120
|
+
}
|
|
84
121
|
}
|
|
85
122
|
}
|
package/dist/supabase-store.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
17
17
|
/** A one-row existence check — the shape `deletePage` needs too. */
|
|
18
18
|
private hasChildren;
|
|
19
19
|
private siblings;
|
|
20
|
+
/** Code-owned roots are left out: their path is their address, and the
|
|
21
|
+
* path index guards it (spec 2026-09-29 nested paths §3). */
|
|
20
22
|
private assertSlugFree;
|
|
21
23
|
/** The node's ancestors (root first, node last), by walking parent_id. */
|
|
22
24
|
private chain;
|
package/dist/supabase-store.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// are two indexed lookups. Every read embeds the locale rows
|
|
7
7
|
// (`*, page_locales(*)`) so a record arrives whole. Path rows are
|
|
8
8
|
// rewritten through ONE RPC per write, so a collision rolls back.
|
|
9
|
-
import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, } from "./store.js";
|
|
9
|
+
import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, storeAnchors, } from "./store.js";
|
|
10
10
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
11
11
|
import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
|
|
12
12
|
import { groupRedirects } from "./store.js";
|
|
@@ -33,6 +33,7 @@ function toRecord(row) {
|
|
|
33
33
|
id: row.id,
|
|
34
34
|
parentId: row.parent_id,
|
|
35
35
|
template: row.template,
|
|
36
|
+
fixed: row.fixed ?? null,
|
|
36
37
|
sort: row.sort === null ? null : Number(row.sort),
|
|
37
38
|
locales,
|
|
38
39
|
};
|
|
@@ -47,8 +48,9 @@ export class SupabasePageStore {
|
|
|
47
48
|
/** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
|
|
48
49
|
this.schemaChecked = false;
|
|
49
50
|
this.options = { ...options, sections: refSections(options) };
|
|
50
|
-
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
51
51
|
this.opts = pathOptions(options);
|
|
52
|
+
// Alone, the store's own index reads the addresses it was given (spec 2026-09-29 anchored paths §2).
|
|
53
|
+
this.paths = options.paths ?? new SupabasePathIndex(db, storeAnchors(options));
|
|
52
54
|
}
|
|
53
55
|
fail(message) {
|
|
54
56
|
throw new Error(`smoodly: ${message}`);
|
|
@@ -89,8 +91,10 @@ export class SupabasePageStore {
|
|
|
89
91
|
this.fail(res.error.message);
|
|
90
92
|
return res.data.map(toRecord).filter((p) => p.id !== except);
|
|
91
93
|
}
|
|
94
|
+
/** Code-owned roots are left out: their path is their address, and the
|
|
95
|
+
* path index guards it (spec 2026-09-29 nested paths §3). */
|
|
92
96
|
async assertSlugFree(parentId, locale, slug, except) {
|
|
93
|
-
if ((await this.siblings(parentId, except)).some((s) => s.locales[locale]?.slug === slug))
|
|
97
|
+
if ((await this.siblings(parentId, except)).some((s) => !s.fixed && s.locales[locale]?.slug === slug))
|
|
94
98
|
throw pageErrors.slugTaken(slug);
|
|
95
99
|
}
|
|
96
100
|
/** The node's ancestors (root first, node last), by walking parent_id. */
|
|
@@ -286,6 +290,15 @@ export class SupabasePageStore {
|
|
|
286
290
|
assertSupportedLocale(input.locale, this.opts.locales);
|
|
287
291
|
assertSegment(input.slug, "page slug");
|
|
288
292
|
const parentId = input.parentId ?? null;
|
|
293
|
+
if (input.fixed !== undefined) {
|
|
294
|
+
if (parentId !== null)
|
|
295
|
+
throw pageErrors.fixedAtRoot();
|
|
296
|
+
const owned = await this.db.from("pages").select("id").eq("fixed", input.fixed).limit(1);
|
|
297
|
+
if (owned.error)
|
|
298
|
+
this.fail(owned.error.message);
|
|
299
|
+
if (owned.data.length > 0)
|
|
300
|
+
throw pageErrors.fixedTaken(input.fixed);
|
|
301
|
+
}
|
|
289
302
|
if (parentId !== null) {
|
|
290
303
|
const parent = await this.must(parentId);
|
|
291
304
|
if (this.isHome(parent))
|
|
@@ -295,15 +308,20 @@ export class SupabasePageStore {
|
|
|
295
308
|
assertDepth((await this.chain(parent)).length + 1, this.opts.depth, "the page");
|
|
296
309
|
}
|
|
297
310
|
const siblings = await this.siblings(parentId);
|
|
298
|
-
if (siblings.some((s) => s.locales[input.locale]?.slug === input.slug))
|
|
311
|
+
if (input.fixed === undefined && siblings.some((s) => !s.fixed && s.locales[input.locale]?.slug === input.slug)) {
|
|
299
312
|
throw pageErrors.slugTaken(input.slug);
|
|
313
|
+
}
|
|
300
314
|
const inserted = await this.db
|
|
301
315
|
.from("pages")
|
|
302
|
-
.insert({ parent_id: parentId, template: input.template, sort: siblings.length + 1 })
|
|
316
|
+
.insert({ parent_id: parentId, template: input.template, sort: siblings.length + 1, fixed: input.fixed ?? null })
|
|
303
317
|
.select("id")
|
|
304
318
|
.single();
|
|
305
|
-
if (inserted.error)
|
|
319
|
+
if (inserted.error) {
|
|
320
|
+
// A racing materializer inserted the owner first: the unique index is the backstop.
|
|
321
|
+
if (inserted.error.code === UNIQUE_VIOLATION && input.fixed !== undefined)
|
|
322
|
+
throw pageErrors.fixedTaken(input.fixed);
|
|
306
323
|
this.fail(inserted.error.message);
|
|
324
|
+
}
|
|
307
325
|
const id = inserted.data.id;
|
|
308
326
|
try {
|
|
309
327
|
const version = input.template === null ? null : await this.insertVersion(id, input.locale, { zones: {} });
|
|
@@ -403,7 +421,8 @@ export class SupabasePageStore {
|
|
|
403
421
|
assertSegment(slug, `page slug (${locale})`);
|
|
404
422
|
if (page.parentId !== null && !(await this.must(page.parentId)).locales[locale])
|
|
405
423
|
throw pageErrors.parentNotInLocale(locale);
|
|
406
|
-
|
|
424
|
+
if (!page.fixed)
|
|
425
|
+
await this.assertSlugFree(page.parentId, locale, slug, id);
|
|
407
426
|
let added;
|
|
408
427
|
try {
|
|
409
428
|
// The SOURCE'S DRAFT is the copy (spec §3); a folder has no tree.
|
|
@@ -456,7 +475,7 @@ export class SupabasePageStore {
|
|
|
456
475
|
(await this.hasChildren(id))) {
|
|
457
476
|
throw pageErrors.homeChildren();
|
|
458
477
|
}
|
|
459
|
-
if (patch.slug !== undefined)
|
|
478
|
+
if (patch.slug !== undefined && !page.fixed)
|
|
460
479
|
await this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
|
|
461
480
|
const columns = {};
|
|
462
481
|
if (patch.slug !== undefined)
|
|
@@ -476,6 +495,8 @@ export class SupabasePageStore {
|
|
|
476
495
|
}
|
|
477
496
|
async move(id, to, options) {
|
|
478
497
|
const page = await this.must(id);
|
|
498
|
+
if (page.fixed && to.parentId !== null)
|
|
499
|
+
throw pageErrors.fixedAtRoot();
|
|
479
500
|
const subtree = await this.subtree(page);
|
|
480
501
|
if (to.parentId !== null) {
|
|
481
502
|
if (to.parentId === id || subtree.some((n) => n.id === to.parentId))
|
|
@@ -504,7 +525,7 @@ export class SupabasePageStore {
|
|
|
504
525
|
const siblings = await this.siblings(to.parentId, id);
|
|
505
526
|
if (to.parentId !== page.parentId) {
|
|
506
527
|
for (const [locale, row] of Object.entries(page.locales)) {
|
|
507
|
-
if (siblings.some((s) => s.locales[locale]?.slug === row.slug))
|
|
528
|
+
if (siblings.some((s) => !s.fixed && s.locales[locale]?.slug === row.slug))
|
|
508
529
|
throw pageErrors.slugTaken(row.slug);
|
|
509
530
|
}
|
|
510
531
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- Smoodly core schema, version 5 (docs/superpowers/specs/2026-09-29-nested-collection-paths-design.md,
|
|
2
|
+
-- docs/superpowers/specs/2026-09-29-anchored-paths-design.md).
|
|
3
|
+
-- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
|
|
4
|
+
-- Never edit a published file: the next change is the next file.
|
|
5
|
+
-- The delta over version 4: `pages.fixed` names the registration that owns a
|
|
6
|
+
-- code-owned record, unique per space; `paths.anchor` names the code address
|
|
7
|
+
-- a URL row hangs under, and the row keeps only the part editors made.
|
|
8
|
+
-- No backfill: a record without `fixed` is an editor's page, a row without
|
|
9
|
+
-- an anchor is the editors' tree.
|
|
10
|
+
|
|
11
|
+
set client_min_messages = warning;
|
|
12
|
+
|
|
13
|
+
alter table "pages" add column if not exists "fixed" text;
|
|
14
|
+
|
|
15
|
+
create unique index if not exists "pages_fixed_idx"
|
|
16
|
+
on "pages" ("space_id", "fixed") where "fixed" is not null;
|
|
17
|
+
|
|
18
|
+
alter table "paths" add column if not exists "anchor" text not null default '';
|
|
19
|
+
alter table "paths" drop constraint if exists "paths_pkey";
|
|
20
|
+
alter table "paths" add constraint "paths_pkey" primary key ("space_id", "locale", "anchor", "path");
|
|
21
|
+
|
|
22
|
+
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
|
|
23
|
+
delete from "paths" p
|
|
24
|
+
using jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
|
|
25
|
+
where p."redirect" and p."locale" = r.locale
|
|
26
|
+
and exists (select 1 from jsonb_to_recordset(r.keys) as k(anchor text, path text) where k.anchor = p."anchor" and k.path = p."path")
|
|
27
|
+
and p."space_id" = "smoodly_current_space"();
|
|
28
|
+
update "paths" p set "redirect" = true
|
|
29
|
+
from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
|
|
30
|
+
where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
|
|
31
|
+
and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
|
|
32
|
+
and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
|
|
33
|
+
cross join lateral jsonb_to_recordset(r.keys) as k(anchor text, path text)
|
|
34
|
+
where r.locale = p."locale" and k.anchor = p."anchor" and k.path = p."path")
|
|
35
|
+
and p."space_id" = "smoodly_current_space"();
|
|
36
|
+
delete from "paths" p
|
|
37
|
+
using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
|
|
38
|
+
where p."kind" = t.kind and p."target_id" = t.id
|
|
39
|
+
and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid)
|
|
40
|
+
where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
|
|
41
|
+
and p."space_id" = "smoodly_current_space"();
|
|
42
|
+
insert into "paths" ("locale", "anchor", "path", "kind", "target_id")
|
|
43
|
+
select r.locale, r.anchor, r.path, r.kind, r.id
|
|
44
|
+
from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid);
|
|
45
|
+
$$;
|
|
46
|
+
|
|
47
|
+
revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;
|
|
48
|
+
|
|
49
|
+
create or replace function "smoodly_resolve_path"(p_locale text, p_keys jsonb) returns setof "paths" language sql stable set search_path = public as $$
|
|
50
|
+
select p.* from "paths" p
|
|
51
|
+
join jsonb_to_recordset(p_keys) as k(anchor text, path text) on p."anchor" = k.anchor and p."path" = k.path
|
|
52
|
+
where p."locale" = p_locale;
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '5')
|
|
56
|
+
on conflict ("key") do update set "value" = excluded."value";
|