astroidjs 0.17.0 → 0.19.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.
Files changed (44) hide show
  1. package/README.md +6 -7
  2. package/bin/astroid.mjs +107 -75
  3. package/dist/commerce/adapters.js +3 -3
  4. package/dist/commerce/checkout.js +1 -1
  5. package/dist/commerce/mirror.js +5 -4
  6. package/dist/commerce/roles.js +3 -3
  7. package/dist/config.d.ts +57 -14
  8. package/dist/config.js +27 -7
  9. package/dist/portal/guard.js +1 -1
  10. package/dist/project/build-output.d.ts +18 -0
  11. package/dist/project/build-output.js +64 -0
  12. package/dist/project/generate.d.ts +19 -0
  13. package/dist/project/generate.js +38 -4
  14. package/dist/project/index.d.ts +3 -0
  15. package/dist/project/index.js +3 -0
  16. package/dist/project/migrations.d.ts +26 -0
  17. package/dist/project/migrations.js +78 -0
  18. package/dist/project/scaffold.d.ts +23 -0
  19. package/dist/project/scaffold.js +86 -2
  20. package/dist/project/ship.d.ts +42 -0
  21. package/dist/project/ship.js +115 -0
  22. package/dist/queues/index.d.ts +2 -2
  23. package/dist/queues/index.js +2 -2
  24. package/dist/queues/messages.d.ts +6 -0
  25. package/dist/queues/messages.js +6 -0
  26. package/dist/queues/scaffold.js +30 -17
  27. package/dist/queues/webhook.d.ts +31 -2
  28. package/dist/queues/webhook.js +31 -0
  29. package/dist/schema/collections.d.ts +6 -0
  30. package/dist/schema/collections.js +14 -2
  31. package/dist/schema/framework.d.ts +4 -3
  32. package/dist/schema/framework.js +8 -7
  33. package/dist/worker/gateway.d.ts +12 -0
  34. package/dist/worker/gateway.js +32 -0
  35. package/dist/worker/generate.js +60 -6
  36. package/dist/worker/index.d.ts +1 -0
  37. package/dist/worker/index.js +1 -0
  38. package/dist/worker/routes.d.ts +1 -1
  39. package/dist/worker/routes.js +6 -1
  40. package/dist/workflow/advance.js +1 -1
  41. package/dist/workflow/config.js +1 -1
  42. package/package.json +3 -3
  43. package/src/components/StageBar.astro +1 -1
  44. package/src/components/media-meta.ts +1 -1
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The post-build fix `astroid build` applies to the Worker config that
4
+ // `@astrojs/cloudflare` writes.
5
+ //
6
+ // The adapter writes the built config from the Wrangler version it bundles, and
7
+ // some of those versions include `legacy_env: true`. Current Wrangler rejects
8
+ // the field, so a Workers Builds deploy running a newer Wrangler fails on the
9
+ // build's own output. `true` was always the default, so deleting the field
10
+ // changes nothing else.
11
+ //
12
+ // Pure: text in, text out, so it's tested directly and the CLI only does I/O.
13
+ /** Where `@astrojs/cloudflare` writes the built config when there's no redirect. */
14
+ export const ASTROID_BUILT_WRANGLER_CONFIG = "dist/server/wrangler.json";
15
+ /** The redirect file Wrangler reads to find the built config. */
16
+ export const WRANGLER_DEPLOY_REDIRECT = ".wrangler/deploy/config.json";
17
+ /**
18
+ * The built config's path, relative to the project root, found the way
19
+ * Wrangler finds it: the `configPath` in `.wrangler/deploy/config.json`, which
20
+ * is relative to that file's own folder, else {@link ASTROID_BUILT_WRANGLER_CONFIG}.
21
+ * Pass the redirect's text, or `null` when there isn't one.
22
+ */
23
+ export function builtWranglerConfigPath(redirect) {
24
+ if (redirect === null)
25
+ return ASTROID_BUILT_WRANGLER_CONFIG;
26
+ let configPath;
27
+ try {
28
+ configPath = JSON.parse(redirect).configPath;
29
+ }
30
+ catch {
31
+ return ASTROID_BUILT_WRANGLER_CONFIG;
32
+ }
33
+ if (typeof configPath !== "string" || !configPath)
34
+ return ASTROID_BUILT_WRANGLER_CONFIG;
35
+ if (configPath.startsWith("/"))
36
+ return configPath;
37
+ // Resolved against `.wrangler/deploy/`, the redirect's folder.
38
+ const parts = [".wrangler", "deploy"];
39
+ for (const segment of configPath.split("/")) {
40
+ if (segment === "..")
41
+ parts.pop();
42
+ else if (segment !== "." && segment !== "")
43
+ parts.push(segment);
44
+ }
45
+ return parts.join("/");
46
+ }
47
+ /**
48
+ * Remove `legacy_env` from a built Wrangler config. Returns the new text, or
49
+ * `null` when the field isn't there, so the caller leaves the file untouched.
50
+ * Keeps the file's indentation and every other key in its order. Throws when
51
+ * the text isn't a JSON object.
52
+ */
53
+ export function stripLegacyEnv(text) {
54
+ const config = JSON.parse(text);
55
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
56
+ throw new TypeError("The built Wrangler config isn't a JSON object.");
57
+ }
58
+ if (!Object.hasOwn(config, "legacy_env"))
59
+ return null;
60
+ const { legacy_env: _removed, ...rest } = config;
61
+ const indent = /^\{\r?\n([ \t]+)"/.exec(text)?.[1] ?? "";
62
+ const trailing = /\r?\n$/.exec(text)?.[0] ?? "";
63
+ return JSON.stringify(rest, null, indent) + trailing;
64
+ }
@@ -5,6 +5,25 @@ export interface GeneratedFile {
5
5
  path: string;
6
6
  contents: string;
7
7
  }
