smoodly 0.0.27 → 0.0.29

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 (96) hide show
  1. package/README.md +75 -3
  2. package/dist/admin/client-ops.js +2 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/EditorView.js +70 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +9 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.d.ts +4 -1
  14. package/dist/admin/editor/PageSettings.js +33 -15
  15. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  16. package/dist/admin/editor/UnknownNode.js +8 -0
  17. package/dist/admin/field-paths.d.ts +3 -0
  18. package/dist/admin/field-paths.js +26 -0
  19. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  20. package/dist/admin/forms/OrphanFields.js +11 -0
  21. package/dist/admin/forms/RefPicker.js +6 -2
  22. package/dist/admin/forms/RichtextElementCard.js +9 -3
  23. package/dist/admin/forms/RichtextWidget.js +3 -1
  24. package/dist/admin/forms/TagsField.js +1 -1
  25. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  26. package/dist/admin/forms/richtext-form-context.js +5 -0
  27. package/dist/admin/health.d.ts +23 -0
  28. package/dist/admin/health.js +42 -0
  29. package/dist/admin/ops-impl.js +124 -14
  30. package/dist/admin/ops.d.ts +53 -1
  31. package/dist/admin/redirect-path.d.ts +6 -0
  32. package/dist/admin/redirect-path.js +33 -0
  33. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  34. package/dist/admin/shell/AddRedirectModal.js +29 -0
  35. package/dist/admin/shell/AdminApp.js +2 -3
  36. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  37. package/dist/admin/shell/ContentHealthPage.js +37 -0
  38. package/dist/admin/shell/EntriesList.js +4 -4
  39. package/dist/admin/shell/EntryForm.js +32 -7
  40. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  41. package/dist/admin/shell/ErrorPane.js +5 -4
  42. package/dist/admin/shell/GlobalForm.js +22 -2
  43. package/dist/admin/shell/IconRail.d.ts +1 -1
  44. package/dist/admin/shell/IconRail.js +3 -2
  45. package/dist/admin/shell/PagesList.js +9 -7
  46. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  47. package/dist/admin/shell/RedirectsPage.js +75 -0
  48. package/dist/admin/shell/SettingsView.d.ts +4 -2
  49. package/dist/admin/shell/SettingsView.js +22 -14
  50. package/dist/admin/shell/health-list.d.ts +27 -0
  51. package/dist/admin/shell/health-list.js +47 -0
  52. package/dist/admin/shell/live-url.d.ts +10 -2
  53. package/dist/admin/shell/live-url.js +9 -4
  54. package/dist/admin/shell/members-list.js +1 -1
  55. package/dist/admin/shell/redirects-list.d.ts +12 -0
  56. package/dist/admin/shell/redirects-list.js +15 -0
  57. package/dist/admin/shell/settings.d.ts +16 -3
  58. package/dist/admin/shell/settings.js +16 -9
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/Combobox.d.ts +8 -2
  62. package/dist/admin/ui/Combobox.js +48 -16
  63. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  64. package/dist/admin/ui/ConfirmDialog.js +5 -4
  65. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  66. package/dist/admin/ui/DriftNotice.js +10 -0
  67. package/dist/admin/ui/combo-box.d.ts +13 -0
  68. package/dist/admin/ui/combo-box.js +8 -0
  69. package/dist/admin/ui/confirm.d.ts +21 -2
  70. package/dist/admin/ui/confirm.js +26 -9
  71. package/dist/admin/ui/theme.js +32 -3
  72. package/dist/admin/validate.js +15 -47
  73. package/dist/entry-store.d.ts +31 -6
  74. package/dist/entry-store.js +39 -11
  75. package/dist/index.d.ts +2 -2
  76. package/dist/next/page-renderer.js +28 -9
  77. package/dist/path-index.d.ts +28 -4
  78. package/dist/path-index.js +37 -4
  79. package/dist/paths.d.ts +16 -0
  80. package/dist/paths.js +9 -0
  81. package/dist/render.js +7 -2
  82. package/dist/schema-version.d.ts +1 -1
  83. package/dist/schema-version.js +1 -1
  84. package/dist/site.d.ts +4 -1
  85. package/dist/site.js +3 -1
  86. package/dist/sql.js +19 -1
  87. package/dist/store.d.ts +39 -5
  88. package/dist/store.js +32 -8
  89. package/dist/supabase-entry-store.d.ts +9 -3
  90. package/dist/supabase-entry-store.js +39 -13
  91. package/dist/supabase-path-index.d.ts +10 -3
  92. package/dist/supabase-path-index.js +50 -17
  93. package/dist/supabase-store.d.ts +15 -3
  94. package/dist/supabase-store.js +42 -8
  95. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  96. package/package.json +1 -1
