smoodly 0.0.27 → 0.0.28

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 (56) hide show
  1. package/README.md +32 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +4 -1
  4. package/dist/admin/editor/PageInspector.d.ts +4 -1
  5. package/dist/admin/editor/PageInspector.js +2 -2
  6. package/dist/admin/editor/PageSettings.d.ts +4 -1
  7. package/dist/admin/editor/PageSettings.js +16 -8
  8. package/dist/admin/ops-impl.js +86 -7
  9. package/dist/admin/ops.d.ts +40 -1
  10. package/dist/admin/redirect-path.d.ts +6 -0
  11. package/dist/admin/redirect-path.js +33 -0
  12. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  13. package/dist/admin/shell/AddRedirectModal.js +29 -0
  14. package/dist/admin/shell/AdminApp.js +2 -3
  15. package/dist/admin/shell/EntryForm.js +13 -5
  16. package/dist/admin/shell/IconRail.d.ts +1 -1
  17. package/dist/admin/shell/IconRail.js +3 -2
  18. package/dist/admin/shell/PagesList.js +6 -4
  19. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  20. package/dist/admin/shell/RedirectsPage.js +75 -0
  21. package/dist/admin/shell/SettingsView.d.ts +4 -2
  22. package/dist/admin/shell/SettingsView.js +18 -14
  23. package/dist/admin/shell/live-url.d.ts +10 -2
  24. package/dist/admin/shell/live-url.js +9 -4
  25. package/dist/admin/shell/members-list.js +1 -1
  26. package/dist/admin/shell/redirects-list.d.ts +12 -0
  27. package/dist/admin/shell/redirects-list.js +15 -0
  28. package/dist/admin/shell/settings.d.ts +12 -3
  29. package/dist/admin/shell/settings.js +13 -9
  30. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  31. package/dist/admin/ui/ConfirmDialog.js +4 -3
  32. package/dist/admin/ui/confirm.d.ts +13 -2
  33. package/dist/admin/ui/confirm.js +9 -5
  34. package/dist/admin/ui/theme.js +3 -0
  35. package/dist/entry-store.d.ts +18 -6
  36. package/dist/entry-store.js +13 -8
  37. package/dist/next/page-renderer.js +28 -9
  38. package/dist/path-index.d.ts +28 -4
  39. package/dist/path-index.js +37 -4
  40. package/dist/paths.d.ts +16 -0
  41. package/dist/paths.js +9 -0
  42. package/dist/schema-version.d.ts +1 -1
  43. package/dist/schema-version.js +1 -1
  44. package/dist/site.d.ts +4 -1
  45. package/dist/site.js +3 -1
  46. package/dist/sql.js +19 -1
  47. package/dist/store.d.ts +30 -5
  48. package/dist/store.js +24 -8
  49. package/dist/supabase-entry-store.d.ts +7 -2
  50. package/dist/supabase-entry-store.js +12 -8
  51. package/dist/supabase-path-index.d.ts +10 -3
  52. package/dist/supabase-path-index.js +50 -17
  53. package/dist/supabase-store.d.ts +13 -2
  54. package/dist/supabase-store.js +15 -8
  55. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  56. package/package.json +1 -1
@@ -13,10 +13,11 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { groupRedirects } from "./store.js";
16
17
  import { assertFresh } from "./draft-token.js";
17
18
  import { globalEntryId } from "./global-id.js";
18
19
  import { DEFAULT_SPACE_ID } from "./sql-space.js";
19
- import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
20
+ import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, rewriteTargets, } from "./paths.js";
20
21
  /** The field types whose value is an array: a scalar `where` on one is
21
22
  * containment, and any-of on one is refused by the site layer (both
22
23
  * adapters read the text form of the whole array, never its elements). */
@@ -270,8 +271,8 @@ export class MemoryEntryStore {
270
271
  }
271
272
  return sets;
272
273
  }
273
- async writePaths(collection, nodes) {
274
- await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
274
+ async writePaths(collection, nodes, redirect = true) {
275
+ await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
275
276
  }
276
277
  writeRefs(entry) {
277
278
  this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection), this.elements));
@@ -396,7 +397,7 @@ export class MemoryEntryStore {
396
397
  }
397
398
  return out;
398
399
  }