8
+ /**
9
+ * The paths of the regenerated trio, relative to the project root. Astroid's
10
+ * code, tested here, not the site's: spread it into a site's
11
+ * `coverage.exclude` so an Astroid upgrade that adds lines to them doesn't
12
+ * move the site's coverage.
13
+ *
14
+ * ```ts
15
+ * // vitest.config.ts
16
+ * import { ASTROID_GENERATED_FILES } from "astroidjs";
17
+ *
18
+ * export default defineConfig({
19
+ * test: { coverage: { exclude: [...ASTROID_GENERATED_FILES] } },
20
+ * });
21
+ * ```
22
+ *
23
+ * A site that lists them by hand would miss a generated file a later release
24
+ * adds; this list gains it.
25
+ */
26
+ export declare const ASTROID_GENERATED_FILES: readonly ["src/schema.ts", "src/worker.ts", "src/middleware.ts"];
8
27
  /**
9
28
  * The regenerated trio—the files that are a pure function of the Astroid config
10
29
  * and carry a "do not hand-edit" banner. `astroid generate` writes exactly these,
@@ -18,12 +18,36 @@ import { ASTROID_VITALS_BINDING, astroidVitalsDataset } from "../analytics/index
18
18
  import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
19
  import { astroidCommerceProviders } from "../commerce/roles.js";
20
20
  import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
21
- import { ASTROID_QUEUE_BINDING, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
21
+ import { ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
22
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
23
  import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
24
24
  import { tenancyZone } from "../tenancy/index.js";
25
25
  import { generateAstroidSchema } from "../schema/generate.js";
26
+ import { ASTROID_AI_GATEWAY_VAR } from "../worker/gateway.js";
26
27
  import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/generate.js";
28
+ /**
29
+ * The paths of the regenerated trio, relative to the project root. Astroid's
30
+ * code, tested here, not the site's: spread it into a site's
31
+ * `coverage.exclude` so an Astroid upgrade that adds lines to them doesn't
32
+ * move the site's coverage.
33
+ *
34
+ * ```ts
35
+ * // vitest.config.ts
36
+ * import { ASTROID_GENERATED_FILES } from "astroidjs";
37
+ *
38
+ * export default defineConfig({
39
+ * test: { coverage: { exclude: [...ASTROID_GENERATED_FILES] } },
40
+ * });
41
+ * ```
42
+ *
43
+ * A site that lists them by hand would miss a generated file a later release
44
+ * adds; this list gains it.
45
+ */
46
+ export const ASTROID_GENERATED_FILES = [
47
+ "src/schema.ts",
48
+ "src/worker.ts",
49
+ "src/middleware.ts",
50
+ ];
27
51
  /**
28
52
  * The regenerated trio—the files that are a pure function of the Astroid config
29
53
  * and carry a "do not hand-edit" banner. `astroid generate` writes exactly these,
@@ -31,10 +55,11 @@ import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/gene
31
55
  * once files (wrangler.jsonc, astro.config, auth.ts) are NOT here by design.
32
56
  */