@@ -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
package/dist/render.js CHANGED
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { EMPTY_ATTR, ZONE_ATTR } from "./marks.js";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "./marks.js";
3
3
  import { SMD_KEY } from "./section-wrapper.js";
4
4
  import { DEFAULT_STYLE_VALUES } from "./styles.js";
5
5
  import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
@@ -23,6 +23,11 @@ export function Zone({ of: content }) {
23
23
  return (_jsx(_Fragment, { children: nodes.map((node) => {
24
24
  const Component = content.components.get(node.type);
25
25
  if (!Component) {
26
+ // Editor mode draws a box the bridge can measure, so the node is selectable in place
27
+ // (spec 2026-09-27 editor safety §5); the site renders nothing, as before.
28
+ if (content.editor) {
29
+ return (_jsxs("div", { [NODE_ATTR]: node.id, style: { padding: "24px 16px", border: "1px dashed #b54708", color: "#b54708", font: "500 14px/1.4 system-ui, sans-serif", textAlign: "center" }, children: ["Unknown section `", node.type, "`"] }, node.id));
30
+ }
26
31
  console.warn(`smoodly: no registered component for node type "${node.type}" — skipping.`);
27
32
  return null;
28
33
  }
@@ -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 = {
@@ -52,25 +52,44 @@ export type PageVersion = {
52
52
  tree: PageTree;
53
53
  savedAt: number;
54
54
  };
55
+ /** One page for the drift scan (spec 2026-09-27 editor safety §3): locale → draft and live tree; null where there is none. */
56
+ export type PageScan = {
57
+ record: PageRecord;
58
+ draft: Record<string, PageTree | null>;
59
+ live: Record<string, PageTree | null>;
60
+ };
55
61
  export interface PageStore {
56
62
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
57
63
  getPage(id: string): Promise<PageRecord | null>;
58
64
  listPages(): Promise<PageRecord[]>;
59
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
65
+ /** Every page with each locale's draft and live tree, in few reads (spec 2026-09-27 editor safety §3). */
66
+ scanTrees(): Promise<PageScan[]>;
67
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
60
68
  /** 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
69
  schemaVersion(): Promise<number | null>;
62
70
  /** locale → path, for the locales the page exists in. */
63
71
  pathsOf(id: string): Promise<Record<string, string>>;
72
+ /** locale → the target's live path, for any page or entry — the renderer's redirect branch (spec 2026-09-26 redirects §7). */
73
+ targetPaths(target: PathTarget): Promise<Record<string, string>>;
74
+ /** locale → this page's old addresses. */
75
+ redirectsOf(id: string): Promise<Record<string, string[]>>;
76
+ listLive(locale: string): Promise<PathRow[]>;
77
+ listRedirects(locale: string): Promise<PathRow[]>;
78
+ addRedirect(row: PathRow): Promise<void>;
79
+ removeRedirect(locale: string, path: string): Promise<void>;
64
80
  /** Refused when the page already has the locale or its parent lacks it (the parent rule). */
65
81
  addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
66
82
  /** Deletes the row, its versions and its paths. Refused while a
67
83
  * descendant has the locale, and for the page's last locale. */
68
84
  removeLocale(id: string, locale: string): Promise<PageRecord>;
85
+ /** `redirect` (default true) keeps a published row's old address as a redirect (spec 2026-09-26 redirects §1). */
69
86
  rename(id: string, patch: {
70
87
  locale: string;
71
88
  slug?: string;
72
89
  title?: string;
73
- }, by?: string): Promise<PageRecord>;
90
+ }, by?: string, options?: {
91
+ redirect?: boolean;
92
+ }): Promise<PageRecord>;
74
93
  /**
75
94
  * Reparents `id` under `to.parentId`. `to.index` is the moved node's
76
95
  * final 0-based position among the destination siblings: the node is
@@ -81,6 +100,8 @@ export interface PageStore {
81
100
  move(id: string, to: {
82
101
  parentId: string | null;
83
102
  index?: number;
103
+ }, options?: {
104
+ redirect?: boolean;
84
105
  }): Promise<PageRecord>;
85
106
  /** A folder becomes a page: an empty draft version per present locale. */
86
107
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
@@ -128,6 +149,8 @@ export type PageStoreOptions = {
128
149
  };
129
150
  export declare const DEFAULT_LOCALES: LocaleSet;
130
151
  /** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
152
+ /** Group an index's redirect rows by locale, paths sorted. */
153
+ export declare function groupRedirects(rows: PathRow[]): Record<string, string[]>;
131
154
  export declare function refSections(options: PageStoreOptions): SectionLike[] | undefined;
132
155
  /** The options every adapter resolves the same way. */
133
156
  export declare function pathOptions(options: PageStoreOptions): PagePathOptions & {
@@ -197,20 +220,31 @@ export declare class MemoryPageStore implements PageStore {
197
220
  private dropVersions;
198
221
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
199
222
  getPage(id: string): Promise<PageRecord | null>;
223
+ scanTrees(): Promise<PageScan[]>;
200
224
  listPages(): Promise<PageRecord[]>;
201
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
225
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
202
226
  schemaVersion(): Promise<number>;
203
227
  pathsOf(id: string): Promise<Record<string, string>>;
228
+ targetPaths(target: PathTarget): Promise<Record<string, string>>;
229
+ redirectsOf(id: string): Promise<Record<string, string[]>>;
230
+ listLive(locale: string): Promise<PathRow[]>;
231
+ listRedirects(locale: string): Promise<PathRow[]>;
232
+ addRedirect(row: PathRow): Promise<void>;
233
+ removeRedirect(locale: string, path: string): Promise<void>;
204
234
  addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
205
235
  removeLocale(id: string, locale: string): Promise<PageRecord>;
206
236
  rename(id: string, patch: {
207
237
  locale: string;
208
238
  slug?: string;
209
239
  title?: string;
210
- }, by?: string): Promise<PageRecord>;
240
+ }, by?: string, options?: {
241
+ redirect?: boolean;
242
+ }): Promise<PageRecord>;
211
243
  move(id: string, to: {
212
244
  parentId: string | null;
213
245
  index?: number;
246
+ }, options?: {
247
+ redirect?: boolean;
214
248
  }): Promise<PageRecord>;
215
249
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
216
250
  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) {
@@ -267,6 +277,14 @@ export class MemoryPageStore {
267
277
  const page = this.pages.get(id);
268
278
  return page ? this.snapshot(page) : null;
269
279
  }
280
+ async scanTrees() {
281
+ const tree = (id) => (id ? structuredClone(this.versions.get(id)?.tree ?? null) : null);
282
+ return (await this.listPages()).map((record) => ({
283
+ record,
284
+ draft: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.draftVersionId)])),
285
+ live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.publishedVersionId)])),
286
+ }));
287
+ }
270
288
  async listPages() {
271
289
  return [...this.pages.values()]
272
290
  // null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
@@ -282,6 +300,12 @@ export class MemoryPageStore {
282
300
  pathsOf(id) {
283
301
  return this.paths.pathsOf({ kind: "page", id });
284
302
  }
303
+ targetPaths(target) { return this.paths.pathsOf(target); }
304
+ async redirectsOf(id) { return groupRedirects(await this.paths.redirectsOf({ kind: "page", id })); }
305
+ listLive(locale) { return this.paths.listLive(locale); }
306
+ listRedirects(locale) { return this.paths.listRedirects(locale); }
307
+ addRedirect(row) { return this.paths.addRedirect(row); }
308
+ removeRedirect(locale, path) { return this.paths.removeRedirect(locale, path); }
285
309
  async addLocale(id, locale, options, by) {
286
310
  assertSupportedLocale(locale, this.opts.locales);
287
311
  const page = this.must(id);
@@ -333,7 +357,7 @@ export class MemoryPageStore {
333
357
  throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
334
358
  delete page.locales[locale];
335
359
  try {
336
- await this.writePaths([page]);
360
+ await this.writePaths([page], false);
337
361
  }
338
362
  catch (e) {
339
363
  page.locales[locale] = removed;
@@ -343,7 +367,7 @@ export class MemoryPageStore {
343
367
  this.writeRefs(page);
344
368
  return this.snapshot(page);
345
369
  }
346
- async rename(id, patch, by) {
370
+ async rename(id, patch, by, options) {
347
371
  const page = this.must(id);
348
372
  const row = localeRow(page, patch.locale);
349
373
  if (patch.slug !== undefined)
@@ -366,7 +390,7 @@ export class MemoryPageStore {
366
390
  if (patch.title !== undefined)
367
391
  row.title = patch.title;
368
392
  try {
369
- await this.writePaths(this.subtree(id));
393
+ await this.writePaths(this.subtree(id), options?.redirect ?? true);
370
394
  }
371
395
  catch (e) {
372
396
  Object.assign(row, before);
@@ -375,7 +399,7 @@ export class MemoryPageStore {
375
399
  touchRow(row, by);
376
400
  return this.snapshot(page);
377
401
  }
378
- async move(id, to) {
402
+ async move(id, to, options) {
379
403
  const page = this.must(id);
380
404
  if (to.parentId !== null) {
381
405
  const parent = this.must(to.parentId);
@@ -409,7 +433,7 @@ export class MemoryPageStore {
409
433
  const previousSort = new Map(siblings.map((s) => [s.id, s.sort]));
410
434
  siblings.forEach((s, i) => (s.sort = i + 1));
411
435
  try {
412
- await this.writePaths(this.subtree(id));
436
+ await this.writePaths(this.subtree(id), options?.redirect ?? true);
413
437
  }
414
438
  catch (e) {
415
439
  Object.assign(page, before);
@@ -1,6 +1,6 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
- import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
3
+ import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryScan, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
4
4
  import { type DraftToken } from "./draft-token.ts";
5
5
  export declare class SupabaseEntryStore implements EntryStore {
6
6
  private db;
@@ -61,21 +61,27 @@ 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>;
68
70
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
71
+ scanEntries(): Promise<EntryScan[]>;
69
72
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
70
73
  restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
71
74
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
72
75
  move(collection: string, id: string, to: {
73
76
  parentId: string | null;
74
77
  index?: number;
78
+ }, options?: {
79
+ redirect?: boolean;
75
80
  }): Promise<EntryRecord>;
76
81
  delete(collection: string, id: string): Promise<void>;
77
- resolvePath(locale: string, path: string): Promise<import("./paths.ts").PathTarget | null>;
82
+ resolvePath(locale: string, path: string): Promise<import("./path-index.ts").PathHit | null>;
78
83
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
84
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
79
85
  reorder(collection: string, orderedIds: string[]): Promise<void>;
80
86
  incoming(targetId: string): Promise<{
81
87
  sourceKind: string;
@@ -7,11 +7,12 @@
7
7
  // calls the smoodly_reorder SQL function: one statement, updated_at
8
8
  // untouched.
9
9
  import { collectionDepth, collectionOrder } from "./collections.js";
10
- import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, schemaOf, splitFields, } from "./entry-store.js";
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
12
  import { SupabasePathIndex } from "./supabase-path-index.js";
13
+ import { groupRedirects } from "./store.js";
13
14
  import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
14
- import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
15
+ import { assertDepth, assertSupportedLocale, entryPathRows, rewriteTargets, slugOf } from "./paths.js";
15
16
  // entries.id is a Postgres uuid column: filtering on a non-UUID string
16
17
  // throws invalid_text_representation. A non-UUID id can't exist in the
17
18
  // table, so treat it the same as a missing one.
@@ -151,11 +152,11 @@ export class SupabaseEntryStore {
151
152
  return new Map(res.data.map((r) => [r.id, r.fields]));
152
153
  }
153
154
  // ── the derived indexes ──
154
- async writePaths(top) {
155
+ async writePaths(top, redirect = true) {
155
156
  const schema = this.schema(top.collection);
156
157
  const ancestors = await this.chain(top);
157
158
  const nodes = await this.subtree(top);
158
- await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema, this.locales));
159
+ await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema, this.locales));
159
160
  }
160
161
  /** Every field set the site can render: each locale's live view, plus a
161
162
  * published snapshot the draft has moved past (see entryRefEdges). */
@@ -462,7 +463,7 @@ export class SupabaseEntryStore {
462
463
  }
463
464
  return out;
464
465
  }
465
- async update(collection, id, locale, fields, by, expected) {
466
+ async update(collection, id, locale, fields, by, expected, options) {
466
467
  const schema = this.schema(collection);
467
468
  const before = await this.must(collection, id);
468
469
  const row = entryLocaleRow(before, locale);
@@ -472,15 +473,16 @@ export class SupabaseEntryStore {
472
473
  if (isGlobalSchema(schema) && slug !== null)
473
474
  throw entryErrors.noSlug(collection);
474
475
  await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
476
+ const nodeFields = keepLocalizedShared(before.fields, shared, schema);
475
477
  // The snapshot of what the row is about to hold, written under the guard BEFORE the live
476
478
  // columns change (spec 2026-09-26 concurrency §3): a stale save leaves nothing behind.
477
- const next = { ...before, fields: shared, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
479
+ const next = { ...before, fields: nodeFields, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
478
480
  const { created } = await this.recordVersion(next, locale, expected !== undefined);
479
- await this.updateNode(id, { fields: shared });
481
+ await this.updateNode(id, { fields: nodeFields });
480
482
  await this.updateLocale(id, locale, { slug, fields: localized }, { by });
481
483
  const record = await this.must(collection, id);
482
484
  try {
483
- await this.writePaths(record);
485
+ await this.writePaths(record, options?.redirect ?? true);
484
486
  }
485
487
  catch (e) {
486
488
  await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
@@ -534,7 +536,7 @@ export class SupabaseEntryStore {
534
536
  const matched = await this.guardedUpdateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
535
537
  if (!matched)
536
538
  throw await this.stale(collection, id, locale);
537
- await this.updateNode(id, { fields: shared });
539
+ await this.updateNode(id, { fields: keepLocalizedShared(record.fields, shared, schema) });
538
540
  if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
539
541
  // The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
540
542
  const gone = await this.db.from("entry_versions").delete().eq("id", row.draftVersionId);
@@ -591,12 +593,32 @@ export class SupabaseEntryStore {
591
593
  // locale until the next write, never leaked content.
592
594
  await this.deleteLocale(id, locale);
593
595
  const remaining = await this.must(collection, id);
594
- await this.writePaths(remaining);
596
+ await this.writePaths(remaining, false);
595
597
  await this.updateNode(id, {});
596
598
  const final = await this.must(collection, id);
597
599
  await this.writeRefs(final);
598
600
  return final;
599
601
  }
602
+ async scanEntries() {
603
+ const res = await this.db.from("entries").select(SELECT).order("created_at", { ascending: true }).order("id", { ascending: true });
604
+ if (res.error)
605
+ this.fail(res.error.message);
606
+ const records = res.data.map(toRecord);
607
+ const ids = records.flatMap((r) => Object.values(r.locales).flatMap((row) => (row.status === "published" && row.publishedVersionId ? [row.publishedVersionId] : [])));
608
+ // In chunks: one in() over hundreds of ids would overflow the request URL.
609
+ const snaps = new Map();
610
+ for (let i = 0; i < ids.length; i += 100) {
611
+ for (const [id, fields] of await this.versionFields(ids.slice(i, i + 100)))
612
+ snaps.set(id, fields);
613
+ }
614
+ return records.map((record) => ({
615
+ record,
616
+ live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [
617
+ l,
618
+ row.status !== "published" ? null : (row.publishedVersionId ? snaps.get(row.publishedVersionId) : undefined) ?? entryFieldsIn(record, l),
619
+ ])),
620
+ }));
621
+ }
600
622
  async listVersions(collection, id, locale) {
601
623
  await this.must(collection, id);
602
624
  const res = await this.db
@@ -620,7 +642,8 @@ export class SupabaseEntryStore {
620
642
  assertFresh(row, expected);
621
643
  if (versionId === row.publishedVersionId)
622
644
  return this.discardDraft(collection, id, locale, by, expected);
623
- const { shared, localized } = splitFields(version.fields, schema);
645
+ const { shared: split, localized } = splitFields(version.fields, schema);
646
+ const shared = keepLocalizedShared(entry.fields, split, schema);
624
647
  // The snapshot first, under the guard, as in update.
625
648
  const next = { ...entry, fields: shared, locales: { ...entry.locales, [locale]: { ...row, fields: localized } } };
626
649
  await this.recordVersion(next, locale, expected !== undefined);
@@ -639,7 +662,7 @@ export class SupabaseEntryStore {
639
662
  this.fail(res.error.message);
640
663
  return res.data ? toVersion(res.data) : null;
641
664
  }
642
- async move(collection, id, to) {
665
+ async move(collection, id, to, options) {
643
666
  if (isGlobalSchema(this.schema(collection)))
644
667
  throw entryErrors.owned(collection, "it has no tree position");
645
668
  const entry = await this.must(collection, id);
@@ -671,7 +694,7 @@ export class SupabaseEntryStore {
671
694
  const ordered = siblings.map((s) => s.id);
672
695
  ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
673
696
  await this.reorder(collection, ordered);
674
- await this.writePaths(await this.must(collection, id));
697
+ await this.writePaths(await this.must(collection, id), options?.redirect ?? true);
675
698
  }
676
699
  catch (e) {
677
700
  await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
@@ -713,6 +736,9 @@ export class SupabaseEntryStore {
713
736
  async pathsOf(collection, id) {
714
737
  return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
715
738
  }
739
+ async redirectsOf(collection, id) {
740
+ return (await this.fetch(collection, id)) ? groupRedirects(await this.paths.redirectsOf({ kind: "entry", id })) : {};
741
+ }
716
742
  async reorder(collection, orderedIds) {
717
743
  if (isGlobalSchema(this.schema(collection)))
718
744
  throw entryErrors.owned(collection, "it has no order");
@@ -1,11 +1,18 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import { type PathIndex, type PathRow, type PathTarget } from "./path-index.ts";
2
+ import { type PathHit, type PathIndex, type PathRow, type PathTarget, type RewriteTarget } from "./path-index.ts";
3
3
  export declare class SupabasePathIndex implements PathIndex {
4
4
  private db;
5
5
  constructor(db: SupabaseClient);
6
6
  private fail;
7
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
7
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
8
8
  remove(targets: PathTarget[]): Promise<void>;
9
- resolve(locale: string, path: string): Promise<PathTarget | null>;
9
+ resolve(locale: string, path: string): Promise<PathHit | null>;
10
+ private rowsOf;
11
+ private rowsIn;
10
12
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
13
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
14
+ listLive(locale: string): Promise<PathRow[]>;
15
+ listRedirects(locale: string): Promise<PathRow[]>;
16
+ addRedirect(row: PathRow): Promise<void>;
17
+ removeRedirect(locale: string, path: string): Promise<void>;
11
18
  }