smoodly 0.0.51 → 0.0.53
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 +14 -13
- package/dist/admin/auth.d.ts +3 -11
- package/dist/admin/auth.js +1 -30
- package/dist/admin/client-ops.d.ts +5 -4
- package/dist/admin/client-ops.js +15 -12
- package/dist/admin/delivery-mode.d.ts +1 -1
- package/dist/admin/delivery-mode.js +2 -2
- package/dist/admin/drift.d.ts +0 -2
- package/dist/admin/drift.js +1 -1
- package/dist/admin/editor/EditorView.d.ts +0 -3
- package/dist/admin/editor/EditorView.js +11 -16
- package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
- package/dist/admin/editor/FloatingToolbar.js +1 -1
- package/dist/admin/editor/SeoSettings.d.ts +0 -2
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/editor/canvas-address.d.ts +8 -0
- package/dist/admin/editor/canvas-address.js +18 -2
- package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
- package/dist/admin/editor/canvas-diagnosis.js +3 -0
- package/dist/admin/editor/inline-dom.d.ts +0 -4
- package/dist/admin/editor/inline-dom.js +1 -1
- package/dist/admin/editor/preview-grant.d.ts +6 -0
- package/dist/admin/editor/preview-grant.js +25 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/fixed-nodes.d.ts +0 -6
- package/dist/admin/fixed-nodes.js +1 -81
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +58 -5
- package/dist/admin/forms/ImagePreview.d.ts +3 -1
- package/dist/admin/forms/ImagePreview.js +9 -3
- package/dist/admin/forms/MediaGrid.js +2 -1
- package/dist/admin/forms/MediaPicker.js +9 -6
- package/dist/admin/forms/MediaWidget.js +11 -9
- package/dist/admin/forms/assets-context.d.ts +7 -3
- package/dist/admin/forms/assets-context.js +5 -3
- package/dist/admin/forms/crop-editor.d.ts +24 -2
- package/dist/admin/forms/crop-editor.js +36 -2
- package/dist/admin/forms/date-picker.d.ts +0 -1
- package/dist/admin/forms/date-picker.js +1 -1
- package/dist/admin/forms/tabs.d.ts +0 -1
- package/dist/admin/forms/tabs.js +1 -1
- package/dist/admin/forms/upload.d.ts +18 -11
- package/dist/admin/forms/upload.js +62 -24
- package/dist/admin/global-items.d.ts +0 -3
- package/dist/admin/global-items.js +0 -54
- package/dist/admin/handler.d.ts +22 -28
- package/dist/admin/handler.js +146 -47
- package/dist/admin/health.d.ts +0 -4
- package/dist/admin/index.d.ts +6 -9
- package/dist/admin/index.js +3 -6
- package/dist/admin/ops.d.ts +40 -32
- package/dist/admin/page-data.d.ts +14 -6
- package/dist/admin/page-data.js +1 -1
- package/dist/admin/session-flow.d.ts +65 -0
- package/dist/admin/session-flow.js +121 -0
- package/dist/admin/shell/AdminApp.d.ts +8 -3
- package/dist/admin/shell/AdminApp.js +37 -58
- package/dist/admin/shell/ContentHealthPage.js +1 -3
- package/dist/admin/shell/GlobalFields.d.ts +0 -3
- package/dist/admin/shell/GlobalFields.js +1 -1
- package/dist/admin/shell/IconRail.js +10 -8
- package/dist/admin/shell/MediaScreen.js +9 -6
- package/dist/admin/shell/MembersPage.d.ts +1 -2
- package/dist/admin/shell/MembersPage.js +19 -90
- package/dist/admin/shell/SettingsView.d.ts +1 -2
- package/dist/admin/shell/SettingsView.js +2 -2
- package/dist/admin/shell/SignedOutView.d.ts +5 -0
- package/dist/admin/shell/SignedOutView.js +9 -0
- package/dist/admin/shell/console-links.d.ts +5 -0
- package/dist/admin/shell/console-links.js +11 -0
- package/dist/admin/shell/members-list.d.ts +1 -14
- package/dist/admin/shell/members-list.js +2 -23
- package/dist/admin/shell/pages-tree.d.ts +0 -2
- package/dist/admin/shell/pages-tree.js +1 -1
- package/dist/admin/shell/presence-socket.d.ts +10 -0
- package/dist/admin/shell/presence-socket.js +191 -0
- package/dist/admin/shell/presence.d.ts +6 -18
- package/dist/admin/shell/presence.js +4 -11
- package/dist/admin/shell/session.d.ts +19 -12
- package/dist/admin/shell/session.js +114 -47
- package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
- package/dist/admin/ui/LocaleSwitcher.js +2 -2
- package/dist/admin/ui/primitives.d.ts +4 -14
- package/dist/admin/ui/primitives.js +7 -9
- package/dist/admin/ui/theme.js +13 -2
- package/dist/admin/validate.js +10 -1
- package/dist/api-client.d.ts +26 -0
- package/dist/api-client.js +107 -0
- package/dist/assemble.d.ts +4 -41
- package/dist/assemble.js +5 -303
- package/dist/asset-store.d.ts +28 -30
- package/dist/asset-store.js +26 -121
- package/dist/astro/cache.d.ts +1 -1
- package/dist/astro/components/Head.astro +4 -1
- package/dist/astro/components/Image.astro +12 -17
- package/dist/astro/components/Video.astro +5 -2
- package/dist/astro/head.d.ts +3 -0
- package/dist/astro/head.js +24 -0
- package/dist/astro/index.js +61 -27
- package/dist/astro/locale-paths.d.ts +5 -2
- package/dist/astro/locale-paths.js +10 -2
- package/dist/astro/routes/admin.js +9 -7
- package/dist/astro/runtime.d.ts +3 -2
- package/dist/astro/runtime.js +5 -4
- package/dist/astro/server.d.ts +7 -11
- package/dist/astro/server.js +22 -28
- package/dist/astro/shell.d.ts +3 -3
- package/dist/astro/shell.js +1 -1
- package/dist/astro/static-media-node.d.ts +4 -0
- package/dist/astro/static-media-node.js +25 -0
- package/dist/astro/static-media.d.ts +17 -0
- package/dist/astro/static-media.js +55 -0
- package/dist/astro/static.d.ts +5 -10
- package/dist/astro/static.js +13 -29
- package/dist/canvas-marker.d.ts +14 -0
- package/dist/canvas-marker.js +36 -0
- package/dist/cli/api.d.ts +14 -0
- package/dist/cli/api.js +48 -0
- package/dist/cli/app-env.d.ts +2 -0
- package/dist/cli/app-env.js +20 -0
- package/dist/cli/export.d.ts +16 -2
- package/dist/cli/export.js +52 -35
- package/dist/cli/import.d.ts +7 -11
- package/dist/cli/import.js +50 -54
- package/dist/cli/load-config.d.ts +0 -1
- package/dist/cli/load-config.js +1 -1
- package/dist/cli/main.d.ts +4 -12
- package/dist/cli/main.js +26 -72
- package/dist/cli/push.d.ts +6 -0
- package/dist/cli/push.js +31 -0
- package/dist/collections.d.ts +0 -1
- package/dist/collections.js +1 -1
- package/dist/config.d.ts +20 -5
- package/dist/config.js +18 -1
- package/dist/content/content-api.d.ts +23 -23
- package/dist/content/content-api.js +48 -56
- package/dist/content/mime.d.ts +1 -0
- package/dist/content/mime.js +6 -0
- package/dist/css.js +1 -1
- package/dist/deploy-store.d.ts +19 -0
- package/dist/entry-store.d.ts +8 -94
- package/dist/entry-store.js +9 -557
- package/dist/env.d.ts +27 -20
- package/dist/env.js +38 -50
- package/dist/fields.d.ts +11 -3
- package/dist/fields.js +33 -2
- package/dist/global-id.d.ts +2 -0
- package/dist/global-id.js +4 -2
- package/dist/image/SmoodlyImage.d.ts +9 -5
- package/dist/image/SmoodlyImage.js +29 -15
- package/dist/image/SmoodlyVideo.d.ts +1 -1
- package/dist/image/SmoodlyVideo.js +3 -2
- package/dist/image/index.d.ts +2 -3
- package/dist/image/index.js +0 -2
- package/dist/image/markup.d.ts +53 -0
- package/dist/image/markup.js +111 -0
- package/dist/index.d.ts +13 -23
- package/dist/index.js +7 -20
- package/dist/media-cut.d.ts +44 -0
- package/dist/media-cut.js +70 -0
- package/dist/media.d.ts +58 -4
- package/dist/media.js +126 -16
- package/dist/member-store.d.ts +2 -50
- package/dist/member-store.js +0 -56
- package/dist/next/admin-route.d.ts +1 -1
- package/dist/next/admin-route.js +5 -3
- package/dist/next/meta.js +2 -3
- package/dist/next/page-renderer.d.ts +2 -2
- package/dist/next/page-renderer.js +22 -11
- package/dist/next/queries.d.ts +2 -2
- package/dist/path-index.d.ts +0 -24
- package/dist/path-index.js +0 -98
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +1 -1
- package/dist/push.d.ts +30 -0
- package/dist/push.js +52 -0
- package/dist/read-counter.d.ts +2 -0
- package/dist/read-counter.js +6 -0
- package/dist/reads.d.ts +7 -7
- package/dist/reads.js +3 -0
- package/dist/registry-json.d.ts +11 -0
- package/dist/registry-json.js +81 -0
- package/dist/render.d.ts +0 -1
- package/dist/render.js +1 -1
- package/dist/resolve.d.ts +12 -6
- package/dist/resolve.js +11 -8
- package/dist/revalidate.d.ts +8 -1
- package/dist/revalidate.js +8 -1
- package/dist/richtext/index.d.ts +1 -1
- package/dist/section-wrapper.d.ts +1 -1
- package/dist/section-wrapper.js +0 -9
- package/dist/signed-message.d.ts +26 -0
- package/dist/signed-message.js +103 -0
- package/dist/site.d.ts +59 -18
- package/dist/site.js +21 -252
- package/dist/space-transfer.d.ts +6 -3
- package/dist/space-transfer.js +18 -6
- package/dist/store.d.ts +2 -80
- package/dist/store.js +3 -489
- package/dist/toolset.d.ts +2 -1
- package/dist/toolset.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -15
- package/dist/admin/auth-config.d.ts +0 -6
- package/dist/admin/browser/fetch-op.d.ts +0 -10
- package/dist/admin/browser/fetch-op.js +0 -44
- package/dist/admin/browser/main.d.ts +0 -1
- package/dist/admin/browser/main.js +0 -23
- package/dist/admin/ops-impl.d.ts +0 -51
- package/dist/admin/ops-impl.js +0 -1403
- package/dist/admin/shell/InviteModal.d.ts +0 -6
- package/dist/admin/shell/InviteModal.js +0 -28
- package/dist/admin/shell/LoginView.d.ts +0 -5
- package/dist/admin/shell/LoginView.js +0 -80
- package/dist/admin/shell/SchemaNotice.d.ts +0 -4
- package/dist/admin/shell/SchemaNotice.js +0 -4
- package/dist/admin/shell/SetPasswordView.d.ts +0 -7
- package/dist/admin/shell/SetPasswordView.js +0 -41
- package/dist/admin/shell/presence-realtime.d.ts +0 -3
- package/dist/admin/shell/presence-realtime.js +0 -32
- package/dist/admin/supabase-auth.d.ts +0 -7
- package/dist/admin/supabase-auth.js +0 -30
- package/dist/astro/deploy-deps.d.ts +0 -10
- package/dist/astro/deploy-deps.js +0 -8
- package/dist/astro/image.d.ts +0 -27
- package/dist/astro/image.js +0 -29
- package/dist/bind.d.ts +0 -19
- package/dist/bind.js +0 -34
- package/dist/cli/add-admin.d.ts +0 -11
- package/dist/cli/add-admin.js +0 -25
- package/dist/cli/load-collections.d.ts +0 -2
- package/dist/cli/load-collections.js +0 -27
- package/dist/cli/migrate.d.ts +0 -43
- package/dist/cli/migrate.js +0 -138
- package/dist/cli/rebuild.d.ts +0 -23
- package/dist/cli/rebuild.js +0 -22
- package/dist/cli/space-io.d.ts +0 -41
- package/dist/cli/space-io.js +0 -84
- package/dist/client.d.ts +0 -11
- package/dist/client.js +0 -54
- package/dist/current-space.d.ts +0 -4
- package/dist/current-space.js +0 -9
- package/dist/document-stamp.d.ts +0 -7
- package/dist/document-stamp.js +0 -48
- package/dist/image/loaders.d.ts +0 -15
- package/dist/image/loaders.js +0 -5
- package/dist/image/next-config.d.ts +0 -18
- package/dist/image/next-config.js +0 -32
- package/dist/image/remote-pattern.d.ts +0 -1
- package/dist/image/remote-pattern.js +0 -5
- package/dist/migrations.d.ts +0 -37
- package/dist/migrations.js +0 -73
- package/dist/project.d.ts +0 -88
- package/dist/project.js +0 -231
- package/dist/published.d.ts +0 -142
- package/dist/published.js +0 -164
- package/dist/ref-index.d.ts +0 -19
- package/dist/ref-index.js +0 -0
- package/dist/schema-read.d.ts +0 -23
- package/dist/schema-read.js +0 -50
- package/dist/schema-version.d.ts +0 -19
- package/dist/schema-version.js +0 -34
- package/dist/sql-space.d.ts +0 -44
- package/dist/sql-space.js +0 -289
- package/dist/sql.d.ts +0 -15
- package/dist/sql.js +0 -556
- package/dist/supabase-asset-store.d.ts +0 -21
- package/dist/supabase-asset-store.js +0 -170
- package/dist/supabase-entry-store.d.ts +0 -92
- package/dist/supabase-entry-store.js +0 -824
- package/dist/supabase-member-store.d.ts +0 -21
- package/dist/supabase-member-store.js +0 -96
- package/dist/supabase-path-index.d.ts +0 -30
- package/dist/supabase-path-index.js +0 -138
- package/dist/supabase-published-store.d.ts +0 -56
- package/dist/supabase-published-store.js +0 -314
- package/dist/supabase-store.d.ts +0 -103
- package/dist/supabase-store.js +0 -678
- package/migrations/20260926120000_smoodly_v1.sql +0 -535
- package/migrations/20260926130000_smoodly_v2.sql +0 -26
- package/migrations/20260926140000_smoodly_v3.sql +0 -38
- package/migrations/20260926150000_smoodly_v4.sql +0 -41
- package/migrations/20260929120000_smoodly_v5.sql +0 -56
- package/migrations/20260929135523_smoodly_v6.sql +0 -200
- package/migrations/20260930040956_smoodly_v7.sql +0 -84
- package/migrations/20261007120000_smoodly_v8.sql +0 -154
- package/migrations/20261007200000_smoodly_v9.sql +0 -30
- package/migrations/20261008120000_smoodly_v10.sql +0 -47
- package/migrations/20261009120000_smoodly_v11.sql +0 -49
- /package/dist/{admin/auth-config.js → deploy-store.js} +0 -0
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import { type InviteInput, type InviteResult, type MemberRecord, type MemberStore } from "./member-store.ts";
|
|
3
|
-
import type { MemberRole } from "./permissions.ts";
|
|
4
|
-
export declare class SupabaseMemberStore implements MemberStore {
|
|
5
|
-
private db;
|
|
6
|
-
constructor(db: SupabaseClient);
|
|
7
|
-
list(): Promise<MemberRecord[]>;
|
|
8
|
-
consoleUrl(): Promise<string | null>;
|
|
9
|
-
private toRecord;
|
|
10
|
-
/** The admin API has no lookup by email: page through the users (tens, not thousands). */
|
|
11
|
-
private userIdByEmail;
|
|
12
|
-
invite(input: InviteInput): Promise<InviteResult>;
|
|
13
|
-
private upsertRow;
|
|
14
|
-
createAccount(input: {
|
|
15
|
-
email: string;
|
|
16
|
-
password: string;
|
|
17
|
-
role: MemberRole;
|
|
18
|
-
}): Promise<MemberRecord>;
|
|
19
|
-
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
20
|
-
remove(userId: string): Promise<void>;
|
|
21
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { memberErrors, sortMembers } from "./member-store.js";
|
|
2
|
-
export class SupabaseMemberStore {
|
|
3
|
-
constructor(db) {
|
|
4
|
-
this.db = db;
|
|
5
|
-
}
|
|
6
|
-
async list() {
|
|
7
|
-
const res = await this.db.from("members").select("user_id, email, role");
|
|
8
|
-
if (res.error)
|
|
9
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
10
|
-
return sortMembers(res.data.map((r) => this.toRecord(r)));
|
|
11
|
-
}
|
|
12
|
-
async consoleUrl() {
|
|
13
|
-
const res = await this.db.from("spaces").select("console_url").limit(1).maybeSingle();
|
|
14
|
-
if (res.error)
|
|
15
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
16
|
-
return res.data?.console_url ?? null;
|
|
17
|
-
}
|
|
18
|
-
toRecord(row) {
|
|
19
|
-
return { userId: row.user_id, email: row.email, role: row.role };
|
|
20
|
-
}
|
|
21
|
-
/** The admin API has no lookup by email: page through the users (tens, not thousands). */
|
|
22
|
-
async userIdByEmail(email) {
|
|
23
|
-
const wanted = email.toLowerCase();
|
|
24
|
-
for (let page = 1; page <= 50; page++) {
|
|
25
|
-
const res = await this.db.auth.admin.listUsers({ page, perPage: 200 });
|
|
26
|
-
if (res.error)
|
|
27
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
28
|
-
const hit = res.data.users.find((u) => u.email?.toLowerCase() === wanted);
|
|
29
|
-
if (hit)
|
|
30
|
-
return hit.id;
|
|
31
|
-
if (res.data.users.length < 200)
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
throw memberErrors.noAccount(email);
|
|
35
|
-
}
|
|
36
|
-
async invite(input) {
|
|
37
|
-
// Self-host: the content client is the auth project's service role, so
|
|
38
|
-
// the admin API is reachable. A cloud write key never gets here — the
|
|
39
|
-
// ops refuse behind the console URL first.
|
|
40
|
-
const sent = await this.db.auth.admin.inviteUserByEmail(input.email, { redirectTo: input.redirectTo });
|
|
41
|
-
let userId;
|
|
42
|
-
let invited;
|
|
43
|
-
if (!sent.error) {
|
|
44
|
-
userId = sent.data.user.id;
|
|
45
|
-
invited = true;
|
|
46
|
-
}
|
|
47
|
-
else if (sent.error.code === "email_exists" || /already (been )?registered/i.test(sent.error.message)) {
|
|
48
|
-
userId = await this.userIdByEmail(input.email);
|
|
49
|
-
invited = false;
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
throw new Error(`smoodly: ${sent.error.message}`);
|
|
53
|
-
}
|
|
54
|
-
return { member: await this.upsertRow(userId, input.email, input.role), invited };
|
|
55
|
-
}
|
|
56
|
-
async upsertRow(userId, email, role) {
|
|
57
|
-
const res = await this.db
|
|
58
|
-
.from("members")
|
|
59
|
-
.upsert({ user_id: userId, email, role }, { onConflict: "space_id,user_id" })
|
|
60
|
-
.select("user_id, email, role")
|
|
61
|
-
.single();
|
|
62
|
-
if (res.error)
|
|
63
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
64
|
-
return this.toRecord(res.data);
|
|
65
|
-
}
|
|
66
|
-
async createAccount(input) {
|
|
67
|
-
const created = await this.db.auth.admin.createUser({ email: input.email, password: input.password, email_confirm: true });
|
|
68
|
-
let userId;
|
|
69
|
-
if (!created.error) {
|
|
70
|
-
userId = created.data.user.id;
|
|
71
|
-
}
|
|
72
|
-
else if (created.error.code === "email_exists" || /already (been )?registered/i.test(created.error.message)) {
|
|
73
|
-
// An existing account keeps its password; it only gains the membership.
|
|
74
|
-
userId = await this.userIdByEmail(input.email);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
throw new Error(`smoodly: ${created.error.message}`);
|
|
78
|
-
}
|
|
79
|
-
return this.upsertRow(userId, input.email, input.role);
|
|
80
|
-
}
|
|
81
|
-
async setRole(userId, role) {
|
|
82
|
-
const res = await this.db.from("members").update({ role }).eq("user_id", userId).select("user_id, email, role").maybeSingle();
|
|
83
|
-
if (res.error)
|
|
84
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
85
|
-
if (!res.data)
|
|
86
|
-
throw memberErrors.notFound(userId);
|
|
87
|
-
return this.toRecord(res.data);
|
|
88
|
-
}
|
|
89
|
-
async remove(userId) {
|
|
90
|
-
const res = await this.db.from("members").delete().eq("user_id", userId).select("user_id");
|
|
91
|
-
if (res.error)
|
|
92
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
93
|
-
if (!res.data || res.data.length === 0)
|
|
94
|
-
throw memberErrors.notFound(userId);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import { type Anchors } from "./anchors.ts";
|
|
3
|
-
import { type PathHit, type PathIndex, type PathRow, type PathTarget, type RewriteTarget } from "./path-index.ts";
|
|
4
|
-
export declare class SupabasePathIndex implements PathIndex {
|
|
5
|
-
private db;
|
|
6
|
-
private anchors;
|
|
7
|
-
/** Held by reference: the config's addresses (spec 2026-09-29 anchored paths §2). */
|
|
8
|
-
constructor(db: SupabaseClient, anchors?: Anchors);
|
|
9
|
-
private fail;
|
|
10
|
-
/** Stored rows as callers see them: full URLs; a row whose anchor has no address is left out. */
|
|
11
|
-
private shown;
|
|
12
|
-
rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
|
|
13
|
-
remove(targets: PathTarget[]): Promise<void>;
|
|
14
|
-
/** Every row at a request URL's candidate keys, in one call, and the winner by precedence (spec §2). */
|
|
15
|
-
private lookup;
|
|
16
|
-
private hit;
|
|
17
|
-
resolve(locale: string, path: string): Promise<PathHit | null>;
|
|
18
|
-
private rowsOf;
|
|
19
|
-
/** A locale's rows, sorted by URL in JS: the stored path is not the URL. */
|
|
20
|
-
private rowsIn;
|
|
21
|
-
pathsOf(target: PathTarget): Promise<Record<string, string>>;
|
|
22
|
-
livePathsIn(kind: "page" | "entry", ids: string[], locale: string): Promise<Record<string, string>>;
|
|
23
|
-
redirectsOf(target: PathTarget): Promise<PathRow[]>;
|
|
24
|
-
listLive(locale: string): Promise<PathRow[]>;
|
|
25
|
-
listRedirects(locale: string): Promise<PathRow[]>;
|
|
26
|
-
/** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
|
|
27
|
-
addRedirect(row: PathRow): Promise<void>;
|
|
28
|
-
/** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
|
|
29
|
-
removeRedirect(locale: string, path: string): Promise<void>;
|
|
30
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { candidates, fullPath, pickHit, sameUrlKeys } from "./anchors.js";
|
|
2
|
-
import { pathLiveError, pathTakenError } from "./path-index.js";
|
|
3
|
-
const UNIQUE_VIOLATION = "23505";
|
|
4
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
-
const COLS = "locale, anchor, path, kind, target_id, redirect";
|
|
6
|
-
export class SupabasePathIndex {
|
|
7
|
-
/** Held by reference: the config's addresses (spec 2026-09-29 anchored paths §2). */
|
|
8
|
-
constructor(db, anchors = { addresses: {}, homePageSlug: "home", defaultLocale: "en" }) {
|
|
9
|
-
this.db = db;
|
|
10
|
-
this.anchors = anchors;
|
|
11
|
-
}
|
|
12
|
-
fail(message) {
|
|
13
|
-
throw new Error(`smoodly: ${message}`);
|
|
14
|
-
}
|
|
15
|
-
/** Stored rows as callers see them: full URLs; a row whose anchor has no address is left out. */
|
|
16
|
-
shown(rows) {
|
|
17
|
-
const out = [];
|
|
18
|
-
for (const r of rows) {
|
|
19
|
-
const path = fullPath(this.anchors, r.anchor, r.path, r.locale);
|
|
20
|
-
if (path !== null)
|
|
21
|
-
out.push({ kind: r.kind, id: r.target_id, locale: r.locale, path });
|
|
22
|
-
}
|
|
23
|
-
return out;
|
|
24
|
-
}
|
|
25
|
-
async rewrite(targets, rows) {
|
|
26
|
-
const res = await this.db.rpc("smoodly_rewrite_paths", {
|
|
27
|
-
p_targets: targets.map((t) => ({ kind: t.kind, id: t.id, ...(t.redirectLocales ? { redirect_locales: [...t.redirectLocales] } : {}) })),
|
|
28
|
-
// `keys`: every stored key that yields the row's full URL (spec §2) — the SQL cannot read the config.
|
|
29
|
-
p_rows: rows.map((r) => ({
|
|
30
|
-
locale: r.locale, anchor: r.anchor ?? "", path: r.path, kind: r.kind, id: r.id,
|
|
31
|
-
keys: sameUrlKeys(this.anchors, r.locale, r.anchor ?? "", r.path),
|
|
32
|
-
})),
|
|
33
|
-
});
|
|
34
|
-
if (res.error) {
|
|
35
|
-
if (res.error.code === UNIQUE_VIOLATION) {
|
|
36
|
-
// details: Key (space_id, locale, anchor, path)=(…, en, collection:news, /hello) already exists.
|
|
37
|
-
const m = /\(space_id, locale, anchor, path\)=\([^,]+, ([^,]+), ([^,]*), (.*)\) already exists/.exec(res.error.details ?? "");
|
|
38
|
-
throw m ? pathTakenError(m[1], fullPath(this.anchors, m[2], m[3], m[1]) ?? m[3]) : pathTakenError("?", "?");
|
|
39
|
-
}
|
|
40
|
-
this.fail(res.error.message);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
async remove(targets) {
|
|
44
|
-
// Every row of the targets, redirects included: a delete on the target index, not the RPC (which spares redirect rows).
|
|
45
|
-
for (const t of targets) {
|
|
46
|
-
const res = await this.db.from("paths").delete().eq("kind", t.kind).eq("target_id", t.id);
|
|
47
|
-
if (res.error)
|
|
48
|
-
this.fail(res.error.message);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
/** Every row at a request URL's candidate keys, in one call, and the winner by precedence (spec §2). */
|
|
52
|
-
async lookup(locale, url) {
|
|
53
|
-
const { keys, coded } = candidates(this.anchors, locale, url);
|
|
54
|
-
const res = await this.db.rpc("smoodly_resolve_path", { p_locale: locale, p_keys: keys });
|
|
55
|
-
if (res.error)
|
|
56
|
-
this.fail(res.error.message);
|
|
57
|
-
const rows = res.data;
|
|
58
|
-
return { rows, hit: pickHit(rows, keys, coded) };
|
|
59
|
-
}
|
|
60
|
-
async hit(locale, url) {
|
|
61
|
-
return (await this.lookup(locale, url)).hit;
|
|
62
|
-
}
|
|
63
|
-
async resolve(locale, path) {
|
|
64
|
-
const row = await this.hit(locale, path);
|
|
65
|
-
return row ? { kind: row.kind, id: row.target_id, redirect: row.redirect } : null;
|
|
66
|
-
}
|
|
67
|
-
async rowsOf(target, redirect) {
|
|
68
|
-
const res = await this.db.from("paths").select(COLS).eq("kind", target.kind).eq("target_id", target.id).eq("redirect", redirect);
|
|
69
|
-
if (res.error)
|
|
70
|
-
this.fail(res.error.message);
|
|
71
|
-
return this.shown(res.data);
|
|
72
|
-
}
|
|
73
|
-
/** A locale's rows, sorted by URL in JS: the stored path is not the URL. */
|
|
74
|
-
async rowsIn(locale, redirect) {
|
|
75
|
-
const res = await this.db.from("paths").select(COLS).eq("locale", locale).eq("redirect", redirect).order("anchor").order("path");
|
|
76
|
-
if (res.error)
|
|
77
|
-
this.fail(res.error.message);
|
|
78
|
-
// An editor's live row under a code address is no live URL: visitors never reach it (spec §4).
|
|
79
|
-
const rows = res.data.filter((r) => redirect || !(r.anchor === "" && candidates(this.anchors, locale, r.path).coded));
|
|
80
|
-
return this.shown(rows).sort((a, b) => a.path.localeCompare(b.path));
|
|
81
|
-
}
|
|
82
|
-
async pathsOf(target) {
|
|
83
|
-
const out = {};
|
|
84
|
-
for (const r of await this.rowsOf(target, false))
|
|
85
|
-
out[r.locale] = r.path;
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
async livePathsIn(kind, ids, locale) {
|
|
89
|
-
const uuids = ids.filter((id) => UUID_RE.test(id));
|
|
90
|
-
if (uuids.length === 0)
|
|
91
|
-
return {};
|
|
92
|
-
const out = {};
|
|
93
|
-
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
94
|
-
for (let i = 0; i < uuids.length; i += 100) {
|
|
95
|
-
const res = await this.db.from("paths").select(COLS).eq("kind", kind).eq("locale", locale).eq("redirect", false).in("target_id", uuids.slice(i, i + 100));
|
|
96
|
-
if (res.error)
|
|
97
|
-
this.fail(res.error.message);
|
|
98
|
-
for (const r of this.shown(res.data))
|
|
99
|
-
out[r.id] = r.path;
|
|
100
|
-
}
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
redirectsOf(target) {
|
|
104
|
-
return this.rowsOf(target, true);
|
|
105
|
-
}
|
|
106
|
-
listLive(locale) {
|
|
107
|
-
return this.rowsIn(locale, false);
|
|
108
|
-
}
|
|
109
|
-
listRedirects(locale) {
|
|
110
|
-
return this.rowsIn(locale, true);
|
|
111
|
-
}
|
|
112
|
-
/** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
|
|
113
|
-
async addRedirect(row) {
|
|
114
|
-
const { rows, hit: existing } = await this.lookup(row.locale, row.path);
|
|
115
|
-
if (existing && !existing.redirect)
|
|
116
|
-
throw pathLiveError(row.locale, row.path);
|
|
117
|
-
// An editor's page hidden under a code address still holds its row: a redirect never stacks on it.
|
|
118
|
-
if (rows.some((r) => !r.redirect && r.anchor === "" && r.path === row.path))
|
|
119
|
-
throw pathLiveError(row.locale, row.path);
|
|
120
|
-
// Every redirect the URL reaches goes, under any anchor: the new one replaces them all.
|
|
121
|
-
if (rows.some((r) => r.redirect))
|
|
122
|
-
await this.removeRedirect(row.locale, row.path);
|
|
123
|
-
const res = await this.db.from("paths").insert({ locale: row.locale, anchor: "", path: row.path, kind: row.kind, target_id: row.id, redirect: true });
|
|
124
|
-
if (res.error) {
|
|
125
|
-
if (res.error.code === UNIQUE_VIOLATION)
|
|
126
|
-
throw pathLiveError(row.locale, row.path);
|
|
127
|
-
this.fail(res.error.message);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
/** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
|
|
131
|
-
async removeRedirect(locale, path) {
|
|
132
|
-
for (const k of candidates(this.anchors, locale, path).keys) {
|
|
133
|
-
const res = await this.db.from("paths").delete().eq("locale", locale).eq("anchor", k.anchor).eq("path", k.path).eq("redirect", true);
|
|
134
|
-
if (res.error)
|
|
135
|
-
this.fail(res.error.message);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
-
import type { PublishedEntryRow, PublishedKey, PublishedPageRow, PublishedQuery, PublishedRoute, PublishedStore, PublishedTreeRow, PutOptions } from "./published.ts";
|
|
4
|
-
export declare class SupabasePublishedStore implements PublishedStore {
|
|
5
|
-
private db;
|
|
6
|
-
private schemas;
|
|
7
|
-
private spaceId;
|
|
8
|
-
/** `collections` encode a `where` on an array field as containment, as SupabaseEntryStore.query does. */
|
|
9
|
-
constructor(db: SupabaseClient, collections?: CollectionSchema<any>[]);
|
|
10
|
-
private fail;
|
|
11
|
-
putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
|
|
12
|
-
putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
|
|
13
|
-
/** The space this client writes in (the service role's is the default): a path delete must not reach past it,
|
|
14
|
-
* since the service role passes every policy. Cached for the life of the client; a failed lookup is not. */
|
|
15
|
-
private space;
|
|
16
|
-
/** The unique (space, locale, path) index holds one row per address: a row of another id at an incoming address
|
|
17
|
-
* (a stale row a capped rename did not reach) is deleted before the upsert. Not atomic with it: rows are derived,
|
|
18
|
-
* and a read heals what a failure between the two leaves. One delete per locale in the chunk. */
|
|
19
|
-
private vacate;
|
|
20
|
-
/** One request per kind and locale filter: the keys without a locale go in one in(). */
|
|
21
|
-
private eachGroup;
|
|
22
|
-
remove(keys: PublishedKey[]): Promise<void>;
|
|
23
|
-
lastRemovalAt(): Promise<number | null>;
|
|
24
|
-
/** The space's recorded stamp (DESIGN §3): one row per space in published_models; the key's space, or the
|
|
25
|
-
* service role's default one. */
|
|
26
|
-
recordedModel(): Promise<string | null>;
|
|
27
|
-
recordModel(model: string): Promise<void>;
|
|
28
|
-
/** Four head counts (no rows travel): each table's rows, and its rows at another stamp. The policies scope a
|
|
29
|
-
* write key to its space; the service role counts the default space's rows by the explicit filter. */
|
|
30
|
-
countBehind(model: string): Promise<{
|
|
31
|
-
behind: number;
|
|
32
|
-
total: number;
|
|
33
|
-
}>;
|
|
34
|
-
/** The static build's watermark (spec 2026-10-09 static mode §5): the newest published_at over both tables. */
|
|
35
|
-
newestPublishedAt(): Promise<number | null>;
|
|
36
|
-
/** Rows published strictly after `since`: the Deploy tile's "changes waiting". Two head counts. */
|
|
37
|
-
countPublishedAfter(since: number): Promise<number>;
|
|
38
|
-
/** The space's last Deploy press, one row in space_deploys (schema v11). */
|
|
39
|
-
deployRequest(): Promise<{
|
|
40
|
-
at: number;
|
|
41
|
-
by: string | null;
|
|
42
|
-
} | null>;
|
|
43
|
-
requestDeploy(by: string | null, at?: number): Promise<void>;
|
|
44
|
-
markStale(keys: PublishedKey[]): Promise<void>;
|
|
45
|
-
/** A route row is for serving, not for the tree: a page route carries the
|
|
46
|
-
* document, model, stale flag and tags; its tree columns are empty, and an
|
|
47
|
-
* entry route's `fields` and times likewise. */
|
|
48
|
-
byAddress(locale: string, path: string, globals?: readonly string[]): Promise<PublishedRoute | null>;
|
|
49
|
-
private route;
|
|
50
|
-
page(id: string, locale: string): Promise<PublishedPageRow | null>;
|
|
51
|
-
entry(id: string, locale: string): Promise<PublishedEntryRow | null>;
|
|
52
|
-
/** A list row carries the document, model and stale flag; the raw `fields`
|
|
53
|
-
* and the tree columns stay in the table (a list never needs them). */
|
|
54
|
-
entries(collection: string, q: PublishedQuery): Promise<PublishedEntryRow[]>;
|
|
55
|
-
tree(locale: string): Promise<PublishedTreeRow[]>;
|
|
56
|
-
}
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
// The Supabase PublishedStore (spec 2026-10-07 delivery model §1, §3): rows in
|
|
2
|
-
// published_pages and published_entries, one address lookup through the
|
|
3
|
-
// published_routes view, lists through smoodly_list_published. Every read is
|
|
4
|
-
// one request; the space is the key's, through the policies as everywhere.
|
|
5
|
-
import { readCurrentSpace } from "./current-space.js";
|
|
6
|
-
import { ARRAY_FIELD_TYPES } from "./entry-store.js";
|
|
7
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
-
const CHUNK = 100;
|
|
9
|
-
const TREE_COLUMNS = "page_id, parent_id, sort, path, title, has_page, link_fields, model, stale";
|
|
10
|
-
const num = (v) => (v === null ? null : Number(v));
|
|
11
|
-
const iso = (ms) => new Date(ms).toISOString();
|
|
12
|
-
const fromPage = (r) => ({
|
|
13
|
-
id: r.page_id, locale: r.locale, parentId: r.parent_id, sort: num(r.sort), path: r.path, title: r.title, hasPage: r.has_page,
|
|
14
|
-
linkFields: r.link_fields, document: r.document, tags: r.tags, model: r.model, stale: r.stale, publishedAt: Date.parse(r.published_at),
|
|
15
|
-
});
|
|
16
|
-
/** `keep` strips published_at from an upsert's payload (PutOptions): the row keeps its own, a new row is dated by the default. */
|
|
17
|
-
function keeping(row, options) {
|
|
18
|
-
if (!options?.keep)
|
|
19
|
-
return row;
|
|
20
|
-
const { published_at: _at, ...rest } = row;
|
|
21
|
-
return rest;
|
|
22
|
-
}
|
|
23
|
-
const toPage = (r) => ({
|
|
24
|
-
page_id: r.id, locale: r.locale, parent_id: r.parentId, sort: r.sort, path: r.path, title: r.title, has_page: r.hasPage,
|
|
25
|
-
link_fields: r.linkFields, document: r.document, tags: r.tags, model: r.model, stale: r.stale, published_at: iso(r.publishedAt),
|
|
26
|
-
});
|
|
27
|
-
const fromEntry = (r) => ({
|
|
28
|
-
id: r.entry_id, locale: r.locale, collection: r.collection, parentId: r.parent_id, sort: num(r.sort), slug: r.slug, path: r.path,
|
|
29
|
-
createdAt: Date.parse(r.created_at), updatedAt: Date.parse(r.updated_at), fields: r.fields, document: r.document, tags: r.tags,
|
|
30
|
-
model: r.model, stale: r.stale, publishedAt: Date.parse(r.published_at),
|
|
31
|
-
});
|
|
32
|
-
const toEntry = (r) => ({
|
|
33
|
-
entry_id: r.id, locale: r.locale, collection: r.collection, parent_id: r.parentId, sort: r.sort, slug: r.slug, path: r.path,
|
|
34
|
-
created_at: iso(r.createdAt), updated_at: iso(r.updatedAt), fields: r.fields, document: r.document, tags: r.tags,
|
|
35
|
-
model: r.model, stale: r.stale, published_at: iso(r.publishedAt),
|
|
36
|
-
});
|
|
37
|
-
export class SupabasePublishedStore {
|
|
38
|
-
/** `collections` encode a `where` on an array field as containment, as SupabaseEntryStore.query does. */
|
|
39
|
-
constructor(db, collections = []) {
|
|
40
|
-
this.db = db;
|
|
41
|
-
this.schemas = new Map(collections.map((c) => [c.name, c]));
|
|
42
|
-
}
|
|
43
|
-
fail(message) {
|
|
44
|
-
throw new Error(`smoodly: published store: ${message}`);
|
|
45
|
-
}
|
|
46
|
-
async putPages(rows, options) {
|
|
47
|
-
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
48
|
-
const chunk = rows.slice(i, i + CHUNK);
|
|
49
|
-
await this.vacate("published_pages", "page_id", chunk);
|
|
50
|
-
// `keep`: published_at leaves the payload, so an updated row keeps its own and an inserted one takes the default (now).
|
|
51
|
-
const res = await this.db.from("published_pages").upsert(chunk.map((r) => keeping(toPage(r), options)), { onConflict: "page_id,locale" });
|
|
52
|
-
if (res.error)
|
|
53
|
-
this.fail(res.error.message);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
async putEntries(rows, options) {
|
|
57
|
-
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
58
|
-
const chunk = rows.slice(i, i + CHUNK);
|
|
59
|
-
await this.vacate("published_entries", "entry_id", chunk);
|
|
60
|
-
const res = await this.db.from("published_entries").upsert(chunk.map((r) => keeping(toEntry(r), options)), { onConflict: "entry_id,locale" });
|
|
61
|
-
if (res.error)
|
|
62
|
-
this.fail(res.error.message);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
/** The space this client writes in (the service role's is the default): a path delete must not reach past it,
|
|
66
|
-
* since the service role passes every policy. Cached for the life of the client; a failed lookup is not. */
|
|
67
|
-
space() {
|
|
68
|
-
if (!this.spaceId) {
|
|
69
|
-
this.spaceId = readCurrentSpace(this.db).catch((e) => {
|
|
70
|
-
this.spaceId = undefined;
|
|
71
|
-
throw e;
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
return this.spaceId;
|
|
75
|
-
}
|
|
76
|
-
/** The unique (space, locale, path) index holds one row per address: a row of another id at an incoming address
|
|
77
|
-
* (a stale row a capped rename did not reach) is deleted before the upsert. Not atomic with it: rows are derived,
|
|
78
|
-
* and a read heals what a failure between the two leaves. One delete per locale in the chunk. */
|
|
79
|
-
async vacate(table, column, rows) {
|
|
80
|
-
const byLocale = new Map();
|
|
81
|
-
for (const r of rows) {
|
|
82
|
-
// An id the table cannot hold (no UUID) vacates nothing: an empty `not in ()` is no filter PostgREST accepts.
|
|
83
|
-
if (r.path === null || !UUID_RE.test(r.id))
|
|
84
|
-
continue;
|
|
85
|
-
if (!byLocale.has(r.locale))
|
|
86
|
-
byLocale.set(r.locale, { paths: [], ids: [] });
|
|
87
|
-
byLocale.get(r.locale).paths.push(r.path);
|
|
88
|
-
byLocale.get(r.locale).ids.push(r.id);
|
|
89
|
-
}
|
|
90
|
-
if (byLocale.size === 0)
|
|
91
|
-
return;
|
|
92
|
-
const space = await this.space();
|
|
93
|
-
for (const [locale, { paths, ids }] of byLocale) {
|
|
94
|
-
const res = await this.db.from(table).delete()
|
|
95
|
-
.eq("space_id", space).eq("locale", locale).in("path", paths)
|
|
96
|
-
.not(column, "in", `(${ids.join(",")})`);
|
|
97
|
-
if (res.error)
|
|
98
|
-
this.fail(res.error.message);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/** One request per kind and locale filter: the keys without a locale go in one in(). */
|
|
102
|
-
async eachGroup(keys, act) {
|
|
103
|
-
const groups = new Map();
|
|
104
|
-
for (const k of keys) {
|
|
105
|
-
if (!UUID_RE.test(k.id))
|
|
106
|
-
continue;
|
|
107
|
-
const g = `${k.kind}:${k.locale ?? ""}`;
|
|
108
|
-
if (!groups.has(g))
|
|
109
|
-
groups.set(g, { kind: k.kind, locale: k.locale, ids: [] });
|
|
110
|
-
groups.get(g).ids.push(k.id);
|
|
111
|
-
}
|
|
112
|
-
for (const { kind, locale, ids } of groups.values()) {
|
|
113
|
-
const [table, column] = kind === "page" ? ["published_pages", "page_id"] : ["published_entries", "entry_id"];
|
|
114
|
-
for (let i = 0; i < ids.length; i += CHUNK)
|
|
115
|
-
await act(table, column, ids.slice(i, i + CHUNK), locale);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
async remove(keys) {
|
|
119
|
-
if (keys.length === 0)
|
|
120
|
-
return;
|
|
121
|
-
let deleted = 0;
|
|
122
|
-
await this.eachGroup(keys, async (table, column, ids, locale) => {
|
|
123
|
-
let q = this.db.from(table).delete({ count: "exact" }).in(column, ids);
|
|
124
|
-
if (locale !== undefined)
|
|
125
|
-
q = q.eq("locale", locale);
|
|
126
|
-
const res = await q;
|
|
127
|
-
if (res.error)
|
|
128
|
-
this.fail(res.error.message);
|
|
129
|
-
deleted += res.count ?? 0;
|
|
130
|
-
});
|
|
131
|
-
// A row that went is a change the count of newer rows cannot see (spec 2026-10-09 static mode §5): the space's
|
|
132
|
-
// row keeps when one last happened. A key with no row (a publish vacating a locale that never had one) is no
|
|
133
|
-
// change. The press's columns are absent from the payload, so they stay as they are.
|
|
134
|
-
if (deleted === 0)
|
|
135
|
-
return;
|
|
136
|
-
const res = await this.db.from("space_deploys").upsert({ space_id: await this.space(), removed_at: new Date().toISOString() }, { onConflict: "space_id" });
|
|
137
|
-
if (res.error)
|
|
138
|
-
this.fail(res.error.message);
|
|
139
|
-
}
|
|
140
|
-
async lastRemovalAt() {
|
|
141
|
-
const res = await this.db.from("space_deploys").select("removed_at").eq("space_id", await this.space()).maybeSingle();
|
|
142
|
-
if (res.error)
|
|
143
|
-
this.fail(res.error.message);
|
|
144
|
-
const at = res.data?.removed_at;
|
|
145
|
-
return at ? Date.parse(at) : null;
|
|
146
|
-
}
|
|
147
|
-
/** The space's recorded stamp (DESIGN §3): one row per space in published_models; the key's space, or the
|
|
148
|
-
* service role's default one. */
|
|
149
|
-
async recordedModel() {
|
|
150
|
-
const res = await this.db.from("published_models").select("model").eq("space_id", await this.space()).maybeSingle();
|
|
151
|
-
if (res.error)
|
|
152
|
-
this.fail(res.error.message);
|
|
153
|
-
return res.data?.model ?? null;
|
|
154
|
-
}
|
|
155
|
-
async recordModel(model) {
|
|
156
|
-
const res = await this.db.from("published_models")
|
|
157
|
-
.upsert({ space_id: await this.space(), model, recorded_at: new Date().toISOString() }, { onConflict: "space_id" });
|
|
158
|
-
if (res.error)
|
|
159
|
-
this.fail(res.error.message);
|
|
160
|
-
}
|
|
161
|
-
/** Four head counts (no rows travel): each table's rows, and its rows at another stamp. The policies scope a
|
|
162
|
-
* write key to its space; the service role counts the default space's rows by the explicit filter. */
|
|
163
|
-
async countBehind(model) {
|
|
164
|
-
const space = await this.space();
|
|
165
|
-
const count = async (table, behind) => {
|
|
166
|
-
let q = this.db.from(table).select("*", { count: "exact", head: true }).eq("space_id", space);
|
|
167
|
-
if (behind)
|
|
168
|
-
q = q.neq("model", model);
|
|
169
|
-
const res = await q;
|
|
170
|
-
if (res.error)
|
|
171
|
-
this.fail(res.error.message);
|
|
172
|
-
return res.count ?? 0;
|
|
173
|
-
};
|
|
174
|
-
const [pages, entries, pagesBehind, entriesBehind] = await Promise.all([
|
|
175
|
-
count("published_pages", false), count("published_entries", false), count("published_pages", true), count("published_entries", true),
|
|
176
|
-
]);
|
|
177
|
-
return { behind: pagesBehind + entriesBehind, total: pages + entries };
|
|
178
|
-
}
|
|
179
|
-
/** The static build's watermark (spec 2026-10-09 static mode §5): the newest published_at over both tables. */
|
|
180
|
-
async newestPublishedAt() {
|
|
181
|
-
const space = await this.space();
|
|
182
|
-
const latest = async (table) => {
|
|
183
|
-
const res = await this.db.from(table).select("published_at").eq("space_id", space).order("published_at", { ascending: false }).limit(1).maybeSingle();
|
|
184
|
-
if (res.error)
|
|
185
|
-
this.fail(res.error.message);
|
|
186
|
-
const at = res.data?.published_at;
|
|
187
|
-
return at ? Date.parse(at) : null;
|
|
188
|
-
};
|
|
189
|
-
const [p, e] = await Promise.all([latest("published_pages"), latest("published_entries")]);
|
|
190
|
-
return p === null && e === null ? null : Math.max(p ?? 0, e ?? 0);
|
|
191
|
-
}
|
|
192
|
-
/** Rows published strictly after `since`: the Deploy tile's "changes waiting". Two head counts. */
|
|
193
|
-
async countPublishedAfter(since) {
|
|
194
|
-
const space = await this.space();
|
|
195
|
-
const count = async (table) => {
|
|
196
|
-
const res = await this.db.from(table).select("*", { count: "exact", head: true }).eq("space_id", space).gt("published_at", new Date(since).toISOString());
|
|
197
|
-
if (res.error)
|
|
198
|
-
this.fail(res.error.message);
|
|
199
|
-
return res.count ?? 0;
|
|
200
|
-
};
|
|
201
|
-
const [p, e] = await Promise.all([count("published_pages"), count("published_entries")]);
|
|
202
|
-
return p + e;
|
|
203
|
-
}
|
|
204
|
-
/** The space's last Deploy press, one row in space_deploys (schema v11). */
|
|
205
|
-
async deployRequest() {
|
|
206
|
-
const res = await this.db.from("space_deploys").select("requested_at, requested_by").eq("space_id", await this.space()).maybeSingle();
|
|
207
|
-
if (res.error)
|
|
208
|
-
this.fail(res.error.message);
|
|
209
|
-
// A removal may have made the row before any press: no requested_at is no press.
|
|
210
|
-
const row = res.data;
|
|
211
|
-
return row?.requested_at ? { at: Date.parse(row.requested_at), by: row.requested_by } : null;
|
|
212
|
-
}
|
|
213
|
-
async requestDeploy(by, at = Date.now()) {
|
|
214
|
-
const res = await this.db.from("space_deploys")
|
|
215
|
-
.upsert({ space_id: await this.space(), requested_at: new Date(at).toISOString(), requested_by: by }, { onConflict: "space_id" });
|
|
216
|
-
if (res.error)
|
|
217
|
-
this.fail(res.error.message);
|
|
218
|
-
}
|
|
219
|
-
async markStale(keys) {
|
|
220
|
-
await this.eachGroup(keys, async (table, column, ids, locale) => {
|
|
221
|
-
let q = this.db.from(table).update({ stale: true }).in(column, ids);
|
|
222
|
-
if (locale !== undefined)
|
|
223
|
-
q = q.eq("locale", locale);
|
|
224
|
-
const res = await q;
|
|
225
|
-
if (res.error)
|
|
226
|
-
this.fail(res.error.message);
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
/** A route row is for serving, not for the tree: a page route carries the
|
|
230
|
-
* document, model, stale flag and tags; its tree columns are empty, and an
|
|
231
|
-
* entry route's `fields` and times likewise. */
|
|
232
|
-
async byAddress(locale, path, globals) {
|
|
233
|
-
if (globals) {
|
|
234
|
-
// One call (schema v9): the route row, then the locale's rows of the named global items.
|
|
235
|
-
const res = await this.db.rpc("smoodly_lookup_address", { p_locale: locale, p_path: path, p_globals: [...globals] });
|
|
236
|
-
if (res.error)
|
|
237
|
-
this.fail(res.error.message);
|
|
238
|
-
const rows = res.data;
|
|
239
|
-
const first = rows.find((r) => r.kind !== "global");
|
|
240
|
-
if (!first)
|
|
241
|
-
return null;
|
|
242
|
-
const route = this.route({ ...first, kind: first.kind, locale, path });
|
|
243
|
-
route.globals = rows.filter((r) => r.kind === "global").map((r) => ({
|
|
244
|
-
id: r.id, locale, collection: r.collection, parentId: null, sort: null, slug: null, path: null, createdAt: 0, updatedAt: 0,
|
|
245
|
-
fields: {}, document: r.document, tags: r.tags, model: r.model, stale: r.stale, publishedAt: 0,
|
|
246
|
-
}));
|
|
247
|
-
return route;
|
|
248
|
-
}
|
|
249
|
-
const res = await this.db.from("published_routes").select("kind, id, locale, path, document, model, stale, tags, collection")
|
|
250
|
-
.eq("locale", locale).eq("path", path).limit(1).maybeSingle();
|
|
251
|
-
if (res.error)
|
|
252
|
-
this.fail(res.error.message);
|
|
253
|
-
const r = res.data;
|
|
254
|
-
return r ? this.route(r) : null;
|
|
255
|
-
}
|
|
256
|
-
route(r) {
|
|
257
|
-
const served = { id: r.id, locale: r.locale, parentId: null, sort: null, path: r.path, tags: r.tags, model: r.model, stale: r.stale, publishedAt: 0 };
|
|
258
|
-
return r.kind === "page"
|
|
259
|
-
? { kind: "page", row: { ...served, title: "", hasPage: true, linkFields: {}, document: r.document } }
|
|
260
|
-
: { kind: "entry", row: { ...served, collection: r.collection, slug: null, createdAt: 0, updatedAt: 0, fields: {}, document: r.document } };
|
|
261
|
-
}
|
|
262
|
-
async page(id, locale) {
|
|
263
|
-
if (!UUID_RE.test(id))
|
|
264
|
-
return null;
|
|
265
|
-
const res = await this.db.from("published_pages").select("*").eq("page_id", id).eq("locale", locale).maybeSingle();
|
|
266
|
-
if (res.error)
|
|
267
|
-
this.fail(res.error.message);
|
|
268
|
-
return res.data ? fromPage(res.data) : null;
|
|
269
|
-
}
|
|
270
|
-
async entry(id, locale) {
|
|
271
|
-
if (!UUID_RE.test(id))
|
|
272
|
-
return null;
|
|
273
|
-
const res = await this.db.from("published_entries").select("*").eq("entry_id", id).eq("locale", locale).maybeSingle();
|
|
274
|
-
if (res.error)
|
|
275
|
-
this.fail(res.error.message);
|
|
276
|
-
return res.data ? fromEntry(res.data) : null;
|
|
277
|
-
}
|
|
278
|
-
/** A list row carries the document, model and stale flag; the raw `fields`
|
|
279
|
-
* and the tree columns stay in the table (a list never needs them). */
|
|
280
|
-
async entries(collection, q) {
|
|
281
|
-
const schema = this.schemas.get(collection);
|
|
282
|
-
const where = {};
|
|
283
|
-
for (const [key, want] of Object.entries(q.where ?? {})) {
|
|
284
|
-
const inArray = ARRAY_FIELD_TYPES.has(schema?.fields[key]?.type ?? "");
|
|
285
|
-
where[key] = Array.isArray(want) ? { in: want.map(String) } : { contains: inArray ? [want] : want };
|
|
286
|
-
}
|
|
287
|
-
const res = await this.db.rpc("smoodly_list_published", {
|
|
288
|
-
p_collection: collection,
|
|
289
|
-
p_locale: q.locale,
|
|
290
|
-
p_where: where,
|
|
291
|
-
p_order: q.order === undefined ? null : q.order === "manual" ? { by: "manual" } : q.order,
|
|
292
|
-
p_limit: q.limit ?? null,
|
|
293
|
-
p_parent: q.parent ?? null,
|
|
294
|
-
p_has_parent: q.parent !== undefined,
|
|
295
|
-
p_ids: q.ids ? q.ids.filter((id) => UUID_RE.test(id)) : null,
|
|
296
|
-
});
|
|
297
|
-
if (res.error)
|
|
298
|
-
this.fail(res.error.message);
|
|
299
|
-
return res.data.map((r) => ({
|
|
300
|
-
id: r.id, locale: q.locale, collection, parentId: null, sort: null, slug: null, path: null, createdAt: 0, updatedAt: 0,
|
|
301
|
-
fields: {}, document: r.document, tags: [], model: r.model, stale: r.stale, publishedAt: 0,
|
|
302
|
-
}));
|
|
303
|
-
}
|
|
304
|
-
async tree(locale) {
|
|
305
|
-
const res = await this.db.from("published_pages").select(TREE_COLUMNS).eq("locale", locale)
|
|
306
|
-
.order("sort", { ascending: true, nullsFirst: false }).order("published_at").order("page_id");
|
|
307
|
-
if (res.error)
|
|
308
|
-
this.fail(res.error.message);
|
|
309
|
-
return res.data.map((r) => ({
|
|
310
|
-
id: r.page_id, parentId: r.parent_id, sort: num(r.sort), path: r.path, title: r.title, hasPage: r.has_page, linkFields: r.link_fields,
|
|
311
|
-
model: r.model, stale: r.stale,
|
|
312
|
-
}));
|
|
313
|
-
}
|
|
314
|
-
}
|