astroidjs 0.16.0 → 0.18.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.
@@ -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` };
@@ -105,9 +105,20 @@ export function generateAstroidQueueSeam(config) {
105
105
  "// MEANS for this project.",
106
106
  "//",
107
107
  "// Throwing marks the message for retry. That's usually right—a failed",
108
- "// refresh means the site is serving stale data—and Cloudflare routes it to",
109
- "// the DLQ once it exceeds max_retries (wrangler.jsonc).",
110
- 'import { astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
108
+ "// refresh means the site is serving stale data—and the queue owns the",
109
+ "// retries: it waits `retry_delay` between deliveries and routes the message",
110
+ "// to the DLQ once it exceeds max_retries (wrangler.jsonc).",
111
+ 'import { astroidQueue, astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
112
+ "",
113
+ `// Send every message through this, never \`env.${ASTROID_QUEUE_BINDING}\` directly: the`,
114
+ "// queue binding when there is one, otherwise `handleQueueMessage` run in the",
115
+ "// request. A staging Preview has no queue, because a consumer can't target a",
116
+ "// Preview, so a producer that sends straight to the binding throws there.",
117
+ "// The inline path outlasts nothing but the request, with no retry and no",
118
+ "// DLQ, so it's a Preview fallback, not a production path.",
119
+ "export function commerceQueue(env: CloudflareEnv) {",
120
+ ` return astroidQueue(env.${ASTROID_QUEUE_BINDING}, (message) => handleQueueMessage(env, message));`,
121
+ "}",
111
122
  "",
112
123
  "export async function handleQueueMessage(",
113
124
  " env: CloudflareEnv,",
