smoodly 0.0.46 → 0.0.48
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/browser/main.js +2 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/delivery-mode.d.ts +5 -0
- package/dist/admin/delivery-mode.js +20 -0
- package/dist/admin/editor/EditorView.js +3 -0
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/handler.d.ts +5 -1
- package/dist/admin/handler.js +2 -1
- package/dist/admin/health.d.ts +4 -0
- package/dist/admin/ops-impl.d.ts +13 -0
- package/dist/admin/ops-impl.js +48 -2
- package/dist/admin/ops.d.ts +11 -0
- package/dist/admin/page-data.d.ts +2 -0
- package/dist/admin/shell/AdminApp.js +47 -1
- package/dist/admin/shell/ContentHealthPage.js +3 -1
- package/dist/admin/shell/EntryForm.js +3 -0
- package/dist/admin/shell/GlobalForm.js +5 -1
- package/dist/admin/shell/IconRail.d.ts +8 -1
- package/dist/admin/shell/IconRail.js +8 -3
- package/dist/admin/shell/deploy-tile.d.ts +20 -0
- package/dist/admin/shell/deploy-tile.js +35 -0
- package/dist/admin/ui/PublishControl.js +3 -1
- package/dist/admin/ui/theme.js +12 -0
- package/dist/astro/deploy-deps.d.ts +10 -0
- package/dist/astro/deploy-deps.js +8 -0
- package/dist/astro/index.d.ts +9 -4
- package/dist/astro/index.js +75 -15
- package/dist/astro/middleware.js +4 -0
- package/dist/astro/routes/admin.js +2 -1
- package/dist/astro/routes/preview.astro +17 -5
- package/dist/astro/routes/static-page.astro +21 -0
- package/dist/astro/runtime.d.ts +6 -0
- package/dist/astro/runtime.js +4 -1
- package/dist/astro/server.d.ts +6 -0
- package/dist/astro/server.js +7 -1
- package/dist/astro/static.d.ts +36 -0
- package/dist/astro/static.js +59 -0
- package/dist/cli/load-config.d.ts +3 -0
- package/dist/cli/load-config.js +8 -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/deploy-state.d.ts +28 -0
- package/dist/deploy-state.js +31 -0
- package/dist/document-stamp.d.ts +7 -0
- package/dist/document-stamp.js +48 -0
- package/dist/project.d.ts +22 -3
- package/dist/project.js +33 -12
- package/dist/published.d.ts +50 -4
- package/dist/published.js +46 -7
- 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 +53 -11
- package/dist/sql-space.d.ts +4 -2
- package/dist/sql-space.js +4 -2
- package/dist/sql.js +20 -1
- package/dist/supabase-published-store.d.ts +24 -3
- package/dist/supabase-published-store.js +104 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/migrations/20261008120000_smoodly_v10.sql +47 -0
- package/migrations/20261009120000_smoodly_v11.sql +49 -0
- package/package.json +1 -1
|
@@ -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,15 @@ 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;
|
|
18
|
+
/** `false` (a behind rebuild on a read, spec 2026-10-09 static mode §5): an existing row keeps its publishedAt, so
|
|
19
|
+
* the rebuild is not counted as a publish the static site has not got. Default true: a publish, `npx smoodly rebuild`. */
|
|
20
|
+
stamp?: boolean;
|
|
15
21
|
};
|
|
16
22
|
export type Projector = {
|
|
17
23
|
/** 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 +32,22 @@ export type Projector = {
|
|
|
26
32
|
healGlobal<V>(item: GlobalSchema<V>, locale: string, options?: HealOptions): Promise<V | null>;
|
|
27
33
|
/** Flags rows to rebuild on their next read: what an op does with the targets of a projection that threw. */
|
|
28
34
|
markStale(keys: PublishedKey[]): Promise<void>;
|
|
35
|
+
/** Records this projector's stamp as the space's current one (DESIGN §3): what a rebuild ends with. */
|
|
36
|
+
record(): Promise<void>;
|
|
37
|
+
/** The delivery tables against this stamp: how many rows are behind it, and which stamp the space recorded. */
|
|
38
|
+
delivery(): Promise<DeliveryStatus>;
|
|
29
39
|
};
|
|
40
|
+
/** What Content health and `migrate --check` show of the delivery tables (DESIGN §3, the document stamp). */
|
|
41
|
+
export type DeliveryStatus = {
|
|
42
|
+
total: number;
|
|
43
|
+
behind: number;
|
|
44
|
+
recorded: string | null;
|
|
45
|
+
stamp: string;
|
|
46
|
+
};
|
|
47
|
+
/** `write` false: serve the assembled document, write and remove nothing (a reader at another stamp than the recorded one). */
|
|
30
48
|
export type HealOptions = {
|
|
31
49
|
seen?: boolean;
|
|
50
|
+
write?: boolean;
|
|
32
51
|
};
|
|
33
52
|
export type ProjectorDeps = {
|
|
34
53
|
assembler: Assembler;
|
|
@@ -43,7 +62,7 @@ export type ProjectorDeps = {
|
|
|
43
62
|
sections: SectionLike[];
|
|
44
63
|
elements?: SectionLike[];
|
|
45
64
|
locales: LocaleSet;
|
|
46
|
-
/** The
|
|
65
|
+
/** The stamp a row is current against: the site's document stamp (§4; DESIGN §3). */
|
|
47
66
|
model: string;
|
|
48
67
|
cap?: number;
|
|
49
68
|
};
|
|
@@ -60,7 +79,7 @@ export type ProjectAllDeps = {
|
|
|
60
79
|
cap?: number;
|
|
61
80
|
};
|
|
62
81
|
/** 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
|
|
82
|
+
* `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
83
|
* entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
|
|
65
84
|
export declare function projectAll(deps: ProjectAllDeps): Promise<{
|
|
66
85
|
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,16 @@ 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
|
-
|
|
130
|
+
const keep = options?.stamp === false;
|
|
131
|
+
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, { keep }), write);
|
|
128
132
|
},
|
|
129
133
|
async entries(ids, options) {
|
|
130
134
|
const a = assembler.batch();
|
|
@@ -137,9 +141,11 @@ export function createProjector(deps) {
|
|
|
137
141
|
else
|
|
138
142
|
missing.push({ kind: "entry", id });
|
|
139
143
|
}
|
|
140
|
-
|
|
144
|
+
const write = options?.write !== false;
|
|
145
|
+
if (write && missing.length > 0)
|
|
141
146
|
await deps.stores().published.remove(missing);
|
|
142
|
-
|
|
147
|
+
const keep = options?.stamp === false;
|
|
148
|
+
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, { keep }), write);
|
|
143
149
|
},
|
|
144
150
|
async healPage(id, locale, options) {
|
|
145
151
|
const { pages, published } = deps.stores();
|
|
@@ -149,24 +155,29 @@ export function createProjector(deps) {
|
|
|
149
155
|
const visible = row !== undefined && (record.template === null || row.status === "published");
|
|
150
156
|
const all = visible ? await pages.listPages() : [];
|
|
151
157
|
const projected = visible ? await pageRow(assembler.batch({ pages: all }), record, all, locale) : null;
|
|
158
|
+
const write = options?.write !== false;
|
|
152
159
|
if (!projected) {
|
|
153
|
-
if (options?.seen !== false)
|
|
160
|
+
if (write && options?.seen !== false)
|
|
154
161
|
await bestEffort(`removing page ${id} (${locale})`, () => published.remove([{ kind: "page", id, locale }]));
|
|
155
162
|
return null;
|
|
156
163
|
}
|
|
157
|
-
|
|
164
|
+
// A heal keeps the row's publishedAt: it rebuilds what a publish wrote, it is not one (spec 2026-10-09 static mode §5).
|
|
165
|
+
if (write)
|
|
166
|
+
await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected], { keep: true }));
|
|
158
167
|
return projected.document;
|
|
159
168
|
},
|
|
160
169
|
async healEntry(id, locale, options) {
|
|
161
170
|
const { published } = deps.stores();
|
|
162
171
|
const hit = await findEntry(id);
|
|
163
172
|
const row = hit ? await entryRow(assembler.batch(), hit.schema, hit.record, locale) : null;
|
|
173
|
+
const write = options?.write !== false;
|
|
164
174
|
if (!row) {
|
|
165
|
-
if (options?.seen !== false)
|
|
175
|
+
if (write && options?.seen !== false)
|
|
166
176
|
await bestEffort(`removing entry ${id} (${locale})`, () => published.remove([{ kind: "entry", id, locale }]));
|
|
167
177
|
return null;
|
|
168
178
|
}
|
|
169
|
-
|
|
179
|
+
if (write)
|
|
180
|
+
await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row], { keep: true }));
|
|
170
181
|
return row.document;
|
|
171
182
|
},
|
|
172
183
|
async healGlobal(item, locale, options) {
|
|
@@ -175,19 +186,27 @@ export function createProjector(deps) {
|
|
|
175
186
|
if (!record)
|
|
176
187
|
return null;
|
|
177
188
|
const row = await entryRow(assembler.batch(), item, record, locale);
|
|
189
|
+
const write = options?.write !== false;
|
|
178
190
|
if (!row) {
|
|
179
|
-
if (options?.seen !== false)
|
|
191
|
+
if (write && options?.seen !== false)
|
|
180
192
|
await bestEffort(`removing global ${item.name} (${locale})`, () => published.remove([{ kind: "entry", id: record.id, locale }]));
|
|
181
193
|
return null;
|
|
182
194
|
}
|
|
183
|
-
|
|
195
|
+
if (write)
|
|
196
|
+
await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row], { keep: true }));
|
|
184
197
|
return row.document;
|
|
185
198
|
},
|
|
186
199
|
markStale: (keys) => deps.stores().published.markStale(keys),
|
|
200
|
+
record: () => deps.stores().published.recordModel(model),
|
|
201
|
+
async delivery() {
|
|
202
|
+
const { published } = deps.stores();
|
|
203
|
+
const [counts, recorded] = await Promise.all([published.countBehind(model), published.recordedModel()]);
|
|
204
|
+
return { ...counts, recorded, stamp: model };
|
|
205
|
+
},
|
|
187
206
|
};
|
|
188
207
|
}
|
|
189
208
|
/** 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
|
|
209
|
+
* `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
210
|
* entries (globals included) published in some locale; `stale` is what a batch could not write, 0 by design. */
|
|
192
211
|
export async function projectAll(deps) {
|
|
193
212
|
const size = deps.cap ?? PROJECTION_CAP;
|
|
@@ -202,6 +221,8 @@ export async function projectAll(deps) {
|
|
|
202
221
|
for (let i = 0; i < entries.length; i += size) {
|
|
203
222
|
stale += (await deps.projector.entries(entries.slice(i, i + size).map((e) => e.id), whole)).length;
|
|
204
223
|
}
|
|
224
|
+
// Every row now carries this stamp: it is the space's current one, and this deployment the writer of record (DESIGN §3).
|
|
225
|
+
await deps.projector.record();
|
|
205
226
|
return {
|
|
206
227
|
pages: pages.filter((p) => p.template !== null && published(p)).length,
|
|
207
228
|
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;
|
|
@@ -64,10 +66,15 @@ export type PublishedKey = {
|
|
|
64
66
|
id: string;
|
|
65
67
|
locale?: string;
|
|
66
68
|
};
|
|
69
|
+
export type PutOptions = {
|
|
70
|
+
keep?: boolean;
|
|
71
|
+
};
|
|
67
72
|
export interface PublishedStore {
|
|
68
73
|
/** Upsert by (id, locale). */
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
/** `keep` (a heal, a behind rebuild on read): an existing row keeps its publishedAt, a new row is dated now; without it
|
|
75
|
+
* every row is dated now (a publish, `npx smoodly rebuild`). The static build's count reads publishedAt (spec 2026-10-09 §5). */
|
|
76
|
+
putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
|
|
77
|
+
putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
|
|
71
78
|
/** Every locale of the id when `locale` is absent. */
|
|
72
79
|
remove(keys: PublishedKey[]): Promise<void>;
|
|
73
80
|
/** The one-call lookup: a reachable page or entry at the address, else null. With `globals` (collection names of
|
|
@@ -80,13 +87,52 @@ export interface PublishedStore {
|
|
|
80
87
|
/** 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
88
|
tree(locale: string): Promise<PublishedTreeRow[]>;
|
|
82
89
|
markStale(keys: PublishedKey[]): Promise<void>;
|
|
90
|
+
/** The document stamp this space recorded as current (DESIGN §3): what `rebuild` last wrote, or the first
|
|
91
|
+
* writer's; null until then. Only a reader at this stamp rewrites rows. */
|
|
92
|
+
recordedModel(): Promise<string | null>;
|
|
93
|
+
/** Record `model` as the space's current stamp (upsert by space). */
|
|
94
|
+
recordModel(model: string): Promise<void>;
|
|
95
|
+
/** Rows of both tables, and how many of them carry a stamp other than `model`: what `migrate --check` and Content health show. */
|
|
96
|
+
countBehind(model: string): Promise<{
|
|
97
|
+
behind: number;
|
|
98
|
+
total: number;
|
|
99
|
+
}>;
|
|
100
|
+
/** The latest published_at over the space's pages and entries (ms); null when nothing is published. The static build's watermark (spec 2026-10-09 static mode §5). */
|
|
101
|
+
newestPublishedAt(): Promise<number | null>;
|
|
102
|
+
/** Rows published strictly after `since` (ms), pages plus entries: the Deploy tile's "changes waiting". */
|
|
103
|
+
countPublishedAfter(since: number): Promise<number>;
|
|
104
|
+
/** The space's last Deploy press (schema v11, space_deploys); null until the first press. */
|
|
105
|
+
deployRequest(): Promise<{
|
|
106
|
+
at: number;
|
|
107
|
+
by: string | null;
|
|
108
|
+
} | null>;
|
|
109
|
+
requestDeploy(by: string | null, at?: number): Promise<void>;
|
|
110
|
+
/** When a row last went (an unpublish, a delete): a change the count of newer rows cannot see (same row, `removed_at`). */
|
|
111
|
+
lastRemovalAt(): Promise<number | null>;
|
|
83
112
|
}
|
|
84
113
|
export declare class MemoryPublishedStore implements PublishedStore {
|
|
85
114
|
private pages;
|
|
86
115
|
private entryRows;
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
private recorded;
|
|
117
|
+
recordedModel(): Promise<string | null>;
|
|
118
|
+
recordModel(model: string): Promise<void>;
|
|
119
|
+
private deploy;
|
|
120
|
+
newestPublishedAt(): Promise<number | null>;
|
|
121
|
+
countPublishedAfter(since: number): Promise<number>;
|
|
122
|
+
deployRequest(): Promise<{
|
|
123
|
+
at: number;
|
|
124
|
+
by: string | null;
|
|
125
|
+
} | null>;
|
|
126
|
+
requestDeploy(by: string | null, at?: number): Promise<void>;
|
|
127
|
+
countBehind(model: string): Promise<{
|
|
128
|
+
behind: number;
|
|
129
|
+
total: number;
|
|
130
|
+
}>;
|
|
131
|
+
putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
|
|
132
|
+
putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
|
|
133
|
+
private removedAt;
|
|
89
134
|
remove(keys: PublishedKey[]): Promise<void>;
|
|
135
|
+
lastRemovalAt(): Promise<number | null>;
|
|
90
136
|
byAddress(locale: string, path: string, globals?: readonly string[]): Promise<PublishedRoute | null>;
|
|
91
137
|
page(id: string, locale: string): Promise<PublishedPageRow | null>;
|
|
92
138
|
entry(id: string, locale: string): Promise<PublishedEntryRow | null>;
|
package/dist/published.js
CHANGED
|
@@ -49,24 +49,63 @@ export class MemoryPublishedStore {
|
|
|
49
49
|
constructor() {
|
|
50
50
|
this.pages = new Map();
|
|
51
51
|
this.entryRows = new Map();
|
|
52
|
+
this.recorded = null;
|
|
53
|
+
this.deploy = null;
|
|
54
|
+
this.removedAt = null;
|
|
52
55
|
}
|
|
53
|
-
async
|
|
56
|
+
async recordedModel() {
|
|
57
|
+
return this.recorded;
|
|
58
|
+
}
|
|
59
|
+
async recordModel(model) {
|
|
60
|
+
this.recorded = model;
|
|
61
|
+
}
|
|
62
|
+
async newestPublishedAt() {
|
|
63
|
+
const all = [...this.pages.values(), ...this.entryRows.values()].map((r) => r.publishedAt);
|
|
64
|
+
return all.length === 0 ? null : Math.max(...all);
|
|
65
|
+
}
|
|
66
|
+
async countPublishedAfter(since) {
|
|
67
|
+
return [...this.pages.values(), ...this.entryRows.values()].filter((r) => r.publishedAt > since).length;
|
|
68
|
+
}
|
|
69
|
+
async deployRequest() {
|
|
70
|
+
return this.deploy;
|
|
71
|
+
}
|
|
72
|
+
async requestDeploy(by, at = Date.now()) {
|
|
73
|
+
this.deploy = { at, by };
|
|
74
|
+
}
|
|
75
|
+
async countBehind(model) {
|
|
76
|
+
const rows = [...this.pages.values(), ...this.entryRows.values()];
|
|
77
|
+
return { behind: rows.filter((r) => r.model !== model).length, total: rows.length };
|
|
78
|
+
}
|
|
79
|
+
async putPages(rows, options) {
|
|
54
80
|
vacate(this.pages, rows);
|
|
55
|
-
for (const row of rows)
|
|
56
|
-
this.pages.
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const kept = options?.keep ? this.pages.get(keyOf(row.id, row.locale))?.publishedAt : undefined;
|
|
83
|
+
this.pages.set(keyOf(row.id, row.locale), structuredClone(kept === undefined ? row : { ...row, publishedAt: kept }));
|
|
84
|
+
}
|
|
57
85
|
}
|
|
58
|
-
async putEntries(rows) {
|
|
86
|
+
async putEntries(rows, options) {
|
|
59
87
|
vacate(this.entryRows, rows);
|
|
60
|
-
for (const row of rows)
|
|
61
|
-
this.entryRows.
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
const kept = options?.keep ? this.entryRows.get(keyOf(row.id, row.locale))?.publishedAt : undefined;
|
|
90
|
+
this.entryRows.set(keyOf(row.id, row.locale), structuredClone(kept === undefined ? row : { ...row, publishedAt: kept }));
|
|
91
|
+
}
|
|
62
92
|
}
|
|
63
93
|
async remove(keys) {
|
|
94
|
+
let deleted = 0;
|
|
64
95
|
for (const key of keys) {
|
|
65
96
|
const map = key.kind === "page" ? this.pages : this.entryRows;
|
|
66
97
|
for (const [k, row] of map)
|
|
67
|
-
if (row.id === key.id && (key.locale === undefined || row.locale === key.locale))
|
|
98
|
+
if (row.id === key.id && (key.locale === undefined || row.locale === key.locale)) {
|
|
68
99
|
map.delete(k);
|
|
100
|
+
deleted += 1;
|
|
101
|
+
}
|
|
69
102
|
}
|
|
103
|
+
// Only a row that went is a change (a key with no row is not): the Deploy tile counts it (spec 2026-10-09 §5).
|
|
104
|
+
if (deleted > 0)
|
|
105
|
+
this.removedAt = Date.now();
|
|
106
|
+
}
|
|
107
|
+
async lastRemovalAt() {
|
|
108
|
+
return this.removedAt;
|
|
70
109
|
}
|
|
71
110
|
async byAddress(locale, path, globals) {
|
|
72
111
|
const found = () => {
|
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 = 11;
|
|
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 = 11;
|
|
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,9 +104,12 @@ 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
|
-
await projector.entries(behind, { cap: Infinity });
|
|
112
|
+
await projector.entries(behind, { cap: Infinity, stamp: false });
|
|
76
113
|
rows = await read();
|
|
77
114
|
}
|
|
78
115
|
catch (e) {
|
|
@@ -96,10 +133,13 @@ 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 {
|
|
102
|
-
await projector.pages(behind, { cap: Infinity });
|
|
142
|
+
await projector.pages(behind, { cap: Infinity, stamp: false });
|
|
103
143
|
rows = await published().tree(locale);
|
|
104
144
|
}
|
|
105
145
|
catch (e) {
|
|
@@ -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,10 @@ 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
|
+
* `space_deploys` (schema v11) one row per space: the last Deploy press of static mode. */
|
|
14
|
+
export declare const DELIVERY_TABLES: readonly ["published_pages", "published_entries", "published_models", "space_deploys"];
|
|
13
15
|
export declare function currentSpaceSQL(): string;
|
|
14
16
|
/** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
|
|
15
17
|
* the write key's insert, update and delete, each alone for its action; its
|
package/dist/sql-space.js
CHANGED
|
@@ -21,8 +21,10 @@ 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
|
+
* `space_deploys` (schema v11) one row per space: the last Deploy press of static mode. */
|
|
27
|
+
export const DELIVERY_TABLES = ["published_pages", "published_entries", "published_models", "space_deploys"];
|
|
26
28
|
export function currentSpaceSQL() {
|
|
27
29
|
return `
|
|
28
30
|
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
package/dist/sql.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// locale (spec 2026-09-06 §7); a versioned collection reads its
|
|
10
10
|
// fields from the published snapshot.
|
|
11
11
|
import { SEO_KEY } from "./seo.js";
|
|
12
|
-
import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
12
|
+
import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL, DEFAULT_SPACE } from "./sql-space.js";
|
|
13
13
|
const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
14
14
|
const q = (s) => `"${s}"`;
|
|
15
15
|
// Casts are fine in views.
|
|
@@ -173,8 +173,27 @@ create unique index if not exists "published_entries_address_idx"
|
|
|
173
173
|
create index if not exists "published_entries_fields_idx"
|
|
174
174
|
on "published_entries" using gin ("fields");
|
|
175
175
|
|
|
176
|
+
-- The document stamp a space recorded as current (DESIGN §3, schema v10): set by \`npx smoodly rebuild\`
|
|
177
|
+
-- and by the first writer into an empty record; only a reader at this stamp rewrites the rows above.
|
|
178
|
+
create table if not exists "published_models" (
|
|
179
|
+
"space_id" uuid primary key default ${DEFAULT_SPACE},
|
|
180
|
+
"model" text not null,
|
|
181
|
+
"recorded_at" timestamptz not null default now()
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
-- The last Deploy press of a space (spec 2026-10-09 static mode §7, schema v11): who pressed and when;
|
|
185
|
+
-- the admin compares it with the build marker the live site serves.
|
|
186
|
+
create table if not exists "space_deploys" (
|
|
187
|
+
"space_id" uuid primary key default ${DEFAULT_SPACE},
|
|
188
|
+
"requested_at" timestamptz,
|
|
189
|
+
"requested_by" text,
|
|
190
|
+
"removed_at" timestamptz
|
|
191
|
+
);
|
|
192
|
+
|
|
176
193
|
alter table "published_pages" enable row level security;
|
|
177
194
|
alter table "published_entries" enable row level security;
|
|
195
|
+
alter table "published_models" enable row level security;
|
|
196
|
+
alter table "space_deploys" enable row level security;
|
|
178
197
|
|
|
179
198
|
-- smoodly_list_entries without the draft branch, over the delivery table (spec 2026-10-07 §3):
|
|
180
199
|
-- \`where\` and \`order\` read the raw published snapshot, the row answers with its document.
|