astroidjs 0.20.0 → 0.21.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/dist/config.d.ts CHANGED
@@ -495,6 +495,24 @@ export interface StatusConfig {
495
495
  */
496
496
  checks?: boolean;
497
497
  }
498
+ /**
499
+ * Incident capture (louise-toolkit ADR 0022). Every Astroid site counts its
500
+ * failures into its own D1 and an Analytics Engine dataset; these settings add
501
+ * to that.
502
+ */
503
+ export interface IncidentsConfig {
504
+ /**
505
+ * The failures that should alert: dotted names (`commerce.checkout`) and
506
+ * path prefixes (`/cart`). A site fact, so there's no default list.
507
+ */
508
+ critical?: string[];
509
+ /**
510
+ * Also send each incident to Sentry, the operator's issue system for a
511
+ * Monitored or Supported site. It reads the DSN from the `SENTRY_DSN`
512
+ * secret, and stays dormant while that's unset or a placeholder.
513
+ */
514
+ sentry?: boolean;
515
+ }
498
516
  export interface DeployConfig {
499
517
  platform: "cloudflare";
500
518
  /** Media base for R2 + `cf-image` resizing—matches Louise's media route
@@ -623,6 +641,8 @@ export interface AstroidConfig {
623
641
  pages?: PagesConfig;
624
642
  /** The public status route's site-owned checks. */
625
643
  status?: StatusConfig;
644
+ /** Incident capture settings: what alerts, and whether Sentry gets a copy. */
645
+ incidents?: IncidentsConfig;
626
646
  /**
627
647
  * Force the contact form + `inquiries` table on or off. Omit to detect from
628
648
  * the config (a `contact` section, or a wholesale-inquiry module). Set `true`
@@ -0,0 +1,2 @@
1
+ export * from "./names.js";
2
+ export * from "./sentry.js";
@@ -0,0 +1,12 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Incident capture, Astroid's side (louise-toolkit ADR 0022, amended).
4
+ //
5
+ // louise-toolkit counts every failure into the site's own D1 and, optionally,
6
+ // Analytics Engine. What's opinion, and so lives here: that every Astroid site
7
+ // captures incidents by default, the binding and dataset names, the migration
8
+ // an existing site needs, and the Sentry sink. Sentry is the operator's issue
9
+ // system for a Monitored or Supported site; it's never the record, and it's
10
+ // never in louise-toolkit's zero-dependency core (ADR 0016 § 7).
11
+ export * from "./names.js";
12
+ export * from "./sentry.js";
@@ -0,0 +1,17 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** The Analytics Engine binding incident counts go to. */
3
+ export declare const ASTROID_INCIDENT_EVENTS_BINDING = "INCIDENT_EVENTS";
4
+ /** The version metadata binding a report's `release` comes from. */
5
+ export declare const ASTROID_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
6
+ /** The binding the Sentry sink reads its DSN from: a Worker secret or a
7
+ * Secrets Store binding. This is its name, not the DSN. */
8
+ export declare const ASTROID_SENTRY_DSN_BINDING = "SENTRY_DSN";
9
+ /** The incident counts dataset: `<key>_incidents`, apart from the Core Web Vitals one. */
10
+ export declare function astroidIncidentEventsDataset(config: AstroidConfig): string;
11
+ /**
12
+ * `migrations/0006_incidents.sql`: the `incidents` and `dead_letters` tables
13
+ * (louise-toolkit/incidents). The same DDL drizzle-kit writes for them, with
14
+ * `IF NOT EXISTS`, since a site that already added a table by hand must not
15
+ * fail on it.
16
+ */
17
+ export declare const ASTROID_INCIDENTS_MIGRATION: string;
@@ -0,0 +1,55 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Incident capture's names and migration (louise-toolkit ADR 0022). No
4
+ // runtime imports, so the CLI's generators can read them without loading any
5
+ // louise-toolkit subpath.
6
+ /** The Analytics Engine binding incident counts go to. */
7
+ export const ASTROID_INCIDENT_EVENTS_BINDING = "INCIDENT_EVENTS";
8
+ /** The version metadata binding a report's `release` comes from. */
9
+ export const ASTROID_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
10
+ /** The binding the Sentry sink reads its DSN from: a Worker secret or a
11
+ * Secrets Store binding. This is its name, not the DSN. */
12
+ export const ASTROID_SENTRY_DSN_BINDING = "SENTRY_DSN";
13
+ /** The incident counts dataset: `<key>_incidents`, apart from the Core Web Vitals one. */
14
+ export function astroidIncidentEventsDataset(config) {
15
+ return `${config.key.replace(/[^a-z0-9_]/gi, "_")}_incidents`;
16
+ }
17
+ /**
18
+ * `migrations/0006_incidents.sql`: the `incidents` and `dead_letters` tables
19
+ * (louise-toolkit/incidents). The same DDL drizzle-kit writes for them, with
20
+ * `IF NOT EXISTS`, since a site that already added a table by hand must not
21
+ * fail on it.
22
+ */
23
+ export const ASTROID_INCIDENTS_MIGRATION = [
24
+ "-- Incidents (louise-toolkit ADR 0022): one row per fingerprint, counted by the",
25
+ "-- worker's d1Incidents sink and read by the Health panel and Watchtower. Dead",
26
+ "-- letters: each message a queue gave up on, kept for a runbook to replay.",
27
+ "-- Scaffolded by astroidjs.",
28
+ "CREATE TABLE IF NOT EXISTS `incidents` (",
29
+ "\t`fingerprint` text PRIMARY KEY NOT NULL,",
30
+ "\t`kind` text NOT NULL,",
31
+ "\t`name` text NOT NULL,",
32
+ "\t`code` text,",
33
+ "\t`message` text NOT NULL,",
34
+ "\t`path` text,",
35
+ "\t`host` text,",
36
+ "\t`release` text,",
37
+ "\t`critical` integer DEFAULT false NOT NULL,",
38
+ "\t`count` integer DEFAULT 1 NOT NULL,",
39
+ "\t`first_seen` integer NOT NULL,",
40
+ "\t`last_seen` integer NOT NULL,",
41
+ "\t`resolved_at` integer,",
42
+ "\t`reopened_at` integer",
43
+ ");",
44
+ "CREATE INDEX IF NOT EXISTS `incidents_last_seen` ON `incidents` (`last_seen`);",
45
+ "CREATE TABLE IF NOT EXISTS `dead_letters` (",
46
+ "\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,",
47
+ "\t`queue` text NOT NULL,",
48
+ "\t`message_id` text NOT NULL,",
49
+ "\t`body` text NOT NULL,",
50
+ "\t`attempts` integer NOT NULL,",
51
+ "\t`received_at` integer NOT NULL",
52
+ ");",
53
+ "CREATE INDEX IF NOT EXISTS `dead_letters_queue` ON `dead_letters` (`queue`);",
54
+ "",
55
+ ].join("\n");
@@ -0,0 +1,20 @@
1
+ import type { IncidentReport, IncidentSink } from "louise-toolkit/incidents";
2
+ import { type SecretSource } from "../secrets.js";
3
+ export interface SentryIncidentsOptions {
4
+ /** A tag naming the site, so one Sentry organization can tell sites apart. */
5
+ site?: string;
6
+ /** Sentry's `environment`. Default `"production"`. */
7
+ environment?: string;
8
+ /** Injected for tests. */
9
+ fetch?: typeof fetch;
10
+ }
11
+ /** The Sentry event for a report: its redacted message, its fingerprint, and the
12
+ * cause's stack frames. Never the raw message, the query string, or a body. */
13
+ export declare function sentryEvent(report: IncidentReport, cause: unknown, options?: Pick<SentryIncidentsOptions, "site" | "environment">): Record<string, unknown>;
14
+ /**
15
+ * A sink that sends each report to Sentry, through its envelope endpoint, with
16
+ * no SDK: `sendDefaultPii` has nothing to turn off, because nothing but the
17
+ * redacted report and the stack's frames is sent. Dormant while the DSN is
18
+ * unset or a placeholder, like every Astroid module.
19
+ */
20
+ export declare function sentryIncidents<Env>(dsn: (env: Env) => SecretSource | undefined, options?: SentryIncidentsOptions): IncidentSink<Env>;
@@ -0,0 +1,118 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The Sentry sink (louise-toolkit ADR 0022, amended): Sentry is the operator's
4
+ // issue system for a Monitored or Supported site. It's never the record, and
5
+ // never in louise-toolkit's zero-dependency core (ADR 0016 § 7).
6
+ import { readModuleSecret } from "../secrets.js";
7
+ function parseDsn(dsn) {
8
+ try {
9
+ const url = new URL(dsn);
10
+ const project = url.pathname.replace(/^\/+|\/+$/g, "");
11
+ if (!url.username || !/^\d+$/.test(project))
12
+ return null;
13
+ return { key: url.username, origin: `${url.protocol}//${url.host}`, project };
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ const V8_FRAME = /^\s*at (?:(.+?) \()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/;
20
+ /** The cause's stack as Sentry frames, oldest call first, or none. Function
21
+ * names and file positions only: no message, no values. */
22
+ function frames(cause) {
23
+ let stack;
24
+ try {
25
+ stack = cause?.stack;
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ if (typeof stack !== "string")
31
+ return [];
32
+ const out = [];
33
+ for (const line of stack.split("\n")) {
34
+ if (!line.trimStart().startsWith("at "))
35
+ continue;
36
+ const match = V8_FRAME.exec(line);
37
+ if (!match)
38
+ continue;
39
+ const [, fn, filename, lineno, colno] = match;
40
+ out.push({
41
+ ...(fn ? { function: fn } : {}),
42
+ filename: filename,
43
+ ...(lineno ? { lineno: Number(lineno) } : {}),
44
+ ...(colno ? { colno: Number(colno) } : {}),
45
+ in_app: !filename.includes("node_modules"),
46
+ });
47
+ }
48
+ return out.slice(0, 50).reverse();
49
+ }
50
+ /** The Sentry event for a report: its redacted message, its fingerprint, and the
51
+ * cause's stack frames. Never the raw message, the query string, or a body. */
52
+ export function sentryEvent(report, cause, options = {}) {
53
+ const stack = frames(cause);
54
+ return {
55
+ event_id: crypto.randomUUID().replace(/-/g, ""),
56
+ timestamp: report.at / 1000,
57
+ platform: "javascript",
58
+ level: report.critical ? "fatal" : "error",
59
+ logger: "louise",
60
+ environment: options.environment ?? "production",
61
+ ...(report.release ? { release: report.release } : {}),
62
+ // One D1 row, one Sentry issue: Watchtower joins them on this.
63
+ fingerprint: [report.fingerprint],
64
+ tags: {
65
+ louise_fingerprint: report.fingerprint,
66
+ kind: report.kind,
67
+ critical: String(report.critical),
68
+ ...(options.site ? { site: options.site } : {}),
69
+ ...(report.code ? { code: report.code } : {}),
70
+ },
71
+ ...(report.kind === "fetch" && report.host && report.path
72
+ ? { request: { url: `https://${report.host}${report.path}` } }
73
+ : {}),
74
+ ...(report.path ? { transaction: report.path } : {}),
75
+ exception: {
76
+ values: [
77
+ {
78
+ type: report.name,
79
+ value: report.message,
80
+ ...(stack.length > 0 ? { stacktrace: { frames: stack } } : {}),
81
+ },
82
+ ],
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * A sink that sends each report to Sentry, through its envelope endpoint, with
88
+ * no SDK: `sendDefaultPii` has nothing to turn off, because nothing but the
89
+ * redacted report and the stack's frames is sent. Dormant while the DSN is
90
+ * unset or a placeholder, like every Astroid module.
91
+ */
92
+ export function sentryIncidents(dsn, options = {}) {
93
+ return async (report, { env, cause }) => {
94
+ const source = dsn(env);
95
+ if (source === undefined)
96
+ return;
97
+ const value = await readModuleSecret(source);
98
+ const parsed = value ? parseDsn(value) : null;
99
+ if (!parsed)
100
+ return;
101
+ const event = sentryEvent(report, cause, options);
102
+ const body = [
103
+ JSON.stringify({ event_id: event.event_id, sent_at: new Date().toISOString() }),
104
+ JSON.stringify({ type: "event" }),
105
+ JSON.stringify(event),
106
+ ].join("\n");
107
+ const response = await (options.fetch ?? fetch)(`${parsed.origin}/api/${parsed.project}/envelope/`, {
108
+ method: "POST",
109
+ headers: {
110
+ "content-type": "application/x-sentry-envelope",
111
+ "x-sentry-auth": `Sentry sentry_version=7, sentry_key=${parsed.key}, sentry_client=astroidjs`,
112
+ },
113
+ body,
114
+ });
115
+ if (!response.ok)
116
+ throw new Error(`Sentry answered ${response.status}`);
117
+ };
118
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./commerce/index.js";
4
4
  export * from "./config.js";
5
5
  export * from "./email/index.js";
6
6
  export * from "./errors.js";
7
+ export * from "./incidents/index.js";
7
8
  export * from "./secrets.js";
8
9
  export * from "./map/index.js";
9
10
  export * from "./portal/index.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./commerce/index.js";
9
9
  export * from "./config.js";
10
10
  export * from "./email/index.js";
11
11
  export * from "./errors.js";
12
+ export * from "./incidents/index.js";
12
13
  export * from "./secrets.js";
13
14
  export * from "./map/index.js";
14
15
  export * from "./portal/index.js";
@@ -0,0 +1,19 @@
1
+ import type { D1Client } from "louise-toolkit/db";
2
+ import { type DraftBufferKV } from "louise-toolkit/editor";
3
+ import type { AstroidConfig } from "../config.js";
4
+ /** The bindings a page draft read uses: the database, and the draft buffer. */
5
+ export interface AstroidPageDraftEnv {
6
+ DB: D1Client;
7
+ /** The draft buffer the generated routes save through, when bound. */
8
+ DRAFTS?: DraftBufferKV;
9
+ }
10
+ /**
11
+ * The editor's work-in-progress snapshot of the page with id `pageId`, or
12
+ * `null` when there's none (render the live row). The buffer comes first, then
13
+ * the newest pending draft in D1, the same order a save builds on.
14
+ *
15
+ * Returns the whole snapshot: which fields a page renders from it (`sections`,
16
+ * `body`, `title`) is the site's call. Call it only in edit mode; a visitor
17
+ * sees the live row.
18
+ */
19
+ export declare function astroidPageDraft(config: AstroidConfig, env: AstroidPageDraftEnv, pageId: number): Promise<Record<string, unknown> | null>;
@@ -0,0 +1,34 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `astroidjs/pages`—the editor's work-in-progress on a page, for a page route
4
+ // in edit mode. A subpath of its own because it reads through louise-toolkit's
5
+ // editor and Drizzle, which the main entry (loaded by astroid.config.ts and the
6
+ // CLI) stays free of.
7
+ //
8
+ // The generated worker saves pages through the DRAFTS buffer, keyed by the pages
9
+ // collection's slug, and flushes to `pages_versions` in D1 behind it. A page
10
+ // route has to read the same way, buffer first, or an editor who reloads before
11
+ // the flush sees an older page than the one they just saved. Every site that
12
+ // read D1 alone hit that, and each wrapped louise-toolkit's `resumeDraft` in the
13
+ // same few lines to fix it. This is those lines, with the slug and the versions
14
+ // table derived from the config rather than restated.
15
+ import { collectionVersionsTable } from "louise-toolkit/content";
16
+ import { resumeDraft } from "louise-toolkit/editor";
17
+ import { astroidPagesCollection } from "../schema/collections.js";
18
+ /**
19
+ * The editor's work-in-progress snapshot of the page with id `pageId`, or
20
+ * `null` when there's none (render the live row). The buffer comes first, then
21
+ * the newest pending draft in D1, the same order a save builds on.
22
+ *
23
+ * Returns the whole snapshot: which fields a page renders from it (`sections`,
24
+ * `body`, `title`) is the site's call. Call it only in edit mode; a visitor
25
+ * sees the live row.
26
+ */
27
+ export function astroidPageDraft(config, env, pageId) {
28
+ const collection = astroidPagesCollection(config);
29
+ return resumeDraft(env.DB, {
30
+ versionsTable: collectionVersionsTable(collection),
31
+ collection: collection.slug,
32
+ bufferKv: env.DRAFTS,
33
+ }, { id: pageId });
34
+ }
@@ -18,6 +18,7 @@ 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, commerceProviderWebhookSecrets, commerceSecretNames, } from "../commerce/secrets.js";
21
+ import { ASTROID_INCIDENT_EVENTS_BINDING, ASTROID_VERSION_METADATA_BINDING, astroidIncidentEventsDataset, } from "../incidents/names.js";
21
22
  import { ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
23
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
24
  import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
@@ -218,6 +219,10 @@ export function generateAstroidWrangler(config) {
218
219
  p(` "retry_delay": ${config.queues?.retryDelay ?? ASTROID_QUEUE_RETRY_DELAY},`);
219
220
  p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
220
221
  p(" },");
222
+ p(" // The dead-letter queue's own consumer: the worker keeps each message");
223
+ p(" // in the `dead_letters` table and counts it as an incident, so a");
224
+ p(" // failed event is never lost unseen. No retries of its own.");
225
+ p(` { "queue": ${JSON.stringify(dlq)}, "max_batch_size": 10, "max_retries": 0 },`);
221
226
  p(" ],");
222
227
  p(" },");
223
228
  }
@@ -229,6 +234,13 @@ export function generateAstroidWrangler(config) {
229
234
  p(" // D1: this app's tables (src/schema.ts), or the database of the app that");
230
235
  p(" // owns the schema, bound by its id. Create one: `wrangler d1 create <name>`.");
231
236
  }
237
+ p(" // The deployed version's ID, which each incident records as its release.");
238
+ p(` "version_metadata": { "binding": ${JSON.stringify(ASTROID_VERSION_METADATA_BINDING)} },`);
239
+ if (!editor) {
240
+ // The editor shape lists this dataset beside the Core Web Vitals one below.
241
+ p(" // Analytics Engine: incident counts over time (louise-toolkit ADR 0022).");
242
+ p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_INCIDENT_EVENTS_BINDING)}, "dataset": ${JSON.stringify(astroidIncidentEventsDataset(config))} }],`);
243
+ }
232
244
  p(' "d1_databases": [');
233
245
  p(" {");
234
246
  p(' "binding": "DB",');
@@ -250,8 +262,9 @@ export function generateAstroidWrangler(config) {
250
262
  p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
251
263
  p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
252
264
  p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
253
- p(" // until those are real the Health badge reads 'not measured yet'.");
254
- p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
265
+ p(" // until those are real the Health badge reads 'not measured yet'. The");
266
+ p(" // second dataset counts incidents over time (louise-toolkit ADR 0022).");
267
+ p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }, { "binding": ${JSON.stringify(ASTROID_INCIDENT_EVENTS_BINDING)}, "dataset": ${JSON.stringify(astroidIncidentEventsDataset(config))} }],`);
255
268
  p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
256
269
  p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
257
270
  p(" // without this binding, were permanently invisible: their routes answer 503");
@@ -26,6 +26,7 @@
26
26
  import { generateAstroidCheckoutRoute, generateAstroidSquareCard, } from "../commerce/checkout-scaffold.js";
27
27
  import { generateCatalogMigrationSql } from "../commerce/mirror.js";
28
28
  import { generateAstroidVitalsBeacon } from "../analytics/index.js";
29
+ import { ASTROID_INCIDENTS_MIGRATION } from "../incidents/names.js";
29
30
  import { generateAstroidActions } from "./actions.js";
30
31
  import { cwvBeaconScript } from "louise-toolkit/analytics";
31
32
  import { generateMapEmbedComponent, generateMapTileRoute } from "../map/scaffold.js";
@@ -186,6 +187,19 @@ export function generateAstroidScaffoldFiles(config) {
186
187
  migration: true,
187
188
  });
188
189
  }
190
+ // --- incident capture's tables -------------------------------------------
191
+ // For every shape: the generated worker counts failures into `incidents`
192
+ // and keeps dead letters in `dead_letters` (louise-toolkit ADR 0022). Written
193
+ // into an existing site by `astroid generate`, like the two above, and
194
+ // renumbered past the site's own migrations. Not when another app migrates
195
+ // this database (`deploy.migrations: false`); that app owns the tables.
196
+ if (config.deploy?.migrations !== false) {
197
+ files.push({
198
+ path: "migrations/0006_incidents.sql",
199
+ contents: ASTROID_INCIDENTS_MIGRATION,
200
+ migration: true,
201
+ });
202
+ }
189
203
  // --- the CWV beacon -------------------------------------------------------
190
204
  // A static file under public/, so it is same-origin and covered by
191
205
  // `script-src 'self'`—an inline script carrying generated content could not
@@ -20,6 +20,23 @@ function catalogBuilders(catalog) {
20
20
  builders.push("real");
21
21
  return builders.sort();
22
22
  }
23
+ /**
24
+ * The incident tables every shape re-exports (louise-toolkit ADR 0022): the
25
+ * worker counts failures into `incidents` and keeps dead-lettered messages in
26
+ * `dead_letters`, so an app owns both, even when it owns nothing else. Not when
27
+ * another app migrates its database (`deploy.migrations: false`): that app owns
28
+ * them, and drizzle-kit here must not write migrations for them.
29
+ */
30
+ function incidentTables(config) {
31
+ return config.deploy?.migrations === false ? [] : INCIDENT_TABLES;
32
+ }
33
+ const INCIDENT_TABLES = [
34
+ "// Incident capture's tables (louise-toolkit ADR 0022): the generated worker",
35
+ "// counts failures into `incidents` and keeps dead-lettered queue messages in",
36
+ "// `dead_letters`.",
37
+ 'export { deadLetters, incidents } from "louise-toolkit/incidents";',
38
+ "",
39
+ ];
23
40
  /**
24
41
  * The schema of an app with no editor (`editor: false`): no `pages`, no
25
42
  * versions, and no framework tables, because nothing here edits them. What's
@@ -50,6 +67,7 @@ function generateAppSchema(config) {
50
67
  catalog,
51
68
  ]
52
69
  : [""]),
70
+ ...incidentTables(config),
53
71
  "// Site-owned tables, declared in src/schema.site.ts and re-exported here so",
54
72
  "// drizzle-kit and the worker see them. Empty until the app adds one.",
55
73
  'export * from "./schema.site.js";',
@@ -97,6 +115,7 @@ export function generateAstroidSchema(config) {
97
115
  ...(catalog ? [catalog] : []),
98
116
  `export { ${framework.join(", ")} };`,
99
117
  "",
118
+ ...incidentTables(config),
100
119
  "// Site-owned tables (the ones Astroid doesn't manage): a project declares",
101
120
  "// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
102
121
  "// so drizzle-kit sees them and the generated worker can import them. The file",
@@ -12,9 +12,10 @@
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";
15
+ import { ASTROID_INCIDENT_EVENTS_BINDING, ASTROID_SENTRY_DSN_BINDING, ASTROID_VERSION_METADATA_BINDING, } from "../incidents/names.js";
15
16
  import { astroidEditorTable } from "../auth/index.js";
16
17
  import { astroidPortal } from "../portal/config.js";
17
- import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
18
+ import { ASTROID_HEALTH_CRON, astroidCron, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
18
19
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
19
20
  import { capturesInquiries } from "../schema/framework.js";
20
21
  import { astroidCspStyleSrc } from "../security/csp-origins.js";
@@ -208,6 +209,7 @@ export function generateAstroidWorker(config) {
208
209
  p('import { cwvSqlQuery, parseCwvRows, summarizeCwv, vitalsRoute } from "louise-toolkit/analytics";');
209
210
  if (inquiries)
210
211
  p('import { defineForm } from "louise-toolkit/forms";');
212
+ p(incidentsImport(queues));
211
213
  if (queues)
212
214
  p('import { processBatch } from "louise-toolkit/queues";');
213
215
  // Only when a route actually takes a runner—a project with no AI assists
@@ -230,6 +232,7 @@ export function generateAstroidWorker(config) {
230
232
  "setAstroidMediaBase",
231
233
  ...(inquiries ? ["sendInquiryMail"] : []),
232
234
  ...(queues ? ["type AstroidQueueMessage"] : []),
235
+ ...(config.incidents?.sentry ? ["sentryIncidents", "type SecretSource"] : []),
233
236
  ].sort();
234
237
  p(`import { ${astroidImports.join(", ")} } from "astroidjs";`);
235
238
  // The config lives at the PROJECT ROOT (create-astroid writes it there); this
@@ -475,6 +478,7 @@ export function generateAstroidWorker(config) {
475
478
  p(" return new Response(obj.body, { headers });");
476
479
  p("};");
477
480
  p();
481
+ emitIncidentPreamble(p, config, queues);
478
482
  // The queue message type parameter is what gives the `queue` consumer below a
479
483
  // typed `MessageBatch` instead of `MessageBatch<unknown>`.
480
484
  p(queues
@@ -510,13 +514,9 @@ export function generateAstroidWorker(config) {
510
514
  p(" // An editor never reads from, and never writes to, the shared entry.");
511
515
  p(" bypass: isEditRequest,");
512
516
  p(" }),");
513
- if (queues) {
514
- p(" // Queue consumer. `processBatch` acks or retries each message");
515
- p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
516
- p(" // batch from acking; Cloudflare routes it to the DLQ once it exceeds");
517
- p(" // max_retries (see wrangler.jsonc).");
518
- p(" queue: (batch, env) => processBatch(batch, (message) => handleQueueMessage(env, message)),");
519
- }
517
+ emitIncidentOption(p, config);
518
+ if (queues)
519
+ emitQueueOption(p, config);
520
520
  // ONE scheduled handler for every cron, dispatching on `controller.cron`.
521
521
  // Cloudflare gives no other way to tell them apart, and the strings here have
522
522
  // to match `astroidCrons` exactly—which is why both read the same constants
@@ -589,11 +589,20 @@ function generateAppWorker(config) {
589
589
  p("// so it serves no editor routes and resolves no editor session.");
590
590
  p('import { handle } from "@astrojs/cloudflare/handler";');
591
591
  p('import { d1Check, statusRoute } from "louise-toolkit/editor";');
592
+ p(incidentsImport(queues));
592
593
  if (queues)
593
594
  p('import { processBatch } from "louise-toolkit/queues";');
594
595
  p('import { composeWorker, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
595
- if (queues)
596
- p('import type { AstroidQueueMessage } from "astroidjs";');
596
+ const astroidImports = [
597
+ ...(queues ? ["type AstroidQueueMessage"] : []),
598
+ ...(config.incidents?.sentry ? ["sentryIncidents", "type SecretSource"] : []),
599
+ ].sort();
600
+ if (astroidImports.length > 0) {
601
+ const typeOnly = astroidImports.every((name) => name.startsWith("type "));
602
+ p(typeOnly
603
+ ? `import type { ${astroidImports.map((name) => name.slice(5)).join(", ")} } from "astroidjs";`
604
+ : `import { ${astroidImports.join(", ")} } from "astroidjs";`);
605
+ }
597
606
  if (config.status?.checks) {
598
607
  p("// Your STATUS seam: the app's own checks for the public status route.");
599
608
  p("// Scaffolded once and yours to edit.");
@@ -627,6 +636,7 @@ function generateAppWorker(config) {
627
636
  p(" statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS }),");
628
637
  p("];");
629
638
  p();
639
+ emitIncidentPreamble(p, config, queues);
630
640
  p(queues
631
641
  ? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
632
642
  : "export default composeWorker<CloudflareEnv>({");
@@ -640,12 +650,9 @@ function generateAppWorker(config) {
640
650
  p(" // directive from every other one, so Cloudflare's cookie-blind edge cache");
641
651
  p(" // never stores a signed-in customer's page.");
642
652
  p(" fetch: withEdgeCache((request, env, ctx) => handle(request, env, ctx)),");
643
- if (queues) {
644
- p(" // Queue consumer. `processBatch` acks or retries each message");
645
- p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
646
- p(" // batch; Cloudflare routes it to the DLQ once it exceeds max_retries.");
647
- p(" queue: (batch, env) => processBatch(batch, (message) => handleQueueMessage(env, message)),");
648
- }
653
+ emitIncidentOption(p, config);
654
+ if (queues)
655
+ emitQueueOption(p, config);
649
656
  if (scheduled) {
650
657
  p(" // Cron. Cloudflare fires this for EVERY trigger in wrangler.jsonc and");
651
658
  p(" // identifies which by `controller.cron`, so dispatch on it.");
@@ -897,3 +904,78 @@ export function generateAstroidMiddleware(config) {
897
904
  "",
898
905
  ].join("\n");
899
906
  }
907
+ /** The `louise-toolkit/incidents` import both worker shapes emit. */
908
+ function incidentsImport(queues) {
909
+ const names = ["analyticsIncidents", "d1Incidents", ...(queues ? ["deadLetterConsumer"] : [])];
910
+ return `import { ${names.join(", ")} } from "louise-toolkit/incidents";`;
911
+ }
912
+ /**
913
+ * The declarations incident capture needs ahead of `composeWorker`, shared by
914
+ * both shapes (louise-toolkit ADR 0022). Its bindings are read through a local
915
+ * type rather than `CloudflareEnv`, because `src/env.d.ts` is scaffold-once: a
916
+ * site made before them still type-checks, and each sink skips what isn't bound.
917
+ */
918
+ function emitIncidentPreamble(p, config, queues) {
919
+ p("// --- incidents --------------------------------------------------------------");
920
+ p("// Every failure this worker sees becomes an incident: counted into the site's");
921
+ p("// own D1 (the `incidents` table), and into Analytics Engine for counts over");
922
+ p("// time (louise-toolkit ADR 0022). These bindings are optional. A site whose");
923
+ p("// wrangler.jsonc predates them still type-checks, and a sink skips what isn't");
924
+ p("// bound.");
925
+ p("type IncidentBindings = {");
926
+ p(` ${ASTROID_INCIDENT_EVENTS_BINDING}?: AnalyticsEngineDataset;`);
927
+ p(` ${ASTROID_VERSION_METADATA_BINDING}?: WorkerVersionMetadata;`);
928
+ if (config.incidents?.sentry)
929
+ p(` ${ASTROID_SENTRY_DSN_BINDING}?: SecretSource;`);
930
+ p("};");
931
+ p("const incidentBindings = (env: CloudflareEnv) => env as CloudflareEnv & IncidentBindings;");
932
+ if (queues) {
933
+ const { dlq } = astroidQueueNames(config);
934
+ p("// The dead-letter queue's consumer: it keeps each message the queue gave up on");
935
+ p("// in the `dead_letters` table, counts it as an incident, and acks it, so a");
936
+ p("// failed webhook event is never lost unseen.");
937
+ p(`const DEAD_LETTER_QUEUE = ${JSON.stringify(dlq)};`);
938
+ p("const keepDeadLetters = deadLetterConsumer<CloudflareEnv, AstroidQueueMessage>(");
939
+ p(" (env) => env.DB,");
940
+ p(");");
941
+ }
942
+ p();
943
+ }
944
+ /** The `onIncident` option both shapes pass `composeWorker`. */
945
+ function emitIncidentOption(p, config) {
946
+ const critical = config.incidents?.critical ?? [];
947
+ p(" // Incident capture (louise-toolkit ADR 0022). A throw is reported, then");
948
+ p(" // re-thrown, so responses don't change; the sinks run after the response.");
949
+ p(" onIncident: {");
950
+ p(" sinks: [");
951
+ p(" // The record: one row per failure, with a count, in the site's own D1.");
952
+ p(" d1Incidents((env: CloudflareEnv) => env.DB),");
953
+ p(` analyticsIncidents((env: CloudflareEnv) => incidentBindings(env).${ASTROID_INCIDENT_EVENTS_BINDING}),`);
954
+ if (config.incidents?.sentry) {
955
+ p(" // A copy for the operator's issue system, with the stack. Dormant until");
956
+ p(` // the ${ASTROID_SENTRY_DSN_BINDING} secret holds a real DSN.`);
957
+ p(` sentryIncidents((env: CloudflareEnv) => incidentBindings(env).${ASTROID_SENTRY_DSN_BINDING}, { site: ${JSON.stringify(config.key)} }),`);
958
+ }
959
+ p(" ],");
960
+ if (critical.length > 0) {
961
+ p(" // What alerts, from `incidents.critical` in your config.");
962
+ p(` critical: ${JSON.stringify(critical)},`);
963
+ }
964
+ p(` release: (env) => incidentBindings(env).${ASTROID_VERSION_METADATA_BINDING}?.id,`);
965
+ p(" },");
966
+ }
967
+ /** The queue consumer both shapes emit: the dead-letter queue's batches to its
968
+ * consumer, and the rest through `processBatch`, which reports a message's
969
+ * last failed delivery as an incident. */
970
+ function emitQueueOption(p, config) {
971
+ p(" // Queue consumer. `processBatch` acks or retries each message");
972
+ p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
973
+ p(" // batch; Cloudflare routes it to the DLQ once it exceeds max_retries,");
974
+ p(" // which `maxRetries` matches, so its last failure counts as an incident.");
975
+ p(" queue: (batch, env, ctx) =>");
976
+ p(" batch.queue === DEAD_LETTER_QUEUE");
977
+ p(" ? keepDeadLetters(batch, env, ctx)");
978
+ p(" : processBatch(batch, (message) => handleQueueMessage(env, message), {");
979
+ p(` maxRetries: ${config.queues?.maxRetries ?? 5},`);
980
+ p(" }),");
981
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -37,6 +37,11 @@
37
37
  "import": "./dist/astro/index.js",
38
38
  "default": "./dist/astro/index.js"
39
39
  },
40
+ "./pages": {
41
+ "types": "./dist/pages/index.d.ts",
42
+ "import": "./dist/pages/index.js",
43
+ "default": "./dist/pages/index.js"
44
+ },
40
45
  "./components/*.astro": "./src/components/*.astro",
41
46
  "./components/Collection": {
42
47
  "types": "./src/components/Collection.tsx",
@@ -64,6 +69,7 @@
64
69
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
65
70
  "@vitest/coverage-v8": "4.1.11",
66
71
  "astro": "^7.2.9",
72
+ "drizzle-orm": "^0.45.2",
67
73
  "louise-toolkit": "^0.37.0",
68
74
  "solid-js": "^1.9.15",
69
75
  "typescript": "^6.0.3",
@@ -71,6 +77,7 @@
71
77
  },
72
78
  "peerDependencies": {
73
79
  "astro": "^7.0.9",
80
+ "drizzle-orm": "^0.45.0",
74
81
  "louise-toolkit": "^0.37.0",
75
82
  "solid-js": "^1.9.0"
76
83
  },