33
57
  export function generateAstroidProject(config) {
58
+ const [schema, worker, middleware] = ASTROID_GENERATED_FILES;
34
59
  return [
35
- { path: "src/schema.ts", contents: generateAstroidSchema(config) },
36
- { path: "src/worker.ts", contents: generateAstroidWorker(config) },
37
- { path: "src/middleware.ts", contents: generateAstroidMiddleware(config) },
60
+ { path: schema, contents: generateAstroidSchema(config) },
61
+ { path: worker, contents: generateAstroidWorker(config) },
62
+ { path: middleware, contents: generateAstroidMiddleware(config) },
38
63
  ];
39
64
  }
40
65
  /**
@@ -174,6 +199,10 @@ export function generateAstroidWrangler(config) {
174
199
  p(` "max_batch_size": ${config.queues?.maxBatchSize ?? 10},`);
175
200
  p(` "max_batch_timeout": ${config.queues?.maxBatchTimeout ?? 30},`);
176
201
  p(` "max_retries": ${config.queues?.maxRetries ?? 5},`);
202
+ // A wait between deliveries, so a failed message doesn't hit a provider
203
+ // that's already failing or rate limiting again the same second. The queue
204
+ // owns retries (the consumer seam says so), so it owns the backoff too.
205
+ p(` "retry_delay": ${config.queues?.retryDelay ?? ASTROID_QUEUE_RETRY_DELAY},`);
177
206
  p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
178
207
  p(" },");
179
208
  p(" ],");
@@ -233,6 +262,11 @@ export function generateAstroidWrangler(config) {
233
262
  p(` "SITE_URL": ${JSON.stringify(primaryHost ? `https://${primaryHost}` : `https://${key}.workers.dev`)},`);
234
263
  p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
235
264
  p(' "OWNER_EMAIL": "",');
265
+ p(" // AI Gateway for the editor's AI assists: request logs, latency and error");
266
+ p(" // rates, and caching. Empty calls Workers AI directly. Create a gateway,");
267
+ p(" // put its id here, and first say on the privacy page that its log holds");
268
+ p(" // the text editors send to the assists.");
269
+ p(` "${ASTROID_AI_GATEWAY_VAR}": "",`);
236
270
  p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
237
271
  p(" // default is the safe state: with it off every render is `no-store` and");
238
272
  p(" // the Worker cache layer stores nothing.");
@@ -1,7 +1,10 @@
1
1
  export * from "./generate.js";
2
2
  export * from "./actions.js";
3
3
  export * from "./scaffold.js";
4
+ export * from "./migrations.js";
4
5
  export * from "./seed.js";
5
6
  export * from "./previews.js";
6
7
  export * from "./release.js";
7
8
  export * from "./provision.js";
9
+ export * from "./ship.js";
10
+ export * from "./build-output.js";
@@ -5,7 +5,10 @@
5
5
  export * from "./generate.js";
6
6
  export * from "./actions.js";
7
7
  export * from "./scaffold.js";
8
+ export * from "./migrations.js";
8
9
  export * from "./seed.js";
9
10
  export * from "./previews.js";
10
11
  export * from "./release.js";
11
12
  export * from "./provision.js";
13
+ export * from "./ship.js";
14
+ export * from "./build-output.js";
@@ -0,0 +1,26 @@
1
+ import type { ScaffoldFile } from "./scaffold.js";
2
+ /**
3
+ * The `DB` binding's `migrations_dir` from `wrangler.jsonc`, or Wrangler's
4
+ * default `migrations` when the binding sets none or the file is missing.
5
+ * Returned as written, without a trailing slash.
6
+ */
7
+ export declare function astroidMigrationsDir(wrangler: string | null | undefined): string;
8
+ /**
9
+ * Resolve each scaffold file's path against the site's migrations directory.
10
+ * Files that aren't migrations pass through unchanged. For a migration:
11
+ *
12
+ * - **Already there under any number:** the path is that file's, so a caller
13
+ * that skips existing files skips it. A site that copied `page_redirects`
14
+ * in by hand as `0009_page_redirects.sql` keeps that file and gets no second.
15
+ * - **Default number free and past the newest migration:** the default, so a
16
+ * standard project numbers exactly as before.
17
+ * - **Otherwise:** the next number after the newest migration, at the same
18
+ * width. A migration never takes a number the site already uses, and never
19
+ * sorts before one that already ran.
20
+ *
21
+ * `existing` is the file names in `migrationsDir`, in any order.
22
+ */
23
+ export declare function resolveAstroidScaffoldPaths(files: ScaffoldFile[], { migrationsDir, existing }: {
24
+ migrationsDir: string;
25
+ existing: string[];
26
+ }): ScaffoldFile[];
@@ -0,0 +1,78 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Where a scaffolded D1 migration lands in a site. The scaffold names each
4
+ // migration with a default path (`migrations/0004_page_redirects.sql`), but a
5
+ // site can point its `DB` binding at another directory with `migrations_dir`,
6
+ // and it may already number its own migrations past the default. Wrangler
7
+ // applies only the directory in `migrations_dir` and tracks each file by name,
8
+ // so a migration written anywhere else never runs, and one written onto a
9
+ // number the site already uses leaves two files claiming that number.
10
+ import { parseJsonc } from "./previews.js";
11
+ /** Wrangler's own default when a D1 binding sets no `migrations_dir`. */
12
+ const DEFAULT_MIGRATIONS_DIR = "migrations";
13
+ /** `0004_page_redirects.sql` → number `"0004"`, name `page_redirects`. */
14
+ const MIGRATION_FILE = /^(\d+)_(.+)\.sql$/;
15
+ /**
16
+ * The `DB` binding's `migrations_dir` from `wrangler.jsonc`, or Wrangler's
17
+ * default `migrations` when the binding sets none or the file is missing.
18
+ * Returned as written, without a trailing slash.
19
+ */
20
+ export function astroidMigrationsDir(wrangler) {
21
+ if (!wrangler)
22
+ return DEFAULT_MIGRATIONS_DIR;
23
+ let parsed;
24
+ try {
25
+ parsed = parseJsonc(wrangler);
26
+ }
27
+ catch {
28
+ return DEFAULT_MIGRATIONS_DIR;
29
+ }
30
+ const dir = (parsed.d1_databases ?? []).find((d) => d.binding === "DB")?.migrations_dir;
31
+ return dir ? dir.replace(/\/+$/, "") : DEFAULT_MIGRATIONS_DIR;
32
+ }
33
+ /**
34
+ * Resolve each scaffold file's path against the site's migrations directory.
35
+ * Files that aren't migrations pass through unchanged. For a migration:
36
+ *
37
+ * - **Already there under any number:** the path is that file's, so a caller
38
+ * that skips existing files skips it. A site that copied `page_redirects`
39
+ * in by hand as `0009_page_redirects.sql` keeps that file and gets no second.
40
+ * - **Default number free and past the newest migration:** the default, so a
41
+ * standard project numbers exactly as before.
42
+ * - **Otherwise:** the next number after the newest migration, at the same
43
+ * width. A migration never takes a number the site already uses, and never
44
+ * sorts before one that already ran.
45
+ *
46
+ * `existing` is the file names in `migrationsDir`, in any order.
47
+ */
48
+ export function resolveAstroidScaffoldPaths(files, { migrationsDir, existing }) {
49
+ const taken = new Map();
50
+ const byName = new Map();
51
+ for (const file of existing) {
52
+ const match = MIGRATION_FILE.exec(file);
53
+ if (!match)
54
+ continue;
55
+ taken.set(Number(match[1]), file);
56
+ byName.set(match[2], file);
57
+ }
58
+ let newest = taken.size ? Math.max(...taken.keys()) : -1;
59
+ return files.map((file) => {
60
+ if (!file.migration)
61
+ return file;
62
+ const base = file.path.slice(file.path.lastIndexOf("/") + 1);
63
+ const match = MIGRATION_FILE.exec(base);
64
+ if (!match)
65
+ return { ...file, path: `${migrationsDir}/${base}` };
66
+ const [, digits, name] = match;
67
+ const present = byName.get(name);
68
+ if (present)
69
+ return { ...file, path: `${migrationsDir}/${present}` };
70
+ const wanted = Number(digits);
71
+ const number = !taken.has(wanted) && wanted > newest ? wanted : newest + 1;
72
+ const filename = `${String(number).padStart(digits.length, "0")}_${name}.sql`;
73
+ taken.set(number, filename);
74
+ byName.set(name, filename);
75
+ newest = Math.max(newest, number);
76
+ return { ...file, path: `${migrationsDir}/${filename}` };
77
+ });
78
+ }
@@ -18,7 +18,30 @@ export interface ScaffoldFile {
18
18
  * Without it a re-run would append a duplicate every time.
19
19
  */
20
20
  marker?: string;
21
+ /**
22
+ * A D1 migration. `path` is its default, `migrations/NNNN_name.sql`; the CLI
23
+ * places it in the `DB` binding's `migrations_dir` and renumbers it past the
24
+ * site's own migrations with {@link resolveAstroidScaffoldPaths}.
25
+ */
26
+ migration?: true;
21
27
  }
