astroidjs 0.12.1 → 0.14.0
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 +42 -42
- package/bin/astroid.mjs +35 -29
- package/dist/analytics/index.d.ts +3 -3
- package/dist/analytics/index.js +11 -11
- package/dist/astro/csp.d.ts +5 -5
- package/dist/astro/csp.js +6 -6
- package/dist/astro/index.js +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.js +5 -5
- package/dist/commerce/adapters.d.ts +6 -6
- package/dist/commerce/adapters.js +9 -9
- package/dist/commerce/checkout-scaffold.d.ts +5 -5
- package/dist/commerce/checkout-scaffold.js +27 -27
- package/dist/commerce/checkout.d.ts +12 -12
- package/dist/commerce/checkout.js +7 -7
- package/dist/commerce/loader.d.ts +2 -2
- package/dist/commerce/loader.js +3 -3
- package/dist/commerce/mirror.d.ts +4 -4
- package/dist/commerce/mirror.js +12 -12
- package/dist/commerce/roles.d.ts +10 -10
- package/dist/commerce/roles.js +13 -13
- package/dist/commerce/secrets.d.ts +9 -9
- package/dist/commerce/secrets.js +9 -9
- package/dist/commerce/sync.d.ts +7 -7
- package/dist/commerce/sync.js +5 -5
- package/dist/components/sections.d.ts +9 -9
- package/dist/components/sections.js +12 -12
- package/dist/config.d.ts +89 -62
- package/dist/config.js +18 -18
- package/dist/email/inquiry.d.ts +2 -2
- package/dist/email/inquiry.js +1 -1
- package/dist/email/send.d.ts +4 -4
- package/dist/email/send.js +7 -7
- package/dist/email/templates.js +3 -3
- package/dist/email/theme.d.ts +1 -1
- package/dist/email/theme.js +4 -4
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/index.js +1 -1
- package/dist/map/pmtiles.d.ts +5 -5
- package/dist/map/pmtiles.js +5 -5
- package/dist/map/scaffold.d.ts +2 -2
- package/dist/map/scaffold.js +8 -8
- package/dist/map/style.d.ts +4 -4
- package/dist/map/style.js +1 -1
- package/dist/portal/config.d.ts +3 -3
- package/dist/portal/config.js +11 -10
- package/dist/portal/guard.d.ts +4 -4
- package/dist/portal/guard.js +4 -4
- package/dist/portal/nav.js +2 -2
- package/dist/portal/scaffold.d.ts +4 -4
- package/dist/portal/scaffold.js +13 -13
- package/dist/portal/session.d.ts +11 -5
- package/dist/portal/session.js +10 -5
- package/dist/portfolio/scaffold.d.ts +1 -1
- package/dist/portfolio/scaffold.js +8 -8
- package/dist/project/actions.d.ts +1 -1
- package/dist/project/actions.js +18 -12
- package/dist/project/generate.d.ts +4 -4
- package/dist/project/generate.js +26 -26
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +2 -1
- package/dist/project/scaffold.d.ts +2 -2
- package/dist/project/scaffold.js +69 -13
- package/dist/project/seed.d.ts +22 -0
- package/dist/project/seed.js +214 -0
- package/dist/pwa/generate.d.ts +11 -11
- package/dist/pwa/generate.js +19 -19
- package/dist/queues/consumer.d.ts +3 -3
- package/dist/queues/consumer.js +2 -2
- package/dist/queues/messages.d.ts +4 -4
- package/dist/queues/messages.js +2 -2
- package/dist/queues/scaffold.d.ts +4 -4
- package/dist/queues/scaffold.js +17 -17
- package/dist/queues/webhook.d.ts +5 -5
- package/dist/queues/webhook.js +3 -3
- package/dist/realtime/scaffold.d.ts +4 -4
- package/dist/realtime/scaffold.js +12 -12
- package/dist/schema/collections.d.ts +42 -11
- package/dist/schema/collections.js +43 -42
- package/dist/schema/framework.d.ts +1 -1
- package/dist/schema/framework.js +2 -2
- package/dist/schema/generate.js +6 -6
- package/dist/schema/index.js +1 -1
- package/dist/secrets.d.ts +6 -6
- package/dist/secrets.js +6 -6
- package/dist/security/csp-origins.d.ts +1 -1
- package/dist/security/csp-origins.js +3 -3
- package/dist/security/rate-rules.d.ts +2 -2
- package/dist/security/rate-rules.js +8 -8
- package/dist/seo/resolve.d.ts +5 -5
- package/dist/seo/resolve.js +2 -2
- package/dist/seo/routes.d.ts +5 -5
- package/dist/seo/routes.js +3 -3
- package/dist/seo/structured-data.d.ts +6 -6
- package/dist/seo/structured-data.js +7 -7
- package/dist/status.d.ts +5 -5
- package/dist/status.js +7 -7
- package/dist/tenancy/index.d.ts +3 -3
- package/dist/tenancy/index.js +11 -11
- package/dist/worker/generate.d.ts +2 -2
- package/dist/worker/generate.js +91 -57
- package/dist/worker/index.js +1 -1
- package/dist/worker/routes.js +11 -11
- package/dist/workflow/advance.d.ts +3 -3
- package/dist/workflow/advance.js +6 -6
- package/dist/workflow/config.d.ts +4 -4
- package/dist/workflow/config.js +4 -4
- package/dist/workflow/generate.d.ts +2 -2
- package/dist/workflow/generate.js +11 -11
- package/package.json +3 -4
- package/src/components/Collection.tsx +5 -5
- package/src/components/Editable.astro +9 -9
- package/src/components/JustifiedGallery.astro +8 -8
- package/src/components/MediaSlot.astro +12 -12
- package/src/components/PortalShell.astro +4 -4
- package/src/components/RegisterSW.astro +3 -3
- package/src/components/Section.astro +8 -8
- package/src/components/Sections.astro +6 -6
- package/src/components/Seo.astro +3 -3
- package/src/components/StageBar.astro +3 -3
- package/src/components/StructuredData.astro +2 -2
- package/src/components/justify.ts +9 -9
- package/src/components/media-meta.ts +10 -10
- package/src/components/sections/AboutIntro.astro +1 -1
- package/src/components/sections/Contact.astro +1 -1
- package/src/components/sections/Cta.astro +1 -1
- package/src/components/sections/Faq.astro +1 -1
- package/src/components/sections/FeatureGrid.astro +2 -2
- package/src/components/sections/Hero.astro +1 -1
- package/src/components/sections/PricingTiers.astro +1 -1
- package/src/components/sections/ProductGrid.astro +1 -1
- package/src/components/sections/SplitImage.astro +1 -1
- package/src/components/sections/Steps.astro +1 -1
- package/src/components/sections/Testimonial.astro +1 -1
- package/src/components/sections.ts +17 -17
package/dist/worker/generate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// generateAstroidWorker / generateAstroidMiddleware
|
|
3
|
+
// generateAstroidWorker / generateAstroidMiddleware—emit the Cloudflare Worker
|
|
4
4
|
// entrypoint and the Astro middleware a Louise site would otherwise hand-write.
|
|
5
5
|
// The worker's editor routes are composed in the fixed order from the route plan
|
|
6
6
|
// (routes.ts), so the "versionsRoute/searchRoute before pagesRoute" collision is
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// One seam is marked with TODO(astroid) and filled by the auth slice: the
|
|
10
10
|
// `resolveEditor` session resolver. The section-catalog validate + sanitize on
|
|
11
|
-
// the pages routes is wired here
|
|
11
|
+
// the pages routes is wired here—versionsRoute runs it through the collection's
|
|
12
12
|
// beforeChange hook, and pagesRoute (which takes no collection config) through
|
|
13
13
|
// the `astroidPagesWriteHooks` spread, so both write paths enforce one contract.
|
|
14
14
|
import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery } from "../analytics/index.js";
|
|
@@ -20,7 +20,7 @@ import { capturesInquiries } from "../schema/framework.js";
|
|
|
20
20
|
import { astroidCspStyleSrc } from "../security/csp-origins.js";
|
|
21
21
|
import { ASTROID_REWRITE_EXCLUDE, ASTROID_TENANT_PREFIX } from "../tenancy/index.js";
|
|
22
22
|
import { astroidEditorRoutePlan } from "./routes.js";
|
|
23
|
-
// Astroid's default editable site_settings surface
|
|
23
|
+
// Astroid's default editable site_settings surface—the columns the Settings
|
|
24
24
|
// panel may write, and which of them hold a media-library image URL.
|
|
25
25
|
//
|
|
26
26
|
// EXPORTED because the generated worker is not the only consumer: the scaffolded
|
|
@@ -58,7 +58,7 @@ export function generateAstroidWorker(config) {
|
|
|
58
58
|
const mediaBase = config.deploy?.mediaBase ?? "/media";
|
|
59
59
|
const seedName = config.theme.name;
|
|
60
60
|
const plan = astroidEditorRoutePlan(config);
|
|
61
|
-
// `realtimeRoute` lives in `louise-toolkit/realtime`, not `/editor
|
|
61
|
+
// `realtimeRoute` lives in `louise-toolkit/realtime`, not `/editor`—it is the
|
|
62
62
|
// one factory in the plan that isn't an editor route. Importing it with the
|
|
63
63
|
// rest type-checks fine HERE (the plan is just strings) and fails only in the
|
|
64
64
|
// scaffold, which is exactly how it got caught.
|
|
@@ -80,7 +80,7 @@ export function generateAstroidWorker(config) {
|
|
|
80
80
|
const routeCall = (name) => {
|
|
81
81
|
switch (name) {
|
|
82
82
|
case "overview":
|
|
83
|
-
// `inbox` only when this project captures inquiries
|
|
83
|
+
// `inbox` only when this project captures inquiries—an absent slice
|
|
84
84
|
// hides its card, which is right for an archetype with no contact form.
|
|
85
85
|
return inquiries
|
|
86
86
|
? "overviewRoute({ resolveEditor, content: overviewContent, inbox: overviewInbox, health: overviewHealth })"
|
|
@@ -101,11 +101,16 @@ export function generateAstroidWorker(config) {
|
|
|
101
101
|
// POST/PATCH path would persist an unknown section `_type`, a setting
|
|
102
102
|
// outside its options, or unsanitized section rich text. See
|
|
103
103
|
// `astroidPagesWriteHooks`.
|
|
104
|
-
|
|
104
|
+
//
|
|
105
|
+
// `versionsTable` makes a DELETE remove the page's version snapshots,
|
|
106
|
+
// which have no foreign key to the page and would otherwise orphan.
|
|
107
|
+
// `afterWrite` rebuilds the search index, which plain CRUD writes leave
|
|
108
|
+
// stale.
|
|
109
|
+
return 'pagesRoute({ table: pages, versionsTable: pagesVersions, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
|
|
105
110
|
case "save":
|
|
106
111
|
// No `bufferKv` here, deliberately: `saveRoute` has no such option. It
|
|
107
112
|
// writes live field saves (title, SEO) straight through, and the draft
|
|
108
|
-
// buffer belongs to the versioned body
|
|
113
|
+
// buffer belongs to the versioned body—that is, to versionsRoute.
|
|
109
114
|
return 'saveRoute({ resolveEditor, collections: { pages: { table: pages, fields: ["title", "seoTitle", "seoDescription"] } } })';
|
|
110
115
|
case "settings": {
|
|
111
116
|
// Site-specific keys (config.settings.customKeys) are merged into
|
|
@@ -114,20 +119,23 @@ export function generateAstroidWorker(config) {
|
|
|
114
119
|
const customArg = (config.settings?.customKeys ?? []).length
|
|
115
120
|
? ", customKeys: SETTINGS_CUSTOM_KEYS"
|
|
116
121
|
: "";
|
|
117
|
-
|
|
122
|
+
// The site's sanitize + read hooks (config.settings.hooks), spread last
|
|
123
|
+
// so they add to the call rather than replace any part of it.
|
|
124
|
+
const hooksArg = config.settings?.hooks ? ", ...settingsHooks" : "";
|
|
125
|
+
return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg}${hooksArg} })`;
|
|
118
126
|
}
|
|
119
127
|
// `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
|
|
120
128
|
// LOUISE_AI kill switch, so all three assists share one definition of
|
|
121
129
|
// "is generation on?" instead of each re-deriving it. Embeddings keep
|
|
122
|
-
// binding-presence as their switch
|
|
130
|
+
// binding-presence as their switch—see the helper's comment.
|
|
123
131
|
case "ai":
|
|
124
132
|
return "aiRoute({ resolveEditor, ai: aiRunner })";
|
|
125
133
|
case "seoFix":
|
|
126
134
|
return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner })";
|
|
127
135
|
case "media": {
|
|
128
136
|
// `altText` fills a new upload's alt from the image itself. Best-effort
|
|
129
|
-
// by contract
|
|
130
|
-
// upload
|
|
137
|
+
// by contract—a model error or a missing binding never fails the
|
|
138
|
+
// upload—so it costs nothing on a project that doesn't want it.
|
|
131
139
|
//
|
|
132
140
|
// `maxBytes` is emitted only when the site raised it: omitted, the
|
|
133
141
|
// route keeps louise-toolkit's DEFAULT_MAX_BYTES, so the generated
|
|
@@ -138,7 +146,7 @@ export function generateAstroidWorker(config) {
|
|
|
138
146
|
}
|
|
139
147
|
case "editors":
|
|
140
148
|
// The editor instance's user table is `louise_`-prefixed (the editor
|
|
141
|
-
// convention
|
|
149
|
+
// convention—the unprefixed `user` table is left for a second/portal
|
|
142
150
|
// instance). This route takes the table NAME, matching the
|
|
143
151
|
// `tablePrefix` the scaffolded `src/auth.ts` passes to `getLouiseAuth`.
|
|
144
152
|
return `editorsRoute({ table: ${JSON.stringify(astroidEditorTable("user"))}, resolveEditor })`;
|
|
@@ -159,10 +167,16 @@ export function generateAstroidWorker(config) {
|
|
|
159
167
|
};
|
|
160
168
|
const lines = [];
|
|
161
169
|
const p = (s = "") => lines.push(s);
|
|
162
|
-
p("// Generated by astroidjs
|
|
170
|
+
p("// Generated by astroidjs—do not hand-edit.");
|
|
163
171
|
p("// Source: your defineAstroid config. The editor route ORDER is fixed by");
|
|
164
|
-
p("// Astroid to avoid matcher collisions
|
|
172
|
+
p("// Astroid to avoid matcher collisions—see each route's note below.");
|
|
173
|
+
p('import { env } from "cloudflare:workers";');
|
|
165
174
|
p('import { handle } from "@astrojs/cloudflare/handler";');
|
|
175
|
+
p('import type { EditorSession } from "louise-toolkit/auth";');
|
|
176
|
+
p('import { createLocalApi } from "louise-toolkit/content";');
|
|
177
|
+
p(inquiries
|
|
178
|
+
? 'import { db, inquiriesForm } from "louise-toolkit/db";'
|
|
179
|
+
: 'import { db } from "louise-toolkit/db";');
|
|
166
180
|
p("import {");
|
|
167
181
|
for (const name of editorImports)
|
|
168
182
|
p(` ${name},`);
|
|
@@ -174,7 +188,7 @@ export function generateAstroidWorker(config) {
|
|
|
174
188
|
p('import { defineForm } from "louise-toolkit/forms";');
|
|
175
189
|
if (queues)
|
|
176
190
|
p('import { processBatch } from "louise-toolkit/queues";');
|
|
177
|
-
// Only when a route actually takes a runner
|
|
191
|
+
// Only when a route actually takes a runner—a project with no AI assists
|
|
178
192
|
// should not import one, and knip would flag it if it did.
|
|
179
193
|
if (plan.some((route) => AI_ROUTES.has(route.name))) {
|
|
180
194
|
p('import { aiRunner } from "louise-toolkit/ai";');
|
|
@@ -182,8 +196,6 @@ export function generateAstroidWorker(config) {
|
|
|
182
196
|
p('import { checkLinks } from "louise-toolkit/browser";');
|
|
183
197
|
p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
|
|
184
198
|
p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
185
|
-
if (inquiries)
|
|
186
|
-
p('import { inquiriesForm } from "louise-toolkit/db";');
|
|
187
199
|
p(`import { ${tables.join(", ")} } from "./schema.js";`);
|
|
188
200
|
const astroidImports = [
|
|
189
201
|
"astroidPagesCollection",
|
|
@@ -200,17 +212,36 @@ export function generateAstroidWorker(config) {
|
|
|
200
212
|
p("// from a request; a truthy result authorizes editor writes. A generated auth");
|
|
201
213
|
p("// module is a later slice.");
|
|
202
214
|
p('import { resolveEditor } from "./auth.js";');
|
|
215
|
+
if (config.pages?.hooks) {
|
|
216
|
+
p("// Your PAGES seam: a transform and extra reserved slugs for the pages");
|
|
217
|
+
p("// route. Scaffolded once and yours to edit.");
|
|
218
|
+
p('import { pagesHooks } from "./pages-hooks.js";');
|
|
219
|
+
}
|
|
220
|
+
if (config.settings?.hooks) {
|
|
221
|
+
p("// Your SETTINGS seam: per-key sanitizers and a GET transform for the");
|
|
222
|
+
p("// Settings panel. Scaffolded once and yours to edit.");
|
|
223
|
+
p('import { settingsHooks } from "./settings-hooks.js";');
|
|
224
|
+
}
|
|
203
225
|
if (queues) {
|
|
204
226
|
p("// Your QUEUE seam: what each message actually does. Scaffolded once and");
|
|
205
|
-
p("// yours to edit
|
|
227
|
+
p("// yours to edit—`astroidQueueHandler` there covers the catalog dispatch.");
|
|
206
228
|
p('import { handleQueueMessage } from "./queue.js";');
|
|
207
229
|
}
|
|
208
230
|
p();
|
|
209
231
|
p(`const MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
|
|
210
232
|
p("const pagesCollection = astroidPagesCollection(astroidConfig);");
|
|
211
233
|
p("// Sanitize + section-catalog validation for the raw pagesRoute, which runs");
|
|
212
|
-
p("// no collection hook
|
|
213
|
-
p(
|
|
234
|
+
p("// no collection hook—the same contract versionsRoute gets from the config.");
|
|
235
|
+
p(config.pages?.hooks
|
|
236
|
+
? "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig, pagesHooks);"
|
|
237
|
+
: "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
|
|
238
|
+
p();
|
|
239
|
+
p("// pagesRoute writes with plain Drizzle, so the full-text index doesn't see a");
|
|
240
|
+
p("// title or slug change until something rebuilds it. Best-effort: pagesRoute");
|
|
241
|
+
p("// swallows a throw here, so a stale index never fails the write itself.");
|
|
242
|
+
p("async function reindexPagesSearch(editor: EditorSession): Promise<void> {");
|
|
243
|
+
p(" await createLocalApi(db(env.DB), pages, pagesCollection).reindexSearch({ session: editor });");
|
|
244
|
+
p("}");
|
|
214
245
|
p();
|
|
215
246
|
p("// Editable site_settings columns the Settings panel may write, and which of");
|
|
216
247
|
p("// them resolve to a media-library asset.");
|
|
@@ -230,11 +261,14 @@ export function generateAstroidWorker(config) {
|
|
|
230
261
|
p("// Delete-safety for the media library: where a media key can be REFERENCED,");
|
|
231
262
|
p("// so deleting an asset that's live on a page warns instead of silently");
|
|
232
263
|
p("// breaking it. Without these the scan has nothing to look at and every");
|
|
233
|
-
p("// delete reports 'no references'. Column names are SQL, not Drizzle keys
|
|
264
|
+
p("// delete reports 'no references'. Column names are SQL, not Drizzle keys—");
|
|
234
265
|
p("// the scan is raw SQL over the table.");
|
|
235
266
|
p("const MEDIA_REFERENCE_SOURCES = [");
|
|
236
267
|
p(' { collection: "pages", table: "pages", columns: ["body", "sections", "og_image"], labelColumn: "title" },');
|
|
237
|
-
p(
|
|
268
|
+
p(
|
|
269
|
+
// `custom` too: a site's own image settings (settings.imageKeys) live in
|
|
270
|
+
// that JSON column, and an unscanned column reports 'no references'.
|
|
271
|
+
' { collection: "settings", table: "site_settings", columns: ["logo_url", "favicon_url", "default_og_image_url", "custom"], labelColumn: "site_name" },');
|
|
238
272
|
p("];");
|
|
239
273
|
p();
|
|
240
274
|
p();
|
|
@@ -246,7 +280,7 @@ export function generateAstroidWorker(config) {
|
|
|
246
280
|
p();
|
|
247
281
|
p("// The same read, adapted for the overview slice. `readHealthSummary` yields");
|
|
248
282
|
p("// `null` for 'no scan yet' while a slice resolver signals absence with");
|
|
249
|
-
p("// `undefined
|
|
283
|
+
p("// `undefined`—the two types are otherwise identical, and this one-line");
|
|
250
284
|
p("// coercion is the whole difference.");
|
|
251
285
|
p("const overviewHealth = async (env: CloudflareEnv) =>");
|
|
252
286
|
p(" (await readSiteHealth(env)) ?? undefined;");
|
|
@@ -256,7 +290,7 @@ export function generateAstroidWorker(config) {
|
|
|
256
290
|
p();
|
|
257
291
|
p("// The daily scan. Crawls the site's own pages for broken links and counts the");
|
|
258
292
|
p("// two accessibility/SEO gaps that are cheap to compute, then persists one");
|
|
259
|
-
p("// snapshot for the dashboard to read. Every part degrades on its own
|
|
293
|
+
p("// snapshot for the dashboard to read. Every part degrades on its own—a");
|
|
260
294
|
p("// failed crawl or a failed COUNT yields zero rather than aborting the scan,");
|
|
261
295
|
p("// because a partial health report is worth strictly more than none.");
|
|
262
296
|
p("async function runHealthScan(env: CloudflareEnv) {");
|
|
@@ -279,7 +313,7 @@ export function generateAstroidWorker(config) {
|
|
|
279
313
|
p(" return summary;");
|
|
280
314
|
p("}");
|
|
281
315
|
p();
|
|
282
|
-
p("/** One COUNT, degrading to 0
|
|
316
|
+
p("/** One COUNT, degrading to 0—a missing table must not abort the scan. */");
|
|
283
317
|
p("async function countRows(env: CloudflareEnv, sql: string): Promise<number> {");
|
|
284
318
|
p(" try {");
|
|
285
319
|
p(" const row = await env.DB.prepare(sql).first<{ n: number }>();");
|
|
@@ -294,7 +328,7 @@ export function generateAstroidWorker(config) {
|
|
|
294
328
|
p("// Unhandled inquiries. The COUNT is the whole table on purpose: the");
|
|
295
329
|
p("// Inquiries tab reviews and CLEARS submissions (GET lists, DELETE removes),");
|
|
296
330
|
p("// so a row that still exists is a message still waiting on you. There is no");
|
|
297
|
-
p("// read/unread column because deletion IS the acknowledgement
|
|
331
|
+
p("// read/unread column because deletion IS the acknowledgement—which also");
|
|
298
332
|
p("// means this number goes down as you work through them, rather than being a");
|
|
299
333
|
p("// total that only ever climbs.");
|
|
300
334
|
p("const overviewInbox = async (env: CloudflareEnv) => {");
|
|
@@ -303,7 +337,7 @@ export function generateAstroidWorker(config) {
|
|
|
303
337
|
p("};");
|
|
304
338
|
}
|
|
305
339
|
p("// The Home dashboard's content counts. Raw SQL because these are COUNTs over");
|
|
306
|
-
p("// THIS project's tables
|
|
340
|
+
p("// THIS project's tables—the toolkit deliberately makes no assumption about");
|
|
307
341
|
p("// column names. A throw here degrades to a hidden card, never a 500.");
|
|
308
342
|
p("const overviewContent = async (env: CloudflareEnv) => {");
|
|
309
343
|
p(" const row = await env.DB.prepare(");
|
|
@@ -331,8 +365,8 @@ export function generateAstroidWorker(config) {
|
|
|
331
365
|
p();
|
|
332
366
|
p("// `sections` writes are validated + sanitized against the section catalog");
|
|
333
367
|
p("// before they persist, on BOTH write paths: versionsRoute runs the pages");
|
|
334
|
-
p("// collection's beforeChange hook (via `config`), and pagesRoute
|
|
335
|
-
p("// no collection config
|
|
368
|
+
p("// collection's beforeChange hook (via `config`), and pagesRoute—which takes");
|
|
369
|
+
p("// no collection config—gets the same contract from the `pagesWriteHooks`");
|
|
336
370
|
p("// spread. An unknown `_type`, a field of the wrong shape, or a setting outside");
|
|
337
371
|
p("// its declared options is a 422, not a hole in the page.");
|
|
338
372
|
p("const editorRoutes: WorkerRoute<CloudflareEnv>[] = [");
|
|
@@ -371,7 +405,7 @@ export function generateAstroidWorker(config) {
|
|
|
371
405
|
: "export default composeWorker<CloudflareEnv>({");
|
|
372
406
|
p(" routes: [...editorRoutes, mediaAssetRoute],");
|
|
373
407
|
p(" // Deny-by-default editor API (ADR 0012). Under /api/louise a request must");
|
|
374
|
-
p(" // resolve to an editor unless it's headed for a public route
|
|
408
|
+
p(" // resolve to an editor unless it's headed for a public route—the contact");
|
|
375
409
|
p(" // form and the vitals beacon mark themselves. Every route above still checks");
|
|
376
410
|
p(" // for itself; this is what catches one that forgets. It also gives route");
|
|
377
411
|
p(" // responses the security headers the middleware never sees, since these");
|
|
@@ -381,13 +415,13 @@ export function generateAstroidWorker(config) {
|
|
|
381
415
|
p(" //");
|
|
382
416
|
p(" // Wrapped UNCONDITIONALLY, and that is safe: `withEdgeCache` only stores a");
|
|
383
417
|
p(" // response that carries a cacheable Cloudflare-CDN-Cache-Control directive,");
|
|
384
|
-
p(" // and a page emits one only via `Astro.cache.set(...)
|
|
418
|
+
p(" // and a page emits one only via `Astro.cache.set(...)`—which the scaffold");
|
|
385
419
|
p(' // gates on ASTROID_EDGE_CACHE being "true" AND the request not being in edit');
|
|
386
420
|
p(" // mode. With the var off (the default) every render is `no-store`, so this");
|
|
387
421
|
p(" // layer stores nothing and is a transparent pass-through.");
|
|
388
422
|
p(" //");
|
|
389
423
|
p(" // It must be THIS cache and not Cloudflare's automatic edge cache: that one");
|
|
390
|
-
p(" // is keyed by URL, runs BEFORE the Worker, and is therefore cookie-blind
|
|
424
|
+
p(" // is keyed by URL, runs BEFORE the Worker, and is therefore cookie-blind—");
|
|
391
425
|
p(" // it will happily serve an editor a cached public page. That exact bug got");
|
|
392
426
|
p(" // this feature reverted twice (#163, #165). `withEdgeCache` strips the CDN");
|
|
393
427
|
p(" // directive from every response so the automatic cache never engages.");
|
|
@@ -408,7 +442,7 @@ export function generateAstroidWorker(config) {
|
|
|
408
442
|
}
|
|
409
443
|
// ONE scheduled handler for every cron, dispatching on `controller.cron`.
|
|
410
444
|
// Cloudflare gives no other way to tell them apart, and the strings here have
|
|
411
|
-
// to match `astroidCrons` exactly
|
|
445
|
+
// to match `astroidCrons` exactly—which is why both read the same constants
|
|
412
446
|
// rather than repeating a literal.
|
|
413
447
|
p(" // Cron. Cloudflare fires this for EVERY trigger in wrangler.jsonc and");
|
|
414
448
|
p(" // identifies which by `controller.cron`, so dispatch on it.");
|
|
@@ -421,8 +455,8 @@ export function generateAstroidWorker(config) {
|
|
|
421
455
|
p(" }");
|
|
422
456
|
if (cron) {
|
|
423
457
|
p(` if (controller.cron === ${JSON.stringify(cron)}) {`);
|
|
424
|
-
p(" // Catalog safety net. Webhooks get missed
|
|
425
|
-
p(" // deploy mid-delivery, a DLQ'd message
|
|
458
|
+
p(" // Catalog safety net. Webhooks get missed—a provider outage, a");
|
|
459
|
+
p(" // deploy mid-delivery, a DLQ'd message—and without this the site");
|
|
426
460
|
p(" // serves stale data until a human notices. Enqueued rather than run");
|
|
427
461
|
p(" // inline so it takes the same retry + DLQ path as everything else.");
|
|
428
462
|
p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
|
|
@@ -444,7 +478,7 @@ export function generateAstroidWorker(config) {
|
|
|
444
478
|
p();
|
|
445
479
|
if (usesRealtime(config)) {
|
|
446
480
|
// Re-exported from the ENTRY because wrangler resolves a Durable Object
|
|
447
|
-
// binding's `class_name` against the worker's exports
|
|
481
|
+
// binding's `class_name` against the worker's exports—the class living in
|
|
448
482
|
// src/edit-session.ts is not enough on its own, and the failure is a deploy
|
|
449
483
|
// error about an unresolvable class rather than anything pointing here.
|
|
450
484
|
p("// The realtime edit-session Durable Object. Re-exported so wrangler can");
|
|
@@ -460,7 +494,7 @@ export function generateAstroidWorker(config) {
|
|
|
460
494
|
* session + sticky `?louise` edit mode → content-freshness + security headers) via
|
|
461
495
|
* `createLouiseMiddleware`.
|
|
462
496
|
*
|
|
463
|
-
* The rate rules are NOT emitted as literals here
|
|
497
|
+
* The rate rules are NOT emitted as literals here—the file calls
|
|
464
498
|
* `astroidRateRules(astroidConfig)`, so the set stays real data in the package
|
|
465
499
|
* (testable, and a `match` predicate survives, which a serialized literal could
|
|
466
500
|
* not). Enabling a portal or commerce in the config adds that surface's rules
|
|
@@ -470,7 +504,7 @@ export function generateAstroidWorker(config) {
|
|
|
470
504
|
* Astro emits a hash-based `content-security-policy` response header on every SSR
|
|
471
505
|
* page and owns `script-src`. The `cspStyleSrc` below tells
|
|
472
506
|
* `createLouiseMiddleware` to rewrite that header's `style-src` to
|
|
473
|
-
* `'self' 'unsafe-inline'
|
|
507
|
+
* `'self' 'unsafe-inline'`—a hash-based `style-src` would, per spec, void the
|
|
474
508
|
* `'unsafe-inline'` that Louise's data-driven `style=""` carriers and the
|
|
475
509
|
* editor's runtime-injected `<style>` require. Script hashes are left verbatim,
|
|
476
510
|
* and the inlined `data:` brand font is auto-allowed.
|
|
@@ -487,7 +521,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
487
521
|
const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
488
522
|
const rewriteExclude = tenancy?.rewriteExclude ?? ASTROID_REWRITE_EXCLUDE;
|
|
489
523
|
return [
|
|
490
|
-
"// Generated by astroidjs
|
|
524
|
+
"// Generated by astroidjs—do not hand-edit.",
|
|
491
525
|
"// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
|
|
492
526
|
"// then resolve the editor session + sticky ?louise edit mode, then apply",
|
|
493
527
|
"// content-freshness + transport-security headers, and rewrite the style-src of",
|
|
@@ -495,8 +529,8 @@ export function generateAstroidMiddleware(config) {
|
|
|
495
529
|
"// styles + inlined data: brand font are allowed.",
|
|
496
530
|
'import { env } from "cloudflare:workers";',
|
|
497
531
|
'import { createLouiseMiddleware } from "@louise-toolkit/astro";',
|
|
498
|
-
// One `astroidjs` import, composed from what this config actually uses
|
|
499
|
-
//
|
|
532
|
+
// One `astroidjs` import, composed from what this config actually uses—two
|
|
533
|
+
// import statements for the same module is legal and reads as an
|
|
500
534
|
// oversight in a file nobody is supposed to hand-edit.
|
|
501
535
|
`import { ${[
|
|
502
536
|
...(tenancy && Object.keys(tenancy.apps ?? {}).length ? ["appPrefix"] : []),
|
|
@@ -508,22 +542,22 @@ export function generateAstroidMiddleware(config) {
|
|
|
508
542
|
'import astroidConfig from "../astroid.config.js";',
|
|
509
543
|
...(tenancy
|
|
510
544
|
? [
|
|
511
|
-
"// TODO(astroid): your TENANT seam
|
|
545
|
+
"// TODO(astroid): your TENANT seam—what a subdomain maps to (src/tenancy.ts).",
|
|
512
546
|
'import { resolveTenant } from "./tenancy.js";',
|
|
513
547
|
]
|
|
514
548
|
: []),
|
|
515
|
-
"// TODO(astroid): your AUTH seam
|
|
549
|
+
"// TODO(astroid): your AUTH seam—same resolveEditor as the generated worker.ts.",
|
|
516
550
|
'import { resolveEditor } from "./auth.js";',
|
|
517
551
|
// The portal's resolver lives in its OWN module, not the editor's auth
|
|
518
|
-
// seam
|
|
552
|
+
// seam—they're separate Better Auth instances and must not share a file.
|
|
519
553
|
...(portal ? ['import { resolvePortalUser } from "./portal-auth.js";'] : []),
|
|
520
554
|
"",
|
|
521
555
|
"// Rate-limit the public, unauthenticated POST surface, keyed by client IP",
|
|
522
556
|
"// (fixed-window KV counter that fails open). Derived from your config: the",
|
|
523
557
|
"// editor magic-link always, plus the portal credential surfaces and checkout",
|
|
524
|
-
"// when those are enabled. Add your own via `security.rateRules` in the config
|
|
558
|
+
"// when those are enabled. Add your own via `security.rateRules` in the config—",
|
|
525
559
|
"// they're matched first, so they can also override a default's budget.",
|
|
526
|
-
"// `env.RL` is read per request (a getter)
|
|
560
|
+
"// `env.RL` is read per request (a getter)—a KV binding is only valid in",
|
|
527
561
|
"// request scope.",
|
|
528
562
|
"const RATE_RULES = astroidRateRules(astroidConfig);",
|
|
529
563
|
...(tenancy
|
|
@@ -538,7 +572,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
538
572
|
? [
|
|
539
573
|
"const PORTAL_GUARD = astroidPortalGuardConfig(astroidConfig)!;",
|
|
540
574
|
"",
|
|
541
|
-
"// The PORTAL session
|
|
575
|
+
"// The PORTAL session—a second, cookie- and table-isolated Better Auth",
|
|
542
576
|
"// instance beside the editor's. `resolvePortalSession` shares the in-flight",
|
|
543
577
|
"// lookup per request, so the guard here and the handler that runs next",
|
|
544
578
|
"// don't each pay a session round-trip.",
|
|
@@ -548,11 +582,11 @@ export function generateAstroidMiddleware(config) {
|
|
|
548
582
|
"export const onRequest = createLouiseMiddleware({",
|
|
549
583
|
" resolveEditor: (request) => resolveEditor(request),",
|
|
550
584
|
" rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
|
|
551
|
-
" // The same deny-by-default gate for /api/louise routes that reach Astro
|
|
585
|
+
" // The same deny-by-default gate for /api/louise routes that reach Astro—",
|
|
552
586
|
" // anything the worker's routes didn't answer. A second check behind the",
|
|
553
587
|
" // worker's gate, and free: the editor is resolved here on every request.",
|
|
554
588
|
" apiGate: true,",
|
|
555
|
-
// `extend` runs once and may need to populate BOTH
|
|
589
|
+
// `extend` runs once and may need to populate BOTH—a tenanted site with a
|
|
556
590
|
// portal resolves a tenant and a customer on the same request.
|
|
557
591
|
...(portal || tenancy
|
|
558
592
|
? [
|
|
@@ -583,7 +617,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
583
617
|
" // A syntactically-valid tenant host whose label resolved to nothing is",
|
|
584
618
|
' // NOT a page (config `tenancy.unknown: "404"`). Falling through would',
|
|
585
619
|
" // render the marketing homepage on a stranger's subdomain. Reserved",
|
|
586
|
-
" // labels, app labels, and the apex never reach here
|
|
620
|
+
" // labels, app labels, and the apex never reach here—tenantLabel",
|
|
587
621
|
" // returns null for all of them, so they are not tenant candidates.",
|
|
588
622
|
" if (tenantLabel(context.url.hostname, TENANCY) && !context.locals.tenant) {",
|
|
589
623
|
' return new Response("Not found", { status: 404 });',
|
|
@@ -593,7 +627,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
593
627
|
...(portal
|
|
594
628
|
? [
|
|
595
629
|
" // Route guard: the declarative prefix→roles table from your config.",
|
|
596
|
-
" // An /api/* route always answers in JSON
|
|
630
|
+
" // An /api/* route always answers in JSON—redirecting fetch() to an",
|
|
597
631
|
" // HTML login page returns 200 and markup, which reads as success.",
|
|
598
632
|
" const decision = portalGuard(",
|
|
599
633
|
" context.url.pathname,",
|
|
@@ -612,16 +646,16 @@ export function generateAstroidMiddleware(config) {
|
|
|
612
646
|
? [
|
|
613
647
|
" // Host dispatch: map the resolved tenant onto an internal path prefix.",
|
|
614
648
|
" // Runs after `guard`, so route policy stays written against the PUBLIC",
|
|
615
|
-
" // path. The visitor's URL is unchanged
|
|
616
|
-
" // redirect
|
|
649
|
+
" // path. The visitor's URL is unchanged—an internal rewrite, not a",
|
|
650
|
+
" // redirect—so links built from Astro.url stay correct.",
|
|
617
651
|
" //",
|
|
618
652
|
" // An unknown subdomain is YOUR decision: `resolveTenant` returning null",
|
|
619
|
-
" // falls through to the ordinary site below
|
|
653
|
+
" // falls through to the ordinary site below—or answers 404 first, when the",
|
|
620
654
|
' // config sets `tenancy.unknown: "404"` (see the guard above).',
|
|
621
655
|
" rewrite: (context) => {",
|
|
622
656
|
" // Host-agnostic paths render from their own address. An API route is",
|
|
623
657
|
" // addressed absolutely by whatever calls it and reads the host from",
|
|
624
|
-
" // `locals.tenant`, so rewriting it moves it where no route matches
|
|
658
|
+
" // `locals.tenant`, so rewriting it moves it where no route matches—",
|
|
625
659
|
" // and on an app host with a catch-all page, the PAGE answers, so",
|
|
626
660
|
" // fetch() gets HTML instead of JSON and every data load quietly fails.",
|
|
627
661
|
` if (isRewriteExcluded(context.url.pathname, ${JSON.stringify(rewriteExclude)})) {`,
|
|
@@ -630,12 +664,12 @@ export function generateAstroidMiddleware(config) {
|
|
|
630
664
|
...(Object.keys(tenancy.apps ?? {}).length
|
|
631
665
|
? [
|
|
632
666
|
" // First-party app hosts (config `tenancy.apps`): a static label→prefix",
|
|
633
|
-
" // map, checked before the tenant
|
|
667
|
+
" // map, checked before the tenant—an app exists whether or not any",
|
|
634
668
|
" // tenant does, and needs no lookup.",
|
|
635
669
|
" const app = appPrefix(context.url.hostname, TENANCY);",
|
|
636
670
|
" // `search` is preserved: the rewrite chooses which page renders, not",
|
|
637
671
|
" // what was asked of it. Dropping it silently loses filters, pagination,",
|
|
638
|
-
" // campaign tags
|
|
672
|
+
" // campaign tags—and every typed search param a routed island reads.",
|
|
639
673
|
" if (app) return `${app}${context.url.pathname}${context.url.search}`;",
|
|
640
674
|
]
|
|
641
675
|
: []),
|
package/dist/worker/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// Worker + middleware generation
|
|
3
|
+
// Worker + middleware generation—config → the collision-free editor route plan
|
|
4
4
|
// → the Worker entrypoint and Astro middleware a site would otherwise hand-write.
|
|
5
5
|
export * from "./routes.js";
|
|
6
6
|
export * from "./generate.js";
|
package/dist/worker/routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// The editor route plan
|
|
3
|
+
// The editor route plan—which louise-toolkit/editor routes a project needs, in
|
|
4
4
|
// the ONE order that avoids matcher collisions. This is where the "versionsRoute
|
|
5
5
|
// and searchRoute MUST precede pagesRoute" tribal knowledge lives: encoded once,
|
|
6
6
|
// as data, instead of re-derived by hand (and mis-ordered) in every site's
|
|
@@ -23,7 +23,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
23
23
|
{
|
|
24
24
|
name: "versions",
|
|
25
25
|
factory: "versionsRoute",
|
|
26
|
-
note: "Draft/publish + version history for pages. MUST precede pagesRoute
|
|
26
|
+
note: "Draft/publish + version history for pages. MUST precede pagesRoute—pagesRoute's /:id matcher would otherwise claim /pages/:id/versions and 400 on the non-integer id.",
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: "search",
|
|
@@ -33,7 +33,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
33
33
|
{
|
|
34
34
|
name: "seoFix",
|
|
35
35
|
factory: "seoFixRoute",
|
|
36
|
-
note: "One-click SEO backfill for published pages missing a title/description. MUST precede pagesRoute for the same reason versions/search do: it mounts at /api/louise/pages/generate-seo, and pagesRoute claims EVERY path under /api/louise/pages/ as an item id
|
|
36
|
+
note: "One-click SEO backfill for published pages missing a title/description. MUST precede pagesRoute for the same reason versions/search do: it mounts at /api/louise/pages/generate-seo, and pagesRoute claims EVERY path under /api/louise/pages/ as an item id—so mounted after, it would never be reached and the request would 400 on the non-integer id `generate-seo`.",
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: "pages",
|
|
@@ -43,7 +43,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
43
43
|
{
|
|
44
44
|
name: "save",
|
|
45
45
|
factory: "saveRoute",
|
|
46
|
-
note: "Live field saves (title, SEO)
|
|
46
|
+
note: "Live field saves (title, SEO)—the versioned body stages drafts via versionsRoute instead.",
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
name: "settings",
|
|
@@ -53,12 +53,12 @@ export function astroidEditorRoutePlan(config) {
|
|
|
53
53
|
{
|
|
54
54
|
name: "media",
|
|
55
55
|
factory: "mediaRoute",
|
|
56
|
-
note: "Media library
|
|
56
|
+
note: "Media library—list, upload, delete, reference checks.",
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
name: "editors",
|
|
60
60
|
factory: "editorsRoute",
|
|
61
|
-
note: "Editor roster (the Users panel) over Better Auth's `user` table
|
|
61
|
+
note: "Editor roster (the Users panel) over Better Auth's `user` table—a row IS an editor, and the same table is the magic-link allowlist (resolveAdmins).",
|
|
62
62
|
},
|
|
63
63
|
];
|
|
64
64
|
// AI assists that own their own path prefix. `/api/louise/ai/*` collides with
|
|
@@ -74,23 +74,23 @@ export function astroidEditorRoutePlan(config) {
|
|
|
74
74
|
routes.push({
|
|
75
75
|
name: "realtime",
|
|
76
76
|
factory: "realtimeRoute",
|
|
77
|
-
note: "WebSocket upgrade for the per-page live editing session (ADR 0002). Owns /api/louise/realtime/*. Guards the handshake as a same-origin, session-gated mutation, then forwards to the per-page Durable Object with the SERVER-resolved editor identity
|
|
77
|
+
note: "WebSocket upgrade for the per-page live editing session (ADR 0002). Owns /api/louise/realtime/*. Guards the handshake as a same-origin, session-gated mutation, then forwards to the per-page Durable Object with the SERVER-resolved editor identity—presence is never taken from the client.",
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
routes.push({
|
|
81
81
|
name: "vitals",
|
|
82
82
|
factory: "vitalsRoute",
|
|
83
|
-
note: "Public CWV ingestion (POST /api/louise/vitals). NOT session-gated
|
|
83
|
+
note: "Public CWV ingestion (POST /api/louise/vitals). NOT session-gated—these are anonymous visitor beacons—but same-origin only, and it accepts-and-drops without the dataset binding. Always 204.",
|
|
84
84
|
});
|
|
85
85
|
routes.push({
|
|
86
86
|
name: "health",
|
|
87
87
|
factory: "healthRoute",
|
|
88
|
-
note: "The site-health panel's read of the persisted scan summary. Owns /api/louise/health. Like overviewRoute, its client half already ships in the editor drawer
|
|
88
|
+
note: "The site-health panel's read of the persisted scan summary. Owns /api/louise/health. Like overviewRoute, its client half already ships in the editor drawer—unmounted, the Health card was dead UI. Returns `{ summary: null }` (a 200) until the daily cron writes the first scan, which the panel renders as 'not checked yet'.",
|
|
89
89
|
});
|
|
90
90
|
routes.push({
|
|
91
91
|
name: "ai",
|
|
92
92
|
factory: "aiRoute",
|
|
93
|
-
note: "Editor AI assists
|
|
93
|
+
note: "Editor AI assists—rewrite/expand/shorten a selection, suggest SEO for a page. Owns /api/louise/ai/*, so it collides with nothing. POST-only and editor-gated, since each call spends AI budget.",
|
|
94
94
|
});
|
|
95
95
|
if (capturesInquiries(config)) {
|
|
96
96
|
routes.push({
|
|
@@ -106,7 +106,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
106
106
|
routes.push({
|
|
107
107
|
name: "seed",
|
|
108
108
|
factory: "seedRoute",
|
|
109
|
-
note: "First-run site_settings seed. Last
|
|
109
|
+
note: "First-run site_settings seed. Last—it's a one-shot bootstrap, not a hot path.",
|
|
110
110
|
});
|
|
111
111
|
return routes;
|
|
112
112
|
}
|
|
@@ -34,7 +34,7 @@ export interface AdvanceOptions {
|
|
|
34
34
|
db: WorkflowDatabase;
|
|
35
35
|
/** Table holding the `stage` column. */
|
|
36
36
|
table: string;
|
|
37
|
-
/** Audit table
|
|
37
|
+
/** Audit table—one row per completed stage. */
|
|
38
38
|
auditTable: string;
|
|
39
39
|
/** Primary key column on `table`. Default `"id"`. */
|
|
40
40
|
idColumn?: string;
|
|
@@ -87,13 +87,13 @@ export interface OverrideOptions extends Omit<AdvanceOptions, "specs" | "expecte
|
|
|
87
87
|
action: OverrideAction;
|
|
88
88
|
/** Override log table. */
|
|
89
89
|
overrideTable: string;
|
|
90
|
-
/** Where the item is now, from the operator's page
|
|
90
|
+
/** Where the item is now, from the operator's page—the same staleness guard. */
|
|
91
91
|
expectedStage: number;
|
|
92
92
|
/** Optional station/context label recorded with the override. */
|
|
93
93
|
station?: string;
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
|
-
* Move an item out of band
|
|
96
|
+
* Move an item out of band—back a stage, or skip one—and log it.
|
|
97
97
|
*
|
|
98
98
|
* Sending an item BACK deletes the audit row for the stage being reopened, so
|
|
99
99
|
* "a sign-off exists" keeps meaning "that stage is genuinely done". Leaving it
|
package/dist/workflow/advance.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// The guarded advance
|
|
3
|
+
// The guarded advance—the one piece of a staged pipeline that is genuinely
|
|
4
4
|
// hard to get right.
|
|
5
5
|
//
|
|
6
6
|
// Two operators standing at two stations both press "sign off" on the same job.
|
|
7
7
|
// A read-then-write advance runs the item forward two stages and writes two
|
|
8
8
|
// audit rows, and nobody notices until the numbers stop adding up. The fix is
|
|
9
|
-
// optimistic concurrency: make the write itself assert the stage it
|
|
10
|
-
//
|
|
9
|
+
// optimistic concurrency: make the write itself assert the stage it
|
|
10
|
+
// expected—`UPDATE … SET stage = ? WHERE id = ? AND stage = ?`—and treat "0 rows
|
|
11
11
|
// changed" as the conflict signal rather than checking first and hoping.
|
|
12
12
|
//
|
|
13
13
|
// ORDERING MATTERS, and the reference gets it wrong. ghostfire's floor route
|
|
14
14
|
// inserts the sign-off row and THEN runs the guarded update, so a double submit
|
|
15
15
|
// writes two audit rows even though only one advance lands. Here the guarded
|
|
16
16
|
// update goes first and the audit row is written only if it actually moved the
|
|
17
|
-
// item
|
|
17
|
+
// item—so the audit table can't record work that didn't happen. The unique
|
|
18
18
|
// index the schema generator emits on `(entity_id, stage)` is the belt to that
|
|
19
19
|
// braces.
|
|
20
20
|
/** Longest initials we store. Three is what a floor actually writes. */
|
|
@@ -66,7 +66,7 @@ export async function advanceWorkflowStage(options) {
|
|
|
66
66
|
}
|
|
67
67
|
const next = expectedStage + 1;
|
|
68
68
|
// The guard. `changes === 0` means the row is gone or someone else already
|
|
69
|
-
// moved it
|
|
69
|
+
// moved it—the two cases are told apart below, but only after the write,
|
|
70
70
|
// so there is no window between the check and the update.
|
|
71
71
|
const advanced = await db
|
|
72
72
|
.prepare(`UPDATE ${table} SET stage = ? WHERE ${idColumn} = ? AND stage = ?`)
|
|
@@ -97,7 +97,7 @@ export async function advanceWorkflowStage(options) {
|
|
|
97
97
|
return { ok: true, stage: next, complete: next >= stageCount };
|
|
98
98
|
}
|
|
99
99
|
/**
|
|
100
|
-
* Move an item out of band
|
|
100
|
+
* Move an item out of band—back a stage, or skip one—and log it.
|
|
101
101
|
*
|
|
102
102
|
* Sending an item BACK deletes the audit row for the stage being reopened, so
|
|
103
103
|
* "a sign-off exists" keeps meaning "that stage is genuinely done". Leaving it
|