399
- async update(collection, id, locale, fields, by, expected) {
400
+ async update(collection, id, locale, fields, by, expected, options) {
400
401
  const schema = this.schema(collection);
401
402
  const entry = this.must(collection, id);
402
403
  const row = entryLocaleRow(entry, locale);
@@ -411,7 +412,7 @@ export class MemoryEntryStore {
411
412
  row.fields = localized;
412
413
  row.slug = slug;
413
414
  try {
414
- await this.writePaths(collection, this.subtree(entry));
415
+ await this.writePaths(collection, this.subtree(entry), options?.redirect ?? true);
415
416
  }
416
417
  catch (e) {
417
418
  entry.fields = before.fields;
@@ -505,7 +506,7 @@ export class MemoryEntryStore {
505
506
  throw entryErrors.localeInUse(locale, blockers);
506
507
  delete entry.locales[locale];
507
508
  try {
508
- await this.writePaths(collection, [entry]);
509
+ await this.writePaths(collection, [entry], false);
509
510
  }
510
511
  catch (e) {
511
512
  entry.locales[locale] = row;
@@ -546,7 +547,7 @@ export class MemoryEntryStore {
546
547
  const v = this.versions.get(versionId);
547
548
  return v ? structuredClone(v) : null;
548
549
  }
549
- async move(collection, id, to) {
550
+ async move(collection, id, to, options) {
550
551
  if (isGlobalSchema(this.schema(collection)))
551
552
  throw entryErrors.owned(collection, "it has no tree position");
552
553
  const entry = this.must(collection, id);
@@ -572,7 +573,7 @@ export class MemoryEntryStore {
572
573
  const previous = new Map(siblings.map((s) => [s.id, s.sort]));
573
574
  siblings.forEach((s, i) => (s.sort = i + 1));
574
575
  try {
575
- await this.writePaths(collection, subtree);
576
+ await this.writePaths(collection, subtree, options?.redirect ?? true);
576
577
  }
577
578
  catch (e) {
578
579
  Object.assign(entry, before);
@@ -621,4 +622,8 @@ export class MemoryEntryStore {
621
622
  pathsOf(collection, id) {
622
623
  return this.row(collection, id) ? this.paths.pathsOf({ kind: "entry", id }) : Promise.resolve({});
623
624
  }
625
+ async redirectsOf(collection, id) {
626
+ this.schema(collection);
627
+ return groupRedirects(await this.paths.redirectsOf({ kind: "entry", id }));
628
+ }
624
629
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { unstable_cache } from "next/cache";
3
3
  import { draftMode } from "next/headers";
4
- import { notFound } from "next/navigation";
4
+ import { notFound, permanentRedirect } from "next/navigation";
5
5
  import { connection } from "next/server";
6
6
  import { EditorBridge } from "../admin/next/bridge.js";
7
7
  import { fixedPathFor } from "../localize.js";
@@ -15,18 +15,31 @@ export function createPageRenderer(options) {
15
15
  const href = options.href ?? ((path) => path);
16
16
  const shapes = new Map(options.pages.filter((p) => p.schema.kind === "page").map((p) => [p.schema.name, p]));
17
17
  const entryPages = new Map(options.pages.filter((p) => p.schema.kind === "entryPage" && p.schema.collection).map((p) => [p.schema.collection, p]));
18
- /** locate (uncached: the id it returns IS the cache tag) then load —
19
- * cached per (locale, path) outside draft mode. */
20
- async function load(locale, path, draft, version) {
21
- const target = await site.locate(locale, path);
22
- if (!target)
23
- return null;
18
+ /** The hit for a located target (locate is uncached: the id it returns
19
+ * IS the cache tag): load — cached per (locale, path) outside draft mode. */
20
+ async function load(target, locale, path, draft, version) {
24
21
  if (draft)
25
22
  return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
26
23
  return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
27
24
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
28
25
  })();
29
26
  }
27
+ /** A redirect row (spec 2026-09-26 redirects §7): the node's CURRENT path in this locale, through href, the query kept; no path there is a 404. */
28
+ async function redirectTo(target, locale, params) {
29
+ const current = (await site.targetPaths(target))[locale];
30
+ if (!current)
31
+ notFound();
32
+ const query = new URLSearchParams();
33
+ for (const [k, v] of Object.entries(params ?? {})) {
34
+ if (typeof v === "string")
35
+ query.append(k, v);
36
+ else
37
+ for (const item of v ?? [])
38
+ query.append(k, item);
39
+ }
40
+ const qs = query.toString();
41
+ permanentRedirect(href(current, locale) + (qs ? `?${qs}` : ""));
42
+ }
30
43
  function render(hit, editor, locale) {
31
44
  if (hit.kind === "page") {
32
45
  const Page = shapes.get(hit.template);
@@ -67,7 +80,10 @@ export function createPageRenderer(options) {
67
80
  const editor = draft && params?.["smoodly-editor"] === "1";
68
81
  const versionParam = params?.["smoodly-version"];
69
82
  const version = draft && typeof versionParam === "string" && versionParam !== "" ? versionParam : undefined;
70
- const hit = await load(locale, path, draft, version);
83
+ const target = await site.locate(locale, path);
84
+ if (target?.redirect)
85
+ await redirectTo(target, locale, params);
86
+ const hit = target ? await load(target, locale, path, draft, version) : null;
71
87
  if (!hit || !accept(hit))
72
88
  notFound();
73
89
  return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
@@ -78,7 +94,10 @@ export function createPageRenderer(options) {
78
94
  const locale = opts?.locale ?? site.locales.default;
79
95
  const path = pathFor(locale);
80
96
  const { isEnabled: draft } = await draftMode();
81
- const hit = await load(locale, path, draft);
97
+ const target = await site.locate(locale, path);
98
+ if (!target || target.redirect)
99
+ return {};
100
+ const hit = await load(target, locale, path, draft);
82
101
  if (!hit || !accept(hit))
83
102
  return {};
84
103
  return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta, { title: hit.title }) : {}), ...alternates(hit) };
@@ -1,16 +1,40 @@
1
1
  import type { PathRow, PathTarget } from "./paths.ts";
2
2
  export type { PathRow, PathTarget } from "./paths.ts";
3
+ /** A rewrite target; `redirectLocales` names the locales whose old live rows stay as redirects (none = drop them). */
4
+ export type RewriteTarget = PathTarget & {
5
+ redirectLocales?: readonly string[];
6
+ };
7
+ /** What lives at an address: the target, and whether the row is a redirect to it. */
8
+ export type PathHit = PathTarget & {
9
+ redirect: boolean;
10
+ };
3
11
  export interface PathIndex {
4
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
12
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
13
+ /** Every row of the targets, redirects included (a deleted node has no old addresses). */
5
14
  remove(targets: PathTarget[]): Promise<void>;
6
- resolve(locale: string, path: string): Promise<PathTarget | null>;
15
+ resolve(locale: string, path: string): Promise<PathHit | null>;
16
+ /** locale → the target's LIVE path. */
7
17
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
18
+ /** The target's redirect rows. */
19
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
20
+ listLive(locale: string): Promise<PathRow[]>;
21
+ listRedirects(locale: string): Promise<PathRow[]>;
22
+ /** A redirect by hand: refused (pathLiveError) at a live address; another redirect row there is replaced. */
23
+ addRedirect(row: PathRow): Promise<void>;
24
+ /** Deletes a redirect row; a live row at the address is left alone. */
25
+ removeRedirect(locale: string, path: string): Promise<void>;
8
26
  }
9
27
  export declare const pathTakenError: (locale: string, path: string) => Error;
28
+ export declare const pathLiveError: (locale: string, path: string) => Error;
10
29
  export declare class MemoryPathIndex implements PathIndex {
11
30
  private rows;
12
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
31
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
13
32
  remove(targets: PathTarget[]): Promise<void>;
14
- resolve(locale: string, path: string): Promise<PathTarget | null>;
33
+ resolve(locale: string, path: string): Promise<PathHit | null>;
15
34
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
35
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
36
+ listLive(locale: string): Promise<PathRow[]>;
37
+ listRedirects(locale: string): Promise<PathRow[]>;
38
+ addRedirect(row: PathRow): Promise<void>;
39
+ removeRedirect(locale: string, path: string): Promise<void>;
16
40
  }
@@ -1,12 +1,27 @@
1
1
  const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id;
2
2
  const key = (locale, path) => `${locale} ${path}`;
3
3
  export const pathTakenError = (locale, path) => new Error(`smoodly: the path "${path}" (${locale}) is already taken.`);
4
+ export const pathLiveError = (locale, path) => new Error(`smoodly: "${path}" (${locale}) is a live address.`);
5
+ const strip = ({ redirect: _redirect, ...row }) => row;
4
6
  export class MemoryPathIndex {
5
7
  constructor() {
6
8
  this.rows = [];
7
9
  }
8
10
  async rewrite(targets, rows) {
9
- const kept = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
11
+ const ofTarget = (r) => targets.find((t) => sameTarget(t, r));
12
+ const newKeys = new Set(rows.map((r) => key(r.locale, r.path)));
13
+ // 1. redirect rows at the new live addresses go, whoever they point at (live wins, rename-back reclaims)
14
+ let kept = this.rows.filter((r) => !(r.redirect && newKeys.has(key(r.locale, r.path))));
15
+ // 2. the targets' old live rows: flipped to redirects in the named locales unless the address stays live
16
+ kept = kept.map((r) => {
17
+ const t = !r.redirect ? ofTarget(r) : undefined;
18
+ return t && t.redirectLocales?.includes(r.locale) && !newKeys.has(key(r.locale, r.path)) ? { ...r, redirect: true } : r;
19
+ });
20
+ // 3. the targets' remaining live rows go — and so do their redirect rows in a locale the
21
+ // target has no new live row in (a removed translation: nothing could resolve them)
22
+ const liveLocales = new Set(rows.map((r) => `${r.kind} ${r.id} ${r.locale}`));
23
+ kept = kept.filter((r) => !ofTarget(r) || (r.redirect && liveLocales.has(`${r.kind} ${r.id} ${r.locale}`)));
24
+ // 4. the new live rows: a collision now is with another target's live row
10
25
  const taken = new Set(kept.map((r) => key(r.locale, r.path)));
11
26
  for (const row of rows) {
12
27
  const k = key(row.locale, row.path);
@@ -14,20 +29,38 @@ export class MemoryPathIndex {
14
29
  throw pathTakenError(row.locale, row.path);
15
30
  taken.add(k);
16
31
  }
17
- this.rows = [...kept, ...rows.map((r) => ({ ...r }))];
32
+ this.rows = [...kept, ...rows.map((r) => ({ ...r, redirect: false }))];
18
33
  }
19
34
  async remove(targets) {
20
35
  this.rows = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
21
36
  }
22
37
  async resolve(locale, path) {
23
38
  const row = this.rows.find((r) => r.locale === locale && r.path === path);
24
- return row ? { kind: row.kind, id: row.id } : null;
39
+ return row ? { kind: row.kind, id: row.id, redirect: row.redirect } : null;
25
40
  }
26
41
  async pathsOf(target) {
27
42
  const out = {};
28
43
  for (const r of this.rows)
29
- if (sameTarget(r, target))
44
+ if (!r.redirect && sameTarget(r, target))
30
45
  out[r.locale] = r.path;
31
46
  return out;
32
47
  }
48
+ async redirectsOf(target) {
49
+ return this.rows.filter((r) => r.redirect && sameTarget(r, target)).map(strip);
50
+ }
51
+ async listLive(locale) {
52
+ return this.rows.filter((r) => !r.redirect && r.locale === locale).map(strip);
53
+ }
54
+ async listRedirects(locale) {
55
+ return this.rows.filter((r) => r.redirect && r.locale === locale).map(strip);
56
+ }
57
+ async addRedirect(row) {
58
+ const existing = this.rows.find((r) => r.locale === row.locale && r.path === row.path);
59
+ if (existing && !existing.redirect)
60
+ throw pathLiveError(row.locale, row.path);
61
+ this.rows = [...this.rows.filter((r) => r !== existing), { ...row, redirect: true }];
62
+ }
63
+ async removeRedirect(locale, path) {
64
+ this.rows = this.rows.filter((r) => !(r.redirect && r.locale === locale && r.path === path));
65
+ }
33
66
  }
package/dist/paths.d.ts CHANGED
@@ -91,6 +91,22 @@ export declare function entrySegment(entry: EntryLike, locale: string): string |
91
91
  export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
92
92
  path?: string | Record<string, string>;
93
93
  }, set: LocaleSet): PathRow[];
94
+ /** The locales a node is published in — the ones whose old addresses may
95
+ * stay as redirects (spec 2026-09-26 redirects §2: only published rows). */
96
+ export declare function publishedLocales(node: {
97
+ locales: Record<string, {
98
+ status: "draft" | "published";
99
+ }>;
100
+ }): string[];
101
+ /** The rewrite targets for a subtree: with `redirect`, each names its published locales. */
102
+ export declare function rewriteTargets<T extends {
103
+ id: string;
104
+ locales: Record<string, {
105
+ status: "draft" | "published";
106
+ }>;
107
+ }>(kind: "page" | "entry", nodes: T[], redirect: boolean): (PathTarget & {
108
+ redirectLocales?: string[];
109
+ })[];
94
110
  /** A target's address in one locale (spec 2026-09-15 §1). A page always
95
111
  * has both; an entry's slug is null when its row claims no URL, and its
96
112
  * path null then or when the collection has no `path`. */
package/dist/paths.js CHANGED
@@ -130,6 +130,15 @@ export function entryPathRows(targets, byId, collection, set) {
130
130
  }
131
131
  return rows;
132
132
  }
133
+ /** The locales a node is published in — the ones whose old addresses may
134
+ * stay as redirects (spec 2026-09-26 redirects §2: only published rows). */
135
+ export function publishedLocales(node) {
136
+ return Object.keys(node.locales).filter((l) => node.locales[l].status === "published");
137
+ }
138
+ /** The rewrite targets for a subtree: with `redirect`, each names its published locales. */
139
+ export function rewriteTargets(kind, nodes, redirect) {
140
+ return nodes.map((n) => (redirect ? { kind, id: n.id, redirectLocales: publishedLocales(n) } : { kind, id: n.id }));
141
+ }
133
142
  /** The page's address in EVERY supported locale, in config order: null
134
143
  * where the node is absent, where an ancestor is, or where `advertised`
135
144
  * says no — the site passes "published, or draft mode", so an
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 3;
4
+ export declare const SCHEMA_VERSION = 4;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 3;
10
+ export const SCHEMA_VERSION = 4;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
package/dist/site.d.ts CHANGED
@@ -9,6 +9,7 @@ import { type GlobalSchema } from "./global.ts";
9
9
  import { type StyleValues, type StyleValuesInput } from "./styles.ts";
10
10
  import { type CustomConfigInput, type ResolvedCustom } from "./custom/index.ts";
11
11
  import type { PageRecord, PageStore } from "./store.ts";
12
+ import type { PathHit } from "./path-index.ts";
12
13
  export type SiteOptions = {
13
14
  stores: () => {
14
15
  pages: PageStore;
@@ -106,7 +107,9 @@ export interface SmoodlySite {
106
107
  /** The custom section's resolved options the renderer hands renderPage. */
107
108
  readonly custom: ResolvedCustom;
108
109
  /** One index hit: what lives at (locale, path). Uncached by design — the id it returns is the cache tag. */
109
- locate(locale: string, path: string): Promise<PathTarget | null>;
110
+ locate(locale: string, path: string): Promise<PathHit | null>;
111
+ /** locale → the target's current live path — where a redirect row sends the visitor (spec 2026-09-26 redirects §7). */
112
+ targetPaths(target: PathTarget): Promise<Record<string, string>>;
110
113
  /** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
111
114
  load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
112
115
  resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
package/dist/site.js CHANGED
@@ -325,13 +325,15 @@ export function createSmoodlySite(options) {
325
325
  styles,
326
326
  custom,
327
327
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
328
+ targetPaths: (target) => options.stores().pages.targetPaths(target),
328
329
  load(target, locale, path, opts = {}) {
329
330
  const draft = opts.draft ?? false;
330
331
  return target.kind === "page" ? loadPage(target.id, locale, path, draft, opts.version) : loadEntry(target.id, locale, path, draft);
331
332
  },
332
333
  async resolve(locale, path, opts) {
334
+ // The content API stays strict (§7): a content address is not a redirect.
333
335
  const target = await site.locate(locale, path);
334
- return target ? site.load(target, locale, path, opts) : null;
336
+ return target && !target.redirect ? site.load(target, locale, path, opts) : null;
335
337
  },
336
338
  ...api,
337
339
  };
package/dist/sql.js CHANGED
@@ -235,12 +235,16 @@ create table if not exists "refs" (
235
235
  -- and no page or entry can share a URL with another in any locale.
236
236
  -- Rows exist for drafts too (the editor needs them); the read policy
237
237
  -- admits a row only when its target is published.
238
+ -- A row with "redirect" set is an old address of its target (spec
239
+ -- 2026-09-26 redirects §2): the same primary key, so a redirect can never
240
+ -- sit where a live URL is and a live row taking the address replaces it.
238
241
  create table if not exists "paths" (
239
242
  ${SPACE_COL},
240
243
  "locale" text not null,
241
244
  "path" text not null,
242
245
  "kind" text not null check ("kind" in ('page', 'entry')),
243
246
  "target_id" uuid not null,
247
+ "redirect" boolean not null default false,
244
248
  primary key ("space_id", "locale", "path")
245
249
  );
246
250
 
@@ -280,10 +284,24 @@ create index if not exists "assets_space_created_idx"
280
284
  -- function runs in the caller's transaction, so a primary-key collision
281
285
  -- rolls the whole rewrite back. Inserted rows get their space from the
282
286
  -- a_set_space trigger (the claim, or the default space).
287
+ -- A target may name redirect_locales: its old live rows there stay as redirects.
283
288
  create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
284
289
  delete from "paths" p
285
- using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
290
+ using jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
291
+ where p."redirect" and p."locale" = r.locale and p."path" = r.path
292
+ and p."space_id" = "smoodly_current_space"();
293
+ update "paths" p set "redirect" = true
294
+ from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
295
+ where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
296
+ and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
297
+ and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
298
+ where r.locale = p."locale" and r.path = p."path")
299
+ and p."space_id" = "smoodly_current_space"();
300
+ delete from "paths" p
301
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
286
302
  where p."kind" = t.kind and p."target_id" = t.id
303
+ and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
304
+ where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
287
305
  and p."space_id" = "smoodly_current_space"();
288
306
  insert into "paths" ("locale", "path", "kind", "target_id")
289
307
  select r.locale, r.path, r.kind, r.id
package/dist/store.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { EntryStore } from "./entry-store.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import type { Toolset } from "./toolset.ts";
5
5
  import type { MemoryRefIndex } from "./ref-index.ts";
6
- import { type PathIndex } from "./path-index.ts";
6
+ import { type PathHit, type PathIndex, type PathRow } from "./path-index.ts";
7
7
  import { type DraftToken } from "./draft-token.ts";
8
8
  import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
9
9
  export type PageLocale = {
@@ -56,21 +56,32 @@ export interface PageStore {
56
56
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
57
57
  getPage(id: string): Promise<PageRecord | null>;
58
58
  listPages(): Promise<PageRecord[]>;
59
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
59
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
60
60
  /** The database's schema version from `smoodly_meta`; null when the table is missing (older than v1). Memory: always current (spec 2026-09-26 §5). */
61
61
  schemaVersion(): Promise<number | null>;
62
62
  /** locale → path, for the locales the page exists in. */
63
63
  pathsOf(id: string): Promise<Record<string, string>>;
64
+ /** locale → the target's live path, for any page or entry — the renderer's redirect branch (spec 2026-09-26 redirects §7). */
65
+ targetPaths(target: PathTarget): Promise<Record<string, string>>;
66
+ /** locale → this page's old addresses. */
67
+ redirectsOf(id: string): Promise<Record<string, string[]>>;
68
+ listLive(locale: string): Promise<PathRow[]>;
69
+ listRedirects(locale: string): Promise<PathRow[]>;
70
+ addRedirect(row: PathRow): Promise<void>;
71
+ removeRedirect(locale: string, path: string): Promise<void>;
64
72
  /** Refused when the page already has the locale or its parent lacks it (the parent rule). */
65
73
  addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
66
74
  /** Deletes the row, its versions and its paths. Refused while a
67
75
  * descendant has the locale, and for the page's last locale. */
68
76
  removeLocale(id: string, locale: string): Promise<PageRecord>;
77
+ /** `redirect` (default true) keeps a published row's old address as a redirect (spec 2026-09-26 redirects §1). */
69
78
  rename(id: string, patch: {
70
79
  locale: string;
71
80
  slug?: string;
72
81
  title?: string;
73
- }, by?: string): Promise<PageRecord>;
82
+ }, by?: string, options?: {
83
+ redirect?: boolean;
84
+ }): Promise<PageRecord>;
74
85
  /**
75
86
  * Reparents `id` under `to.parentId`. `to.index` is the moved node's
76
87
  * final 0-based position among the destination siblings: the node is
@@ -81,6 +92,8 @@ export interface PageStore {
81
92
  move(id: string, to: {
82
93
  parentId: string | null;
83
94
  index?: number;
95
+ }, options?: {
96
+ redirect?: boolean;
84
97
  }): Promise<PageRecord>;
85
98
  /** A folder becomes a page: an empty draft version per present locale. */
86
99
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
@@ -128,6 +141,8 @@ export type PageStoreOptions = {
128
141
  };
129
142
  export declare const DEFAULT_LOCALES: LocaleSet;
130
143
  /** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
144
+ /** Group an index's redirect rows by locale, paths sorted. */
145
+ export declare function groupRedirects(rows: PathRow[]): Record<string, string[]>;
131
146
  export declare function refSections(options: PageStoreOptions): SectionLike[] | undefined;
132
147
  /** The options every adapter resolves the same way. */
133
148
  export declare function pathOptions(options: PageStoreOptions): PagePathOptions & {
@@ -198,19 +213,29 @@ export declare class MemoryPageStore implements PageStore {
198
213
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
199
214
  getPage(id: string): Promise<PageRecord | null>;
200
215
  listPages(): Promise<PageRecord[]>;
201
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
216
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
202
217
  schemaVersion(): Promise<number>;
203
218
  pathsOf(id: string): Promise<Record<string, string>>;
219
+ targetPaths(target: PathTarget): Promise<Record<string, string>>;
220
+ redirectsOf(id: string): Promise<Record<string, string[]>>;
221
+ listLive(locale: string): Promise<PathRow[]>;
222
+ listRedirects(locale: string): Promise<PathRow[]>;
223
+ addRedirect(row: PathRow): Promise<void>;
224
+ removeRedirect(locale: string, path: string): Promise<void>;
204
225
  addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
205
226
  removeLocale(id: string, locale: string): Promise<PageRecord>;
206
227
  rename(id: string, patch: {
207
228
  locale: string;
208
229
  slug?: string;
209
230
  title?: string;
210
- }, by?: string): Promise<PageRecord>;
231
+ }, by?: string, options?: {
232
+ redirect?: boolean;
233
+ }): Promise<PageRecord>;
211
234
  move(id: string, to: {
212
235
  parentId: string | null;
213
236
  index?: number;
237
+ }, options?: {
238
+ redirect?: boolean;
214
239
  }): Promise<PageRecord>;
215
240
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
216
241
  saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
package/dist/store.js CHANGED
@@ -15,9 +15,19 @@ import { customToolset, withCustomSchema } from "./custom/index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
16
  import { assertFresh } from "./draft-token.js";
17
17
  import { SCHEMA_VERSION } from "./schema-version.js";
18
- import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
18
+ import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, rewriteTargets, } from "./paths.js";
19
19
  export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
20
20
  /** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
21
+ /** Group an index's redirect rows by locale, paths sorted. */
22
+ export function groupRedirects(rows) {
23
+ var _a;
24
+ const out = {};
25
+ for (const r of rows)
26
+ (out[_a = r.locale] ?? (out[_a] = [])).push(r.path);
27
+ for (const l of Object.keys(out))
28
+ out[l].sort();
29
+ return out;
30
+ }
21
31
  export function refSections(options) {
22
32
  return options.sections ? withCustomSchema(options.sections, customToolset(options.custom?.toolset)) : undefined;
23
33
  }
@@ -158,8 +168,8 @@ export class MemoryPageStore {
158
168
  walk(id);
159
169
  return out;
160
170
  }
161
- async writePaths(nodes) {
162
- await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, this.byId, this.opts));
171
+ async writePaths(nodes, redirect = true) {
172
+ await this.paths.rewrite(rewriteTargets("page", nodes, redirect), pagePathRows(nodes, this.byId, this.opts));
163
173
  }
164
174
  /** Every locale's draft and published trees, deduped by version id. */
165
175
  liveTrees(page) {
@@ -282,6 +292,12 @@ export class MemoryPageStore {
282
292
  pathsOf(id) {
283
293
  return this.paths.pathsOf({ kind: "page", id });
284
294
  }
295
+ targetPaths(target) { return this.paths.pathsOf(target); }
296
+ async redirectsOf(id) { return groupRedirects(await this.paths.redirectsOf({ kind: "page", id })); }
297
+ listLive(locale) { return this.paths.listLive(locale); }
298
+ listRedirects(locale) { return this.paths.listRedirects(locale); }
299
+ addRedirect(row) { return this.paths.addRedirect(row); }
300
+ removeRedirect(locale, path) { return this.paths.removeRedirect(locale, path); }
285
301
  async addLocale(id, locale, options, by) {
286
302
  assertSupportedLocale(locale, this.opts.locales);
287
303
  const page = this.must(id);
@@ -333,7 +349,7 @@ export class MemoryPageStore {
333
349
  throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
334
350
  delete page.locales[locale];
335
351
  try {
336
- await this.writePaths([page]);
352
+ await this.writePaths([page], false);
337
353
  }
338
354
  catch (e) {
339
355
  page.locales[locale] = removed;
@@ -343,7 +359,7 @@ export class MemoryPageStore {
343
359
  this.writeRefs(page);
344
360
  return this.snapshot(page);
345
361
  }
346
- async rename(id, patch, by) {
362
+ async rename(id, patch, by, options) {
347
363
  const page = this.must(id);
348
364
  const row = localeRow(page, patch.locale);
349
365
  if (patch.slug !== undefined)
@@ -366,7 +382,7 @@ export class MemoryPageStore {
366
382
  if (patch.title !== undefined)
367
383
  row.title = patch.title;
368
384
  try {
369
- await this.writePaths(this.subtree(id));
385
+ await this.writePaths(this.subtree(id), options?.redirect ?? true);
370
386
  }
371
387
  catch (e) {
372
388
  Object.assign(row, before);
@@ -375,7 +391,7 @@ export class MemoryPageStore {
375
391
  touchRow(row, by);
376
392
  return this.snapshot(page);
377
393
  }
378
- async move(id, to) {
394
+ async move(id, to, options) {
379
395
  const page = this.must(id);
380
396
  if (to.parentId !== null) {
381
397
  const parent = this.must(to.parentId);
@@ -409,7 +425,7 @@ export class MemoryPageStore {
409
425
  const previousSort = new Map(siblings.map((s) => [s.id, s.sort]));
410
426
  siblings.forEach((s, i) => (s.sort = i + 1));
411
427
  try {
412
- await this.writePaths(this.subtree(id));
428
+ await this.writePaths(this.subtree(id), options?.redirect ?? true);
413
429
  }
414
430
  catch (e) {
415
431
  Object.assign(page, before);
@@ -61,7 +61,9 @@ export declare class SupabaseEntryStore implements EntryStore {
61
61
  getMany(collection: string, ids: string[], locale: string, options?: {
62
62
  status?: "published";
63
63
  }): Promise<Record<string, Record<string, unknown>>>;
64
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
64
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
65
+ redirect?: boolean;
66
+ }): Promise<EntryRecord>;
65
67
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
66
68
  discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
67
69
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
@@ -72,10 +74,13 @@ export declare class SupabaseEntryStore implements EntryStore {
72
74
  move(collection: string, id: string, to: {
73
75
  parentId: string | null;
74
76
  index?: number;
77
+ }, options?: {
78
+ redirect?: boolean;
75
79
  }): Promise<EntryRecord>;
76
80
  delete(collection: string, id: string): Promise<void>;
77
- resolvePath(locale: string, path: string): Promise<import("./paths.ts").PathTarget | null>;
81
+ resolvePath(locale: string, path: string): Promise<import("./path-index.ts").PathHit | null>;
78
82
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
83
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
79
84
  reorder(collection: string, orderedIds: string[]): Promise<void>;
80
85
  incoming(targetId: string): Promise<{
81
86
  sourceKind: string;