smoodly 0.0.26 → 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 (66) hide show
  1. package/README.md +70 -6
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +5 -2
  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 +24 -10
  15. package/dist/admin/shell/EntryForm.js +14 -6
  16. package/dist/admin/shell/GlobalForm.js +1 -1
  17. package/dist/admin/shell/IconRail.d.ts +1 -1
  18. package/dist/admin/shell/IconRail.js +3 -2
  19. package/dist/admin/shell/PagesList.js +6 -4
  20. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  21. package/dist/admin/shell/RedirectsPage.js +75 -0
  22. package/dist/admin/shell/SettingsView.d.ts +4 -2
  23. package/dist/admin/shell/SettingsView.js +18 -14
  24. package/dist/admin/shell/live-url.d.ts +10 -2
  25. package/dist/admin/shell/live-url.js +9 -4
  26. package/dist/admin/shell/members-list.js +1 -1
  27. package/dist/admin/shell/presence-realtime.d.ts +1 -1
  28. package/dist/admin/shell/presence-realtime.js +19 -9
  29. package/dist/admin/shell/presence.d.ts +18 -0
  30. package/dist/admin/shell/presence.js +13 -0
  31. package/dist/admin/shell/redirects-list.d.ts +12 -0
  32. package/dist/admin/shell/redirects-list.js +15 -0
  33. package/dist/admin/shell/settings.d.ts +12 -3
  34. package/dist/admin/shell/settings.js +13 -9
  35. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  36. package/dist/admin/ui/ConfirmDialog.js +4 -3
  37. package/dist/admin/ui/confirm.d.ts +13 -2
  38. package/dist/admin/ui/confirm.js +9 -5
  39. package/dist/admin/ui/primitives.d.ts +4 -1
  40. package/dist/admin/ui/primitives.js +4 -2
  41. package/dist/admin/ui/theme.js +3 -0
  42. package/dist/entry-store.d.ts +18 -6
  43. package/dist/entry-store.js +13 -8
  44. package/dist/next/page-renderer.js +28 -9
  45. package/dist/path-index.d.ts +28 -4
  46. package/dist/path-index.js +37 -4
  47. package/dist/paths.d.ts +16 -0
  48. package/dist/paths.js +9 -0
  49. package/dist/schema-version.d.ts +1 -1
  50. package/dist/schema-version.js +1 -1
  51. package/dist/site.d.ts +4 -1
  52. package/dist/site.js +3 -1
  53. package/dist/sql-space.d.ts +6 -0
  54. package/dist/sql-space.js +37 -10
  55. package/dist/sql.js +24 -5
  56. package/dist/store.d.ts +30 -5
  57. package/dist/store.js +24 -8
  58. package/dist/supabase-entry-store.d.ts +7 -2
  59. package/dist/supabase-entry-store.js +12 -8
  60. package/dist/supabase-path-index.d.ts +10 -3
  61. package/dist/supabase-path-index.js +50 -17
  62. package/dist/supabase-store.d.ts +13 -2
  63. package/dist/supabase-store.js +15 -8
  64. package/migrations/20260926140000_smoodly_v3.sql +38 -0
  65. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  66. package/package.json +1 -1
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
  };
@@ -15,3 +15,9 @@ export declare function spaceLayerSQL(): string;
15
15
  * Schema-qualified calls: storage policies do not run with public on
16
16
  * the search path. Last in the migration, after the functions exist. */
17
17
  export declare function storageSQL(): string;
