smoodly 0.0.51 → 0.0.52
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-config.d.ts +6 -5
- package/dist/admin/auth.d.ts +3 -11
- package/dist/admin/auth.js +1 -30
- package/dist/admin/browser/fetch-op.js +3 -2
- package/dist/admin/browser/main.js +6 -2
- package/dist/admin/client-ops.d.ts +5 -4
- package/dist/admin/client-ops.js +14 -11
- 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 +5 -8
- package/dist/admin/index.js +3 -6
- package/dist/admin/ops.d.ts +36 -22
- package/dist/admin/page-data.d.ts +14 -6
- package/dist/admin/page-data.js +1 -1
- package/dist/admin/session-flow.d.ts +43 -0
- package/dist/admin/session-flow.js +94 -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 +5 -4
- 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 +20 -12
- package/dist/admin/shell/session.js +113 -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 +5 -8
- 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 +4 -302
- package/dist/asset-store.d.ts +28 -30
- package/dist/asset-store.js +26 -121
- 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 +54 -25
- 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 +2 -1
- package/dist/astro/runtime.js +4 -3
- package/dist/astro/server.d.ts +7 -11
- package/dist/astro/server.js +22 -28
- package/dist/astro/shell.d.ts +2 -2
- 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 +22 -23
- package/dist/content/content-api.js +46 -56
- package/dist/content/mime.d.ts +1 -0
- package/dist/content/mime.js +6 -0
- package/dist/deploy-store.d.ts +19 -0
- package/dist/deploy-store.js +1 -0
- package/dist/entry-store.d.ts +3 -89
- package/dist/entry-store.js +2 -550
- 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 +12 -22
- 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 +57 -3
- package/dist/media.js +125 -15
- package/dist/member-store.d.ts +3 -22
- package/dist/member-store.js +1 -53
- 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/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 +5 -2
- package/dist/space-transfer.js +17 -5
- 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/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/published.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// The delivery model's store (spec 2026-10-07 delivery model §1, §5): the
|
|
2
|
-
// finished documents Publish writes, one row per page, entry or global per
|
|
3
|
-
// locale, read in one lookup. Derived from the editing tables and
|
|
4
|
-
// rebuildable; the memory adapter is two maps with the matcher in code, the
|
|
5
|
-
// Supabase one (supabase-published-store.ts) reads published_pages,
|
|
6
|
-
// published_entries, the published_routes view and smoodly_list_published.
|
|
7
|
-
import { matchesWhere } from "./entry-store.js";
|
|
8
|
-
const keyOf = (id, locale) => `${id}:${locale}`;
|
|
9
|
-
const asc = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
10
|
-
/** smoodly_list_published's ORDER BY: manual by sort, else the built-in time or
|
|
11
|
-
* the field, absent, null and empty last in both directions; ties by creation, then id. */
|
|
12
|
-
function compareRows(order) {
|
|
13
|
-
return (a, b) => {
|
|
14
|
-
if (order === "manual") {
|
|
15
|
-
const d = (a.sort ?? Infinity) - (b.sort ?? Infinity);
|
|
16
|
-
if (d !== 0 && !Number.isNaN(d))
|
|
17
|
-
return d;
|
|
18
|
-
}
|
|
19
|
-
else if (order) {
|
|
20
|
-
const value = (r) => {
|
|
21
|
-
const v = order.by === "createdAt" ? r.createdAt : order.by === "updatedAt" ? r.updatedAt : r.fields[order.by];
|
|
22
|
-
return v === "" ? null : v;
|
|
23
|
-
};
|
|
24
|
-
const va = value(a), vb = value(b);
|
|
25
|
-
if (va == null && vb != null)
|
|
26
|
-
return 1;
|
|
27
|
-
if (vb == null && va != null)
|
|
28
|
-
return -1;
|
|
29
|
-
if (va != null && vb != null) {
|
|
30
|
-
const d = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
|
|
31
|
-
if (d !== 0)
|
|
32
|
-
return order.direction === "asc" ? d : -d;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return asc(a.createdAt, b.createdAt) || asc(a.id, b.id);
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/** An address holds one row per table, as the SQL's unique (space, locale, path) index says: a row of another id
|
|
39
|
-
* left at an incoming address (a stale row a capped rename did not reach) goes before the write. */
|
|
40
|
-
function vacate(map, rows) {
|
|
41
|
-
const incoming = new Map(rows.filter((r) => r.path !== null).map((r) => [`${r.locale}\u0000${r.path}`, r.id]));
|
|
42
|
-
for (const [k, row] of map) {
|
|
43
|
-
const holder = row.path === null ? undefined : incoming.get(`${row.locale}\u0000${row.path}`);
|
|
44
|
-
if (holder !== undefined && holder !== row.id)
|
|
45
|
-
map.delete(k);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
export class MemoryPublishedStore {
|
|
49
|
-
constructor() {
|
|
50
|
-
this.pages = new Map();
|
|
51
|
-
this.entryRows = new Map();
|
|
52
|
-
this.recorded = null;
|
|
53
|
-
this.deploy = null;
|
|
54
|
-
this.removedAt = null;
|
|
55
|
-
}
|
|
56
|
-
async recordedModel() {
|
|
57
|
-
return this.recorded;
|
|
58
|
-
}
|
|
59
|
-
async recordModel(model) {
|
|
60
|
-
this.recorded = model;
|
|
61
|
-
}
|
|
62
|
-
async newestPublishedAt() {
|
|
63
|
-
const all = [...this.pages.values(), ...this.entryRows.values()].map((r) => r.publishedAt);
|
|
64
|
-
return all.length === 0 ? null : Math.max(...all);
|
|
65
|
-
}
|
|
66
|
-
async countPublishedAfter(since) {
|
|
67
|
-
return [...this.pages.values(), ...this.entryRows.values()].filter((r) => r.publishedAt > since).length;
|
|
68
|
-
}
|
|
69
|
-
async deployRequest() {
|
|
70
|
-
return this.deploy;
|
|
71
|
-
}
|
|
72
|
-
async requestDeploy(by, at = Date.now()) {
|
|
73
|
-
this.deploy = { at, by };
|
|
74
|
-
}
|
|
75
|
-
async countBehind(model) {
|
|
76
|
-
const rows = [...this.pages.values(), ...this.entryRows.values()];
|
|
77
|
-
return { behind: rows.filter((r) => r.model !== model).length, total: rows.length };
|
|
78
|
-
}
|
|
79
|
-
async putPages(rows, options) {
|
|
80
|
-
vacate(this.pages, rows);
|
|
81
|
-
for (const row of rows) {
|
|
82
|
-
const kept = options?.keep ? this.pages.get(keyOf(row.id, row.locale))?.publishedAt : undefined;
|
|
83
|
-
this.pages.set(keyOf(row.id, row.locale), structuredClone(kept === undefined ? row : { ...row, publishedAt: kept }));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
async putEntries(rows, options) {
|
|
87
|
-
vacate(this.entryRows, rows);
|
|
88
|
-
for (const row of rows) {
|
|
89
|
-
const kept = options?.keep ? this.entryRows.get(keyOf(row.id, row.locale))?.publishedAt : undefined;
|
|
90
|
-
this.entryRows.set(keyOf(row.id, row.locale), structuredClone(kept === undefined ? row : { ...row, publishedAt: kept }));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
async remove(keys) {
|
|
94
|
-
let deleted = 0;
|
|
95
|
-
for (const key of keys) {
|
|
96
|
-
const map = key.kind === "page" ? this.pages : this.entryRows;
|
|
97
|
-
for (const [k, row] of map)
|
|
98
|
-
if (row.id === key.id && (key.locale === undefined || row.locale === key.locale)) {
|
|
99
|
-
map.delete(k);
|
|
100
|
-
deleted += 1;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
// Only a row that went is a change (a key with no row is not): the Deploy tile counts it (spec 2026-10-09 §5).
|
|
104
|
-
if (deleted > 0)
|
|
105
|
-
this.removedAt = Date.now();
|
|
106
|
-
}
|
|
107
|
-
async lastRemovalAt() {
|
|
108
|
-
return this.removedAt;
|
|
109
|
-
}
|
|
110
|
-
async byAddress(locale, path, globals) {
|
|
111
|
-
const found = () => {
|
|
112
|
-
for (const row of this.pages.values()) {
|
|
113
|
-
if (row.locale === locale && row.path === path && row.hasPage)
|
|
114
|
-
return { kind: "page", row: structuredClone(row) };
|
|
115
|
-
}
|
|
116
|
-
for (const row of this.entryRows.values()) {
|
|
117
|
-
if (row.locale === locale && row.path === path)
|
|
118
|
-
return { kind: "entry", row: structuredClone(row) };
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
};
|
|
122
|
-
const route = found();
|
|
123
|
-
if (!route || !globals)
|
|
124
|
-
return route;
|
|
125
|
-
const wanted = new Set(globals);
|
|
126
|
-
route.globals = structuredClone([...this.entryRows.values()].filter((r) => r.locale === locale && wanted.has(r.collection)));
|
|
127
|
-
return route;
|
|
128
|
-
}
|
|
129
|
-
async page(id, locale) {
|
|
130
|
-
const row = this.pages.get(keyOf(id, locale));
|
|
131
|
-
return row ? structuredClone(row) : null;
|
|
132
|
-
}
|
|
133
|
-
async entry(id, locale) {
|
|
134
|
-
const row = this.entryRows.get(keyOf(id, locale));
|
|
135
|
-
return row ? structuredClone(row) : null;
|
|
136
|
-
}
|
|
137
|
-
async entries(collection, q) {
|
|
138
|
-
const wanted = q.ids ? new Set(q.ids) : null;
|
|
139
|
-
const rows = [...this.entryRows.values()].filter((r) => r.collection === collection &&
|
|
140
|
-
r.locale === q.locale &&
|
|
141
|
-
(q.parent === undefined || r.parentId === q.parent) &&
|
|
142
|
-
(!wanted || wanted.has(r.id)) &&
|
|
143
|
-
(!q.where || matchesWhere(r.fields, q.where)));
|
|
144
|
-
rows.sort(compareRows(q.order));
|
|
145
|
-
return structuredClone(typeof q.limit === "number" ? rows.slice(0, q.limit) : rows);
|
|
146
|
-
}
|
|
147
|
-
async tree(locale) {
|
|
148
|
-
return [...this.pages.values()]
|
|
149
|
-
.filter((r) => r.locale === locale)
|
|
150
|
-
.sort((a, b) => {
|
|
151
|
-
const d = (a.sort ?? Infinity) - (b.sort ?? Infinity);
|
|
152
|
-
return (d !== 0 && !Number.isNaN(d) ? d : 0) || asc(a.publishedAt, b.publishedAt) || asc(a.id, b.id);
|
|
153
|
-
})
|
|
154
|
-
.map(({ id, parentId, sort, path, title, hasPage, linkFields, model, stale }) => structuredClone({ id, parentId, sort, path, title, hasPage, linkFields, model, stale }));
|
|
155
|
-
}
|
|
156
|
-
async markStale(keys) {
|
|
157
|
-
for (const key of keys) {
|
|
158
|
-
const map = key.kind === "page" ? this.pages : this.entryRows;
|
|
159
|
-
for (const row of map.values())
|
|
160
|
-
if (row.id === key.id && (key.locale === undefined || row.locale === key.locale))
|
|
161
|
-
row.stale = true;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
package/dist/ref-index.d.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { RefEdge } from "./refs.ts";
|
|
2
|
-
export type RefSource = {
|
|
3
|
-
sourceKind: string;
|
|
4
|
-
sourceId: string;
|
|
5
|
-
};
|
|
6
|
-
export declare class MemoryRefIndex {
|
|
7
|
-
private rows;
|
|
8
|
-
/** Replace a source's outgoing edges wholesale (what every save does). */
|
|
9
|
-
rewrite(source: RefSource, edges: RefEdge[]): void;
|
|
10
|
-
remove({ sourceKind, sourceId }: RefSource): void;
|
|
11
|
-
/** The reverse question: who points at this target? */
|
|
12
|
-
to(targetCollection: string, targetId: string): {
|
|
13
|
-
sourceKind: string;
|
|
14
|
-
sourceId: string;
|
|
15
|
-
sourceField: string;
|
|
16
|
-
}[];
|
|
17
|
-
/** Reverse lookup by id alone (ids are globally unique) — the fan-out walk. */
|
|
18
|
-
incoming(targetId: string): RefSource[];
|
|
19
|
-
}
|
package/dist/ref-index.js
DELETED
|
Binary file
|
package/dist/schema-read.d.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
export type SchemaRead = {
|
|
3
|
-
version: number;
|
|
4
|
-
} | {
|
|
5
|
-
missing: "key" | "schema";
|
|
6
|
-
};
|
|
7
|
-
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
8
|
-
export declare function readSchemaVersion(db: SupabaseClient): Promise<SchemaRead>;
|
|
9
|
-
/** How long a refused key is believed before it is read again. */
|
|
10
|
-
export declare const KEY_RECHECK_MS = 30000;
|
|
11
|
-
/** The site's schema check, once per process (spec 2026-09-26 §5). An old or missing schema warns
|
|
12
|
-
* and never blocks. A key that cannot see the version row reads every table as empty rather than
|
|
13
|
-
* refused, so lookups fail instead of serving empty pages and 404s (decided 2026-10-02) — but only
|
|
14
|
-
* for {@link KEY_RECHECK_MS}: a fixed key or a briefly missing row recovers without a restart. A
|
|
15
|
-
* failed read refuses nothing and is tried again; the lookup itself names that error. */
|
|
16
|
-
export declare class SecretKeyCheck {
|
|
17
|
-
private readonly read;
|
|
18
|
-
private readonly now;
|
|
19
|
-
private pending;
|
|
20
|
-
private refusedAt;
|
|
21
|
-
constructor(read: () => Promise<SchemaRead>, now?: () => number);
|
|
22
|
-
refused(): Promise<boolean>;
|
|
23
|
-
}
|
package/dist/schema-read.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
|
|
2
|
-
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
3
|
-
export async function readSchemaVersion(db) {
|
|
4
|
-
const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
|
|
5
|
-
if (res.error) {
|
|
6
|
-
// PGRST205: not in PostgREST's schema cache; 42P01: undefined table; 42501: no grant.
|
|
7
|
-
if (res.error.code === "PGRST205" || res.error.code === "42P01")
|
|
8
|
-
return { missing: "schema" };
|
|
9
|
-
if (res.error.code === "42501")
|
|
10
|
-
return { missing: "key" };
|
|
11
|
-
throw new Error(`smoodly: reading the schema version: ${res.error.message}`);
|
|
12
|
-
}
|
|
13
|
-
const n = Number(res.data?.value);
|
|
14
|
-
return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
|
|
15
|
-
}
|
|
16
|
-
/** How long a refused key is believed before it is read again. */
|
|
17
|
-
export const KEY_RECHECK_MS = 30000;
|
|
18
|
-
/** The site's schema check, once per process (spec 2026-09-26 §5). An old or missing schema warns
|
|
19
|
-
* and never blocks. A key that cannot see the version row reads every table as empty rather than
|
|
20
|
-
* refused, so lookups fail instead of serving empty pages and 404s (decided 2026-10-02) — but only
|
|
21
|
-
* for {@link KEY_RECHECK_MS}: a fixed key or a briefly missing row recovers without a restart. A
|
|
22
|
-
* failed read refuses nothing and is tried again; the lookup itself names that error. */
|
|
23
|
-
export class SecretKeyCheck {
|
|
24
|
-
constructor(read, now = Date.now) {
|
|
25
|
-
this.read = read;
|
|
26
|
-
this.now = now;
|
|
27
|
-
this.pending = null;
|
|
28
|
-
this.refusedAt = null;
|
|
29
|
-
}
|
|
30
|
-
refused() {
|
|
31
|
-
if (this.refusedAt !== null && this.now() - this.refusedAt >= KEY_RECHECK_MS)
|
|
32
|
-
this.pending = null;
|
|
33
|
-
this.pending ?? (this.pending = this.read().then((read) => {
|
|
34
|
-
if ("missing" in read && read.missing === "key") {
|
|
35
|
-
this.refusedAt = this.now();
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
this.refusedAt = null;
|
|
39
|
-
if ("missing" in read)
|
|
40
|
-
console.warn(schemaBehindMessage(null));
|
|
41
|
-
else if (schemaBehind(read.version))
|
|
42
|
-
console.warn(schemaBehindMessage(read.version));
|
|
43
|
-
return false;
|
|
44
|
-
}, () => {
|
|
45
|
-
this.pending = null;
|
|
46
|
-
return false;
|
|
47
|
-
}));
|
|
48
|
-
return this.pending;
|
|
49
|
-
}
|
|
50
|
-
}
|
package/dist/schema-version.d.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
|
-
* that adds a table, column, function or policy the package reads; a
|
|
3
|
-
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
-
export declare const SCHEMA_VERSION = 11;
|
|
5
|
-
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
|
-
export declare const CORE_FILE_RE: RegExp;
|
|
7
|
-
export declare const VIEWS_FILE_RE: RegExp;
|
|
8
|
-
/** The version a core file leaves behind, from its name; null for anything else. */
|
|
9
|
-
export declare function coreFileVersion(name: string): number | null;
|
|
10
|
-
/** The last statement of every core file. */
|
|
11
|
-
export declare function schemaVersionSQL(version: number): string;
|
|
12
|
-
/** Behind blocks the admin and warns on the site (§5). Null = no
|
|
13
|
-
* `smoodly_meta` table, a database older than version 1. Ahead is the
|
|
14
|
-
* normal state of a cloud app between our migration and its package
|
|
15
|
-
* bump, and is fine. */
|
|
16
|
-
export declare const schemaBehind: (database: number | null, required?: number) => boolean;
|
|
17
|
-
export declare function schemaBehindMessage(database: number | null, required?: number): string;
|
|
18
|
-
/** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
|
|
19
|
-
export declare const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version \u2014 it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
|
package/dist/schema-version.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// packages/smoodly/src/schema-version.ts
|
|
2
|
-
// The schema version this package needs (spec 2026-09-26 §1) and the two
|
|
3
|
-
// things every side agrees on: the `smoodly_meta` row that records a
|
|
4
|
-
// database's version, and the names of the files the package ships. Pure
|
|
5
|
-
// and framework-free — the SQL emitters, the CLI, the stores, the admin
|
|
6
|
-
// op and the shell all read it from here.
|
|
7
|
-
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
|
-
* that adds a table, column, function or policy the package reads; a
|
|
9
|
-
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
-
export const SCHEMA_VERSION = 11;
|
|
11
|
-
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
|
-
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
|
-
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
|
14
|
-
/** The version a core file leaves behind, from its name; null for anything else. */
|
|
15
|
-
export function coreFileVersion(name) {
|
|
16
|
-
const m = CORE_FILE_RE.exec(name);
|
|
17
|
-
return m ? Number(m[2]) : null;
|
|
18
|
-
}
|
|
19
|
-
/** The last statement of every core file. */
|
|
20
|
-
export function schemaVersionSQL(version) {
|
|
21
|
-
return (`insert into "smoodly_meta" ("key", "value") values ('${SCHEMA_VERSION_KEY}', '${version}')\n` +
|
|
22
|
-
` on conflict ("key") do update set "value" = excluded."value";`);
|
|
23
|
-
}
|
|
24
|
-
/** Behind blocks the admin and warns on the site (§5). Null = no
|
|
25
|
-
* `smoodly_meta` table, a database older than version 1. Ahead is the
|
|
26
|
-
* normal state of a cloud app between our migration and its package
|
|
27
|
-
* bump, and is fine. */
|
|
28
|
-
export const schemaBehind = (database, required = SCHEMA_VERSION) => database === null || database < required;
|
|
29
|
-
export function schemaBehindMessage(database, required = SCHEMA_VERSION) {
|
|
30
|
-
return (`smoodly: the database schema is at version ${database ?? "none"}; this Smoodly needs version ${required}. ` +
|
|
31
|
-
"Run `npx smoodly migrate`, then apply it: `supabase db push` (hosted) or `supabase migration up` (local).");
|
|
32
|
-
}
|
|
33
|
-
/** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
|
|
34
|
-
export const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version — it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
|
package/dist/sql-space.d.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/** The one space a self-host install has; the space a request with no
|
|
2
|
-
* claim resolves to. The bare uuid — code needs the value (a shared
|
|
3
|
-
* item's id namespace), SQL needs it quoted. */
|
|
4
|
-
export declare const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
5
|
-
export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
|
|
6
|
-
export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
|
|
7
|
-
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths", "assets"];
|
|
8
|
-
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
9
|
-
/** The delivery model's derived tables (spec 2026-10-07 §1): policy-covered and
|
|
10
|
-
* space-stamped like content, but not transferred (export skips them, import
|
|
11
|
-
* rebuilds them) and not counted toward the row cap, since they are derived.
|
|
12
|
-
* `published_models` (schema v10) holds one row per space: the document stamp the space recorded as current;
|
|
13
|
-
* `space_deploys` (schema v11) one row per space: the last Deploy press of static mode. */
|
|
14
|
-
export declare const DELIVERY_TABLES: readonly ["published_pages", "published_entries", "published_models", "space_deploys"];
|
|
15
|
-
export declare function currentSpaceSQL(): string;
|
|
16
|
-
/** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
|
|
17
|
-
* the write key's insert, update and delete, each alone for its action; its
|
|
18
|
-
* reads go through the table's one SELECT policy. `table` is as SQL names
|
|
19
|
-
* it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
|
|
20
|
-
export declare function writePoliciesSQL(table: string, write: string, prefix: string): string;
|
|
21
|
-
/** The content tables' and members' policies. Each content table has ONE
|
|
22
|
-
* select policy — its own space, not revoked, and a write key (spec
|
|
23
|
-
* 2026-09-29 content API contract §8): no key but a write key reads
|
|
24
|
-
* content; the publishable key serves sign-in and presence. Split write
|
|
25
|
-
* policies beside it. `refs` and `members` keep their `for all` write
|
|
26
|
-
* policy, which is also their only select. */
|
|
27
|
-
export declare function contentPoliciesSQL(): string;
|
|
28
|
-
export declare function spaceLayerSQL(): string;
|
|
29
|
-
/** The media bucket and its object policies (spec 2026-09-08 media §3,
|
|
30
|
-
* §12). Public read: the CDN and next/image fetch without tokens. Writes:
|
|
31
|
-
* a write key under its own space's prefix only; the service role
|
|
32
|
-
* bypasses; signed uploads are minted server-side with that same key.
|
|
33
|
-
* Schema-qualified calls: storage policies do not run with public on
|
|
34
|
-
* the search path. Last in the migration, after the functions exist. */
|
|
35
|
-
export declare function storageSQL(): string;
|
|
36
|
-
/** The media bucket's write policies: insert, update, delete for a write
|
|
37
|
-
* key under its own space's prefix; reads are the public select above. */
|
|
38
|
-
export declare function mediaPoliciesSQL(): string;
|
|
39
|
-
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
40
|
-
* 2026-09-26 security pass §2): executable by the service role and by
|
|
41
|
-
* `authenticated` (the role a cloud write key carries), not by anon or
|
|
42
|
-
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
43
|
-
* rpc(). Last in the schema, after every definition exists. */
|
|
44
|
-
export declare function grantsSQL(): string;
|
package/dist/sql-space.js
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
// The space layer of the stable schema (spec 2026-09-04 §2–§3). A space
|
|
2
|
-
// is one cloud environment; a self-host install is one space with the
|
|
3
|
-
// default id. Everything that needs "which space is this request in"
|
|
4
|
-
// reads smoodly_current_space() — the adapters never mention spaces.
|
|
5
|
-
/** The one space a self-host install has; the space a request with no
|
|
6
|
-
* claim resolves to. The bare uuid — code needs the value (a shared
|
|
7
|
-
* item's id namespace), SQL needs it quoted. */
|
|
8
|
-
export const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
9
|
-
export const DEFAULT_SPACE = `'${DEFAULT_SPACE_ID}'`;
|
|
10
|
-
export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
|
|
11
|
-
export const CONTENT_TABLES = [
|
|
12
|
-
"pages",
|
|
13
|
-
"page_locales",
|
|
14
|
-
"page_versions",
|
|
15
|
-
"entries",
|
|
16
|
-
"entry_locales",
|
|
17
|
-
"entry_versions",
|
|
18
|
-
"refs",
|
|
19
|
-
"paths",
|
|
20
|
-
"assets",
|
|
21
|
-
];
|
|
22
|
-
/** The delivery model's derived tables (spec 2026-10-07 §1): policy-covered and
|
|
23
|
-
* space-stamped like content, but not transferred (export skips them, import
|
|
24
|
-
* rebuilds them) and not counted toward the row cap, since they are derived.
|
|
25
|
-
* `published_models` (schema v10) holds one row per space: the document stamp the space recorded as current;
|
|
26
|
-
* `space_deploys` (schema v11) one row per space: the last Deploy press of static mode. */
|
|
27
|
-
export const DELIVERY_TABLES = ["published_pages", "published_entries", "published_models", "space_deploys"];
|
|
28
|
-
export function currentSpaceSQL() {
|
|
29
|
-
return `
|
|
30
|
-
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
|
31
|
-
-- default space in self-host (service role, anon key). Policies, the
|
|
32
|
-
-- insert trigger and the reorder RPC all read this ONE function.
|
|
33
|
-
create or replace function "smoodly_current_space"() returns uuid
|
|
34
|
-
language sql stable set search_path = '' as $$
|
|
35
|
-
select coalesce((auth.jwt() ->> 'space_id')::uuid, ${DEFAULT_SPACE});
|
|
36
|
-
$$;`.trim();
|
|
37
|
-
}
|
|
38
|
-
/** Tables whose existing indexes do not lead with space_id. */
|
|
39
|
-
const NEEDS_SPACE_INDEX = [
|
|
40
|
-
"page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
|
|
41
|
-
];
|
|
42
|
-
/** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
|
|
43
|
-
* the write key's insert, update and delete, each alone for its action; its
|
|
44
|
-
* reads go through the table's one SELECT policy. `table` is as SQL names
|
|
45
|
-
* it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
|
|
46
|
-
export function writePoliciesSQL(table, write, prefix) {
|
|
47
|
-
return [
|
|
48
|
-
`drop policy if exists "${prefix}_write" on ${table};`,
|
|
49
|
-
`drop policy if exists "${prefix}_insert" on ${table};\ncreate policy "${prefix}_insert" on ${table} for insert\n with check (${write});`,
|
|
50
|
-
`drop policy if exists "${prefix}_update" on ${table};\ncreate policy "${prefix}_update" on ${table} for update\n using (${write})\n with check (${write});`,
|
|
51
|
-
`drop policy if exists "${prefix}_delete" on ${table};\ncreate policy "${prefix}_delete" on ${table} for delete\n using (${write});`,
|
|
52
|
-
].join("\n\n");
|
|
53
|
-
}
|
|
54
|
-
const OWN = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
|
|
55
|
-
const WRITE = `${OWN} and (select "smoodly_key_kind"()) = 'write'`;
|
|
56
|
-
/** The content tables' and members' policies. Each content table has ONE
|
|
57
|
-
* select policy — its own space, not revoked, and a write key (spec
|
|
58
|
-
* 2026-09-29 content API contract §8): no key but a write key reads
|
|
59
|
-
* content; the publishable key serves sign-in and presence. Split write
|
|
60
|
-
* policies beside it. `refs` and `members` keep their `for all` write
|
|
61
|
-
* policy, which is also their only select. */
|
|
62
|
-
export function contentPoliciesSQL() {
|
|
63
|
-
const statements = [];
|
|
64
|
-
for (const table of [...CONTENT_TABLES, ...DELIVERY_TABLES, "members"]) {
|
|
65
|
-
if (table === "refs" || table === "members") {
|
|
66
|
-
statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
|
|
67
|
-
`create policy "smoodly_write" on "${table}" for all\n` +
|
|
68
|
-
` using (${WRITE})\n` +
|
|
69
|
-
` with check (${WRITE});`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
statements.push(writePoliciesSQL(`"${table}"`, WRITE, "smoodly"));
|
|
73
|
-
statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
|
|
74
|
-
`create policy "smoodly_read" on "${table}" for select\n` +
|
|
75
|
-
` using (${WRITE});`);
|
|
76
|
-
}
|
|
77
|
-
return statements.join("\n\n");
|
|
78
|
-
}
|
|
79
|
-
export function spaceLayerSQL() {
|
|
80
|
-
const statements = [];
|
|
81
|
-
statements.push(`
|
|
82
|
-
-- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
|
|
83
|
-
-- tables, shipped for everyone with ONE definition. Self-host fills them
|
|
84
|
-
-- by hand or leaves them empty; smoodly.io writes rows with the service
|
|
85
|
-
-- role. There is never cloud-only SQL on a content database.
|
|
86
|
-
-- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
|
|
87
|
-
-- email is stored so the Members page needs no Auth lookup — the auth
|
|
88
|
-
-- project is not the content project in the cloud.
|
|
89
|
-
create table if not exists "members" (
|
|
90
|
-
${SPACE_COL},
|
|
91
|
-
"user_id" uuid not null,
|
|
92
|
-
"email" text,
|
|
93
|
-
"role" text not null default 'editor',
|
|
94
|
-
primary key ("space_id", "user_id")
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
create table if not exists "space_keys" (
|
|
98
|
-
"id" uuid primary key,
|
|
99
|
-
"space_id" uuid not null,
|
|
100
|
-
"kind" text not null check ("kind" in ('read', 'write')),
|
|
101
|
-
"revoked_at" timestamptz
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
-- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
|
|
105
|
-
-- row = self-host: no cap, members managed in /admin. console_url set =
|
|
106
|
-
-- smoodly.io manages the members and /admin links there.
|
|
107
|
-
create table if not exists "spaces" (
|
|
108
|
-
"space_id" uuid primary key,
|
|
109
|
-
"row_cap" integer,
|
|
110
|
-
"console_url" text
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
alter table "members" enable row level security;
|
|
114
|
-
alter table "space_keys" enable row level security;
|
|
115
|
-
alter table "spaces" enable row level security;
|
|
116
|
-
|
|
117
|
-
-- ── Claim readers. The two lookups run as definer so they can read the
|
|
118
|
-
-- service-role-only tables from inside a policy; search_path is pinned.
|
|
119
|
-
create or replace function "smoodly_key_kind"() returns text
|
|
120
|
-
language sql stable set search_path = '' as $$
|
|
121
|
-
select auth.jwt() ->> 'kind';
|
|
122
|
-
$$;
|
|
123
|
-
|
|
124
|
-
create or replace function "smoodly_space_key_revoked"() returns boolean
|
|
125
|
-
language sql stable security definer set search_path = public as $$
|
|
126
|
-
select coalesce(
|
|
127
|
-
(select k."revoked_at" is not null from "space_keys" k
|
|
128
|
-
where k."id" = (auth.jwt() ->> 'key_id')::uuid),
|
|
129
|
-
false);
|
|
130
|
-
$$;
|
|
131
|
-
|
|
132
|
-
create or replace function "smoodly_space_row_cap"() returns integer
|
|
133
|
-
language sql stable security definer set search_path = public as $$
|
|
134
|
-
select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
|
|
135
|
-
$$;
|
|
136
|
-
|
|
137
|
-
-- The presence gate (spec 2026-09-26 security pass §1): the caller is a
|
|
138
|
-
-- member of the space the Realtime topic names. auth.uid() is the session
|
|
139
|
-
-- token's subject — the anon key and a read key carry none, so a visitor
|
|
140
|
-
-- is refused at join. Definer, because members has no read policy for
|
|
141
|
-
-- anyone but a write key. No space claim is read: the same rule serves
|
|
142
|
-
-- the cloud once the content project accepts smoodly.io's tokens (OQ 32).
|
|
143
|
-
create or replace function "smoodly_presence_member"() returns boolean
|
|
144
|
-
language sql stable security definer set search_path = public as $$
|
|
145
|
-
select exists (select 1 from "members" m
|
|
146
|
-
where m."user_id" = auth.uid()
|
|
147
|
-
and realtime.topic() = 'space:' || m."space_id"::text);
|
|
148
|
-
$$;
|
|
149
|
-
|
|
150
|
-
-- ── Triggers. A key's claim wins over whatever the insert said, so an
|
|
151
|
-
-- adapter never has to know its space and cannot write into another.
|
|
152
|
-
-- No claim (service role, anon) leaves the given or default value —
|
|
153
|
-
-- that is how smoodly.io writes cloud-state rows for a chosen space.
|
|
154
|
-
create or replace function "smoodly_set_space"() returns trigger
|
|
155
|
-
language plpgsql set search_path = '' as $$
|
|
156
|
-
begin
|
|
157
|
-
if auth.jwt() ->> 'space_id' is not null then
|
|
158
|
-
new."space_id" := (auth.jwt() ->> 'space_id')::uuid;
|
|
159
|
-
end if;
|
|
160
|
-
return new;
|
|
161
|
-
end;
|
|
162
|
-
$$;
|
|
163
|
-
|
|
164
|
-
-- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
|
|
165
|
-
-- a_ (smoodly_set_space) runs first, so new.space_id already equals the
|
|
166
|
-
-- current space for a keyed insert; smoodly_space_row_cap() reads the
|
|
167
|
-
-- current space itself, so this never needs the row's space_id as an
|
|
168
|
-
-- argument (which would let one space's insert probe another's cap).
|
|
169
|
-
-- A service-role insert that names another space explicitly is a
|
|
170
|
-
-- different case: there is no JWT claim, so smoodly_current_space()
|
|
171
|
-
-- still resolves to the default space, and the cap read is the default
|
|
172
|
-
-- space's (none) — the service role is never capped by another space's row.
|
|
173
|
-
-- search_path is pinned empty, so every reference below is schema-qualified.
|
|
174
|
-
create or replace function "smoodly_enforce_row_cap"() returns trigger
|
|
175
|
-
language plpgsql set search_path = '' as $$
|
|
176
|
-
declare
|
|
177
|
-
cap integer;
|
|
178
|
-
n bigint;
|
|
179
|
-
begin
|
|
180
|
-
cap := public."smoodly_space_row_cap"();
|
|
181
|
-
if cap is null then
|
|
182
|
-
return new;
|
|
183
|
-
end if;
|
|
184
|
-
-- Advisory only (free-tier abuse guard), not a hard invariant: two
|
|
185
|
-
-- concurrent inserts can both observe n = cap - 1 and both pass.
|
|
186
|
-
execute format('select count(*) from %I.%I where "space_id" = $1', tg_table_schema, tg_table_name)
|
|
187
|
-
into n using new."space_id";
|
|
188
|
-
if n >= cap then
|
|
189
|
-
raise exception 'smoodly: row cap (%) reached for this space in %', cap, tg_table_name
|
|
190
|
-
using errcode = 'check_violation';
|
|
191
|
-
end if;
|
|
192
|
-
return new;
|
|
193
|
-
end;
|
|
194
|
-
$$;`.trim());
|
|
195
|
-
for (const table of NEEDS_SPACE_INDEX) {
|
|
196
|
-
statements.push(`create index if not exists "${table}_space_idx" on "${table}" ("space_id");`);
|
|
197
|
-
}
|
|
198
|
-
// Trigger names sort a_ before b_: same-event triggers fire alphabetically.
|
|
199
|
-
for (const table of [...CONTENT_TABLES, ...DELIVERY_TABLES, "members"]) {
|
|
200
|
-
statements.push(`drop trigger if exists "smoodly_a_set_space" on "${table}";\n` +
|
|
201
|
-
`create trigger "smoodly_a_set_space" before insert on "${table}"\n` +
|
|
202
|
-
` for each row execute function "smoodly_set_space"();`);
|
|
203
|
-
}
|
|
204
|
-
for (const table of CONTENT_TABLES) {
|
|
205
|
-
statements.push(`drop trigger if exists "smoodly_b_row_cap" on "${table}";\n` +
|
|
206
|
-
`create trigger "smoodly_b_row_cap" before insert on "${table}"\n` +
|
|
207
|
-
` for each row execute function "smoodly_enforce_row_cap"();`);
|
|
208
|
-
}
|
|
209
|
-
// Policies (permissive, so read OR write admits). The service role
|
|
210
|
-
// bypasses them all — self-host adapters are untouched. Each function
|
|
211
|
-
// call sits in its own scalar subquery so the planner hoists it into
|
|
212
|
-
// an InitPlan, evaluated once per statement rather than once per row
|
|
213
|
-
// — needed for smoodly_space_key_revoked, a security definer function
|
|
214
|
-
// Postgres never inlines; applied to all three calls for the same win.
|
|
215
|
-
const own = OWN;
|
|
216
|
-
const write = WRITE;
|
|
217
|
-
statements.push(contentPoliciesSQL());
|
|
218
|
-
// A write key reads its own space's row — the console URL the Members
|
|
219
|
-
// page links to (spec 2026-09-25 §3). Nothing else touches spaces or
|
|
220
|
-
// space_keys but the two definer functions and the service role.
|
|
221
|
-
statements.push(`drop policy if exists "smoodly_read" on "spaces";\n` +
|
|
222
|
-
`create policy "smoodly_read" on "spaces" for select\n` +
|
|
223
|
-
` using (${write});`);
|
|
224
|
-
// The package reads its schema version with the write key (the
|
|
225
|
-
// service role bypasses anyway); nothing else sees the table. No
|
|
226
|
-
// space_id: the schema is one per database (spec 2026-09-26 §1).
|
|
227
|
-
statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
|
|
228
|
-
`create policy "smoodly_read" on "smoodly_meta" for select\n` +
|
|
229
|
-
` using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));`);
|
|
230
|
-
// The editing-presence channel (spec 2026-09-26 concurrency §5; gated
|
|
231
|
-
// 2026-09-26 security pass §1): one private Realtime topic per space,
|
|
232
|
-
// `space:<id>`. A caller receives on and sends to a topic only when a
|
|
233
|
-
// members row of that space names their session's user — the browser
|
|
234
|
-
// authenticates the socket with the editor's session token, never with
|
|
235
|
-
// the public key alone. Identical in self-host and the cloud.
|
|
236
|
-
const member = `(select public."smoodly_presence_member"())`;
|
|
237
|
-
statements.push(`drop policy if exists "smoodly_presence_read" on "realtime"."messages";\n` +
|
|
238
|
-
`create policy "smoodly_presence_read" on "realtime"."messages" for select\n` +
|
|
239
|
-
` using (${member});`);
|
|
240
|
-
statements.push(`drop policy if exists "smoodly_presence_write" on "realtime"."messages";\n` +
|
|
241
|
-
`create policy "smoodly_presence_write" on "realtime"."messages" for insert\n` +
|
|
242
|
-
` with check (${member});`);
|
|
243
|
-
statements.push(storageSQL());
|
|
244
|
-
return statements.join("\n\n");
|
|
245
|
-
}
|
|
246
|
-
/** The media bucket and its object policies (spec 2026-09-08 media §3,
|
|
247
|
-
* §12). Public read: the CDN and next/image fetch without tokens. Writes:
|
|
248
|
-
* a write key under its own space's prefix only; the service role
|
|
249
|
-
* bypasses; signed uploads are minted server-side with that same key.
|
|
250
|
-
* Schema-qualified calls: storage policies do not run with public on
|
|
251
|
-
* the search path. Last in the migration, after the functions exist. */
|
|
252
|
-
export function storageSQL() {
|
|
253
|
-
return `
|
|
254
|
-
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
255
|
-
values ('media', 'media', true, 104857600)
|
|
256
|
-
on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
|
|
257
|
-
|
|
258
|
-
drop policy if exists "smoodly_media_read" on storage.objects;
|
|
259
|
-
create policy "smoodly_media_read" on storage.objects for select
|
|
260
|
-
using ("bucket_id" = 'media');
|
|
261
|
-
|
|
262
|
-
${mediaPoliciesSQL()}`.trim();
|
|
263
|
-
}
|
|
264
|
-
/** The media bucket's write policies: insert, update, delete for a write
|
|
265
|
-
* key under its own space's prefix; reads are the public select above. */
|
|
266
|
-
export function mediaPoliciesSQL() {
|
|
267
|
-
const write = `"bucket_id" = 'media'` +
|
|
268
|
-
` and (select public."smoodly_key_kind"()) = 'write'` +
|
|
269
|
-
` and not (select public."smoodly_space_key_revoked"())` +
|
|
270
|
-
` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
|
|
271
|
-
return writePoliciesSQL("storage.objects", write, "smoodly_media");
|
|
272
|
-
}
|
|
273
|
-
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
274
|
-
* 2026-09-26 security pass §2): executable by the service role and by
|
|
275
|
-
* `authenticated` (the role a cloud write key carries), not by anon or
|
|
276
|
-
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
277
|
-
* rpc(). Last in the schema, after every definition exists. */
|
|
278
|
-
export function grantsSQL() {
|
|
279
|
-
// smoodly_list_entries and smoodly_list_published are callable by every
|
|
280
|
-
// role on purpose: invoker rights and the read policies decide what a key
|
|
281
|
-
// sees (spec 2026-09-29 content API contract §5), so there is nothing to
|
|
282
|
-
// revoke; the published_routes view is security_invoker for the same reason.
|
|
283
|
-
return [
|
|
284
|
-
`revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
|
|
285
|
-
`revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
|
|
286
|
-
`revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
|
|
287
|
-
`revoke execute on function "smoodly_space_row_cap"() from public, anon;`,
|
|
288
|
-
].join("\n");
|
|
289
|
-
}
|