smoodly 0.0.46 → 0.0.47
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/dist/admin/files.generated.js +1 -1
- package/dist/admin/health.d.ts +4 -0
- package/dist/admin/ops-impl.js +2 -2
- package/dist/admin/shell/ContentHealthPage.js +3 -1
- package/dist/cli/migrate.d.ts +10 -0
- package/dist/cli/migrate.js +22 -1
- package/dist/cli/space-io.js +3 -3
- package/dist/document-stamp.d.ts +7 -0
- package/dist/document-stamp.js +48 -0
- package/dist/project.d.ts +19 -3
- package/dist/project.js +30 -12
- package/dist/published.d.ts +19 -0
- package/dist/published.js +11 -0
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +51 -9
- package/dist/sql-space.d.ts +3 -2
- package/dist/sql-space.js +3 -2
- package/dist/sql.js +10 -1
- package/dist/supabase-published-store.d.ts +10 -0
- package/dist/supabase-published-store.js +32 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/migrations/20261008120000_smoodly_v10.sql +47 -0
- package/package.json +1 -1
package/dist/admin/health.d.ts
CHANGED
|
@@ -13,11 +13,15 @@ export type HealthDocument = {
|
|
|
13
13
|
collection: string | null;
|
|
14
14
|
findings: HealthFinding[];
|
|
15
15
|
};
|
|
16
|
+
import type { DeliveryStatus } from "../project.ts";
|
|
17
|
+
/** `delivery` (DESIGN §3, the document stamp): the delivery rows behind the current stamp, with the recorded one;
|
|
18
|
+
* absent only in a report built without a projector (static tests). */
|
|
16
19
|
export type HealthReport = {
|
|
17
20
|
documents: HealthDocument[];
|
|
18
21
|
collections: {
|
|
19
22
|
name: string;
|
|
20
23
|
count: number;
|
|
21
24
|
}[];
|
|
25
|
+
delivery?: DeliveryStatus;
|
|
22
26
|
};
|
|
23
27
|
export declare function buildHealthReport(registry: AdminRegistry, pages: PageScan[], entries: EntryScan[]): HealthReport;
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1311,8 +1311,8 @@ export function createAdminOps(deps) {
|
|
|
1311
1311
|
},
|
|
1312
1312
|
health: {
|
|
1313
1313
|
scan: () => guard(async () => {
|
|
1314
|
-
const [trees, rows] = await Promise.all([pages.scanTrees(), entries.scanEntries()]);
|
|
1315
|
-
return ok(buildHealthReport(adminRegistry(), trees, rows));
|
|
1314
|
+
const [trees, rows, delivery] = await Promise.all([pages.scanTrees(), entries.scanEntries(), projector.delivery()]);
|
|
1315
|
+
return ok({ ...buildHealthReport(adminRegistry(), trees, rows), delivery });
|
|
1316
1316
|
}),
|
|
1317
1317
|
},
|
|
1318
1318
|
schema: {
|
|
@@ -26,7 +26,9 @@ export function ContentHealthPage({ ops, registry, initial = null }) {
|
|
|
26
26
|
return _jsx(ErrorPane, { message: error, back: false, onRetry: scan });
|
|
27
27
|
const sections = report ? healthSections(report, registry) : null;
|
|
28
28
|
const brokenCount = sections ? sections.broken.length + sections.collections.length : 0;
|
|
29
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }),
|
|
29
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), report?.delivery && (_jsx("p", { className: "sm-health__delivery", children: report.delivery.behind === 0
|
|
30
|
+
? "Every published document is current."
|
|
31
|
+
: _jsxs(_Fragment, { children: [report.delivery.behind, " ", report.delivery.behind === 1 ? "document is" : "documents are", " behind the current stamp. Run ", _jsx("code", { children: "npx smoodly rebuild" }), " in the site's folder."] }) })), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
|
|
30
32
|
}
|
|
31
33
|
function Group({ group }) {
|
|
32
34
|
const adminHref = useAdminHref();
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -29,5 +29,15 @@ export type MigrateDeps = {
|
|
|
29
29
|
env?: Record<string, string | undefined>;
|
|
30
30
|
/** Test seam: replaces the database read behind the key check. */
|
|
31
31
|
readKey?: (env: Record<string, string | undefined>) => Promise<SchemaRead>;
|
|
32
|
+
/** Test seam: the delivery rows behind the site's document stamp (DESIGN §3); default: the app's config, loaded, against the key's space. */
|
|
33
|
+
delivery?: (env: Record<string, string | undefined>, cwd: string) => Promise<{
|
|
34
|
+
behind: number;
|
|
35
|
+
total: number;
|
|
36
|
+
}>;
|
|
32
37
|
};
|
|
38
|
+
/** The delivery line of `--check` (DESIGN §3, the document stamp): a count with the one command to run, never a verdict. */
|
|
39
|
+
export declare const deliveryLine: (d: {
|
|
40
|
+
behind: number;
|
|
41
|
+
total: number;
|
|
42
|
+
}) => string;
|
|
33
43
|
export declare function runMigrate(args: MigrateArgs, out: (line: string) => void, deps?: MigrateDeps): Promise<number>;
|
package/dist/cli/migrate.js
CHANGED
|
@@ -15,6 +15,10 @@ import { resolveSmoodlyEnv } from "../env.js";
|
|
|
15
15
|
import { loadAppEnv } from "./space-io.js";
|
|
16
16
|
import { planIsEmpty, planMigrate } from "../migrations.js";
|
|
17
17
|
import { loadCollections } from "./load-collections.js";
|
|
18
|
+
import { loadConfig } from "./load-config.js";
|
|
19
|
+
import { siteOptions } from "../bind.js";
|
|
20
|
+
import { documentStamp } from "../document-stamp.js";
|
|
21
|
+
import { SupabasePublishedStore } from "../supabase-published-store.js";
|
|
18
22
|
export const DEFAULT_COLLECTIONS = "smoodly/collections.ts";
|
|
19
23
|
/** The Astro starter keeps the setup under src/ (plan 2026-10-06 astro adapter 3); the default follows it when the Next path is absent. */
|
|
20
24
|
export function collectionsFile(cwd, given, exists = existsSync) {
|
|
@@ -47,6 +51,16 @@ export function describePlan(plan, check) {
|
|
|
47
51
|
}
|
|
48
52
|
return lines;
|
|
49
53
|
}
|
|
54
|
+
/** The delivery line of `--check` (DESIGN §3, the document stamp): a count with the one command to run, never a verdict. */
|
|
55
|
+
export const deliveryLine = (d) => d.behind === 0
|
|
56
|
+
? `Delivery: every published document is current (${d.total}).`
|
|
57
|
+
: `Delivery: ${d.behind} of ${d.total} documents are behind the current stamp — run npx smoodly rebuild.`;
|
|
58
|
+
async function countDelivery(env, cwd) {
|
|
59
|
+
const config = await loadConfig(cwd);
|
|
60
|
+
const stamp = documentStamp(siteOptions(config, () => { throw new Error("smoodly: the stamp reads no store"); }));
|
|
61
|
+
const db = createSmoodlyClient(resolveSmoodlyEnv(env));
|
|
62
|
+
return new SupabasePublishedStore(db, config.registry.collections ?? []).countBehind(stamp);
|
|
63
|
+
}
|
|
50
64
|
/** Errors that are a verdict on the env itself, not on the network: they fail the build. */
|
|
51
65
|
const ENV_VERDICT = /SMOODLY_(SECRET|PUBLISHABLE)_KEY|SMOODLY_URL "/;
|
|
52
66
|
/** The build's key check (decided 2026-10-02): a key the site would be refused with fails the
|
|
@@ -72,8 +86,15 @@ async function checkKey(out, cwd, deps) {
|
|
|
72
86
|
out(`Key not checked: ${message}`);
|
|
73
87
|
return 0;
|
|
74
88
|
}
|
|
75
|
-
if ("version" in read)
|
|
89
|
+
if ("version" in read) {
|
|
76
90
|
out(keyChecked(read.version));
|
|
91
|
+
try {
|
|
92
|
+
out(deliveryLine(await (deps.delivery ?? countDelivery)(env, cwd)));
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
out(`Delivery: not checked: ${e instanceof Error ? e.message : String(e)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
77
98
|
else if (read.missing === "key") {
|
|
78
99
|
out(secretKeyRefusedMessage);
|
|
79
100
|
return 1;
|
package/dist/cli/space-io.js
CHANGED
|
@@ -45,10 +45,10 @@ export { readSchemaVersion } from "../schema-read.js";
|
|
|
45
45
|
* the site built from the config as the app's glue builds them, then every page and entry. */
|
|
46
46
|
export async function rebuildSpace(db, config) {
|
|
47
47
|
const { pages, entries, assets, published } = createSupabaseStores(db, config);
|
|
48
|
-
const { projector,
|
|
48
|
+
const { projector, stamp } = createSmoodlySite(siteOptions(config, () => ({ pages, entries, assets, published })));
|
|
49
49
|
const names = [...(config.registry.collections ?? []), ...(config.registry.globals ?? [])].map((c) => c.name);
|
|
50
|
-
// The
|
|
51
|
-
return { ...(await projectAll({ stores: { pages, entries }, projector, names })), model:
|
|
50
|
+
// The document stamp every row now carries, recorded as the space's current one (DESIGN §3).
|
|
51
|
+
return { ...(await projectAll({ stores: { pages, entries }, projector, names })), model: stamp };
|
|
52
52
|
}
|
|
53
53
|
/** The line a rebuild ends with. */
|
|
54
54
|
export const rebuiltMessage = (counts) => `Rebuilt the delivery tables: ${counts.pages} ${counts.pages === 1 ? "page" : "pages"}, ${counts.entries} ${counts.entries === 1 ? "entry" : "entries"}.`;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SiteOptions } from "./site.ts";
|
|
2
|
+
/** Bumped by hand when the assembler's output changes shape; the upgrading guide names each bump. */
|
|
3
|
+
export declare const DOCUMENT_FORMAT = 1;
|
|
4
|
+
/** The parts of the registry that reach a document: what `cacheKey` hashes, minus the presentation. */
|
|
5
|
+
export declare function documentModel(options: SiteOptions): unknown;
|
|
6
|
+
/** `<format>:<model hash>` — what a delivery row's `model` column holds. */
|
|
7
|
+
export declare function documentStamp(options: SiteOptions): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The stamp a delivery row carries (DESIGN §3, the document stamp, 2026-10-08): which document format and
|
|
2
|
+
// which content model built it. Not the cache key: the package version is in that one, not here, because a
|
|
3
|
+
// release that leaves documents alone must leave them alone.
|
|
4
|
+
import { modelKey } from "./model-key.js";
|
|
5
|
+
import { schemaOf } from "./registration.js";
|
|
6
|
+
/** Bumped by hand when the assembler's output changes shape; the upgrading guide names each bump. */
|
|
7
|
+
export const DOCUMENT_FORMAT = 1;
|
|
8
|
+
/** Schema keys that reach the admin only: a change to one leaves every document as it was. */
|
|
9
|
+
const PRESENTATION = new Set(["title", "description", "sample", "icon", "preview", "group", "admin"]);
|
|
10
|
+
/** A field descriptor without the parts that reach the admin only (a hidden condition decides what the editor shows). */
|
|
11
|
+
function fieldModel(field) {
|
|
12
|
+
if (!field || typeof field !== "object")
|
|
13
|
+
return field;
|
|
14
|
+
const { hidden: _hidden, ...rest } = field;
|
|
15
|
+
return Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, k === "fields" ? fieldsModel(v) : k === "of" ? fieldModel(v) : v]));
|
|
16
|
+
}
|
|
17
|
+
function fieldsModel(fields) {
|
|
18
|
+
if (!fields || typeof fields !== "object")
|
|
19
|
+
return fields;
|
|
20
|
+
return Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, fieldModel(v)]));
|
|
21
|
+
}
|
|
22
|
+
/** A schema without its presentation. */
|
|
23
|
+
function schemaModel(schema) {
|
|
24
|
+
if (!schema || typeof schema !== "object")
|
|
25
|
+
return schema;
|
|
26
|
+
return Object.fromEntries(Object.entries(schema)
|
|
27
|
+
.filter(([k]) => !PRESENTATION.has(k))
|
|
28
|
+
.map(([k, v]) => [k, k === "fields" || k === "settings" ? fieldsModel(v) : v]));
|
|
29
|
+
}
|
|
30
|
+
/** The parts of the registry that reach a document: what `cacheKey` hashes, minus the presentation. */
|
|
31
|
+
export function documentModel(options) {
|
|
32
|
+
return {
|
|
33
|
+
collections: options.collections.map(schemaModel),
|
|
34
|
+
// A global whole, its presentation dropped: its settings' defaults reach a visual item's document.
|
|
35
|
+
globals: (options.globals ?? []).map(schemaModel),
|
|
36
|
+
sections: options.sections.map((x) => schemaModel(schemaOf(x))),
|
|
37
|
+
elements: (options.elements ?? []).map((x) => schemaModel(schemaOf(x))),
|
|
38
|
+
pages: options.pages.map((x) => schemaModel(schemaOf(x))),
|
|
39
|
+
locales: options.locales,
|
|
40
|
+
homePageSlug: options.homePageSlug,
|
|
41
|
+
styles: options.styles,
|
|
42
|
+
custom: options.custom,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** `<format>:<model hash>` — what a delivery row's `model` column holds. */
|
|
46
|
+
export function documentStamp(options) {
|
|
47
|
+
return `${DOCUMENT_FORMAT}:${modelKey(documentModel(options))}`;
|
|
48
|
+
}
|
package/dist/project.d.ts
CHANGED
|
@@ -9,9 +9,12 @@ import type { SiteEntry, SitePage } from "./site.ts";
|
|
|
9
9
|
import type { PageStore } from "./store.ts";
|
|
10
10
|
/** The documents one projection writes before the rest are left to heal on read (§2). */
|
|
11
11
|
export declare const PROJECTION_CAP = 100;
|
|
12
|
-
/** `cap` overrides the projector's own for one call: a rebuild passes Infinity, a test a small number.
|
|
12
|
+
/** `cap` overrides the projector's own for one call: a rebuild passes Infinity, a test a small number. `write`
|
|
13
|
+
* false (DESIGN §3, the document stamp): the rows are built but nothing is written, removed or marked — a reader
|
|
14
|
+
* whose stamp the space did not record. Default true. */
|
|
13
15
|
export type ProjectOptions = {
|
|
14
16
|
cap?: number;
|
|
17
|
+
write?: boolean;
|
|
15
18
|
};
|
|
16
19
|
export type Projector = {
|
|
17
20
|
/** Writes the delivery rows of these pages in every supported locale (a row per visible locale, removed where not visible). Returns the keys it could not fit under the cap, already marked stale. */
|
|
@@ -26,9 +29,22 @@ export type Projector = {
|
|
|
26
29
|
healGlobal<V>(item: GlobalSchema<V>, locale: string, options?: HealOptions): Promise<V | null>;
|
|
27
30
|
/** Flags rows to rebuild on their next read: what an op does with the targets of a projection that threw. */
|
|
28
31
|
markStale(keys: PublishedKey[]): Promise<void>;
|
|
32
|
+
/** Records this projector's stamp as the space's current one (DESIGN §3): what a rebuild ends with. */
|
|
33
|
+
record(): Promise<void>;
|
|
34
|
+
/** The delivery tables against this stamp: how many rows are behind it, and which stamp the space recorded. */
|
|
35
|
+
delivery(): Promise<DeliveryStatus>;
|
|
29
36
|
};
|
|
37
|
+
/** What Content health and `migrate --check` show of the delivery tables (DESIGN §3, the document stamp). */
|
|
38
|
+
export type DeliveryStatus = {
|
|
39
|
+
total: number;
|
|
40
|
+
behind: number;
|
|
41
|
+
recorded: string | null;
|
|
42
|
+
stamp: string;
|
|
43
|
+
};
|
|
44
|
+
/** `write` false: serve the assembled document, write and remove nothing (a reader at another stamp than the recorded one). */
|
|
30
45
|
export type HealOptions = {
|
|
31
46
|
seen?: boolean;
|
|
47
|
+
write?: boolean;
|
|
32
48
|
};
|
|
33
49
|
export type ProjectorDeps = {
|
|
34
50
|
assembler: Assembler;
|
|
@@ -43,7 +59,7 @@ export type ProjectorDeps = {
|
|
|
43
59
|
sections: SectionLike[];
|
|
44
60
|
elements?: SectionLike[];
|
|
45
61
|
locales: LocaleSet;
|
|
46
|
-
/** The
|
|
62
|
+
/** The stamp a row is current against: the site's document stamp (§4; DESIGN §3). */
|
|
47
63
|
model: string;
|
|
48
64
|
cap?: number;
|
|
49
65
|
};
|
|
@@ -60,7 +76,7 @@ export type ProjectAllDeps = {
|
|
|
60
76
|
cap?: number;
|
|
61
77
|
};
|
|
62
78
|
/** The rebuild (spec 2026-10-07 delivery model §2): every page and entry of the space projected, in batches of
|
|
63
|
-
* `cap` ids, none left stale. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
|
|
79
|
+
* `cap` ids, none left stale, and the stamp recorded as the space's current one. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
|
|
64
80
|
* entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
|
|
65
81
|
export declare function projectAll(deps: ProjectAllDeps): Promise<{
|
|
66
82
|
pages: number;
|
package/dist/project.js
CHANGED
|
@@ -89,7 +89,7 @@ export function createProjector(deps) {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
/** Builds rows for each (id, locale) until the cap; the rest are marked stale and returned. */
|
|
92
|
-
async function project(cap, kind, targets, idOf, build, put) {
|
|
92
|
+
async function project(cap, kind, targets, idOf, build, put, write) {
|
|
93
93
|
const { published } = deps.stores();
|
|
94
94
|
const rows = [];
|
|
95
95
|
const gone = [];
|
|
@@ -107,6 +107,8 @@ export function createProjector(deps) {
|
|
|
107
107
|
gone.push({ kind, id: idOf(t), locale });
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
if (!write)
|
|
111
|
+
return over;
|
|
110
112
|
if (rows.length > 0)
|
|
111
113
|
await put(rows);
|
|
112
114
|
if (gone.length > 0)
|
|
@@ -117,14 +119,15 @@ export function createProjector(deps) {
|
|
|
117
119
|
}
|
|
118
120
|
return {
|
|
119
121
|
async pages(ids, options) {
|
|
122
|
+
const write = options?.write !== false;
|
|
120
123
|
const all = await deps.stores().pages.listPages();
|
|
121
124
|
const a = assembler.batch({ pages: all });
|
|
122
125
|
const byId = new Map(all.map((p) => [p.id, p]));
|
|
123
126
|
// A page no longer in the tree has no row in any locale.
|
|
124
127
|
const missing = ids.filter((id) => !byId.has(id));
|
|
125
|
-
if (missing.length > 0)
|
|
128
|
+
if (write && missing.length > 0)
|
|
126
129
|
await deps.stores().published.remove(missing.map((id) => ({ kind: "page", id })));
|
|
127
|
-
return project(options?.cap ?? cap, "page", ids.filter((id) => byId.has(id)).map((id) => byId.get(id)), (p) => p.id, (p, locale) => pageRow(a, p, all, locale), (rows) => deps.stores().published.putPages(rows));
|
|
130
|
+
return project(options?.cap ?? cap, "page", ids.filter((id) => byId.has(id)).map((id) => byId.get(id)), (p) => p.id, (p, locale) => pageRow(a, p, all, locale), (rows) => deps.stores().published.putPages(rows), write);
|
|
128
131
|
},
|
|
129
132
|
async entries(ids, options) {
|
|
130
133
|
const a = assembler.batch();
|
|
@@ -137,9 +140,10 @@ export function createProjector(deps) {
|
|
|
137
140
|
else
|
|
138
141
|
missing.push({ kind: "entry", id });
|
|
139
142
|
}
|
|
140
|
-
|
|
143
|
+
const write = options?.write !== false;
|
|
144
|
+
if (write && missing.length > 0)
|
|
141
145
|
await deps.stores().published.remove(missing);
|
|
142
|
-
return project(options?.cap ?? cap, "entry", found, (h) => h.record.id, (h, locale) => entryRow(a, h.schema, h.record, locale), (rows) => deps.stores().published.putEntries(rows));
|
|
146
|
+
return project(options?.cap ?? cap, "entry", found, (h) => h.record.id, (h, locale) => entryRow(a, h.schema, h.record, locale), (rows) => deps.stores().published.putEntries(rows), write);
|
|
143
147
|
},
|
|
144
148
|
async healPage(id, locale, options) {
|
|
145
149
|
const { pages, published } = deps.stores();
|
|
@@ -149,24 +153,28 @@ export function createProjector(deps) {
|
|
|
149
153
|
const visible = row !== undefined && (record.template === null || row.status === "published");
|
|
150
154
|
const all = visible ? await pages.listPages() : [];
|
|
151
155
|
const projected = visible ? await pageRow(assembler.batch({ pages: all }), record, all, locale) : null;
|
|
156
|
+
const write = options?.write !== false;
|
|
152
157
|
if (!projected) {
|
|
153
|
-
if (options?.seen !== false)
|
|
158
|
+
if (write && options?.seen !== false)
|
|
154
159
|
await bestEffort(`removing page ${id} (${locale})`, () => published.remove([{ kind: "page", id, locale }]));
|
|
155
160
|
return null;
|
|
156
161
|
}
|
|
157
|
-
|
|
162
|
+
if (write)
|
|
163
|
+
await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected]));
|
|
158
164
|
return projected.document;
|
|
159
165
|
},
|
|
160
166
|
async healEntry(id, locale, options) {
|
|
161
167
|
const { published } = deps.stores();
|
|
162
168
|
const hit = await findEntry(id);
|
|
163
169
|
const row = hit ? await entryRow(assembler.batch(), hit.schema, hit.record, locale) : null;
|
|
170
|
+
const write = options?.write !== false;
|
|
164
171
|
if (!row) {
|
|
165
|
-
if (options?.seen !== false)
|
|
172
|
+
if (write && options?.seen !== false)
|
|
166
173
|
await bestEffort(`removing entry ${id} (${locale})`, () => published.remove([{ kind: "entry", id, locale }]));
|
|
167
174
|
return null;
|
|
168
175
|
}
|
|
169
|
-
|
|
176
|
+
if (write)
|
|
177
|
+
await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row]));
|
|
170
178
|
return row.document;
|
|
171
179
|
},
|
|
172
180
|
async healGlobal(item, locale, options) {
|
|
@@ -175,19 +183,27 @@ export function createProjector(deps) {
|
|
|
175
183
|
if (!record)
|
|
176
184
|
return null;
|
|
177
185
|
const row = await entryRow(assembler.batch(), item, record, locale);
|
|
186
|
+
const write = options?.write !== false;
|
|
178
187
|
if (!row) {
|
|
179
|
-
if (options?.seen !== false)
|
|
188
|
+
if (write && options?.seen !== false)
|
|
180
189
|
await bestEffort(`removing global ${item.name} (${locale})`, () => published.remove([{ kind: "entry", id: record.id, locale }]));
|
|
181
190
|
return null;
|
|
182
191
|
}
|
|
183
|
-
|
|
192
|
+
if (write)
|
|
193
|
+
await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row]));
|
|
184
194
|
return row.document;
|
|
185
195
|
},
|
|
186
196
|
markStale: (keys) => deps.stores().published.markStale(keys),
|
|
197
|
+
record: () => deps.stores().published.recordModel(model),
|
|
198
|
+
async delivery() {
|
|
199
|
+
const { published } = deps.stores();
|
|
200
|
+
const [counts, recorded] = await Promise.all([published.countBehind(model), published.recordedModel()]);
|
|
201
|
+
return { ...counts, recorded, stamp: model };
|
|
202
|
+
},
|
|
187
203
|
};
|
|
188
204
|
}
|
|
189
205
|
/** The rebuild (spec 2026-10-07 delivery model §2): every page and entry of the space projected, in batches of
|
|
190
|
-
* `cap` ids, none left stale. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
|
|
206
|
+
* `cap` ids, none left stale, and the stamp recorded as the space's current one. Import ends with it, and `smoodly rebuild` is it. The counts are the pages and the
|
|
191
207
|
* entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
|
|
192
208
|
export async function projectAll(deps) {
|
|
193
209
|
const size = deps.cap ?? PROJECTION_CAP;
|
|
@@ -202,6 +218,8 @@ export async function projectAll(deps) {
|
|
|
202
218
|
for (let i = 0; i < entries.length; i += size) {
|
|
203
219
|
stale += (await deps.projector.entries(entries.slice(i, i + size).map((e) => e.id), whole)).length;
|
|
204
220
|
}
|
|
221
|
+
// Every row now carries this stamp: it is the space's current one, and this deployment the writer of record (DESIGN §3).
|
|
222
|
+
await deps.projector.record();
|
|
205
223
|
return {
|
|
206
224
|
pages: pages.filter((p) => p.template !== null && published(p)).length,
|
|
207
225
|
entries: entries.filter(published).length,
|
package/dist/published.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type PublishedPageRow = {
|
|
|
15
15
|
linkFields: Record<string, unknown>;
|
|
16
16
|
document: SitePage | null;
|
|
17
17
|
tags: string[];
|
|
18
|
+
/** The document stamp that built the row (document-stamp.ts); the column name predates the name. */
|
|
18
19
|
model: string;
|
|
19
20
|
stale: boolean;
|
|
20
21
|
publishedAt: number;
|
|
@@ -34,6 +35,7 @@ export type PublishedEntryRow = {
|
|
|
34
35
|
fields: Record<string, unknown>;
|
|
35
36
|
document: SiteEntry | unknown;
|
|
36
37
|
tags: string[];
|
|
38
|
+
/** The document stamp that built the row (document-stamp.ts); the column name predates the name. */
|
|
37
39
|
model: string;
|
|
38
40
|
stale: boolean;
|
|
39
41
|
publishedAt: number;
|
|
@@ -80,10 +82,27 @@ export interface PublishedStore {
|
|
|
80
82
|
/** The light columns of every row in the locale, with the model and stale flag, in tree order (sort asc nulls last, published, id). */
|
|
81
83
|
tree(locale: string): Promise<PublishedTreeRow[]>;
|
|
82
84
|
markStale(keys: PublishedKey[]): Promise<void>;
|
|
85
|
+
/** The document stamp this space recorded as current (DESIGN §3): what `rebuild` last wrote, or the first
|
|
86
|
+
* writer's; null until then. Only a reader at this stamp rewrites rows. */
|
|
87
|
+
recordedModel(): Promise<string | null>;
|
|
88
|
+
/** Record `model` as the space's current stamp (upsert by space). */
|
|
89
|
+
recordModel(model: string): Promise<void>;
|
|
90
|
+
/** Rows of both tables, and how many of them carry a stamp other than `model`: what `migrate --check` and Content health show. */
|
|
91
|
+
countBehind(model: string): Promise<{
|
|
92
|
+
behind: number;
|
|
93
|
+
total: number;
|
|
94
|
+
}>;
|
|
83
95
|
}
|
|
84
96
|
export declare class MemoryPublishedStore implements PublishedStore {
|
|
85
97
|
private pages;
|
|
86
98
|
private entryRows;
|
|
99
|
+
private recorded;
|
|
100
|
+
recordedModel(): Promise<string | null>;
|
|
101
|
+
recordModel(model: string): Promise<void>;
|
|
102
|
+
countBehind(model: string): Promise<{
|
|
103
|
+
behind: number;
|
|
104
|
+
total: number;
|
|
105
|
+
}>;
|
|
87
106
|
putPages(rows: PublishedPageRow[]): Promise<void>;
|
|
88
107
|
putEntries(rows: PublishedEntryRow[]): Promise<void>;
|
|
89
108
|
remove(keys: PublishedKey[]): Promise<void>;
|
package/dist/published.js
CHANGED
|
@@ -49,6 +49,17 @@ export class MemoryPublishedStore {
|
|
|
49
49
|
constructor() {
|
|
50
50
|
this.pages = new Map();
|
|
51
51
|
this.entryRows = new Map();
|
|
52
|
+
this.recorded = null;
|
|
53
|
+
}
|
|
54
|
+
async recordedModel() {
|
|
55
|
+
return this.recorded;
|
|
56
|
+
}
|
|
57
|
+
async recordModel(model) {
|
|
58
|
+
this.recorded = model;
|
|
59
|
+
}
|
|
60
|
+
async countBehind(model) {
|
|
61
|
+
const rows = [...this.pages.values(), ...this.entryRows.values()];
|
|
62
|
+
return { behind: rows.filter((r) => r.model !== model).length, total: rows.length };
|
|
52
63
|
}
|
|
53
64
|
async putPages(rows) {
|
|
54
65
|
vacate(this.pages, rows);
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
2
|
* that adds a table, column, function or policy the package reads; a
|
|
3
3
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
-
export declare const SCHEMA_VERSION =
|
|
4
|
+
export declare const SCHEMA_VERSION = 10;
|
|
5
5
|
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
6
|
export declare const CORE_FILE_RE: RegExp;
|
|
7
7
|
export declare const VIEWS_FILE_RE: RegExp;
|
package/dist/schema-version.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
8
|
* that adds a table, column, function or policy the package reads; a
|
|
9
9
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 10;
|
|
11
11
|
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
12
|
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
13
|
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
package/dist/site.d.ts
CHANGED
|
@@ -122,6 +122,8 @@ export interface SmoodlySite {
|
|
|
122
122
|
/** The package version and a hash of the registered model: a part of every cache key, so an upgrade or a
|
|
123
123
|
* model change never serves a unit cached in the old shape (spec 2026-09-30 tester findings §3). */
|
|
124
124
|
readonly cacheKey: string;
|
|
125
|
+
/** The document stamp a delivery row carries (DESIGN §3): the format number and the model hash, never the version. */
|
|
126
|
+
readonly stamp: string;
|
|
125
127
|
/** The resolved styles values the renderer and `getGlobal` hand the wrapper. */
|
|
126
128
|
readonly styles: StyleValues;
|
|
127
129
|
/** The custom section's resolved options the renderer hands renderPage. */
|
package/dist/site.js
CHANGED
|
@@ -3,6 +3,7 @@ import { entrySegment } from "./paths.js";
|
|
|
3
3
|
import { lastPart } from "./addresses.js";
|
|
4
4
|
import { withDefaults } from "./field-defaults.js";
|
|
5
5
|
import { modelKey } from "./model-key.js";
|
|
6
|
+
import { documentStamp } from "./document-stamp.js";
|
|
6
7
|
import { SMOODLY_VERSION } from "./version.js";
|
|
7
8
|
import { toEntryItem, toPageItem } from "./read-shapes.js";
|
|
8
9
|
import { pageSeo } from "./seo.js";
|
|
@@ -33,30 +34,63 @@ export function createSmoodlySite(options) {
|
|
|
33
34
|
const cacheKey = `${SMOODLY_VERSION}:${modelKey({
|
|
34
35
|
// Whole schemas: a collection's order, tree or title field changes a read as its fields do (functions drop out in the hash).
|
|
35
36
|
collections: options.collections,
|
|
36
|
-
globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, section: g.section })),
|
|
37
|
+
globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, settings: g.settings, section: g.section })),
|
|
37
38
|
sections: options.sections.map((x) => ("schema" in x ? x.schema : x)),
|
|
38
39
|
elements: (options.elements ?? []).map((x) => ("schema" in x ? x.schema : x)),
|
|
39
40
|
pages: pageSchemas,
|
|
40
41
|
locales, homePageSlug, styles, custom,
|
|
41
42
|
})}`;
|
|
43
|
+
// What a delivery row is stamped with (DESIGN §3, the document stamp): the document format and the schema parts
|
|
44
|
+
// that reach a document, never the package version, so a release leaves current documents current.
|
|
45
|
+
const stamp = documentStamp(options);
|
|
42
46
|
const localeOf = (o) => o?.locale ?? locales.default;
|
|
43
47
|
const published = () => options.stores().published;
|
|
44
48
|
const projector = createProjector({
|
|
45
49
|
assembler,
|
|
46
50
|
stores: () => { const s = options.stores(); return { pages: s.pages, entries: s.entries, published: s.published }; },
|
|
47
51
|
collections: options.collections, globals: options.globals ?? [], sections: options.sections, elements: options.elements,
|
|
48
|
-
locales, model:
|
|
52
|
+
locales, model: stamp,
|
|
49
53
|
});
|
|
50
54
|
// Delivery (spec 2026-10-07 delivery model §3–§4): outside draft mode a read takes the stored document when it is
|
|
51
55
|
// current — built by this model and not marked stale — and otherwise assembles that one document, stores it and serves it.
|
|
52
|
-
const current = (row) => row.model ===
|
|
56
|
+
const current = (row) => row.model === stamp && !row.stale;
|
|
57
|
+
/** One writer of record (DESIGN §3, the document stamp): only the deployment whose stamp the space recorded rewrites
|
|
58
|
+
* rows; the first writer into an empty record records itself; every other deployment serves assembled documents
|
|
59
|
+
* and writes nothing. Read only when a heal is about to write, so a current page stays one call. A "no" is kept for
|
|
60
|
+
* the process (a later rebuild at this stamp is seen by the next process, which costs assembled reads, never wrong
|
|
61
|
+
* ones); a "yes" is read again on each heal, so a rebuild elsewhere stops this writer at its next heal. */
|
|
62
|
+
let yields = false;
|
|
63
|
+
async function mayWrite() {
|
|
64
|
+
if (yields)
|
|
65
|
+
return false;
|
|
66
|
+
// The record is derived like the rows: a read or write of it that fails is logged, and the page is served
|
|
67
|
+
// assembled with nothing written (spec 2026-10-07 §2 Failure), never failed on it.
|
|
68
|
+
try {
|
|
69
|
+
const recorded = await published().recordedModel();
|
|
70
|
+
if (recorded === null) {
|
|
71
|
+
await published().recordModel(stamp);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
yields = recorded !== stamp;
|
|
75
|
+
return !yields;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
console.error("smoodly: reading the space's recorded document stamp failed; served assembled, nothing written.", e);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
53
82
|
const fresh = (row) => current(row) && row.document != null;
|
|
54
83
|
const itemOf = (doc) => toEntryItem(doc.entry, doc.entry.fields, { path: doc.path, locale: doc.locale, locales: doc.locales, ...(doc.seo ? { seo: doc.seo } : {}) });
|
|
55
84
|
/** A page row's answer: its document when current (null for a folder or a hidden node), else the healed one. */
|
|
56
|
-
const pageOf = (row, id, locale) => row && current(row) ?
|
|
85
|
+
const pageOf = async (row, id, locale) => row && current(row) ? row.document : projector.healPage(id, locale, { seen: row !== null, write: await mayWrite() });
|
|
57
86
|
/** An entry document is served at an address only when its collection has addresses. */
|
|
58
87
|
const served = (doc) => (doc && doc.path !== null ? doc : null);
|
|
59
|
-
const entryOf = async (row, id, locale) => row && fresh(row) ? row.document : projector.healEntry(id, locale, { seen: row !== null });
|
|
88
|
+
const entryOf = async (row, id, locale) => row && fresh(row) ? row.document : projector.healEntry(id, locale, { seen: row !== null, write: await mayWrite() });
|
|
89
|
+
/** An entry document as the projector would write it, from an assembled read (the address-less shape, path from the item). */
|
|
90
|
+
const assembledEntry = (collection, locale) => (read) => ({
|
|
91
|
+
kind: "entry", locale, collection, entry: { ...read.record, fields: read.item.fields },
|
|
92
|
+
path: read.item.path, locales: read.item.locales, ...(read.item.seo ? { seo: read.item.seo } : {}),
|
|
93
|
+
});
|
|
60
94
|
/** The published documents of a collection's query. Stale rows are rebuilt in one batched projection and the
|
|
61
95
|
* query read once more (spec §4): a rebuild that finds an entry unpublished removes its row, so it drops out. */
|
|
62
96
|
async function deliveredEntries(collection, o) {
|
|
@@ -70,6 +104,9 @@ export function createSmoodlySite(options) {
|
|
|
70
104
|
let rows = await read();
|
|
71
105
|
const behind = rows.filter((r) => !fresh(r)).map((r) => r.id);
|
|
72
106
|
if (behind.length > 0) {
|
|
107
|
+
// Not the writer of record: the list assembled now, nothing written (DESIGN §3).
|
|
108
|
+
if (!(await mayWrite()))
|
|
109
|
+
return (await assembler.entries(collection, { ...o, locale, draft: false })).map(assembledEntry(collection, locale));
|
|
73
110
|
// A failed rebuild never fails the read: the rows as read are served, and the next read tries again.
|
|
74
111
|
try {
|
|
75
112
|
await projector.entries(behind, { cap: Infinity });
|
|
@@ -96,6 +133,9 @@ export function createSmoodlySite(options) {
|
|
|
96
133
|
let rows = await published().tree(locale);
|
|
97
134
|
const behind = rows.filter((r) => !current(r)).map((r) => r.id);
|
|
98
135
|
if (behind.length > 0) {
|
|
136
|
+
// Not the writer of record: the tree assembled now, nothing written (DESIGN §3).
|
|
137
|
+
if (!(await mayWrite()))
|
|
138
|
+
return assembler.tree(locale, { draft: false });
|
|
99
139
|
// One batched projection for every row behind (one tree read in the projector), then one more tree read.
|
|
100
140
|
// A failed rebuild never fails the read: the rows as read are served, and the next read tries again.
|
|
101
141
|
try {
|
|
@@ -147,7 +187,7 @@ export function createSmoodlySite(options) {
|
|
|
147
187
|
return itemOf(row.document);
|
|
148
188
|
// A miss is a record no projection wrote yet, or a slug: one `get` tells them apart.
|
|
149
189
|
if (row?.collection === collection || (await options.stores().entries.get(collection, idOrPath))) {
|
|
150
|
-
const doc = await projector.healEntry(idOrPath, locale, { seen: row !== null });
|
|
190
|
+
const doc = await projector.healEntry(idOrPath, locale, { seen: row !== null, write: await mayWrite() });
|
|
151
191
|
if (doc)
|
|
152
192
|
return itemOf(doc);
|
|
153
193
|
}
|
|
@@ -169,7 +209,7 @@ export function createSmoodlySite(options) {
|
|
|
169
209
|
return assembler.global(item, locale, { draft: true });
|
|
170
210
|
// The item's row by its collection, in one read: a global is a one-record collection.
|
|
171
211
|
const [row] = await published().entries(item.name, { locale, limit: 1 });
|
|
172
|
-
return row && fresh(row) ? row.document : projector.healGlobal(item, locale, { seen: row !== undefined });
|
|
212
|
+
return row && fresh(row) ? row.document : projector.healGlobal(item, locale, { seen: row !== undefined, write: await mayWrite() });
|
|
173
213
|
},
|
|
174
214
|
};
|
|
175
215
|
/** The published read of an address; `code`: the code-owned page answers where the index has no row. */
|
|
@@ -182,7 +222,8 @@ export function createSmoodlySite(options) {
|
|
|
182
222
|
let healed = null;
|
|
183
223
|
if (route) {
|
|
184
224
|
// Stale: rebuilt by id. Unpublished or moved since the row was written, the address is answered as a miss.
|
|
185
|
-
|
|
225
|
+
const write = await mayWrite();
|
|
226
|
+
healed = route.kind === "page" ? await projector.healPage(route.row.id, locale, { write }) : served(await projector.healEntry(route.row.id, locale, { write }));
|
|
186
227
|
if (healed && healed.path === path)
|
|
187
228
|
return { hit: healed, rows };
|
|
188
229
|
}
|
|
@@ -225,6 +266,7 @@ export function createSmoodlySite(options) {
|
|
|
225
266
|
locales,
|
|
226
267
|
homePageSlug,
|
|
227
268
|
cacheKey,
|
|
269
|
+
stamp,
|
|
228
270
|
styles,
|
|
229
271
|
custom,
|
|
230
272
|
locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
|
|
@@ -251,7 +293,7 @@ export function createSmoodlySite(options) {
|
|
|
251
293
|
// pays the call where a page renders it. A stale row heals once and is written.
|
|
252
294
|
if (!row)
|
|
253
295
|
continue;
|
|
254
|
-
globals.set(item.name, fresh(row) ? row.document : await projector.healGlobal(item, locale, { seen: true }));
|
|
296
|
+
globals.set(item.name, fresh(row) ? row.document : await projector.healGlobal(item, locale, { seen: true, write: await mayWrite() }));
|
|
255
297
|
}
|
|
256
298
|
}
|
|
257
299
|
return { hit, globals };
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -8,8 +8,9 @@ export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_ve
|
|
|
8
8
|
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
9
9
|
/** The delivery model's derived tables (spec 2026-10-07 §1): policy-covered and
|
|
10
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
|
-
|
|
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
|
+
export declare const DELIVERY_TABLES: readonly ["published_pages", "published_entries", "published_models"];
|
|
13
14
|
export declare function currentSpaceSQL(): string;
|
|
14
15
|
/** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
|
|
15
16
|
* the write key's insert, update and delete, each alone for its action; its
|
package/dist/sql-space.js
CHANGED
|
@@ -21,8 +21,9 @@ export const CONTENT_TABLES = [
|
|
|
21
21
|
];
|
|
22
22
|
/** The delivery model's derived tables (spec 2026-10-07 §1): policy-covered and
|
|
23
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
|
-
|
|
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
|
+
export const DELIVERY_TABLES = ["published_pages", "published_entries", "published_models"];
|
|
26
27
|
export function currentSpaceSQL() {
|
|
27
28
|
return `
|
|
28
29
|
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|