18
+ /** The three mutation RPCs are the stores' verbs, never a browser's (spec
19
+ * 2026-09-26 security pass §2): executable by the service role and by
20
+ * `authenticated` (the role a cloud write key carries), not by anon or
21
+ * public. `smoodly_current_space` is NOT here — the stores read it through
22
+ * rpc(). Last in the schema, after every definition exists. */
23
+ export declare function grantsSQL(): string;
package/dist/sql-space.js CHANGED
@@ -25,7 +25,7 @@ export function currentSpaceSQL() {
25
25
  -- default space in self-host (service role, anon key). Policies, the
26
26
  -- insert trigger and the reorder RPC all read this ONE function.
27
27
  create or replace function "smoodly_current_space"() returns uuid
28
- language sql stable as $$
28
+ language sql stable set search_path = '' as $$
29
29
  select coalesce((auth.jwt() ->> 'space_id')::uuid, ${DEFAULT_SPACE});
30
30
  $$;`.trim();
31
31
  }
@@ -90,7 +90,7 @@ alter table "spaces" enable row level security;
90
90
  -- ── Claim readers. The two lookups run as definer so they can read the
91
91
  -- service-role-only tables from inside a policy; search_path is pinned.
92
92
  create or replace function "smoodly_key_kind"() returns text
93
- language sql stable as $$
93
+ language sql stable set search_path = '' as $$
94
94
  select auth.jwt() ->> 'kind';
95
95
  $$;
96
96
 
@@ -107,12 +107,25 @@ language sql stable security definer set search_path = public as $$
107
107
  select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
108
108
  $$;
109
109
 
110
+ -- The presence gate (spec 2026-09-26 security pass §1): the caller is a
111
+ -- member of the space the Realtime topic names. auth.uid() is the session
112
+ -- token's subject — the anon key and a read key carry none, so a visitor
113
+ -- is refused at join. Definer, because members has no read policy for
114
+ -- anyone but a write key. No space claim is read: the same rule serves
115
+ -- the cloud once the content project accepts smoodly.io's tokens (OQ 32).
116
+ create or replace function "smoodly_presence_member"() returns boolean
117
+ language sql stable security definer set search_path = public as $$
118
+ select exists (select 1 from "members" m
119
+ where m."user_id" = auth.uid()
120
+ and realtime.topic() = 'space:' || m."space_id"::text);
121
+ $$;
122
+
110
123
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
111
124
  -- adapter never has to know its space and cannot write into another.
112
125
  -- No claim (service role, anon) leaves the given or default value —
113
126
  -- that is how smoodly.io writes cloud-state rows for a chosen space.
114
127
  create or replace function "smoodly_set_space"() returns trigger
115
- language plpgsql as $$
128
+ language plpgsql set search_path = '' as $$
116
129
  begin
117
130
  if auth.jwt() ->> 'space_id' is not null then
118
131
  new."space_id" := (auth.jwt() ->> 'space_id')::uuid;
@@ -199,17 +212,19 @@ $$;`.trim());
199
212
  statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
200
213
  `create policy "smoodly_read" on "smoodly_meta" for select\n` +
201
214
  ` using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));`);
202
- // The editing-presence channel (spec 2026-09-26 concurrency §6): one
203
- // private Realtime topic per space, `space:<id>`. A caller receives on
204
- // and sends to its own space's topic only — the anon key resolves to the
205
- // default space, a cloud read key to its claim. Identical everywhere.
206
- const topic = `(select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text`;
215
+ // The editing-presence channel (spec 2026-09-26 concurrency §5; gated
216
+ // 2026-09-26 security pass §1): one private Realtime topic per space,
217
+ // `space:<id>`. A caller receives on and sends to a topic only when a
218
+ // members row of that space names their session's user — the browser
219
+ // authenticates the socket with the editor's session token, never with
220
+ // the public key alone. Identical in self-host and the cloud.
221
+ const member = `(select public."smoodly_presence_member"())`;
207
222
  statements.push(`drop policy if exists "smoodly_presence_read" on "realtime"."messages";\n` +
208
223
  `create policy "smoodly_presence_read" on "realtime"."messages" for select\n` +
209
- ` using (${topic});`);
224
+ ` using (${member});`);
210
225
  statements.push(`drop policy if exists "smoodly_presence_write" on "realtime"."messages";\n` +
211
226
  `create policy "smoodly_presence_write" on "realtime"."messages" for insert\n` +
212
- ` with check (${topic});`);
227
+ ` with check (${member});`);
213
228
  statements.push(storageSQL());
214
229
  return statements.join("\n\n");
215
230
  }
@@ -238,3 +253,15 @@ create policy "smoodly_media_write" on storage.objects for all
238
253
  using (${write})