28
+ /**
29
+ * `migrations/0004_page_redirects.sql`: the table that keeps a renamed page's
30
+ * old URL working (louise-toolkit's `pageRedirects`). The same DDL drizzle-kit
31
+ * writes for it, with `IF NOT EXISTS`, since a site that already added the
32
+ * table by hand must not fail on it.
33
+ */
34
+ export declare const ASTROID_PAGE_REDIRECTS_MIGRATION: string;
35
+ /**
36
+ * `migrations/0005_media_alt_undecided.sql`: the one-time data migration for
37
+ * the three alt text states. Before louise-toolkit 0.35, an empty alt meant
38
+ * both "not written" and "cleared", so an existing `''` is ambiguous. This
39
+ * makes each one "not written" (NULL), so nothing silently becomes decorative,
40
+ * and the owner marks what is. The statement is louise-toolkit's
41
+ * `MEDIA_ALT_UNDECIDED_SQL("media")`, written out rather than imported so the
42
+ * CLI doesn't load `louise-toolkit/editor` and its drizzle-orm peer.
43
+ */
44
+ export declare const ASTROID_MEDIA_ALT_MIGRATION: string;
22
45
  /**
23
46
  * Every scaffold-once file this config implies.
24
47
  *
@@ -58,6 +58,69 @@ function generateAstroidPagesHooks() {
58
58
  "",
59
59
  ].join("\n");
60
60
  }
61
+ /** `src/status-checks.ts`—the site's own status checks, scaffolded once. */
62
+ function generateAstroidStatusChecks() {
63
+ return [
64
+ "// The site's own checks for the public status route (GET /api/louise/status),",
65
+ "// which an outside probe reads. The generated worker spreads `statusChecks`",
66
+ "// after Astroid's own `d1` and `content` checks. Scaffolded once and yours to",
67
+ "// edit.",
68
+ "//",
69
+ "// A check gets `env` and an abort signal, and returns true, false, or",
70
+ "// `{ ok, ageMs }`. Keep each one cheap: anyone can make it run. Its name is",
71
+ "// in the public response, so don't put anything in one you wouldn't publish.",
72
+ 'import type { StatusCheck } from "louise-toolkit/editor";',
73
+ "",
74
+ "export const statusChecks: Record<string, StatusCheck<CloudflareEnv>> = {",
75
+ " // For example, fail when the daily health scan is over 36 hours old:",
76
+ " //",
77
+ ' // import { ageCheck } from "louise-toolkit/editor";',
78
+ ' // import { readHealthSummary } from "louise-toolkit/health";',
79
+ " //",
80
+ " // healthScan: ageCheck(",
81
+ " // async (env) => (await readHealthSummary(env.RL))?.checkedAt,",
82
+ " // 36 * 60 * 60 * 1000,",
83
+ " // ),",
84
+ "};",
85
+ "",
86
+ ].join("\n");
87
+ }
88
+ /**
89
+ * `migrations/0004_page_redirects.sql`: the table that keeps a renamed page's
90
+ * old URL working (louise-toolkit's `pageRedirects`). The same DDL drizzle-kit
91
+ * writes for it, with `IF NOT EXISTS`, since a site that already added the
92
+ * table by hand must not fail on it.
93
+ */
94
+ export const ASTROID_PAGE_REDIRECTS_MIGRATION = [
95
+ "-- Page redirects: a renamed page's old URL answers a 301 to the new one.",
96
+ "-- pagesRoute and versionsRoute record `/old → /new` when a slug changes, and",
97
+ "-- the middleware's redirectFor serves them. Scaffolded by astroidjs.",
98
+ "CREATE TABLE IF NOT EXISTS `page_redirects` (",
99
+ "\t`from_path` text PRIMARY KEY NOT NULL,",
100
+ "\t`to_path` text NOT NULL,",
101
+ "\t`code` integer DEFAULT 301 NOT NULL,",
102
+ "\t`created_at` integer",
103
+ ");",
104
+ "",
105
+ ].join("\n");
106
+ /**
107
+ * `migrations/0005_media_alt_undecided.sql`: the one-time data migration for
108
+ * the three alt text states. Before louise-toolkit 0.35, an empty alt meant
109
+ * both "not written" and "cleared", so an existing `''` is ambiguous. This
110
+ * makes each one "not written" (NULL), so nothing silently becomes decorative,
111
+ * and the owner marks what is. The statement is louise-toolkit's
112
+ * `MEDIA_ALT_UNDECIDED_SQL("media")`, written out rather than imported so the
113
+ * CLI doesn't load `louise-toolkit/editor` and its drizzle-orm peer.
114
+ */
115
+ export const ASTROID_MEDIA_ALT_MIGRATION = [
116
+ "-- Alt text has three states: NULL is not written yet, '' is decorative (an",
117
+ "-- image the owner marked for screen readers to skip), and anything else is",
118
+ "-- the description. An existing '' predates the decorative state, so it's",
119
+ '-- ambiguous; this makes each one "not written". It runs once, before the',
120
+ "-- health scan starts counting only NULL as missing. Scaffolded by astroidjs.",
121
+ 'UPDATE "media" SET "alt" = NULL WHERE "alt" = \'\';',
122
+ "",
123
+ ].join("\n");
61
124
  /** `src/settings-hooks.ts`—the site's settings sanitizers, scaffolded once. */
