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.
- package/README.md +70 -6
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +5 -2
- package/dist/admin/editor/PageInspector.d.ts +4 -1
- package/dist/admin/editor/PageInspector.js +2 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +16 -8
- package/dist/admin/ops-impl.js +86 -7
- package/dist/admin/ops.d.ts +40 -1
- package/dist/admin/redirect-path.d.ts +6 -0
- package/dist/admin/redirect-path.js +33 -0
- package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
- package/dist/admin/shell/AddRedirectModal.js +29 -0
- package/dist/admin/shell/AdminApp.js +24 -10
- package/dist/admin/shell/EntryForm.js +14 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +6 -4
- package/dist/admin/shell/RedirectsPage.d.ts +11 -0
- package/dist/admin/shell/RedirectsPage.js +75 -0
- package/dist/admin/shell/SettingsView.d.ts +4 -2
- package/dist/admin/shell/SettingsView.js +18 -14
- package/dist/admin/shell/live-url.d.ts +10 -2
- package/dist/admin/shell/live-url.js +9 -4
- package/dist/admin/shell/members-list.js +1 -1
- package/dist/admin/shell/presence-realtime.d.ts +1 -1
- package/dist/admin/shell/presence-realtime.js +19 -9
- package/dist/admin/shell/presence.d.ts +18 -0
- package/dist/admin/shell/presence.js +13 -0
- package/dist/admin/shell/redirects-list.d.ts +12 -0
- package/dist/admin/shell/redirects-list.js +15 -0
- package/dist/admin/shell/settings.d.ts +12 -3
- package/dist/admin/shell/settings.js +13 -9
- package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +4 -3
- package/dist/admin/ui/confirm.d.ts +13 -2
- package/dist/admin/ui/confirm.js +9 -5
- package/dist/admin/ui/primitives.d.ts +4 -1
- package/dist/admin/ui/primitives.js +4 -2
- package/dist/admin/ui/theme.js +3 -0
- package/dist/entry-store.d.ts +18 -6
- package/dist/entry-store.js +13 -8
- package/dist/next/page-renderer.js +28 -9
- package/dist/path-index.d.ts +28 -4
- package/dist/path-index.js +37 -4
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +9 -0
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +4 -1
- package/dist/site.js +3 -1
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +37 -10
- package/dist/sql.js +24 -5
- package/dist/store.d.ts +30 -5
- package/dist/store.js +24 -8
- package/dist/supabase-entry-store.d.ts +7 -2
- package/dist/supabase-entry-store.js +12 -8
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +13 -2
- package/dist/supabase-store.js +15 -8
- package/migrations/20260926140000_smoodly_v3.sql +38 -0
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- 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<
|
|
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-space.d.ts
CHANGED
|
@@ -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 §
|
|
203
|
-
// private Realtime topic per space,
|
|
204
|
-
// and sends to
|
|
205
|
-
//
|
|
206
|
-
|
|
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 (${
|
|
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 (${
|
|
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
|
-
|
|
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(
|
|
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<
|
|
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
|
|
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<
|
|
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
|
|
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(
|
|
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
|
|
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("./
|
|
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(
|
|
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:
|
|
7
|
+
rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
|
|
8
8
|
remove(targets: PathTarget[]): Promise<void>;
|
|
9
|
-
resolve(locale: string, path: string): Promise<
|
|
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
|
-
|
|
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
|
|
40
|
-
const res = await this.db
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
}
|