239
254
  with check (${write});`.trim();
240
255
  }
256
+ /** The three mutation RPCs are the stores' verbs, never a browser's (spec
257
+ * 2026-09-26 security pass §2): executable by the service role and by
258
+ * `authenticated` (the role a cloud write key carries), not by anon or
259
+ * public. `smoodly_current_space` is NOT here — the stores read it through
260
+ * rpc(). Last in the schema, after every definition exists. */
261
+ export function grantsSQL() {
262
+ return [
263
+ `revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
264
+ `revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
265
+ `revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
266
+ ].join("\n");
267
+ }
package/dist/sql.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // locale (spec 2026-09-06 §7); a versioned collection reads its
12
12
  // fields from the published snapshot.
13
13
  import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
14
- import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
14
+ import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
15
15
  const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
16
16
  const q = (s) => `"${s}"`;
17
17
  // Casts are fine in views (unlike index expressions, which must be
@@ -111,7 +111,7 @@ create table if not exists "page_locales" (
111
111
  );
112
112
 
113
113
  -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
114
- create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
114
+ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql set search_path = public as $$
115
115
  update "pages" pg set "sort" = u.ord
116
116
  from unnest(p_ids) with ordinality as u(id, ord)
117
117
  where pg."id" = u.id
@@ -211,7 +211,7 @@ alter table "entry_locales" add constraint "entry_locales_draft_version_fkey"
211
211
  create or replace function "smoodly_reorder"(
212
212
  p_collection text,
213
213
  p_ids uuid[]
214
- ) returns void language sql as $$
214
+ ) returns void language sql set search_path = public as $$
215
215
  update "entries" e set "sort" = u.ord
216
216
  from unnest(p_ids) with ordinality as u(id, ord)
217
217
  where e."id" = u.id
@@ -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).
283
- create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
287
+ -- A target may name redirect_locales: its old live rows there stay as redirects.
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
@@ -318,5 +336,6 @@ alter table "assets" enable row level security;
318
336
  alter table "smoodly_meta" enable row level security;