@@ -135,20 +146,22 @@ export function generateAstroidQueueSeam(config) {
135
146
  ` // const r = await astroidCatalogSync(items, { db: env.DB, table: ${JSON.stringify(table)} });`,
136
147
  ' // if (r.failed > 0) console.warn("[catalog] skipped", r.failed, r.errors);',
137
148
  " //",
138
- // Nobody is watching a queue message, which flips the retry trade-off that
139
- // an attended checkout route settles the other way.
149
+ // One layer owns retries, and it's the queue: it survives the isolate,
150
+ // waits between deliveries, and ends in the DLQ. Retries inside the handler
151
+ // multiply with the queue's (#61).
140
152
  ...(provider === "square"
141
153
  ? [
142
- " // Nobody is watching this run, so let the client retry: SquareConfig",
143
- " // takes `retry: { attempts: 3 }`, backing off on 429/5xx inside every",
144
- " // verb. It is off by default because a checkout route has a customer",
145
- " // watching a spinner, and there a fast failure beats a slow one. Here",
146
- " // the opposite holds—a catalog push that gives up halfway is worse.",
154
+ " // Leave SquareConfig's `retry` off here. The queue already redelivers",
155
+ " // a failed message, `retry_delay` apart, up to max_retries times",
156
+ " // (wrangler.jsonc). Client retries multiply with those deliveries:",
157
+ " // `retry: { attempts: 3 }` on a queue with max_retries 5 is up to 24",
158
+ " // Square calls for one message, against a provider that's already",
159
+ " // failing or rate limiting.",
147
160
  ]
148
161
  : [
149
- " // Nobody is watching this run, so ask for backoff on 429/5xx wherever",
150
- " // the client offers it. Attended paths are better off failing fast; a",
151
- " // catalog push that gives up halfway is not.",
162
+ " // Leave the client's own retries off here. The queue already",
163
+ " // redelivers a failed message, `retry_delay` apart, up to max_retries",
164
+ " // times (wrangler.jsonc), and client retries multiply with those.",
152
165
  ]),
153
166
  " //",
154
167
  " // The sync is idempotent (keyed on the provider's id) and never",
@@ -198,12 +211,13 @@ export function generateAstroidWebhookRoute(config, forProvider) {
198
211
  "// secret land afterwards instead of being lost.",
199
212
  "//",
200
213
  "// A staging Preview has no queue, since a consumer can't target one, so",
201
- "// there the event runs in the request through the same handler instead.",
214
+ "// `commerceQueue` runs the event in the request through the consumer's own",
215
+ "// handler instead (src/queue.ts).",
202
216
  'import type { APIRoute } from "astro";',
203
217
  'import { handleWebhook, readModuleSecret } from "astroidjs";',
204
218
  'import { env } from "cloudflare:workers";',
205
219
  `import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
206
- 'import { handleQueueMessage } from "../../../queue";',
220
+ 'import { commerceQueue } from "../../../queue";',
207
221
  "",
208
222
  "export const prerender = false;",
209
223
  "",
@@ -214,8 +228,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
214
228
  " return handleWebhook(request, url, {",
215
229
  ` provider: ${JSON.stringify(provider)},`,
216
230
  ` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
217
- ` queue: env.${ASTROID_QUEUE_BINDING},`,
218
- " inline: (message) => handleQueueMessage(env, message),",
231
+ " queue: commerceQueue(env),",
219
232
  ` verify: ({ raw, secret }) => ${p.call},`,
220
233
  " });",
221
234
  "};",
@@ -10,6 +10,29 @@ import type { AstroidQueueMessage } from "./messages.js";
10
10
  export interface QueueProducer<T = AstroidQueueMessage> {
11
11
  send(message: T): Promise<unknown>;
12
12
  }
13
+ /**
14
+ * The queue binding when it's bound, and otherwise a stand-in producer that
15
+ * runs `handler` on each message in the request.
16
+ *
17
+ * A staging Preview leaves the queue unbound, because a queue consumer can't
18
+ * target a Preview (louise-toolkit ADR 0017), so every producer that sends
19
+ * straight to the binding throws there. Send through this instead, with the
20
+ * handler the queue consumer runs, and the same code works on both:
21
+ *
22
+ * ```ts
23
+ * export const commerceQueue = (env: CloudflareEnv) =>
24
+ * astroidQueue(env.COMMERCE_QUEUE, (message) => handleQueueMessage(env, message));
25
+ *
26
+ * await commerceQueue(env).send({ kind: "catalog_refresh" });
27
+ * ```
28
+ *
29
+ * It's a Preview fallback, not a production path. A message run inline lasts
30
+ * only as long as the request, gets no retry and no dead-letter queue, and a
31
+ * slow one holds the response open. The stand-in's `send` throws whatever the
32
+ * handler throws, where a real `send` would have succeeded and left the failure
33
+ * to the consumer. The binding always wins when it's there.
34
+ */
35
+ export declare function astroidQueue<T = AstroidQueueMessage>(queue: QueueProducer<T> | null | undefined, handler: (message: T) => Promise<void>): QueueProducer<T>;
13
36
  export interface WebhookVerifyInput {
14
37
  /** The raw request body, exactly as received. */
15
38
  raw: string;
@@ -28,10 +51,16 @@ export interface WebhookRouteOptions {
28
51
  secret: string | null;
29
52
  /** Signature check over the raw body—for example, `verifySquareSignature`. */
30
53
  verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
31
- /** The queue binding, or null/undefined when Queues aren't provisioned. */
54
+ /**
55
+ * The queue binding, or null/undefined when Queues aren't provisioned. Pass
56
+ * {@link astroidQueue}'s producer to run the message in the request when the
57
+ * binding is absent, such as on a staging Preview.
58
+ */
32
59
  queue?: QueueProducer | null;
33
60
  /**
34
- * Run a message in the request instead, when `queue` is absent. A staging
61
+ * Run a message in the request instead, when `queue` is absent. Prefer
62
+ * passing {@link astroidQueue}'s producer as `queue`, which covers every
63
+ * producer in a site rather than this one route. A staging
35
64
  * Preview leaves the queue unbound, because a queue consumer can't target a
36
65
  * Preview (louise-toolkit ADR 0017), so without this every webhook a Preview
37
66
  * receives answers 503 and the provider retries it forever.
@@ -14,6 +14,37 @@
14
14
  // available: a 5xx means "try again", a 4xx means "never again", and returning
15
15
  // the wrong one either loses the event permanently or pins the provider in a
16
16
  // retry loop. Each code below is chosen for what it tells the sender to do.
17
+ /**
18
+ * The queue binding when it's bound, and otherwise a stand-in producer that
19
+ * runs `handler` on each message in the request.
20
+ *
21
+ * A staging Preview leaves the queue unbound, because a queue consumer can't
22
+ * target a Preview (louise-toolkit ADR 0017), so every producer that sends
23
+ * straight to the binding throws there. Send through this instead, with the
24
+ * handler the queue consumer runs, and the same code works on both:
25
+ *
26
+ * ```ts
27
+ * export const commerceQueue = (env: CloudflareEnv) =>
28
+ * astroidQueue(env.COMMERCE_QUEUE, (message) => handleQueueMessage(env, message));
29
+ *
30
+ * await commerceQueue(env).send({ kind: "catalog_refresh" });
31
+ * ```
32
+ *
33
+ * It's a Preview fallback, not a production path. A message run inline lasts
34
+ * only as long as the request, gets no retry and no dead-letter queue, and a
35
+ * slow one holds the response open. The stand-in's `send` throws whatever the
36
+ * handler throws, where a real `send` would have succeeded and left the failure
37
+ * to the consumer. The binding always wins when it's there.
38
+ */
39
+ export function astroidQueue(queue, handler) {
40
+ if (queue)
41
+ return queue;
42
+ return {
43
+ send: async (message) => {
44
+ await handler(message);
45
+ },
46
+ };
47
+ }
17
48
  const text = (body, status) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
18
49
  /** Default event-type reader: a top-level `type` string. */
19
50
  function defaultEventType(payload) {
@@ -1,13 +1,14 @@
1
1
  import type { AstroidConfig } from "../config.js";
2
2
  /** A ready-made table Astroid re-exports from `louise-toolkit/db`. */
3
- export type AstroidFrameworkTable = "inquiries" | "media" | "siteSettings";
3
+ export type AstroidFrameworkTable = "inquiries" | "media" | "pageRedirects" | "siteSettings";
4
4
  /** True when the site captures inquiries—a contact section or a
5
5
  * wholesale-inquiry module. Shared by table selection (here) and route selection
6
6
  * (the worker route plan). */
7
7
  export declare function capturesInquiries(config: AstroidConfig): boolean;
8
8
  /**
9
9
  * The framework tables this project needs, sorted alphabetically (so the emitted
10
- * import/export lists are stable). `media` + `siteSettings` always; `inquiries`
11
- * when a brand captures them.
10
+ * import/export lists are stable). `media`, `pageRedirects`, and `siteSettings`
11
+ * always; `inquiries` when a brand captures them. `pageRedirects` keeps a
12
+ * renamed page's old URL working, which every site with a Pages panel needs.
12
13
  */
13
14
  export declare function astroidFrameworkTables(config: AstroidConfig): AstroidFrameworkTable[];
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // Which ready-made Louise tables (louise-toolkit/db) an Astroid project needs.
4
4
  // Astroid re-exports these rather than redefining them, so the core content
5
- // tables never drift from Louise. `media` and `siteSettings` are universal;
6
- // `inquiries` is pulled in only when a brand actually captures inquiries (a
7
- // contact section, or a wholesale-inquiry module).
5
+ // tables never drift from Louise. `media`, `pageRedirects`, and `siteSettings`
6
+ // are universal; `inquiries` is pulled in only when a brand actually captures
7
+ // inquiries (a contact section, or a wholesale-inquiry module).
8
8
  /** True when the site captures inquiries—a contact section or a
9
9
  * wholesale-inquiry module. Shared by table selection (here) and route selection
10
10
  * (the worker route plan). */
@@ -21,11 +21,12 @@ export function capturesInquiries(config) {
21
21
  }
22
22
  /**
23
23
  * The framework tables this project needs, sorted alphabetically (so the emitted
24
- * import/export lists are stable). `media` + `siteSettings` always; `inquiries`
25
- * when a brand captures them.
24
+ * import/export lists are stable). `media`, `pageRedirects`, and `siteSettings`
25
+ * always; `inquiries` when a brand captures them. `pageRedirects` keeps a
26
+ * renamed page's old URL working, which every site with a Pages panel needs.
26
27
  */
27
28
  export function astroidFrameworkTables(config) {
28
- const tables = ["media", "siteSettings"];
29
+ const tables = ["media", "pageRedirects", "siteSettings"];
29
30
  if (capturesInquiries(config))
30
31
  tables.push("inquiries");
31
32
  return tables.sort();
@@ -0,0 +1,12 @@
1
+ import type { AiGatewayOptions } from "louise-toolkit/ai";
2
+ /** The `vars` entry that names the site's AI Gateway. */
3
+ export declare const ASTROID_AI_GATEWAY_VAR = "AI_GATEWAY_ID";
4
+ /**
5
+ * The AI Gateway options for this request's environment, or `undefined` to
6
+ * call Workers AI directly. Reads `AI_GATEWAY_ID`: unset, empty, or the
7
+ * placeholder sentinel all mean off.
8
+ *
9
+ * Takes `env` as `unknown`, like the toolkit's `aiRunner`, so a site whose
10
+ * `CloudflareEnv` doesn't declare the variable still compiles.
11
+ */
12
+ export declare function astroidAiGateway(env: unknown): AiGatewayOptions | undefined;
@@ -0,0 +1,32 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // AI Gateway for the editor's AI assists, off until a site names one.
4
+ //
5
+ // Every louise-toolkit AI route takes a `gateway` option, and without one no
6
+ // successful AI call is logged anywhere: no latency, no error rate, and no
7
+ // cache. A gateway gives all three with no toolkit change. It's a variable,
8
+ // not config, so production can log through one while a Preview, which sets
9
+ // no variable, calls Workers AI directly.
10
+ //
11
+ // The log holds the text an editor sends to the assists. Say so on the site's
12
+ // privacy page before you set the variable.
13
+ import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
14
+ /** The `vars` entry that names the site's AI Gateway. */
15
+ export const ASTROID_AI_GATEWAY_VAR = "AI_GATEWAY_ID";
16
+ /**
17
+ * The AI Gateway options for this request's environment, or `undefined` to
18
+ * call Workers AI directly. Reads `AI_GATEWAY_ID`: unset, empty, or the
19
+ * placeholder sentinel all mean off.
20
+ *
21
+ * Takes `env` as `unknown`, like the toolkit's `aiRunner`, so a site whose
22
+ * `CloudflareEnv` doesn't declare the variable still compiles.
23
+ */
24
+ export function astroidAiGateway(env) {
25
+ const id = env?.[ASTROID_AI_GATEWAY_VAR];
26
+ if (typeof id !== "string")
27
+ return undefined;
28
+ const trimmed = id.trim();
29
+ if (!trimmed || trimmed === ASTROID_SECRET_PLACEHOLDER)
30
+ return undefined;
31
+ return { id: trimmed };
32
+ }
@@ -68,10 +68,14 @@ export function generateAstroidWorker(config) {
68
68
  const AI_ROUTES = new Set(["ai", "seoFix", "media"]);
69
69
  const editorImports = [
70
70
  "DEFAULT_PAGE_FIELDS",
71
+ "type PagesWrite",
72
+ "MEDIA_ALT_MISSING_SQL",
73
+ "d1Check",
71
74
  ...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
72
75
  ].sort();
73
76
  const tables = [
74
77
  "media",
78
+ "pageRedirects",
75
79
  "pages",
76
80
  "pagesVersions",
77
81
  "siteSettings",
@@ -89,10 +93,13 @@ export function generateAstroidWorker(config) {
89
93
  return `vitalsRoute({ dataset: (env) => env.${ASTROID_VITALS_BINDING} })`;
90
94
  case "health":
91
95
  return "healthRoute({ resolveEditor, read: readSiteHealth })";
96
+ case "status":
97
+ return "statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS })";
92
98
  case "realtime":
93
99
  return `realtimeRoute({ resolveEditor, namespace: (env) => env.${ASTROID_REALTIME_BINDING} })`;
94
100
  case "versions":
95
- return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor, bufferKv: (env) => env.DRAFTS })";
101
+ // `redirects` records `/old → /new` when a publish changes the slug.
102
+ return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor, bufferKv: (env) => env.DRAFTS, redirects: pageRedirects })";
96
103
  case "search":
97
104
  return "searchRoute({ table: pages, config: pagesCollection, resolveEditor })";
98
105
  case "pages":
@@ -104,9 +111,17 @@ export function generateAstroidWorker(config) {
104
111
  //
105
112
  // `versionsTable` makes a DELETE remove the page's version snapshots,
106
113
  // 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 })';
114
+ // `afterWrite` syncs the written page's search index entry, which plain
115
+ // CRUD writes leave stale.
116
+ //
117
+ // `drafts` carries a Pages panel update into the page's pending draft,
118
+ // with the same config and buffer versionsRoute uses. Without it, a
119
+ // rename made while a draft was pending came undone at the next
120
+ // publish, which copies the whole draft snapshot onto the live row.
121
+ //
122
+ // `redirects` records `/old → /new` in the same batch as a slug change,
123
+ // and the middleware's `redirectFor` serves it.
124
+ return 'pagesRoute({ table: pages, versionsTable: pagesVersions, drafts: { config: pagesCollection, bufferKv: (env) => env.DRAFTS }, redirects: pageRedirects, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
110
125
  case "save":
111
126
  // No `bufferKv` here, deliberately: `saveRoute` has no such option. It
112
127
  // writes live field saves (title, SEO) straight through, and the draft
@@ -130,10 +145,12 @@ export function generateAstroidWorker(config) {
130
145
  // LOUISE_AI kill switch, so all three assists share one definition of
131
146
  // "is generation on?" instead of each re-deriving it. Embeddings keep
132
147
  // binding-presence as their switch—see the helper's comment.
148
+ // `gateway` routes both through AI Gateway when the site sets
149
+ // `AI_GATEWAY_ID`, and calls Workers AI directly when it doesn't.
133
150
  case "ai":
134
- return "aiRoute({ resolveEditor, ai: aiRunner })";
151
+ return "aiRoute({ resolveEditor, ai: aiRunner, gateway: astroidAiGateway })";
135
152
  case "seoFix":
136
- return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner })";
153
+ return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner, gateway: astroidAiGateway })";
137
154
  case "media": {
138
155
  // `altText` fills a new upload's alt from the image itself. Best-effort
139
156
  // by contract—a model error or a missing binding never fails the
@@ -175,7 +192,7 @@ export function generateAstroidWorker(config) {
175
192
  p('import { env } from "cloudflare:workers";');
176
193
  p('import { handle } from "@astrojs/cloudflare/handler";');
177
194
  p('import type { EditorSession } from "louise-toolkit/auth";');
178
- p('import { createLocalApi } from "louise-toolkit/content";');
195
+ p('import { reindexDoc } from "louise-toolkit/content";');
179
196
  p(inquiries
180
197
  ? 'import { db, inquiriesForm } from "louise-toolkit/db";'
181
198
  : 'import { db } from "louise-toolkit/db";');
@@ -196,10 +213,14 @@ export function generateAstroidWorker(config) {
196
213
  p('import { aiRunner } from "louise-toolkit/ai";');
197
214
  }
198
215
  p('import { checkLinks } from "louise-toolkit/browser";');
216
+ p('import { reportDegraded } from "louise-toolkit/errors";');
199
217
  p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
200
218
  p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
201
219
  p(`import { ${tables.join(", ")} } from "./schema.js";`);
202
220
  const astroidImports = [
221
+ ...(plan.some((route) => route.name === "ai" || route.name === "seoFix")
222
+ ? ["astroidAiGateway"]
223
+ : []),
203
224
  "astroidPagesCollection",
204
225
  "astroidPagesWriteHooks",
205
226
  "readModuleSecret",
@@ -220,6 +241,11 @@ export function generateAstroidWorker(config) {
220
241
  p("// route. Scaffolded once and yours to edit.");
221
242
  p('import { pagesHooks } from "./pages-hooks.js";');
222
243
  }
244
+ if (config.status?.checks) {
245
+ p("// Your STATUS seam: the site's own checks for the public status route,");
246
+ p("// such as a catalog snapshot's age. Scaffolded once and yours to edit.");
247
+ p('import { statusChecks } from "./status-checks.js";');
248
+ }
223
249
  if (config.settings?.hooks) {
224
250
  p("// Your SETTINGS seam: per-key sanitizers and a GET transform for the");
225
251
  p("// Settings panel. Scaffolded once and yours to edit.");
@@ -251,10 +277,12 @@ export function generateAstroidWorker(config) {
251
277
  : "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
252
278
  p();
253
279
  p("// pagesRoute writes with plain Drizzle, so the full-text index doesn't see a");
254
- p("// title or slug change until something rebuilds it. Best-effort: pagesRoute");
255
- p("// swallows a throw here, so a stale index never fails the write itself.");
256
- p("async function reindexPagesSearch(editor: EditorSession): Promise<void> {");
257
- p(" await createLocalApi(db(env.DB), pages, pagesCollection).reindexSearch({ session: editor });");
280
+ p("// title or slug change until something syncs it. This syncs only the page the");
281
+ p("// write touched, rather than rebuilding the whole index on every save. After a");
282
+ p("// delete the row is gone, and reindexDoc removes its entry. Best-effort:");
283
+ p("// pagesRoute swallows a throw here, so a stale index never fails the write.");
284
+ p("async function reindexPagesSearch(_editor: EditorSession, { id }: PagesWrite): Promise<void> {");
285
+ p(" await reindexDoc(db(env.DB), pages, pagesCollection, id);");
258
286
  p("}");
259
287
  p();
260
288
  p("// Editable site_settings columns the Settings panel may write, and which of");
@@ -311,7 +339,9 @@ export function generateAstroidWorker(config) {
311
339
  p(" const origin = env.SITE_URL ?? mediaBaseOf(env);");
312
340
  p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
313
341
  p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
314
- p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
342
+ p(" // Only an unwritten alt (NULL) is missing. An empty one is an image the");
343
+ p(" // owner marked decorative, which HTML says to skip.");
344
+ p(" countRows(env, `SELECT COUNT(*) AS n FROM media WHERE ${MEDIA_ALT_MISSING_SQL}`),");
315
345
  p(" countRows(");
316
346
  p(" env,");
317
347
  p(" \"SELECT COUNT(*) AS n FROM pages WHERE status = 'published'\" +");
@@ -327,6 +357,27 @@ export function generateAstroidWorker(config) {
327
357
  p(" return summary;");
328
358
  p("}");
329
359
  p();
360
+ p("// --- public status ---------------------------------------------------------");
361
+ p("// What statusRoute answers an outside probe with: 200 when every check");
362
+ p("// passes, 503 when any fails, throws, or takes over two seconds. Anyone can");
363
+ p("// make these run, so each is one cheap read, and a burst of probes reuses one");
364
+ p("// result per isolate for STATUS_REUSE_MS.");
365
+ p("const STATUS_REUSE_MS = 10_000;");
366
+ p("const STATUS_CHECKS = {");
367
+ p(" // D1 answers `SELECT 1`.");
368
+ p(" d1: d1Check((env: CloudflareEnv) => env.DB),");
369
+ p(" // The public home page reads real content, not the seed-me fallback the");
370
+ p(" // page renders when its row is missing (an unseeded or wiped database,");
371
+ p(" // or a migration that never ran, which throws and fails the check too).");
372
+ p(" content: async (env: CloudflareEnv) =>");
373
+ p(` (await env.DB.prepare("SELECT 1 FROM pages WHERE slug = 'home'").first()) !== null,`);
374
+ if (config.status?.checks) {
375
+ p(" // The site's own, from src/status-checks.ts. Spread last, so a site");
376
+ p(" // check with the same name replaces Astroid's on purpose.");
377
+ p(" ...statusChecks,");
378
+ }
379
+ p("};");
380
+ p();
330
381
  p("/** One COUNT, degrading to 0—a missing table must not abort the scan. */");
331
382
  p("async function countRows(env: CloudflareEnv, sql: string): Promise<number> {");
332
383
  p(" try {");
@@ -473,7 +524,9 @@ export function generateAstroidWorker(config) {
473
524
  p(` if (controller.cron === ${JSON.stringify(ASTROID_HEALTH_CRON)}) {`);
474
525
  p(" // Daily site-health scan. `waitUntil` because the crawl outlives the");
475
526
  p(" // handler's return, and a scan that throws must not retry the cron.");
476
- p(" ctx.waitUntil(runHealthScan(env).catch(() => {}));");
527
+ p(" // Reported rather than swallowed: the Health panel keeps showing the");
528
+ p(" // last good scan, so this log line is the only sign that one failed.");
529
+ p(' ctx.waitUntil(runHealthScan(env).catch((error) => reportDegraded("health.scan", error)));');
477
530
  p(" return;");
478
531
  p(" }");
479
532
  if (cron) {
@@ -552,6 +605,7 @@ export function generateAstroidMiddleware(config) {
552
605
  "// styles + inlined data: brand font are allowed.",
553
606
  'import { env } from "cloudflare:workers";',
554
607
  'import { createLouiseMiddleware } from "@louise-toolkit/astro";',
608
+ 'import { db, pageRedirects, resolvePageRedirect } from "louise-toolkit/db";',
555
609
  // One `astroidjs` import, composed from what this config actually uses—two
556
610
  // import statements for the same module is legal and reads as an
557
611
  // oversight in a file nobody is supposed to hand-edit.
@@ -609,6 +663,9 @@ export function generateAstroidMiddleware(config) {
609
663
  " // anything the worker's routes didn't answer. A second check behind the",
610
664
  " // worker's gate, and free: the editor is resolved here on every request.",
611
665
  " apiGate: true,",
666
+ " // A renamed page's old URL answers a 301 to the new one. It runs only after",
667
+ " // the page answered 404, so a page later created on the old path wins.",
668
+ " redirectFor: (path) => resolvePageRedirect(db(env.DB), pageRedirects, path),",
612
669
  // `extend` runs once and may need to populate BOTH—a tenanted site with a
613
670
  // portal resolves a tenant and a customer on the same request.
614
671
  ...(portal || tenancy
@@ -1,2 +1,3 @@
1
1
  export * from "./routes.js";
2
2
  export * from "./generate.js";
3
+ export * from "./gateway.js";
@@ -4,3 +4,4 @@
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";
7
+ export * from "./gateway.js";