smoodly 0.0.50 → 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 +28 -15
- 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 +9 -8
- package/dist/reads.js +7 -3
- 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 -1400
- 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
|
@@ -1,824 +0,0 @@
|
|
|
1
|
-
// The Supabase adapter for the EntryStore contract, over entries +
|
|
2
|
-
// entry_locales + entry_versions + refs + paths. Service-role client (or
|
|
3
|
-
// a cloud write key), server-side only. Every read embeds the locale
|
|
4
|
-
// rows (`*, entry_locales(*)`) so a record arrives whole; a versioned
|
|
5
|
-
// collection's published read goes through entry_versions. Path rows are
|
|
6
|
-
// rewritten through ONE RPC per write, so a collision rolls back. Reorder
|
|
7
|
-
// calls the smoodly_reorder SQL function: one statement, updated_at
|
|
8
|
-
// untouched.
|
|
9
|
-
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
10
|
-
import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, keepLocalizedShared, schemaOf, splitFields, } from "./entry-store.js";
|
|
11
|
-
import { globalEntryId } from "./global-id.js";
|
|
12
|
-
import { readCurrentSpace } from "./current-space.js";
|
|
13
|
-
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
14
|
-
import { anchorsFor } from "./anchors.js";
|
|
15
|
-
import { groupRedirects } from "./store.js";
|
|
16
|
-
import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
|
|
17
|
-
import { assertDepth, assertSupportedLocale, entryPathRows, rewriteTargets, slugOf } from "./paths.js";
|
|
18
|
-
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
19
|
-
// throws invalid_text_representation. A non-UUID id can't exist in the
|
|
20
|
-
// table, so treat it the same as a missing one.
|
|
21
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
-
const UNIQUE_VIOLATION = "23505";
|
|
23
|
-
const SELECT = "*, entry_locales(*, draft:entry_versions!entry_locales_draft_version_fkey(saved_at))";
|
|
24
|
-
function toLocale(row) {
|
|
25
|
-
return {
|
|
26
|
-
slug: row.slug,
|
|
27
|
-
status: row.status,
|
|
28
|
-
fields: row.fields,
|
|
29
|
-
draftVersionId: row.draft_version_id,
|
|
30
|
-
draftSavedAt: row.draft?.saved_at ?? null,
|
|
31
|
-
publishedVersionId: row.published_version_id,
|
|
32
|
-
updatedAt: Date.parse(row.updated_at),
|
|
33
|
-
updatedBy: row.updated_by,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
function toRecord(row) {
|
|
37
|
-
return {
|
|
38
|
-
id: row.id,
|
|
39
|
-
collection: row.collection,
|
|
40
|
-
parentId: row.parent_id,
|
|
41
|
-
fields: row.fields,
|
|
42
|
-
locales: Object.fromEntries((row.entry_locales ?? []).map((l) => [l.locale, toLocale(l)])),
|
|
43
|
-
sort: row.sort,
|
|
44
|
-
createdAt: Date.parse(row.created_at),
|
|
45
|
-
updatedAt: Date.parse(row.updated_at),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
function toVersion(row) {
|
|
49
|
-
return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, savedAt: Date.parse(row.saved_at) };
|
|
50
|
-
}
|
|
51
|
-
/** Rows per request when `list` reads a whole collection: under PostgREST's default cap. */
|
|
52
|
-
const LIST_PAGE = 1000;
|
|
53
|
-
export class SupabaseEntryStore {
|
|
54
|
-
constructor(db, collections, options = {}) {
|
|
55
|
-
this.db = db;
|
|
56
|
-
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
57
|
-
this.schemas = [...collections, ...(options.globals ?? [])];
|
|
58
|
-
// Alone, the store's own index reads its collections' addresses (spec 2026-09-29 anchored paths §2).
|
|
59
|
-
this.paths = options.paths ?? new SupabasePathIndex(db, anchorsFor({ locales: this.locales, collections }));
|
|
60
|
-
this.elements = options.elements;
|
|
61
|
-
}
|
|
62
|
-
fail(message) {
|
|
63
|
-
throw new Error(`smoodly: ${message}`);
|
|
64
|
-
}
|
|
65
|
-
schema(collection) {
|
|
66
|
-
return schemaOf(this.schemas, collection);
|
|
67
|
-
}
|
|
68
|
-
/** The space this client acts in — the ONE function the policies and
|
|
69
|
-
* the insert trigger read (src/sql-space.ts): a key's claim in the
|
|
70
|
-
* cloud, the default space under the service role. Constant for the
|
|
71
|
-
* life of the client, so the PROMISE is cached and concurrent creates
|
|
72
|
-
* share one round trip; a failed lookup is not cached. */
|
|
73
|
-
space() {
|
|
74
|
-
if (!this.spaceId) {
|
|
75
|
-
this.spaceId = readCurrentSpace(this.db).catch((e) => {
|
|
76
|
-
// A failed lookup is not cached: the next write retries it.
|
|
77
|
-
this.spaceId = undefined;
|
|
78
|
-
throw e;
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
return this.spaceId;
|
|
82
|
-
}
|
|
83
|
-
// ── reads ──
|
|
84
|
-
async fetch(collection, id) {
|
|
85
|
-
if (!UUID_RE.test(id))
|
|
86
|
-
return null;
|
|
87
|
-
// Locale rows ordered by locale, as in `list`: the embed otherwise arrives in
|
|
88
|
-
// physical row order, which shifts with every update to the table.
|
|
89
|
-
const res = await this.db
|
|
90
|
-
.from("entries")
|
|
91
|
-
.select(SELECT)
|
|
92
|
-
.eq("collection", collection)
|
|
93
|
-
.eq("id", id)
|
|
94
|
-
.order("locale", { referencedTable: "entry_locales", ascending: true })
|
|
95
|
-
.maybeSingle();
|
|
96
|
-
if (res.error)
|
|
97
|
-
this.fail(res.error.message);
|
|
98
|
-
return res.data ? toRecord(res.data) : null;
|
|
99
|
-
}
|
|
100
|
-
async must(collection, id) {
|
|
101
|
-
const entry = await this.fetch(collection, id);
|
|
102
|
-
if (!entry)
|
|
103
|
-
throw entryErrors.notFound(collection, id);
|
|
104
|
-
return entry;
|
|
105
|
-
}
|
|
106
|
-
async siblings(collection, parentId, except) {
|
|
107
|
-
let query = this.db.from("entries").select(SELECT).eq("collection", collection);
|
|
108
|
-
query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
|
|
109
|
-
const res = await query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
|
|
110
|
-
if (res.error)
|
|
111
|
-
this.fail(res.error.message);
|
|
112
|
-
return res.data.map(toRecord).filter((e) => e.id !== except);
|
|
113
|
-
}
|
|
114
|
-
byIdMap(rows) {
|
|
115
|
-
const map = new Map(rows.map((r) => [r.id, r]));
|
|
116
|
-
return (id) => map.get(id);
|
|
117
|
-
}
|
|
118
|
-
async chain(entry) {
|
|
119
|
-
const chain = [entry];
|
|
120
|
-
let current = entry;
|
|
121
|
-
while (current.parentId !== null) {
|
|
122
|
-
const parent = await this.fetch(current.collection, current.parentId);
|
|
123
|
-
if (!parent)
|
|
124
|
-
this.fail(`broken parent chain at "${entry.id}".`);
|
|
125
|
-
chain.unshift(parent);
|
|
126
|
-
current = parent;
|
|
127
|
-
}
|
|
128
|
-
return chain;
|
|
129
|
-
}
|
|
130
|
-
async subtree(entry) {
|
|
131
|
-
const out = [entry];
|
|
132
|
-
let frontier = [entry.id];
|
|
133
|
-
while (frontier.length > 0) {
|
|
134
|
-
const res = await this.db.from("entries").select(SELECT).eq("collection", entry.collection).in("parent_id", frontier);
|
|
135
|
-
if (res.error)
|
|
136
|
-
this.fail(res.error.message);
|
|
137
|
-
const rows = res.data.map(toRecord);
|
|
138
|
-
out.push(...rows);
|
|
139
|
-
frontier = rows.map((r) => r.id);
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
async versionFields(ids) {
|
|
144
|
-
if (ids.length === 0)
|
|
145
|
-
return new Map();
|
|
146
|
-
const res = await this.db.from("entry_versions").select("id, fields").in("id", ids);
|
|
147
|
-
if (res.error)
|
|
148
|
-
this.fail(res.error.message);
|
|
149
|
-
return new Map(res.data.map((r) => [r.id, r.fields]));
|
|
150
|
-
}
|
|
151
|
-
// ── the derived indexes ──
|
|
152
|
-
async writePaths(top, redirect = true) {
|
|
153
|
-
const schema = this.schema(top.collection);
|
|
154
|
-
const ancestors = await this.chain(top);
|
|
155
|
-
const nodes = await this.subtree(top);
|
|
156
|
-
await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byIdMap([...ancestors, ...nodes]), schema.name));
|
|
157
|
-
}
|
|
158
|
-
/** Every field set the site can render: each locale's live view, plus a
|
|
159
|
-
* published snapshot the draft has moved past (see entryRefEdges). */
|
|
160
|
-
async liveFieldSets(entry) {
|
|
161
|
-
const sets = [];
|
|
162
|
-
const snapshotIds = [];
|
|
163
|
-
for (const [locale, row] of Object.entries(entry.locales)) {
|
|
164
|
-
sets.push(entryFieldsIn(entry, locale));
|
|
165
|
-
if (row.status === "published" && row.publishedVersionId && row.publishedVersionId !== row.draftVersionId) {
|
|
166
|
-
snapshotIds.push(row.publishedVersionId);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
for (const fields of (await this.versionFields(snapshotIds)).values())
|
|
170
|
-
sets.push(fields);
|
|
171
|
-
return sets;
|
|
172
|
-
}
|
|
173
|
-
async writeRefs(entry) {
|
|
174
|
-
const schema = this.schema(entry.collection);
|
|
175
|
-
const deleted = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", entry.id);
|
|
176
|
-
if (deleted.error)
|
|
177
|
-
this.fail(deleted.error.message);
|
|
178
|
-
// dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
|
|
179
|
-
const rows = new Map();
|
|
180
|
-
for (const edge of entryRefEdges(await this.liveFieldSets(entry), schema, this.elements)) {
|
|
181
|
-
rows.set(`${edge.field}\x00${edge.targetId}`, {
|
|
182
|
-
source_kind: "entry",
|
|
183
|
-
source_id: entry.id,
|
|
184
|
-
source_field: edge.field,
|
|
185
|
-
target_collection: edge.targetCollection,
|
|
186
|
-
target_id: edge.targetId,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
if (rows.size > 0) {
|
|
190
|
-
const inserted = await this.db.from("refs").insert([...rows.values()]);
|
|
191
|
-
if (inserted.error)
|
|
192
|
-
this.fail(inserted.error.message);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// ── guards ──
|
|
196
|
-
/** The friendly pre-check per locale (spec 2026-09-06 §11); the paths
|
|
197
|
-
* primary key is the invariant. */
|
|
198
|
-
async assertSiblingSlug(collection, parentId, locale, slug, except) {
|
|
199
|
-
if (slug === null)
|
|
200
|
-
return;
|
|
201
|
-
for (const s of await this.siblings(collection, parentId, except)) {
|
|
202
|
-
if (s.locales[locale]?.slug === slug)
|
|
203
|
-
throw entryErrors.slugTaken(collection);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
async assertParent(collection, parentId, subtreeHeight) {
|
|
207
|
-
if (parentId === null)
|
|
208
|
-
return null;
|
|
209
|
-
if (!UUID_RE.test(parentId))
|
|
210
|
-
throw entryErrors.notFound(collection, parentId);
|
|
211
|
-
const res = await this.db.from("entries").select(SELECT).eq("id", parentId).maybeSingle();
|
|
212
|
-
if (res.error)
|
|
213
|
-
this.fail(res.error.message);
|
|
214
|
-
if (!res.data)
|
|
215
|
-
throw entryErrors.notFound(collection, parentId);
|
|
216
|
-
const parent = toRecord(res.data);
|
|
217
|
-
if (parent.collection !== collection)
|
|
218
|
-
throw entryErrors.foreignParent();
|
|
219
|
-
assertDepth((await this.chain(parent)).length + subtreeHeight, collectionDepth(this.schema(collection)), "the entry");
|
|
220
|
-
return parent;
|
|
221
|
-
}
|
|
222
|
-
assertParentHasLocale(parent, locale) {
|
|
223
|
-
if (parent && !parent.locales[locale])
|
|
224
|
-
throw entryErrors.parentNotInLocale(locale);
|
|
225
|
-
}
|
|
226
|
-
// ── row writers ──
|
|
227
|
-
// `touch` defaults to true (an edit bumps updated_at). A rollback or a
|
|
228
|
-
// structure change passes { touch: false } so the column is left alone.
|
|
229
|
-
async updateNode(id, patch, options = {}) {
|
|
230
|
-
const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
231
|
-
const res = await this.db.from("entries").update(payload).eq("id", id);
|
|
232
|
-
if (res.error)
|
|
233
|
-
this.fail(res.error.message);
|
|
234
|
-
}
|
|
235
|
-
async insertLocale(entryId, locale, row, by) {
|
|
236
|
-
const res = await this.db
|
|
237
|
-
.from("entry_locales")
|
|
238
|
-
.insert({ entry_id: entryId, locale, slug: row.slug, fields: row.fields, updated_by: by ?? null });
|
|
239
|
-
if (res.error) {
|
|
240
|
-
if (res.error.code === UNIQUE_VIOLATION)
|
|
241
|
-
throw entryErrors.alreadyInLocale(entryId, locale);
|
|
242
|
-
this.fail(res.error.message);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
/** A touch writes the audit pair — `updated_at` and `updated_by` (spec 2026-09-13 history §2). */
|
|
246
|
-
async updateLocale(entryId, locale, patch, options = {}) {
|
|
247
|
-
const payload = (options.touch ?? true)
|
|
248
|
-
? { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null }
|
|
249
|
-
: { ...patch };
|
|
250
|
-
const res = await this.db.from("entry_locales").update(payload).eq("entry_id", entryId).eq("locale", locale);
|
|
251
|
-
if (res.error)
|
|
252
|
-
this.fail(res.error.message);
|
|
253
|
-
}
|
|
254
|
-
/** The row's stale error, built from a fresh read: what a reload would get, and who wrote last. */
|
|
255
|
-
async stale(collection, id, locale) {
|
|
256
|
-
const row = entryLocaleRow(await this.must(collection, id), locale);
|
|
257
|
-
return new StaleDraftError(draftToken(row), row.updatedBy, row.updatedAt);
|
|
258
|
-
}
|
|
259
|
-
/** `updateLocale` with the pointer as a filter (spec 2026-09-26 concurrency §3): the row is
|
|
260
|
-
* written only while `draft_version_id` still equals `guard` (null = still no pointer).
|
|
261
|
-
* `undefined` guard = unconditional. Resolves false when no row matched. */
|
|
262
|
-
async guardedUpdateLocale(entryId, locale, patch, options) {
|
|
263
|
-
const payload = (options.touch ?? true)
|
|
264
|
-
? { ...patch, updated_at: new Date().toISOString(), updated_by: options.by ?? null }
|
|
265
|
-
: { ...patch };
|
|
266
|
-
let query = this.db.from("entry_locales").update(payload).eq("entry_id", entryId).eq("locale", locale);
|
|
267
|
-
if (options.guard !== undefined)
|
|
268
|
-
query = options.guard === null ? query.is("draft_version_id", null) : query.eq("draft_version_id", options.guard);
|
|
269
|
-
const res = await query.select("entry_id").maybeSingle();
|
|
270
|
-
if (res.error)
|
|
271
|
-
this.fail(res.error.message);
|
|
272
|
-
return res.data !== null;
|
|
273
|
-
}
|
|
274
|
-
/** The row goes first (it is what makes the entry exist in the locale), then its versions. */
|
|
275
|
-
async deleteLocale(entryId, locale) {
|
|
276
|
-
const row = await this.db.from("entry_locales").delete().eq("entry_id", entryId).eq("locale", locale);
|
|
277
|
-
if (row.error)
|
|
278
|
-
this.fail(row.error.message);
|
|
279
|
-
const versions = await this.db.from("entry_versions").delete().eq("entry_id", entryId).eq("locale", locale);
|
|
280
|
-
if (versions.error)
|
|
281
|
-
this.fail(versions.error.message);
|
|
282
|
-
}
|
|
283
|
-
/** The draft rule for every collection (spec 2026-09-14 §1), see MemoryEntryStore.recordVersion —
|
|
284
|
-
* guarded (spec 2026-09-26 concurrency §3) when the caller verified `row` against the token:
|
|
285
|
-
* the rewrite filters on saved_at, the first draft on the pointer. Returns the version row it
|
|
286
|
-
* created, or null when it rewrote in place, so a failed later step can undo it. */
|
|
287
|
-
async recordVersion(entry, locale, guarded) {
|
|
288
|
-
const row = entry.locales[locale];
|
|
289
|
-
const fields = entryFieldsIn(entry, locale);
|
|
290
|
-
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
291
|
-
let query = this.db.from("entry_versions").update({ fields, saved_at: new Date().toISOString() }).eq("id", row.draftVersionId);
|
|
292
|
-
if (guarded)
|
|
293
|
-
query = query.eq("saved_at", row.draftSavedAt ?? "");
|
|
294
|
-
const res = await query.select("id").maybeSingle();
|
|
295
|
-
if (res.error)
|
|
296
|
-
this.fail(res.error.message);
|
|
297
|
-
if (res.data === null)
|
|
298
|
-
throw await this.stale(entry.collection, entry.id, locale);
|
|
299
|
-
return { created: null };
|
|
300
|
-
}
|
|
301
|
-
const res = await this.db.from("entry_versions").insert({ entry_id: entry.id, locale, fields }).select("id").single();
|
|
302
|
-
if (res.error)
|
|
303
|
-
this.fail(res.error.message);
|
|
304
|
-
const id = res.data.id;
|
|
305
|
-
let matched;
|
|
306
|
-
if (guarded)
|
|
307
|
-
matched = await this.guardedUpdateLocale(entry.id, locale, { draft_version_id: id }, { guard: row.draftVersionId, touch: false });
|
|
308
|
-
else {
|
|
309
|
-
await this.updateLocale(entry.id, locale, { draft_version_id: id }, { touch: false });
|
|
310
|
-
matched = true;
|
|
311
|
-
}
|
|
312
|
-
if (!matched) {
|
|
313
|
-
// The other writer took the pointer first: this row must never show in history.
|
|
314
|
-
const gone = await this.db.from("entry_versions").delete().eq("id", id);
|
|
315
|
-
if (gone.error)
|
|
316
|
-
this.fail(gone.error.message);
|
|
317
|
-
throw await this.stale(entry.collection, entry.id, locale);
|
|
318
|
-
}
|
|
319
|
-
return { created: id };
|
|
320
|
-
}
|
|
321
|
-
/** See MemoryEntryStore.ensureLiveSnapshot: the snapshot of what is live, both pointers on it. */
|
|
322
|
-
async ensureLiveSnapshot(entry, locale) {
|
|
323
|
-
const row = entry.locales[locale];
|
|
324
|
-
if (row.status !== "published" || row.publishedVersionId !== null)
|
|
325
|
-
return;
|
|
326
|
-
const res = await this.db.from("entry_versions").insert({ entry_id: entry.id, locale, fields: entryFieldsIn(entry, locale) }).select("id").single();
|
|
327
|
-
if (res.error)
|
|
328
|
-
this.fail(res.error.message);
|
|
329
|
-
const id = res.data.id;
|
|
330
|
-
await this.updateLocale(entry.id, locale, { draft_version_id: id, published_version_id: id }, { touch: false });
|
|
331
|
-
row.draftVersionId = id;
|
|
332
|
-
row.publishedVersionId = id;
|
|
333
|
-
}
|
|
334
|
-
// ── the contract ──
|
|
335
|
-
async create(collection, fields, options, by) {
|
|
336
|
-
const schema = this.schema(collection);
|
|
337
|
-
assertSupportedLocale(options.locale, this.locales);
|
|
338
|
-
const parentId = options.parentId ?? null;
|
|
339
|
-
if (isGlobalSchema(schema)) {
|
|
340
|
-
const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
|
|
341
|
-
if (existing.error)
|
|
342
|
-
this.fail(existing.error.message);
|
|
343
|
-
if (existing.data.length > 0)
|
|
344
|
-
throw entryErrors.singleton(collection);
|
|
345
|
-
if (slugOf(fields) !== null)
|
|
346
|
-
throw entryErrors.noSlug(collection);
|
|
347
|
-
}
|
|
348
|
-
const parent = await this.assertParent(collection, parentId, 1);
|
|
349
|
-
this.assertParentHasLocale(parent, options.locale);
|
|
350
|
-
const { shared, localized, slug } = splitFields(fields, schema);
|
|
351
|
-
await this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
352
|
-
// A global item's row id is DERIVED from (space, name), not generated
|
|
353
|
-
// (spec 2026-09-07 §10): the SELECT above is a fast path two racing
|
|
354
|
-
// materializations can both pass, so the primary key is the backstop
|
|
355
|
-
// — a duplicate would be unremovable, since `delete` refuses every
|
|
356
|
-
// global row.
|
|
357
|
-
const row = { collection, parent_id: parentId, fields: shared };
|
|
358
|
-
if (isGlobalSchema(schema))
|
|
359
|
-
row.id = await globalEntryId(await this.space(), collection);
|
|
360
|
-
const inserted = await this.db.from("entries").insert(row).select("id").single();
|
|
361
|
-
if (inserted.error) {
|
|
362
|
-
if (isGlobalSchema(schema) && inserted.error.code === UNIQUE_VIOLATION)
|
|
363
|
-
throw entryErrors.singleton(collection);
|
|
364
|
-
this.fail(inserted.error.message);
|
|
365
|
-
}
|
|
366
|
-
const id = inserted.data.id;
|
|
367
|
-
try {
|
|
368
|
-
await this.insertLocale(id, options.locale, { slug, fields: localized }, by);
|
|
369
|
-
let record = await this.must(collection, id);
|
|
370
|
-
await this.recordVersion(record, options.locale, false);
|
|
371
|
-
record = await this.must(collection, id);
|
|
372
|
-
await this.writePaths(record);
|
|
373
|
-
await this.writeRefs(record);
|
|
374
|
-
return record;
|
|
375
|
-
}
|
|
376
|
-
catch (e) {
|
|
377
|
-
// Everything hangs off the node: locale rows and versions cascade.
|
|
378
|
-
// `paths` does not — it has no FK — and writeRefs runs AFTER the
|
|
379
|
-
// rewrite, so clear the rows first, the order `delete` uses.
|
|
380
|
-
await this.paths.remove([{ kind: "entry", id }]);
|
|
381
|
-
await this.db.from("entries").delete().eq("id", id);
|
|
382
|
-
throw e;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
async get(collection, id) {
|
|
386
|
-
return this.fetch(collection, id);
|
|
387
|
-
}
|
|
388
|
-
async list(collection, options) {
|
|
389
|
-
const schema = this.schema(collection);
|
|
390
|
-
// A second, aliased embed of entry_locales carries the locale gate as an
|
|
391
|
-
// inner join — presence in the locale, and its status when asked — so
|
|
392
|
-
// `limit` counts rows that are really there; the first embed still returns
|
|
393
|
-
// EVERY locale row of each record (spec 2026-09-11 §3), ordered by locale
|
|
394
|
-
// so a record's key order never depends on physical row order.
|
|
395
|
-
const gate = options?.status !== undefined || options?.locale !== undefined;
|
|
396
|
-
// Built per request: without a `limit` the list is read in pages, because
|
|
397
|
-
// PostgREST caps one response (1000 rows by default) without saying so.
|
|
398
|
-
const build = () => {
|
|
399
|
-
let query = this.db
|
|
400
|
-
.from("entries")
|
|
401
|
-
.select(gate ? `${SELECT}, gate:entry_locales!inner(locale)` : SELECT)
|
|
402
|
-
.eq("collection", collection)
|
|
403
|
-
.order("locale", { referencedTable: "entry_locales", ascending: true });
|
|
404
|
-
if (gate) {
|
|
405
|
-
query = query.eq("gate.locale", options.locale ?? this.locales.default);
|
|
406
|
-
if (options.status !== undefined)
|
|
407
|
-
query = query.eq("gate.status", options.status);
|
|
408
|
-
}
|
|
409
|
-
if (options?.parent !== undefined) {
|
|
410
|
-
query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
|
|
411
|
-
}
|
|
412
|
-
for (const [key, want] of Object.entries(options?.where ?? {})) {
|
|
413
|
-
// Any-of compares the text form (`->>`). A scalar is JSONB containment;
|
|
414
|
-
// an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
|
|
415
|
-
// a scalar field `value` — the memory adapter's matchesWhere agrees.
|
|
416
|
-
if (Array.isArray(want)) {
|
|
417
|
-
query = query.in(`fields->>${key}`, want.map(String));
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
|
|
421
|
-
query = query.contains("fields", { [key]: inArray ? [want] : want });
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
const order = options?.order ?? collectionOrder(schema);
|
|
425
|
-
if (order === "manual") {
|
|
426
|
-
// `id` last in both orders: a stable order, so no row falls between two pages.
|
|
427
|
-
query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true }).order("id", { ascending: true });
|
|
428
|
-
}
|
|
429
|
-
else {
|
|
430
|
-
// `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
|
|
431
|
-
// field (an order field is never localized), matching the memory comparator.
|
|
432
|
-
const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
|
|
433
|
-
query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
|
|
434
|
-
}
|
|
435
|
-
return query;
|
|
436
|
-
};
|
|
437
|
-
const rows = (res) => {
|
|
438
|
-
if (res.error)
|
|
439
|
-
this.fail(res.error.message);
|
|
440
|
-
return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
|
|
441
|
-
};
|
|
442
|
-
if (typeof options?.limit === "number")
|
|
443
|
-
return rows(await build().limit(options.limit));
|
|
444
|
-
const all = [];
|
|
445
|
-
for (let from = 0;; from += LIST_PAGE) {
|
|
446
|
-
const page = rows(await build().range(from, from + LIST_PAGE - 1));
|
|
447
|
-
all.push(...page);
|
|
448
|
-
if (page.length < LIST_PAGE)
|
|
449
|
-
return all;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
async query(collection, q) {
|
|
453
|
-
const schema = this.schema(collection);
|
|
454
|
-
const where = {};
|
|
455
|
-
for (const [key, want] of Object.entries(q.where ?? {})) {
|
|
456
|
-
const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
|
|
457
|
-
where[key] = Array.isArray(want) ? { in: want.map(String) } : { contains: inArray ? [want] : want };
|
|
458
|
-
}
|
|
459
|
-
const order = q.order ?? collectionOrder(schema);
|
|
460
|
-
const res = await this.db.rpc("smoodly_list_entries", {
|
|
461
|
-
p_collection: collection,
|
|
462
|
-
p_locale: q.locale,
|
|
463
|
-
p_draft: q.draft,
|
|
464
|
-
p_where: where,
|
|
465
|
-
p_order: order === "manual" ? { by: "manual" } : order,
|
|
466
|
-
p_limit: q.limit ?? null,
|
|
467
|
-
p_parent: q.parent ?? null,
|
|
468
|
-
p_has_parent: q.parent !== undefined,
|
|
469
|
-
p_ids: q.ids ? q.ids.filter((id) => UUID_RE.test(id)) : null,
|
|
470
|
-
});
|
|
471
|
-
if (res.error)
|
|
472
|
-
this.fail(res.error.message);
|
|
473
|
-
const shown = res.data;
|
|
474
|
-
if (shown.length === 0)
|
|
475
|
-
return [];
|
|
476
|
-
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
477
|
-
const byId = new Map();
|
|
478
|
-
for (let i = 0; i < shown.length; i += 100) {
|
|
479
|
-
const recs = await this.db.from("entries").select(SELECT).in("id", shown.slice(i, i + 100).map((r) => r.id));
|
|
480
|
-
if (recs.error)
|
|
481
|
-
this.fail(recs.error.message);
|
|
482
|
-
for (const r of recs.data)
|
|
483
|
-
byId.set(r.id, toRecord(r));
|
|
484
|
-
}
|
|
485
|
-
return shown.flatMap((r) => (byId.has(r.id) ? [{ record: byId.get(r.id), fields: r.fields }] : []));
|
|
486
|
-
}
|
|
487
|
-
async getMany(collection, ids, locale, options) {
|
|
488
|
-
this.schema(collection);
|
|
489
|
-
const uuidIds = ids.filter((id) => UUID_RE.test(id));
|
|
490
|
-
if (uuidIds.length === 0)
|
|
491
|
-
return {};
|
|
492
|
-
const res = await this.db.from("entries").select(SELECT).eq("collection", collection).in("id", uuidIds);
|
|
493
|
-
if (res.error)
|
|
494
|
-
this.fail(res.error.message);
|
|
495
|
-
const records = res.data.map(toRecord);
|
|
496
|
-
const out = {};
|
|
497
|
-
if (!options?.status) {
|
|
498
|
-
for (const record of records)
|
|
499
|
-
if (record.locales[locale])
|
|
500
|
-
out[record.id] = entryFieldsIn(record, locale);
|
|
501
|
-
return out;
|
|
502
|
-
}
|
|
503
|
-
const published = records.filter((r) => r.locales[locale]?.status === options.status);
|
|
504
|
-
// A versioned collection serves what was published, not the live row.
|
|
505
|
-
const snapshots = await this.versionFields(published.map((r) => r.locales[locale].publishedVersionId).filter((v) => v !== null));
|
|
506
|
-
for (const record of published) {
|
|
507
|
-
const pointer = record.locales[locale].publishedVersionId;
|
|
508
|
-
out[record.id] = (pointer !== null ? snapshots.get(pointer) : undefined) ?? entryFieldsIn(record, locale);
|
|
509
|
-
}
|
|
510
|
-
return out;
|
|
511
|
-
}
|
|
512
|
-
async update(collection, id, locale, fields, by, expected, options) {
|
|
513
|
-
const schema = this.schema(collection);
|
|
514
|
-
const before = await this.must(collection, id);
|
|
515
|
-
const row = entryLocaleRow(before, locale);
|
|
516
|
-
assertFresh(row, expected);
|
|
517
|
-
await this.ensureLiveSnapshot(before, locale); // mutates `row`'s pointers for a legacy row
|
|
518
|
-
const { shared, localized, slug } = splitFields(fields, schema);
|
|
519
|
-
if (isGlobalSchema(schema) && slug !== null)
|
|
520
|
-
throw entryErrors.noSlug(collection);
|
|
521
|
-
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
522
|
-
const nodeFields = keepLocalizedShared(before.fields, shared, schema);
|
|
523
|
-
// The snapshot of what the row is about to hold, written under the guard BEFORE the live
|
|
524
|
-
// columns change (spec 2026-09-26 concurrency §3): a stale save leaves nothing behind.
|
|
525
|
-
const next = { ...before, fields: nodeFields, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
|
|
526
|
-
const { created } = await this.recordVersion(next, locale, expected !== undefined);
|
|
527
|
-
await this.updateNode(id, { fields: nodeFields });
|
|
528
|
-
await this.updateLocale(id, locale, { slug, fields: localized }, { by });
|
|
529
|
-
const record = await this.must(collection, id);
|
|
530
|
-
try {
|
|
531
|
-
await this.writePaths(record, options?.redirect ?? true);
|
|
532
|
-
}
|
|
533
|
-
catch (e) {
|
|
534
|
-
await this.updateNode(id, { fields: before.fields, updated_at: new Date(before.updatedAt).toISOString() }, { touch: false });
|
|
535
|
-
await this.updateLocale(id, locale, { slug: row.slug, fields: row.fields, updated_at: new Date(row.updatedAt).toISOString(), updated_by: row.updatedBy, ...(created ? { draft_version_id: row.draftVersionId } : {}) }, { touch: false });
|
|
536
|
-
if (created)
|
|
537
|
-
await this.db.from("entry_versions").delete().eq("id", created);
|
|
538
|
-
else {
|
|
539
|
-
// The in-place rewrite moved saved_at too: put the token back as it was read.
|
|
540
|
-
const undo = { fields: entryFieldsIn(before, locale), ...(row.draftSavedAt ? { saved_at: row.draftSavedAt } : {}) };
|
|
541
|
-
await this.db.from("entry_versions").update(undo).eq("id", row.draftVersionId);
|
|
542
|
-
}
|
|
543
|
-
throw e;
|
|
544
|
-
}
|
|
545
|
-
const final = await this.must(collection, id);
|
|
546
|
-
await this.writeRefs(final);
|
|
547
|
-
return final;
|
|
548
|
-
}
|
|
549
|
-
async setStatus(collection, id, locale, status, by, expected) {
|
|
550
|
-
let record = await this.must(collection, id);
|
|
551
|
-
let row = entryLocaleRow(record, locale);
|
|
552
|
-
if (status === "published") {
|
|
553
|
-
assertFresh(row, expected);
|
|
554
|
-
if (!row.draftVersionId) {
|
|
555
|
-
await this.recordVersion(record, locale, false);
|
|
556
|
-
record = await this.must(collection, id);
|
|
557
|
-
row = entryLocaleRow(record, locale);
|
|
558
|
-
}
|
|
559
|
-
const matched = await this.guardedUpdateLocale(id, locale, { status, published_version_id: row.draftVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
560
|
-
if (!matched)
|
|
561
|
-
throw await this.stale(collection, id, locale);
|
|
562
|
-
}
|
|
563
|
-
else {
|
|
564
|
-
// Unpublish keeps the pointer for a later republish (spec §2) and is not guarded.
|
|
565
|
-
await this.updateLocale(id, locale, { status }, { by });
|
|
566
|
-
}
|
|
567
|
-
await this.updateNode(id, {});
|
|
568
|
-
const final = await this.must(collection, id);
|
|
569
|
-
await this.writeRefs(final);
|
|
570
|
-
return final;
|
|
571
|
-
}
|
|
572
|
-
async discardDraft(collection, id, locale, by, expected) {
|
|
573
|
-
const schema = this.schema(collection);
|
|
574
|
-
const record = await this.must(collection, id);
|
|
575
|
-
const row = entryLocaleRow(record, locale);
|
|
576
|
-
assertFresh(row, expected);
|
|
577
|
-
const snapshot = row.publishedVersionId ? (await this.versionFields([row.publishedVersionId])).get(row.publishedVersionId) : undefined;
|
|
578
|
-
if (!snapshot)
|
|
579
|
-
throw entryErrors.nothingPublished(collection, id, locale);
|
|
580
|
-
const { shared, localized } = splitFields(snapshot, schema);
|
|
581
|
-
// The pointer moves under the guard before the fields (spec 2026-09-26 concurrency §3).
|
|
582
|
-
const matched = await this.guardedUpdateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
583
|
-
if (!matched)
|
|
584
|
-
throw await this.stale(collection, id, locale);
|
|
585
|
-
await this.updateNode(id, { fields: keepLocalizedShared(record.fields, shared, schema) });
|
|
586
|
-
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
587
|
-
// The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
|
|
588
|
-
const gone = await this.db.from("entry_versions").delete().eq("id", row.draftVersionId);
|
|
589
|
-
if (gone.error)
|
|
590
|
-
this.fail(gone.error.message);
|
|
591
|
-
}
|
|
592
|
-
const final = await this.must(collection, id);
|
|
593
|
-
await this.writeRefs(final);
|
|
594
|
-
return final;
|
|
595
|
-
}
|
|
596
|
-
async addLocale(collection, id, locale, options, by) {
|
|
597
|
-
assertSupportedLocale(locale, this.locales);
|
|
598
|
-
const entry = await this.must(collection, id);
|
|
599
|
-
// The source is read FIRST (the memory adapter's order): "copy from a
|
|
600
|
-
// locale that isn't there" is the more specific complaint, even when
|
|
601
|
-
// the target locale already exists.
|
|
602
|
-
const source = entryLocaleRow(entry, options.from);
|
|
603
|
-
if (entry.locales[locale])
|
|
604
|
-
throw entryErrors.alreadyInLocale(id, locale);
|
|
605
|
-
const parent = entry.parentId === null ? null : await this.fetch(collection, entry.parentId);
|
|
606
|
-
this.assertParentHasLocale(parent, locale);
|
|
607
|
-
await this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
|
|
608
|
-
await this.insertLocale(id, locale, { slug: source.slug, fields: source.fields }, by);
|
|
609
|
-
let added;
|
|
610
|
-
try {
|
|
611
|
-
added = await this.must(collection, id);
|
|
612
|
-
await this.recordVersion(added, locale, false);
|
|
613
|
-
added = await this.must(collection, id);
|
|
614
|
-
await this.writePaths(added);
|
|
615
|
-
}
|
|
616
|
-
catch (e) {
|
|
617
|
-
await this.deleteLocale(id, locale);
|
|
618
|
-
throw e;
|
|
619
|
-
}
|
|
620
|
-
await this.writeRefs(added);
|
|
621
|
-
return added;
|
|
622
|
-
}
|
|
623
|
-
async removeLocale(collection, id, locale) {
|
|
624
|
-
const schema = this.schema(collection);
|
|
625
|
-
if (isGlobalSchema(schema))
|
|
626
|
-
throw entryErrors.everyLocale(collection);
|
|
627
|
-
const entry = await this.must(collection, id);
|
|
628
|
-
entryLocaleRow(entry, locale);
|
|
629
|
-
if (Object.keys(entry.locales).length === 1)
|
|
630
|
-
throw entryErrors.lastLocale(id);
|
|
631
|
-
const children = await this.db.from("entries").select(SELECT).eq("parent_id", id);
|
|
632
|
-
if (children.error)
|
|
633
|
-
this.fail(children.error.message);
|
|
634
|
-
const blockers = children.data.map(toRecord).filter((c) => c.locales[locale] !== undefined).map((c) => entryTitleIn(c, locale, schema));
|
|
635
|
-
if (blockers.length > 0)
|
|
636
|
-
throw entryErrors.localeInUse(locale, blockers);
|
|
637
|
-
// Not atomic across the two statements: a failure between them leaves
|
|
638
|
-
// a present node without that locale's path row — a 404 for that
|
|
639
|
-
// locale until the next write, never leaked content.
|
|
640
|
-
await this.deleteLocale(id, locale);
|
|
641
|
-
const remaining = await this.must(collection, id);
|
|
642
|
-
await this.writePaths(remaining, false);
|
|
643
|
-
await this.updateNode(id, {});
|
|
644
|
-
const final = await this.must(collection, id);
|
|
645
|
-
await this.writeRefs(final);
|
|
646
|
-
return final;
|
|
647
|
-
}
|
|
648
|
-
async scanEntries() {
|
|
649
|
-
const res = await this.db.from("entries").select(SELECT).order("created_at", { ascending: true }).order("id", { ascending: true });
|
|
650
|
-
if (res.error)
|
|
651
|
-
this.fail(res.error.message);
|
|
652
|
-
const records = res.data.map(toRecord);
|
|
653
|
-
const ids = records.flatMap((r) => Object.values(r.locales).flatMap((row) => (row.status === "published" && row.publishedVersionId ? [row.publishedVersionId] : [])));
|
|
654
|
-
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
655
|
-
const snaps = new Map();
|
|
656
|
-
for (let i = 0; i < ids.length; i += 100) {
|
|
657
|
-
for (const [id, fields] of await this.versionFields(ids.slice(i, i + 100)))
|
|
658
|
-
snaps.set(id, fields);
|
|
659
|
-
}
|
|
660
|
-
return records.map((record) => ({
|
|
661
|
-
record,
|
|
662
|
-
live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [
|
|
663
|
-
l,
|
|
664
|
-
row.status !== "published" ? null : (row.publishedVersionId ? snaps.get(row.publishedVersionId) : undefined) ?? entryFieldsIn(record, l),
|
|
665
|
-
])),
|
|
666
|
-
}));
|
|
667
|
-
}
|
|
668
|
-
async listVersions(collection, id, locale) {
|
|
669
|
-
await this.must(collection, id);
|
|
670
|
-
const res = await this.db
|
|
671
|
-
.from("entry_versions")
|
|
672
|
-
.select("*")
|
|
673
|
-
.eq("entry_id", id)
|
|
674
|
-
.eq("locale", locale)
|
|
675
|
-
.order("saved_at", { ascending: false })
|
|
676
|
-
.order("id", { ascending: false });
|
|
677
|
-
if (res.error)
|
|
678
|
-
this.fail(res.error.message);
|
|
679
|
-
return res.data.map(toVersion);
|
|
680
|
-
}
|
|
681
|
-
async restore(collection, id, locale, versionId, by, expected) {
|
|
682
|
-
const schema = this.schema(collection);
|
|
683
|
-
const entry = await this.must(collection, id);
|
|
684
|
-
const row = entryLocaleRow(entry, locale);
|
|
685
|
-
const version = await this.getVersion(collection, versionId);
|
|
686
|
-
if (!version || version.entryId !== id || version.locale !== locale)
|
|
687
|
-
throw entryErrors.foreignVersion(collection, id, locale, versionId);
|
|
688
|
-
assertFresh(row, expected);
|
|
689
|
-
if (versionId === row.publishedVersionId)
|
|
690
|
-
return this.discardDraft(collection, id, locale, by, expected);
|
|
691
|
-
const { shared: split, localized } = splitFields(version.fields, schema);
|
|
692
|
-
const shared = keepLocalizedShared(entry.fields, split, schema);
|
|
693
|
-
// The snapshot first, under the guard, as in update.
|
|
694
|
-
const next = { ...entry, fields: shared, locales: { ...entry.locales, [locale]: { ...row, fields: localized } } };
|
|
695
|
-
await this.recordVersion(next, locale, expected !== undefined);
|
|
696
|
-
await this.updateNode(id, { fields: shared });
|
|
697
|
-
await this.updateLocale(id, locale, { fields: localized }, { by }); // the slug is the row's own and never versioned
|
|
698
|
-
const final = await this.must(collection, id);
|
|
699
|
-
await this.writeRefs(final);
|
|
700
|
-
return final;
|
|
701
|
-
}
|
|
702
|
-
async getVersion(collection, versionId) {
|
|
703
|
-
this.schema(collection);
|
|
704
|
-
if (!UUID_RE.test(versionId))
|
|
705
|
-
return null;
|
|
706
|
-
const res = await this.db.from("entry_versions").select("*").eq("id", versionId).maybeSingle();
|
|
707
|
-
if (res.error)
|
|
708
|
-
this.fail(res.error.message);
|
|
709
|
-
return res.data ? toVersion(res.data) : null;
|
|
710
|
-
}
|
|
711
|
-
async move(collection, id, to, options) {
|
|
712
|
-
if (isGlobalSchema(this.schema(collection)))
|
|
713
|
-
throw entryErrors.owned(collection, "it has no tree position");
|
|
714
|
-
const entry = await this.must(collection, id);
|
|
715
|
-
const subtree = await this.subtree(entry);
|
|
716
|
-
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
717
|
-
throw entryErrors.cycle();
|
|
718
|
-
const byId = this.byIdMap(subtree);
|
|
719
|
-
// `entry`'s own parentId points OUTSIDE this map (its pre-move parent):
|
|
720
|
-
// stop at `entry` itself, the map's root for this computation.
|
|
721
|
-
const heightOf = (e) => (e.id === entry.id ? 1 : 1 + heightOf(byId(e.parentId)));
|
|
722
|
-
const height = Math.max(...subtree.map(heightOf));
|
|
723
|
-
const parent = await this.assertParent(collection, to.parentId, height);
|
|
724
|
-
if (parent)
|
|
725
|
-
for (const locale of Object.keys(entry.locales))
|
|
726
|
-
if (!parent.locales[locale])
|
|
727
|
-
throw entryErrors.moveLocales(locale);
|
|
728
|
-
const siblings = await this.siblings(collection, to.parentId, id);
|
|
729
|
-
if (to.parentId !== entry.parentId) {
|
|
730
|
-
for (const [locale, row] of Object.entries(entry.locales)) {
|
|
731
|
-
if (row.slug !== null && siblings.some((s) => s.locales[locale]?.slug === row.slug))
|
|
732
|
-
throw entryErrors.slugTaken(collection);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
// The destination siblings' sorts as they stood BEFORE the reorder: a
|
|
736
|
-
// rollback restores these exact values rather than renumbering 1..N.
|
|
737
|
-
const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
|
|
738
|
-
try {
|
|
739
|
-
await this.updateNode(id, { parent_id: to.parentId }, { touch: false });
|
|
740
|
-
const ordered = siblings.map((s) => s.id);
|
|
741
|
-
ordered.splice(Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length), 0, id);
|
|
742
|
-
await this.reorder(collection, ordered);
|
|
743
|
-
await this.writePaths(await this.must(collection, id), options?.redirect ?? true);
|
|
744
|
-
}
|
|
745
|
-
catch (e) {
|
|
746
|
-
await this.updateNode(id, { parent_id: entry.parentId, sort: entry.sort }, { touch: false });
|
|
747
|
-
for (const s of previousSorts)
|
|
748
|
-
await this.updateNode(s.id, { sort: s.sort }, { touch: false });
|
|
749
|
-
throw e;
|
|
750
|
-
}
|
|
751
|
-
return this.must(collection, id);
|
|
752
|
-
}
|
|
753
|
-
async delete(collection, id) {
|
|
754
|
-
if (!UUID_RE.test(id))
|
|
755
|
-
return;
|
|
756
|
-
const entry = await this.fetch(collection, id);
|
|
757
|
-
if (!entry)
|
|
758
|
-
return;
|
|
759
|
-
if (isGlobalSchema(this.schema(collection)))
|
|
760
|
-
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
761
|
-
const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
|
|
762
|
-
if (children.error)
|
|
763
|
-
this.fail(children.error.message);
|
|
764
|
-
if (children.data.length > 0)
|
|
765
|
-
throw entryErrors.hasChildren(collection, id);
|
|
766
|
-
const usage = await this.referencesTo(collection, id);
|
|
767
|
-
if (usage.length > 0) {
|
|
768
|
-
this.fail(`"${collection}/${id}" is referenced by ${usage.length} document(s) — remove those references first.`);
|
|
769
|
-
}
|
|
770
|
-
await this.paths.remove([{ kind: "entry", id }]);
|
|
771
|
-
const refs = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", id);
|
|
772
|
-
if (refs.error)
|
|
773
|
-
this.fail(refs.error.message);
|
|
774
|
-
// locale rows and versions cascade from the node
|
|
775
|
-
const res = await this.db.from("entries").delete().eq("collection", collection).eq("id", id);
|
|
776
|
-
if (res.error)
|
|
777
|
-
this.fail(res.error.message);
|
|
778
|
-
}
|
|
779
|
-
resolvePath(locale, path) {
|
|
780
|
-
return this.paths.resolve(locale, path);
|
|
781
|
-
}
|
|
782
|
-
pathsIn(collection, ids, locale) {
|
|
783
|
-
this.schema(collection);
|
|
784
|
-
return this.paths.livePathsIn("entry", ids, locale);
|
|
785
|
-
}
|
|
786
|
-
async pathsOf(collection, id) {
|
|
787
|
-
return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
|
|
788
|
-
}
|
|
789
|
-
async redirectsOf(collection, id) {
|
|
790
|
-
return (await this.fetch(collection, id)) ? groupRedirects(await this.paths.redirectsOf({ kind: "entry", id })) : {};
|
|
791
|
-
}
|
|
792
|
-
async reorder(collection, orderedIds) {
|
|
793
|
-
if (isGlobalSchema(this.schema(collection)))
|
|
794
|
-
throw entryErrors.owned(collection, "it has no order");
|
|
795
|
-
const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
|
|
796
|
-
if (res.error)
|
|
797
|
-
this.fail(res.error.message);
|
|
798
|
-
}
|
|
799
|
-
async incoming(targetId) {
|
|
800
|
-
const res = await this.db.from("refs").select("source_kind, source_id").eq("target_id", targetId);
|
|
801
|
-
if (res.error)
|
|
802
|
-
this.fail(res.error.message);
|
|
803
|
-
const seen = new Set(); // one row per source (multiple fields collapse)
|
|
804
|
-
const out = [];
|
|
805
|
-
for (const r of res.data) {
|
|
806
|
-
const key = `${r.source_kind} ${r.source_id}`;
|
|
807
|
-
if (seen.has(key))
|
|
808
|
-
continue;
|
|
809
|
-
seen.add(key);
|
|
810
|
-
out.push({ sourceKind: r.source_kind, sourceId: r.source_id });
|
|
811
|
-
}
|
|
812
|
-
return out;
|
|
813
|
-
}
|
|
814
|
-
async referencesTo(collection, id) {
|
|
815
|
-
const res = await this.db
|
|
816
|
-
.from("refs")
|
|
817
|
-
.select("source_kind, source_id, source_field")
|
|
818
|
-
.eq("target_collection", collection)
|
|
819
|
-
.eq("target_id", id);
|
|
820
|
-
if (res.error)
|
|
821
|
-
this.fail(res.error.message);
|
|
822
|
-
return res.data.map((r) => ({ sourceKind: r.source_kind, sourceId: r.source_id, sourceField: r.source_field }));
|
|
823
|
-
}
|
|
824
|
-
}
|