62
125
  function generateAstroidSettingsHooks() {
63
126
  return [
@@ -97,8 +160,25 @@ export function generateAstroidScaffoldFiles(config) {
97
160
  // scaffolded a `products` table into src/schema.ts that no migration ever
98
161
  // created, and the first sync wrote nothing while reporting success.
99
162
  const catalogSql = generateCatalogMigrationSql(config);
100
- if (catalogSql)
101
- files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql });
163
+ if (catalogSql) {
164
+ files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql, migration: true });
165
+ }
166
+ // --- louise-toolkit 0.35's two schema changes -----------------------------
167
+ // Numbered after the catalog's 0003, and written into an existing site by
168
+ // `astroid generate` because a missing scaffold file is always written. The
169
+ // alt update is a no-op on a fresh database. Wrangler tracks migrations by
170
+ // filename. The CLI moves each into the site's `migrations_dir` and past
171
+ // the site's own numbers (see migrations.ts), so a site that already has its
172
+ // own 0004 gets the next free number instead of a second 0004.
173
+ files.push({
174
+ path: "migrations/0004_page_redirects.sql",
175
+ contents: ASTROID_PAGE_REDIRECTS_MIGRATION,
176
+ migration: true,
177
+ }, {
178
+ path: "migrations/0005_media_alt_undecided.sql",
179
+ contents: ASTROID_MEDIA_ALT_MIGRATION,
180
+ migration: true,
181
+ });
102
182
  // --- the CWV beacon -------------------------------------------------------
