astroidjs 0.19.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.
@@ -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
+ }
@@ -17,10 +17,12 @@
17
17
  import { ASTROID_VITALS_BINDING, astroidVitalsDataset } from "../analytics/index.js";
18
18
  import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
19
  import { astroidCommerceProviders } from "../commerce/roles.js";
20
- import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
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";
25
+ import { astroidHasEditor } from "../shape.js";
24
26
  import { tenancyZone } from "../tenancy/index.js";
25
27
  import { generateAstroidSchema } from "../schema/generate.js";
26
28
  import { ASTROID_AI_GATEWAY_VAR } from "../worker/gateway.js";
@@ -93,7 +95,10 @@ export function generateAstroidSecretsEnv(config) {
93
95
  for (const provider of providers) {
94
96
  const spec = COMMERCE_PROVIDER_SECRETS[provider];
95
97
  lines.push("#", `# ${provider}: ${COMMERCE_PROVIDER_SETUP[provider]}`);
96
- for (const name of [...spec.credentials, spec.webhook]) {
98
+ for (const name of [
99
+ ...spec.credentials,
100
+ ...commerceProviderWebhookSecrets(provider, config.commerce),
101
+ ]) {
97
102
  lines.push(`${name}=${ASTROID_SECRET_PLACEHOLDER}`);
98
103
  }
99
104
  }
@@ -116,6 +121,12 @@ const COMPATIBILITY_DATE = "2026-06-20";
116
121
  */
117
122
  export function generateAstroidWrangler(config) {
118
123
  const key = config.key;
124
+ // An app with no editor binds none of what the editor uses: the media bucket
125
+ // and Images, the draft buffer, Workers AI, and the CWV dataset. It sends mail
126
+ // only for a portal's password resets.
127
+ const editor = astroidHasEditor(config);
128
+ const mail = editor || Boolean(config.portal?.enabled);
129
+ const crons = astroidCrons(config);
119
130
  const mediaBase = config.deploy?.mediaBase ?? "/media";
120
131
  const hosts = config.hosts ?? [];
121
132
  const primaryHost = hosts[0];
@@ -184,7 +195,10 @@ export function generateAstroidWrangler(config) {
184
195
  // Daily: the site-health scan (broken links, missing alt text, SEO gaps).
185
196
  // Hourly (commerce only): the catalog re-sync safety net, so a missed or DLQ'd
186
197
  // webhook can only leave the site stale until the next tick.
187
- p(` "triggers": { "crons": ${JSON.stringify(astroidCrons(config))} },`);
198
+ // None at all for an app with nothing scheduled: a trigger with no handler
199
+ // is an invocation that fails every time it fires.
200
+ if (crons.length > 0)
201
+ p(` "triggers": { "crons": ${JSON.stringify(crons)} },`);
188
202
  if (astroidUsesQueues(config)) {
189
203
  const { queue, dlq } = astroidQueueNames(config);
190
204
  p(" // Provider webhooks are verified at the edge, then enqueued here so the");
@@ -205,44 +219,74 @@ export function generateAstroidWrangler(config) {
205
219
  p(` "retry_delay": ${config.queues?.retryDelay ?? ASTROID_QUEUE_RETRY_DELAY},`);
206
220
  p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
207
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 },`);
208
226
  p(" ],");
209
227
  p(" },");
210
228
  }
211
- p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
212
- p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
229
+ if (editor) {
230
+ p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
231
+ p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
232
+ }
233
+ else {
234
+ p(" // D1: this app's tables (src/schema.ts), or the database of the app that");
235
+ p(" // owns the schema, bound by its id. Create one: `wrangler d1 create <name>`.");
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
+ }
213
244
  p(' "d1_databases": [');
214
245
  p(" {");
215
246
  p(' "binding": "DB",');
216
247
  p(` "database_name": ${JSON.stringify(key)},`);
217
248
  p(' "database_id": "<run: wrangler d1 create ' + key + '>",');
218
- p(' "migrations_dir": "migrations",');
249
+ // Left out when another app migrates this database (`deploy.migrations:
250
+ // false`), which `astroid doctor` would otherwise report as a contradiction.
251
+ if (config.deploy?.migrations !== false)
252
+ p(' "migrations_dir": "migrations",');
219
253
  p(" },");
220
254
  p(" ],");
221
- p(" // R2 bucket for uploaded media, streamed back through the Worker at MEDIA_URL");
222
- p(" // (no public bucket). Create it: `wrangler r2 bucket create <name>-media`.");
223
- p(` "r2_buckets": [{ "binding": "MEDIA", "bucket_name": ${JSON.stringify(`${key}-media`)} }],`);
224
- p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
225
- p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
226
- p(' "images": { "binding": "IMAGES" },');
227
- p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
228
- p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
229
- p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
230
- p(" // until those are real the Health badge reads 'not measured yet'.");
231
- p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
232
- p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
233
- p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
234
- p(" // without this binding, were permanently invisible: their routes answer 503");
235
- p(" // and the client hides the button. No account setup beyond the binding, and");
236
- p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
237
- p(' "ai": { "binding": "AI" },');
238
- p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
239
- p(" // summary under its own key—one small singleton blob, not worth a binding");
240
- p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
241
- p(" // Named for the project, so two sites in one account don't collide.");
242
- p(" // `astroid provision` creates each and fills in its id.");
255
+ if (editor) {
256
+ p(" // R2 bucket for uploaded media, streamed back through the Worker at MEDIA_URL");
257
+ p(" // (no public bucket). Create it: `wrangler r2 bucket create <name>-media`.");
258
+ p(` "r2_buckets": [{ "binding": "MEDIA", "bucket_name": ${JSON.stringify(`${key}-media`)} }],`);
259
+ p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
260
+ p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
261
+ p(' "images": { "binding": "IMAGES" },');
262
+ p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
263
+ p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
264
+ p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
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))} }],`);
268
+ p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
269
+ p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
270
+ p(" // without this binding, were permanently invisible: their routes answer 503");
271
+ p(" // and the client hides the button. No account setup beyond the binding, and");
272
+ p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
273
+ p(' "ai": { "binding": "AI" },');
274
+ p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
275
+ p(" // summary under its own key—one small singleton blob, not worth a binding");
276
+ p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
277
+ p(" // Named for the project, so two sites in one account don't collide.");
278
+ p(" // `astroid provision` creates each and fills in its id.");
279
+ }
280
+ else {
281
+ p(" // KV: RL = the security rate limiter. Named for the project, so two apps in");
282
+ p(" // one account don't collide. `astroid provision` creates it and fills in");
283
+ p(" // its id.");
284
+ }
243
285
  p(' "kv_namespaces": [');
244
286
  p(` { "binding": "RL", "id": "<run: wrangler kv namespace create ${key}-rl>" },`);
245
- p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
287
+ if (editor) {
288
+ p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
289
+ }
246
290
  p(" ],");
247
291
  // Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
248
292
  // required member, and Better Auth's magic-link path console-logs the link in
@@ -251,31 +295,44 @@ export function generateAstroidWrangler(config) {
251
295
  // sign-in was impossible on every DEPLOYED site, while every local build
252
296
  // and every CI scaffold passed. Nothing in this repo runs a deployed scaffold,
253
297
  // which is why it survived.
254
- p(" // Cloudflare Email Sending—magic-link sign-in + inquiry notifications.");
255
- p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
256
- p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
257
- p(' "send_email": [{ "name": "EMAIL" }],');
258
- p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
259
- p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
298
+ if (editor) {
299
+ p(" // Cloudflare Email Sending—magic-link sign-in + inquiry notifications.");
300
+ p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
301
+ }
302
+ else if (mail) {
303
+ p(" // Cloudflare Email Sending—the portal's password-reset mail, which is");
304
+ p(" // logged in dev and emailed in production.");
305
+ }
306
+ if (mail) {
307
+ p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
308
+ p(' "send_email": [{ "name": "EMAIL" }],');
309
+ }
310
+ if (editor) {
311
+ p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
312
+ p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
313
+ }
260
314
  p(' "vars": {');
261
- p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
315
+ if (editor)
316
+ p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
262
317
  p(` "SITE_URL": ${JSON.stringify(primaryHost ? `https://${primaryHost}` : `https://${key}.workers.dev`)},`);
263
- p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
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}": "",`);
270
- p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
271
- p(" // default is the safe state: with it off every render is `no-store` and");
272
- p(" // the Worker cache layer stores nothing.");
273
- p(" //");
274
- p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
275
- p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
276
- p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
277
- p(" // undo—this feature was reverted twice for exactly that.");
278
- p(' "ASTROID_EDGE_CACHE": "false",');
318
+ if (editor) {
319
+ p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
320
+ p(' "OWNER_EMAIL": "",');
321
+ p(" // AI Gateway for the editor's AI assists: request logs, latency and error");
322
+ p(" // rates, and caching. Empty calls Workers AI directly. Create a gateway,");
323
+ p(" // put its id here, and first say on the privacy page that its log holds");
324
+ p(" // the text editors send to the assists.");
325
+ p(` "${ASTROID_AI_GATEWAY_VAR}": "",`);
326
+ p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
327
+ p(" // default is the safe state: with it off every render is `no-store` and");
328
+ p(" // the Worker cache layer stores nothing.");
329
+ p(" //");
330
+ p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
331
+ p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
332
+ p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
333
+ p(" // undo—this feature was reverted twice for exactly that.");
334
+ p(' "ASTROID_EDGE_CACHE": "false",');
335
+ }
279
336
  for (const v of astroidCheckoutVars(config)) {
280
337
  // Public, not secret—the app id ships to the browser to mount the card
281
338
  // field, and the environment is a choice. Keeping them out of the secret
@@ -26,11 +26,13 @@
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";
32
33
  import { generateAstroidGalleryPage } from "../portfolio/scaffold.js";
33
34
  import { astroidPortal } from "../portal/config.js";
35
+ import { astroidHasEditor } from "../shape.js";
34
36
  import { generateAstroidPortalAuth, generateAstroidPortalAuthRoute } from "../portal/scaffold.js";
35
37
  import { generateAstroidTenancy } from "../tenancy/index.js";
36
38
  import { generateAstroidEditSession } from "../realtime/scaffold.js";
@@ -154,6 +156,10 @@ function generateAstroidSettingsHooks() {
154
156
  */
155
157
  export function generateAstroidScaffoldFiles(config) {
156
158
  const files = [];
159
+ // An app with no editor gets none of the editor's files: the content
160
+ // migrations, the CWV beacon its Health panel reads, the Actions surface
161
+ // over pages and settings, and the gallery page over the media library.
162
+ const editor = astroidHasEditor(config);
157
163
  // --- commerce: the catalog table's migration ------------------------------
158
164
  // Numbered 0003 so it lands after the template's 0000_content and the auth
159
165
  // pair (0001, 0002) that `create-astroid` writes. Without it `--commerce`
@@ -170,21 +176,38 @@ export function generateAstroidScaffoldFiles(config) {
170
176
  // filename. The CLI moves each into the site's `migrations_dir` and past
171
177
  // the site's own numbers (see migrations.ts), so a site that already has its
172
178
  // 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
- });
179
+ if (editor) {
180
+ files.push({
181
+ path: "migrations/0004_page_redirects.sql",
182
+ contents: ASTROID_PAGE_REDIRECTS_MIGRATION,
183
+ migration: true,
184
+ }, {
185
+ path: "migrations/0005_media_alt_undecided.sql",
186
+ contents: ASTROID_MEDIA_ALT_MIGRATION,
187
+ migration: true,
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
+ }
182
203
  // --- the CWV beacon -------------------------------------------------------
183
204
  // A static file under public/, so it is same-origin and covered by
184
205
  // `script-src 'self'`—an inline script carrying generated content could not
185
206
  // be hashed into the CSP and would be blocked.
186
- const beacon = generateAstroidVitalsBeacon(config, cwvBeaconScript());
187
- files.push({ path: beacon.path, contents: beacon.contents });
207
+ if (editor) {
208
+ const beacon = generateAstroidVitalsBeacon(config, cwvBeaconScript());
209
+ files.push({ path: beacon.path, contents: beacon.contents });
210
+ }
188
211
  // --- site-owned schema tables --------------------------------------------
189
212
  // Always: the generated src/schema.ts re-exports `./schema.site.js`, so the
190
213
  // file must exist even when empty. A project declares tables Astroid doesn't
@@ -218,10 +241,12 @@ export function generateAstroidScaffoldFiles(config) {
218
241
  files.push({ path: "src/settings-hooks.ts", contents: generateAstroidSettingsHooks() });
219
242
  }
220
243
  // --- the typed Astro Actions surface --------------------------------------
221
- // Always: every project has editable pages, and the routes alone leave the
222
- // Astro-native half of ADR 0001 unbuilt. Scaffold-once because it is meant to
223
- // be added to.
224
- files.push({ path: "src/actions/index.ts", contents: generateAstroidActions(config) });
244
+ // Every project with an editor: it has editable pages, and the routes alone
245
+ // leave the Astro-native half of ADR 0001 unbuilt. Scaffold-once because it
246
+ // is meant to be added to.
247
+ if (editor) {
248
+ files.push({ path: "src/actions/index.ts", contents: generateAstroidActions(config) });
249
+ }
225
250
  // --- commerce: the server-authoritative payment seam ----------------------
226
251
  // Scaffold-once: a real store adds shipping, tax, an order row, a receipt.
227
252
  // What's fixed is the sequence that keeps a charge correct.
@@ -243,7 +268,7 @@ export function generateAstroidScaffoldFiles(config) {
243
268
  }
244
269
  // --- portfolio: the gallery page -----------------------------------------
245
270
  // "Which assets appear, in what order" is the first thing a portfolio changes.
246
- const gallery = generateAstroidGalleryPage(config);
271
+ const gallery = editor ? generateAstroidGalleryPage(config) : null;
247
272
  if (gallery)
248
273
  files.push({ path: "src/pages/work.astro", contents: gallery });
249
274
  // --- pwa: the service worker, manifest, and its headers -------------------
@@ -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, ASTROID_QUEUE_RETRY_DELAY, 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, astroidCommercePipeline, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
3
3
  export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
4
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, ASTROID_QUEUE_RETRY_DELAY, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
3
+ export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCommercePipeline, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
4
4
  export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
5
5
  export { astroidQueue, handleWebhook, } from "./webhook.js";
@@ -7,6 +7,12 @@ import type { AstroidConfig } from "../config.js";
7
7
  * provider's delivery timeout is shorter than your catalog sync.
8
8
  */
9
9
  export declare function astroidUsesQueues(config: AstroidConfig): boolean;
10
+ /**
11
+ * Whether this project runs the commerce pipeline: the webhook receivers and the
12
+ * catalog re-sync. On whenever commerce is configured, unless the config sets
13
+ * `commerce.pipeline: false` because another project runs it.
14
+ */
15
+ export declare function astroidCommercePipeline(config: AstroidConfig): boolean;
10
16
  /** Hourly. Frequent enough that stale data has a bounded lifetime, rare enough
11
17
  * to be free. */
12
18
  export declare const ASTROID_DEFAULT_CRON = "0 * * * *";
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
2
  //
3
3
  // What flows through the project's queue, and when it matters.
4
+ import { astroidHasEditor } from "../shape.js";
4
5
  /**
5
6
  * Whether this project runs a queue consumer + cron.
6
7
  *
@@ -9,7 +10,15 @@
9
10
  * provider's delivery timeout is shorter than your catalog sync.
10
11
  */
11
12
  export function astroidUsesQueues(config) {
12
- return config.queues?.enabled ?? Boolean(config.commerce);
13
+ return config.queues?.enabled ?? astroidCommercePipeline(config);
14
+ }
15
+ /**
16
+ * Whether this project runs the commerce pipeline: the webhook receivers and the
17
+ * catalog re-sync. On whenever commerce is configured, unless the config sets
18
+ * `commerce.pipeline: false` because another project runs it.
19
+ */
20
+ export function astroidCommercePipeline(config) {
21
+ return Boolean(config.commerce) && config.commerce?.pipeline !== false;
13
22
  }
14
23
  /** Hourly. Frequent enough that stale data has a bounded lifetime, rare enough
15
24
  * to be free. */
@@ -18,6 +27,10 @@ export const ASTROID_DEFAULT_CRON = "0 * * * *";
18
27
  export function astroidCron(config) {
19
28
  if (!astroidUsesQueues(config))
20
29
  return null;
30
+ // The re-sync is part of the pipeline, so a project that leaves the pipeline
31
+ // to another one leaves this to it too, even when its queue runs for crons.
32
+ if (config.commerce && !astroidCommercePipeline(config))
33
+ return null;
21
34
  const cron = config.queues?.cron;
22
35
  if (cron === false)
23
36
  return null;
@@ -40,7 +53,9 @@ export const ASTROID_HEALTH_CRON = "17 4 * * *";
40
53
  * agree exactly, and a mismatch is a job that silently never runs.
41
54
  */
42
55
  export function astroidCrons(config) {
43
- const crons = [ASTROID_HEALTH_CRON];
56
+ // The health scan reports to the editor's Health panel, over the editor's
57
+ // pages and media, so an app with no editor has nothing to scan or show.
58
+ const crons = astroidHasEditor(config) ? [ASTROID_HEALTH_CRON] : [];
44
59
  const catalog = astroidCron(config);
45
60
  if (catalog)
46
61
  crons.push(catalog);
@@ -11,7 +11,7 @@
11
11
  import { astroidCatalogMirror } from "../commerce/mirror.js";
12
12
  import { astroidCommerceProviders, astroidCommerceRoles } from "../commerce/roles.js";
13
13
  import { COMMERCE_PROVIDER_SECRETS } from "../commerce/secrets.js";
14
- import { ASTROID_QUEUE_BINDING } from "./messages.js";
14
+ import { ASTROID_QUEUE_BINDING, astroidCommercePipeline } from "./messages.js";
15
15
  /**
16
16
  * Per-provider webhook facts: the header, the verifier, and how it's called.
17
17
  *
@@ -244,6 +244,10 @@ export function generateAstroidWebhookRoute(config, forProvider) {
244
244
  * two roles still has one endpoint and one secret.
245
245
  */
246
246
  export function generateAstroidWebhookRoutes(config) {
247
+ // A project that leaves the pipeline to another one receives no webhooks: the
248
+ // provider delivers each event to one endpoint, and it's the other project's.
249
+ if (!astroidCommercePipeline(config))
250
+ return [];
247
251
  return astroidCommerceProviders(config.commerce).flatMap((provider) => {
248
252
  const contents = generateAstroidWebhookRoute(config, provider);
249
253
  return contents ? [{ path: `src/pages/api/webhooks/${provider}.ts`, contents }] : [];
@@ -11,7 +11,69 @@
11
11
  // result to disk and formats it. Import ordering matches the site's schema.ts so
12
12
  // a formatter never re-flags the generated file.
13
13
  import { generateCatalogTable } from "../commerce/mirror.js";
14
+ import { astroidHasEditor } from "../shape.js";
14
15
  import { astroidFrameworkTables } from "./framework.js";
16
+ /** The Drizzle column builders a catalog table's source uses. */
17
+ function catalogBuilders(catalog) {
18
+ const builders = ["integer", "sqliteTable", "text"];
19
+ if (catalog?.includes("real("))
20
+ builders.push("real");
21
+ return builders.sort();
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
+ ];
40
+ /**
41
+ * The schema of an app with no editor (`editor: false`): no `pages`, no
42
+ * versions, and no framework tables, because nothing here edits them. What's
43
+ * left is what the config itself implies, the commerce catalog, and the site's
44
+ * own tables.
45
+ *
46
+ * A framework table the app only READS, such as `siteSettings` in a database
47
+ * another app owns, is imported straight from `louise-toolkit/db` where it's
48
+ * queried. Re-exporting it here would put it in front of drizzle-kit, which
49
+ * would then write migrations for a table this app doesn't own.
50
+ */
51
+ function generateAppSchema(config) {
52
+ const catalog = generateCatalogTable(config);
53
+ return [
54
+ "// Generated by astroidjs—do not hand-edit.",
55
+ "// Source: your defineAstroid config. This app has no editor (`editor: false`),",
56
+ "// so it owns no content tables. To read one that another app owns, such as",
57
+ '// `siteSettings`, import it from "louise-toolkit/db" where you query it.',
58
+ ...(catalog
59
+ ? [
60
+ `import { ${catalogBuilders(catalog).join(", ")} } from "drizzle-orm/sqlite-core";`,
61
+ // The catalog's JSON columns are typed with it; the editor schema
62
+ // imports it for `pages.sections` anyway.
63
+ ...(catalog.includes("JsonValue")
64
+ ? ['import type { JsonValue } from "louise-toolkit/content";']
65
+ : []),
66
+ "",
67
+ catalog,
68
+ ]
69
+ : [""]),
70
+ ...incidentTables(config),
71
+ "// Site-owned tables, declared in src/schema.site.ts and re-exported here so",
72
+ "// drizzle-kit and the worker see them. Empty until the app adds one.",
73
+ 'export * from "./schema.site.js";',
74
+ "",
75
+ ].join("\n");
76
+ }
15
77
  /**
16
78
  * Generate the TypeScript source of a site's Drizzle schema from an Astroid
17
79
  * config. The output is a drop-in replacement for the hand-written `schema.ts`:
@@ -19,6 +81,8 @@ import { astroidFrameworkTables } from "./framework.js";
19
81
  * table, and the re-exported framework tables the config selects.
20
82
  */
21
83
  export function generateAstroidSchema(config) {
84
+ if (!astroidHasEditor(config))
85
+ return generateAppSchema(config);
22
86
  const framework = astroidFrameworkTables(config); // sorted
23
87
  // pagesColumns is spread into `pages`; the framework tables are re-exported.
24
88
  // One import brings them all in (matching the site) so nothing is unused.
@@ -27,13 +91,11 @@ export function generateAstroidSchema(config) {
27
91
  // Only import the column builders the emitted source actually uses—an
28
92
  // unused import is a lint error in the project we're generating into, and a
29
93
  // missing one (`real`, from the catalog's price/sortOrder) is a type error.
30
- const drizzleImports = ["integer", "sqliteTable", "text"];
31
- if (catalog?.includes("real("))
32
- drizzleImports.push("real");
94
+ const drizzleImports = catalogBuilders(catalog);
33
95
  return [
34
96
  "// Generated by astroidjs—do not hand-edit.",
35
97
  "// Source: your defineAstroid config.",
36
- `import { ${drizzleImports.sort().join(", ")} } from "drizzle-orm/sqlite-core";`,
98
+ `import { ${drizzleImports.join(", ")} } from "drizzle-orm/sqlite-core";`,
37
99
  'import { collectionVersionsTable, type JsonValue } from "louise-toolkit/content";',
38
100
  `import { ${dbImports.join(", ")} } from "louise-toolkit/db";`,
39
101
  "",
@@ -53,6 +115,7 @@ export function generateAstroidSchema(config) {
53
115
  ...(catalog ? [catalog] : []),
54
116
  `export { ${framework.join(", ")} };`,
55
117
  "",
118
+ ...incidentTables(config),
56
119
  "// Site-owned tables (the ones Astroid doesn't manage): a project declares",
57
120
  "// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
58
121
  "// so drizzle-kit sees them and the generated worker can import them. The file",
@@ -1 +1 @@
1
- export { ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, type RateRule, } from "./rate-rules.js";
1
+ export { ASTROID_API_PREFIX, ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, type RateRule, } from "./rate-rules.js";
@@ -1,2 +1,2 @@
1
1
  // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
- export { ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, } from "./rate-rules.js";
2
+ export { ASTROID_API_PREFIX, ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, } from "./rate-rules.js";
@@ -9,10 +9,17 @@ export type { RateRule };
9
9
  export declare const ASTROID_PORTAL_BASE_PATH = "/api/portal-auth";
10
10
  /** Path the commerce module's checkout POSTs to. */
11
11
  export declare const ASTROID_CHECKOUT_PATH = "/api/checkout";
12
+ /**
13
+ * Where an app with no editor (`editor: false`) serves its versioned JSON API.
14
+ * A web client and a later native client call the same routes, so a new
15
+ * version is a new prefix rather than a change to the one clients already use.
16
+ */
17
+ export declare const ASTROID_API_PREFIX = "/api/v1";
12
18
  /**
13
19
  * The rule set for a project, derived from its config: the editor sign-in
14
- * surface always, the portal's credential surfaces when a portal is enabled, and
15
- * checkout when commerce is configured.
20
+ * surface unless the project has no editor, the portal's credential surfaces
21
+ * when a portal is enabled, checkout when commerce is configured, and the
22
+ * versioned API for an app with no editor.
16
23
  *
17
24
  * Rules are matched first-wins, and `security.rateRules` from the config are
18
25
  * placed FIRST—so a site tightens or loosens any default by declaring its own