smoodly 0.0.32 → 0.0.34

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.
Files changed (76) hide show
  1. package/README.md +77 -15
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +5 -3
  5. package/dist/admin/editor/PageSettings.js +7 -2
  6. package/dist/admin/editor/autosave.d.ts +2 -1
  7. package/dist/admin/editor/autosave.js +6 -0
  8. package/dist/admin/editor/useCanvasBridge.js +25 -2
  9. package/dist/admin/fixed-nodes.d.ts +9 -24
  10. package/dist/admin/fixed-nodes.js +36 -72
  11. package/dist/admin/forms/FieldWidget.d.ts +5 -1
  12. package/dist/admin/forms/FieldWidget.js +6 -3
  13. package/dist/admin/forms/slug-field.d.ts +17 -0
  14. package/dist/admin/forms/slug-field.js +39 -0
  15. package/dist/admin/health.js +9 -0
  16. package/dist/admin/next/create-admin.d.ts +4 -3
  17. package/dist/admin/next/create-admin.js +14 -3
  18. package/dist/admin/ops-impl.js +144 -14
  19. package/dist/admin/redirect-path.d.ts +3 -0
  20. package/dist/admin/redirect-path.js +9 -0
  21. package/dist/admin/serialize.d.ts +3 -0
  22. package/dist/admin/serialize.js +1 -0
  23. package/dist/admin/shell/AddRedirectModal.js +2 -2
  24. package/dist/admin/shell/EntryForm.js +46 -6
  25. package/dist/admin/shell/GlobalForm.js +1 -1
  26. package/dist/admin/shell/PageDialogs.js +11 -5
  27. package/dist/admin/shell/PagesList.js +29 -12
  28. package/dist/admin/shell/live-url.d.ts +3 -1
  29. package/dist/admin/shell/live-url.js +5 -2
  30. package/dist/admin/shell/pages-tree.d.ts +19 -14
  31. package/dist/admin/shell/pages-tree.js +49 -16
  32. package/dist/admin/ui/primitives.js +1 -0
  33. package/dist/admin/ui/theme.js +1 -0
  34. package/dist/admin/validate.d.ts +2 -0
  35. package/dist/admin/validate.js +9 -0
  36. package/dist/anchors.d.ts +42 -0
  37. package/dist/anchors.js +72 -0
  38. package/dist/cli/export.js +3 -1
  39. package/dist/cli/import.d.ts +1 -0
  40. package/dist/cli/import.js +13 -2
  41. package/dist/cli/space-io.d.ts +1 -2
  42. package/dist/cli/space-io.js +5 -5
  43. package/dist/collections.js +15 -0
  44. package/dist/config.d.ts +6 -0
  45. package/dist/config.js +53 -18
  46. package/dist/current-space.d.ts +4 -0
  47. package/dist/current-space.js +9 -0
  48. package/dist/entry-store.js +4 -2
  49. package/dist/env.d.ts +4 -3
  50. package/dist/env.js +15 -17
  51. package/dist/index.d.ts +4 -2
  52. package/dist/index.js +4 -2
  53. package/dist/next/page-renderer.js +2 -2
  54. package/dist/path-index.d.ts +11 -0
  55. package/dist/path-index.js +52 -25
  56. package/dist/paths.d.ts +32 -12
  57. package/dist/paths.js +80 -21
  58. package/dist/schema-version.d.ts +1 -1
  59. package/dist/schema-version.js +1 -1
  60. package/dist/site.d.ts +7 -3
  61. package/dist/site.js +26 -15
  62. package/dist/space-transfer.d.ts +2 -0
  63. package/dist/space-transfer.js +8 -1
  64. package/dist/sql.js +35 -9
  65. package/dist/store.d.ts +15 -1
  66. package/dist/store.js +27 -6
  67. package/dist/supabase-entry-store.d.ts +0 -1
  68. package/dist/supabase-entry-store.js +6 -11
  69. package/dist/supabase-path-index.d.ts +12 -1
  70. package/dist/supabase-path-index.js +56 -19
  71. package/dist/supabase-store.d.ts +2 -0
  72. package/dist/supabase-store.js +30 -9
  73. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  74. package/package.json +1 -1
  75. package/dist/admin/shell/page-dialogs.d.ts +0 -9
  76. package/dist/admin/shell/page-dialogs.js +0 -19
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
- this.assertSlugFree(parentId, input.locale, input.slug);
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
- this.assertSlugFree(page.parentId, locale, slug, id);
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.fetchSpace().catch((e) => {
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, this.locales));
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
- constructor(db: SupabaseClient);
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 toRow = (r) => ({ kind: r.kind, id: r.target_id, locale: r.locale, path: r.path });
4
+ const COLS = "locale, anchor, path, kind, target_id, redirect";
4
5
  export class SupabasePathIndex {
5
- constructor(db) {
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
- p_rows: rows.map((r) => ({ locale: r.locale, path: r.path, kind: r.kind, id: r.id })),
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)=(…, fi, /yritys) already exists.
19
- const m = /\(space_id, locale, path\)=\([^,]+, ([^,]+), (.+)\) already exists/.exec(res.error.details ?? "");
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
- async resolve(locale, path) {
34
- const res = await this.db.from("paths").select("kind, target_id, redirect").eq("locale", locale).eq("path", path).maybeSingle();
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 row = res.data;
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("locale, path, kind, target_id").eq("kind", target.kind).eq("target_id", target.id).eq("redirect", redirect);
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.map(toRow);
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("locale, path, kind, target_id").eq("locale", locale).eq("redirect", redirect).order("path");
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
- return res.data.map(toRow);
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.resolve(row.locale, row.path);
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
- if (existing)
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 res = await this.db.from("paths").delete().eq("locale", locale).eq("path", path).eq("redirect", true);
82
- if (res.error)
83
- this.fail(res.error.message);
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
  }
@@ -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;
@@ -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
- await this.assertSlugFree(page.parentId, locale, slug, id);
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,9 +0,0 @@
1
- /** A URL segment from a title: lowercase ASCII letters, digits and
2
- * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
3
- * (ä → a, é → e), everything else a hyphen, no leading, trailing or
4
- * doubled hyphens. */
5
- export declare function slugify(title: string): string;
6
- /** The slug field follows the title until the editor types a slug of
7
- * their own; a slug equal to what the previous title produced is still
8
- * "following". */
9
- export declare function followsTitle(slug: string, previousTitle: string): boolean;
@@ -1,19 +0,0 @@
1
- // Pure helpers for the page dialogs (PageDialogs.tsx).
2
- /** A URL segment from a title: lowercase ASCII letters, digits and
3
- * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
4
- * (ä → a, é → e), everything else a hyphen, no leading, trailing or
5
- * doubled hyphens. */
6
- export function slugify(title) {
7
- return title
8
- .normalize("NFD")
9
- .replace(/[̀-ͯ]/g, "")
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, "-")
12
- .replace(/^-+|-+$/g, "");
13
- }
14
- /** The slug field follows the title until the editor types a slug of
15
- * their own; a slug equal to what the previous title produced is still
16
- * "following". */
17
- export function followsTitle(slug, previousTitle) {
18
- return slug === "" || slug === slugify(previousTitle);
19
- }