103
183
  // A static file under public/, so it is same-origin and covered by
104
184
  // `script-src 'self'`—an inline script carrying generated content could not
@@ -126,6 +206,10 @@ export function generateAstroidScaffoldFiles(config) {
126
206
  if (config.pages?.hooks) {
127
207
  files.push({ path: "src/pages-hooks.ts", contents: generateAstroidPagesHooks() });
128
208
  }
209
+ // --- status: the site's own probe checks -----------------------------------
210
+ if (config.status?.checks) {
211
+ files.push({ path: "src/status-checks.ts", contents: generateAstroidStatusChecks() });
212
+ }
129
213
  // --- settings: the sanitize + read seam ------------------------------------
130
214
  // Only when asked for (settings.hooks): the generated worker and the Actions
131
215
  // surface both import it, so it must exist whenever either does. Scaffolded
@@ -0,0 +1,42 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** Whether this app applies D1 migrations when it deploys. Default `true`. */
3
+ export declare function astroidRunsMigrations(config: AstroidConfig): boolean;
4
+ /** One step of a ship, in order. */
5
+ export type ShipStep =
6
+ /** Run `wrangler` with these arguments; a non-zero exit stops the ship. */
7
+ {
8
+ run: string[];
9
+ }
10
+ /** Print a line. */
11
+ | {
12
+ note: string;
13
+ }
14
+ /** Write a file, relative to the project root, before the next step. */
15
+ | {
16
+ write: {
17
+ path: string;
18
+ contents: string;
19
+ };
20
+ };
21
+ /** Where `ship preview` writes the config that names the staging database. */
22
+ export declare const ASTROID_PREVIEW_MIGRATIONS_CONFIG = ".wrangler/astroid-preview-migrations.jsonc";
23
+ /** Printed in place of the migrations step when `deploy.migrations` is `false`. */
24
+ export declare const ASTROID_SKIP_MIGRATIONS_NOTE = "(deploy.migrations is false, so this app applies no migrations: another app owns this database's schema)";
25
+ interface ShipContext {
26
+ /** The project's `wrangler.jsonc` text. */
27
+ wrangler: string;
28
+ /** The project root's absolute path. */
29
+ root: string;
30
+ /** Workers Builds' `WORKERS_CI_BRANCH`, which names the Preview. */
31
+ branch?: string;
32
+ }
33
+ /** The steps `astroid ship <target>` runs for a project. */
34
+ export declare function astroidShipPlan(target: "production" | "preview", config: AstroidConfig, { wrangler, root, branch }: ShipContext): ShipStep[];
35
+ /**
36
+ * `astroid doctor`'s check on who migrates this app's database. Returns an
37
+ * error when `deploy.migrations` is `false` but `wrangler.jsonc` still names a
38
+ * `migrations_dir` for `DB`, since that contradiction means someone expects
39
+ * this app to migrate. `null` when there's nothing to report.
40
+ */
41
+ export declare function migrationsOwnershipError(config: AstroidConfig, wrangler: string): string | null;
42
+ export {};
@@ -0,0 +1,115 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // What `astroid ship` runs, as a plan: Workers Builds calls
4
+ // `astroid ship production` on the `deploy/production` build and
5
+ // `astroid ship preview` on every other branch.
6
+ //
7
+ // By default both targets apply D1 migrations first. An app whose database
8
+ // another app migrates sets `deploy.migrations: false`, and then neither target
9
+ // touches the migrations ledger. Two apps on one database that both migrated
10
+ // would race: one release tag deploys both Workers in no guaranteed order,
11
+ // nothing serializes two `wrangler d1 migrations apply` runs, and a
12
+ // non-idempotent statement such as `ALTER TABLE … ADD COLUMN` then fails the
13
+ // second deploy.
14
+ //
15
+ // Pure: config and wrangler.jsonc text in, steps out, so it's tested directly
16
+ // and the CLI only runs them.
17
+ import { parseJsonc } from "./previews.js";
18
+ /** Whether this app applies D1 migrations when it deploys. Default `true`. */
19
+ export function astroidRunsMigrations(config) {
20
+ return config.deploy?.migrations !== false;
21
+ }
22
+ /** Where `ship preview` writes the config that names the staging database. */
23
+ export const ASTROID_PREVIEW_MIGRATIONS_CONFIG = ".wrangler/astroid-preview-migrations.jsonc";
24
+ /** Printed in place of the migrations step when `deploy.migrations` is `false`. */
25
+ export const ASTROID_SKIP_MIGRATIONS_NOTE = "(deploy.migrations is false, so this app applies no migrations: another app owns this database's schema)";
26
+ /** The steps `astroid ship <target>` runs for a project. */
27
+ export function astroidShipPlan(target, config, { wrangler, root, branch }) {
28
+ const migrate = astroidRunsMigrations(config);
29
+ if (target === "production") {
30
+ return [
31
+ migrate
32
+ ? { run: ["d1", "migrations", "apply", "DB", "--remote"] }
33
+ : { note: ASTROID_SKIP_MIGRATIONS_NOTE },
34
+ { run: ["deploy"] },
35
+ ];
36
+ }
37
+ const steps = [];
38
+ if (!migrate) {
39
+ steps.push({ note: ASTROID_SKIP_MIGRATIONS_NOTE });
40
+ }
41
+ else {
42
+ // The staging database is declared only inside `previews`, and wrangler's
43
+ // migrations command reads top-level `d1_databases`. So write a throwaway
44
+ // config naming it, derived from wrangler.jsonc on every run, rather than a
45
+ // second committed file that could drift from the binding.
46
+ const parsed = parseJsonc(wrangler);
47
+ const prodDb = (parsed.d1_databases ?? []).find((d) => d.binding === "DB");
48
+ const stagingDb = (parsed.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
49
+ if (stagingDb && prodDb) {
50
+ steps.push({
51
+ write: {
52
+ path: ASTROID_PREVIEW_MIGRATIONS_CONFIG,
53
+ contents: JSON.stringify({
54
+ d1_databases: [
55
+ {
56
+ binding: "PREVIEW_DB",
57
+ database_name: stagingDb.database_name,
58
+ database_id: stagingDb.database_id,
59
+ // Absolute, so it doesn't depend on how Wrangler resolves a
60
+ // relative path in a config that lives in `.wrangler/`.
61
+ migrations_dir: fromRoot(root, prodDb.migrations_dir ?? "migrations"),
62
+ },
63
+ ],
64
+ }, null, 2),
65
+ },
66
+ }, {
67
+ run: [
68
+ "d1",
69
+ "migrations",
70
+ "apply",
71
+ "PREVIEW_DB",
72
+ "--remote",
73
+ "--config",
74
+ ASTROID_PREVIEW_MIGRATIONS_CONFIG,
75
+ ],
76
+ });
77
+ }
78
+ else {
79
+ steps.push({ note: "(no staging D1 in `previews`, so no staging migrations to apply)" });
80
+ }
81
+ }
82
+ // Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
83
+ // DNS label, so a branch like feature/12-login becomes feature-12-login.
84
+ const name = branch
85
+ ? branch
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9-]+/g, "-")
88
+ .replace(/^-+|-+$/g, "")
89
+ .slice(0, 63)
90
+ : undefined;
91
+ steps.push({ run: ["preview", ...(name ? ["--name", name] : [])] });
92
+ return steps;
93
+ }
94
+ /** A project-relative path made absolute. An absolute one is kept. */
95
+ function fromRoot(root, path) {
96
+ if (path.startsWith("/"))
97
+ return path;
98
+ const relative = path.replace(/^\.\//, "").replace(/\/+$/, "");
99
+ return `${root.replace(/\/+$/, "")}/${relative}`;
100
+ }
101
+ /**
102
+ * `astroid doctor`'s check on who migrates this app's database. Returns an
103
+ * error when `deploy.migrations` is `false` but `wrangler.jsonc` still names a
104
+ * `migrations_dir` for `DB`, since that contradiction means someone expects
105
+ * this app to migrate. `null` when there's nothing to report.
106
+ */
107
+ export function migrationsOwnershipError(config, wrangler) {
108
+ if (astroidRunsMigrations(config))
109
+ return null;
110
+ if (!/"migrations_dir"\s*:/.test(wrangler))
111
+ return null;
112
+ return ("astroid.config sets deploy.migrations to false, but wrangler.jsonc still declares a " +
113
+ "`migrations_dir`. Remove `migrations_dir` if another app owns this database's schema, " +
114
+ "or drop `migrations: false` if this app does.");
115
+ }
@@ -1,4 +1,4 @@
1
1
  export { astroidQueueHandler, type QueueHandlerOptions } from "./consumer.js";
2
- export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, type AstroidQueueMessage, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
2
+ export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, type AstroidQueueMessage, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
3
3
  export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
4
- export { handleWebhook, type QueueProducer, type WebhookRouteOptions, type WebhookVerifyInput, } from "./webhook.js";
4
+ export { astroidQueue, handleWebhook, type QueueProducer, type WebhookRouteOptions, type WebhookVerifyInput, } from "./webhook.js";
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
2
  export { astroidQueueHandler } from "./consumer.js";
3
- export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
3
+ export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
4
4
  export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
5
- export { handleWebhook, } from "./webhook.js";
5
+ export { astroidQueue, handleWebhook, } from "./webhook.js";
@@ -31,6 +31,12 @@ export declare const ASTROID_HEALTH_CRON = "17 4 * * *";
31
31
  export declare function astroidCrons(config: AstroidConfig): string[];
32
32
  /** Binding name for the project's queue producer. */
33
33
  export declare const ASTROID_QUEUE_BINDING = "COMMERCE_QUEUE";
34
+ /**
35
+ * Seconds Cloudflare waits before redelivering a failed message, when the
36
+ * config sets no `queues.retryDelay`. A consumer that sets its own delay per
37
+ * message, as `processBatch` can, overrides it.
38
+ */
39
+ export declare const ASTROID_QUEUE_RETRY_DELAY = 30;
34
40
  /** Queue names derived from the project key—the main queue and its DLQ. */
35
41
  export declare function astroidQueueNames(config: AstroidConfig): {
36
42
  queue: string;
@@ -52,6 +52,12 @@ export function astroidCrons(config) {
52
52
  }
53
53
  /** Binding name for the project's queue producer. */
54
54
  export const ASTROID_QUEUE_BINDING = "COMMERCE_QUEUE";
55
+ /**
56
+ * Seconds Cloudflare waits before redelivering a failed message, when the
57
+ * config sets no `queues.retryDelay`. A consumer that sets its own delay per
58
+ * message, as `processBatch` can, overrides it.
59
+ */
60
+ export const ASTROID_QUEUE_RETRY_DELAY = 30;
55
61
  /** Queue names derived from the project key—the main queue and its DLQ. */
56
62
  export function astroidQueueNames(config) {
57
63
  return { queue: `${config.key}-commerce`, dlq: `${config.key}-commerce-dlq` };