319
337
  `.trim(),
320
338
  spaceLayerSQL(),
339
+ grantsSQL(),
321
340
  ].join("\n\n");
322
341
  }
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;
@@ -10,8 +10,9 @@ import { collectionDepth, collectionOrder } from "./collections.js";
10
10
  import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, 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);
@@ -480,7 +481,7 @@ export class SupabaseEntryStore {
480
481
  await this.updateLocale(id, locale, { slug, fields: localized }, { by });
481
482
  const record = await this.must(collection, id);
482
483
  try {
483
- await this.writePaths(record);
484
+ await this.writePaths(record, options?.redirect ?? true);
484
485
  }
485
486
  catch (e) {
486
487
  await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
@@ -591,7 +592,7 @@ export class SupabaseEntryStore {
591
592
  // locale until the next write, never leaked content.
592
593
  await this.deleteLocale(id, locale);
593
594
  const remaining = await this.must(collection, id);
594
- await this.writePaths(remaining);
595
+ await this.writePaths(remaining, false);
595
596
  await this.updateNode(id, {});
596
597
  const final = await this.must(collection, id);
597
598
  await this.writeRefs(final);
@@ -639,7 +640,7 @@ export class SupabaseEntryStore {
639
640
  this.fail(res.error.message);
640
641
  return res.data ? toVersion(res.data) : null;
641
642
  }
642
- async move(collection, id, to) {
643
+ async move(collection, id, to, options) {
643
644
  if (isGlobalSchema(this.schema(collection)))
644
645
  throw entryErrors.owned(collection, "it has no tree position");
645
646
  const entry = await this.must(collection, id);
@@ -671,7 +672,7 @@ export class SupabaseEntryStore {
671
672
  const ordered = siblings.map((s) => s.id);
672
673
  ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
673
674
  await this.reorder(collection, ordered);
674
- await this.writePaths(await this.must(collection, id));
675
+ await this.writePaths(await this.must(collection, id), options?.redirect ?? true);
675
676
  }
676
677
  catch (e) {
677
678
  await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
@@ -713,6 +714,9 @@ export class SupabaseEntryStore {
713
714
  async pathsOf(collection, id) {
714
715
  return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
715
716
  }
717
+ async redirectsOf(collection, id) {
718
+ return (await this.fetch(collection, id)) ? groupRedirects(await this.paths.redirectsOf({ kind: "entry", id })) : {};
719
+ }
716
720
  async reorder(collection, orderedIds) {
717
721
  if (isGlobalSchema(this.schema(collection)))
718
722
  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
  }
@@ -1,5 +1,6 @@
1
- import { pathTakenError } from "./path-index.js";
1
+ import { pathLiveError, pathTakenError } from "./path-index.js";
2
2
  const UNIQUE_VIOLATION = "23505";
3
+ const toRow = (r) => ({ kind: r.kind, id: r.target_id, locale: r.locale, path: r.path });
3
4
  export class SupabasePathIndex {
4
5
  constructor(db) {
5
6
  this.db = db;
@@ -9,7 +10,7 @@ export class SupabasePathIndex {
9
10
  }
10
11
  async rewrite(targets, rows) {
11
12
  const res = await this.db.rpc("smoodly_rewrite_paths", {
12
- p_targets: targets.map((t) => ({ kind: t.kind, id: t.id })),
13
+ p_targets: targets.map((t) => ({ kind: t.kind, id: t.id, ...(t.redirectLocales ? { redirect_locales: [...t.redirectLocales] } : {}) })),
13
14
  p_rows: rows.map((r) => ({ locale: r.locale, path: r.path, kind: r.kind, id: r.id })),
14
15
  });
15
16
  if (res.error) {
@@ -22,31 +23,63 @@ export class SupabasePathIndex {
22
23
  }
23
24
  }
24
25
  async remove(targets) {
25
- await this.rewrite(targets, []);
26
+ // Every row of the targets, redirects included: a delete on the target index, not the RPC (which spares redirect rows).
27
+ for (const t of targets) {
28
+ const res = await this.db.from("paths").delete().eq("kind", t.kind).eq("target_id", t.id);
29
+ if (res.error)
30
+ this.fail(res.error.message);
31
+ }
26
32
  }
27
33
  async resolve(locale, path) {
28
- const res = await this.db
29
- .from("paths")
30
- .select("kind, target_id")
31
- .eq("locale", locale)
32
- .eq("path", path)
33
- .maybeSingle();
34
+ const res = await this.db.from("paths").select("kind, target_id, redirect").eq("locale", locale).eq("path", path).maybeSingle();
34
35
  if (res.error)
35
36
  this.fail(res.error.message);
36
37
  const row = res.data;
37
- return row ? { kind: row.kind, id: row.target_id } : null;
38
+ return row ? { kind: row.kind, id: row.target_id, redirect: row.redirect } : null;
38
39
  }
39
- async pathsOf(target) {
40
- const res = await this.db
41
- .from("paths")
42
- .select("locale, path")
43
- .eq("kind", target.kind)
44
- .eq("target_id", target.id);
40
+ 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);
42
+ if (res.error)
43
+ this.fail(res.error.message);
44
+ return res.data.map(toRow);
45
+ }
46
+ 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");
45
48
  if (res.error)
46
49
  this.fail(res.error.message);
50
+ return res.data.map(toRow);
51
+ }
52
+ async pathsOf(target) {
47
53
  const out = {};
48
- for (const r of res.data)
54
+ for (const r of await this.rowsOf(target, false))
49
55
  out[r.locale] = r.path;
50
56
  return out;
51
57
  }
58
+ redirectsOf(target) {
59
+ return this.rowsOf(target, true);
60
+ }
61
+ listLive(locale) {
62
+ return this.rowsIn(locale, false);
63
+ }
64
+ listRedirects(locale) {
65
+ return this.rowsIn(locale, true);
66
+ }
67
+ async addRedirect(row) {
68
+ const existing = await this.resolve(row.locale, row.path);
69
+ if (existing && !existing.redirect)
70
+ throw pathLiveError(row.locale, row.path);
71
+ if (existing)
72
+ 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 });
74
+ if (res.error) {
75
+ if (res.error.code === UNIQUE_VIOLATION)
76
+ throw pathLiveError(row.locale, row.path);
77
+ this.fail(res.error.message);
78
+ }
79
+ }
80
+ 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);
84
+ }
52
85
  }