astroidjs 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +41 -7
  96. package/dist/schema/collections.js +101 -12
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -4
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
@@ -0,0 +1,129 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // What each commerce provider needs before it can do anything, and the gate
4
+ // derived from it.
5
+ //
6
+ // The rest of this module is deliberately credential-free — the adapters are
7
+ // pure, the mirror and the loader only touch D1, and `verifyCheckout` takes a
8
+ // price lookup rather than a token. That's the right shape: it keeps the parts
9
+ // testable and lets the caller own how the fetch happens.
10
+ //
11
+ // But something still has to answer "is commerce actually on?", and every
12
+ // consuming site answered it by hand, differently. Squaring that away here is
13
+ // the commerce half of the dormant-until-provisioned convention (#252): the
14
+ // names below are the single source of truth for what a provider requires, so
15
+ // the wrangler generator can seed them, `astroid doctor` can report them, and a
16
+ // storefront can decide between a live catalog and a simulated one — all from
17
+ // one declaration instead of three hand-maintained lists.
18
+ //
19
+ // The webhook secret lives here too, rather than beside the verifier in
20
+ // `queues/scaffold.ts`, because it is the same fact: a provider's secret set.
21
+ // The scaffold imports it from here so the two can't drift.
22
+ import { resolveModuleSecrets } from "../secrets.js";
23
+ import { astroidCommerceProviders, astroidCommerceRoles } from "./roles.js";
24
+ /**
25
+ * Per-provider secret names, split by what they gate.
26
+ *
27
+ * `credentials` is what the provider's `louise-toolkit/commerce/*` client needs
28
+ * to make a call at all; `webhook` is the signing secret its receiver verifies
29
+ * with. They're separable on purpose — a site can receive verified webhooks
30
+ * before it has finished provisioning API access, and the reverse is the normal
31
+ * state of a brand-new integration.
32
+ *
33
+ * Names match what the scaffolded `env.d.ts` declares, so a project can read
34
+ * `env.SQUARE_ACCESS_TOKEN` and have it typed.
35
+ */
36
+ export const COMMERCE_PROVIDER_SECRETS = {
37
+ // The location id is not a secret, but it IS required: Square's orders and
38
+ // payments endpoints refuse a request without one, so a token on its own
39
+ // leaves checkout broken rather than dormant. Requiring both is what makes
40
+ // "configured" mean "can actually take money".
41
+ square: {
42
+ credentials: ["SQUARE_ACCESS_TOKEN", "SQUARE_LOCATION_ID"],
43
+ webhook: "SQUARE_WEBHOOK_SECRET",
44
+ },
45
+ stripe: {
46
+ credentials: ["STRIPE_SECRET_KEY"],
47
+ webhook: "STRIPE_WEBHOOK_SECRET",
48
+ },
49
+ // Fourthwall's storefront token is public-safe (it's sent as a query param
50
+ // from the browser in their own SDK), but it is still provisioned, so it
51
+ // follows the same gate — an absent token means no catalog.
52
+ fourthwall: {
53
+ credentials: ["FOURTHWALL_STOREFRONT_TOKEN"],
54
+ webhook: "FOURTHWALL_WEBHOOK_SECRET",
55
+ },
56
+ };
57
+ /**
58
+ * Where a developer actually gets each provider's credentials.
59
+ *
60
+ * Carried next to the names because the scaffold's job is to leave someone able
61
+ * to finish provisioning without a search: a `.env.example` line that says
62
+ * `SQUARE_ACCESS_TOKEN=DUMMY_REPLACE_ME` and nothing else has told them what to
63
+ * do, only that something is missing.
64
+ */
65
+ export const COMMERCE_PROVIDER_SETUP = {
66
+ square: "developer.squareup.com → your app → Credentials (access token) and Locations (location id). Webhook secret: the same app → Webhooks → Subscriptions.",
67
+ stripe: "dashboard.stripe.com → Developers → API keys (secret key). Webhook secret: Developers → Webhooks → your endpoint → Signing secret.",
68
+ fourthwall: "Fourthwall dashboard → Settings → For developers → Storefront token. Webhook secret: the same page → Webhooks.",
69
+ };
70
+ /**
71
+ * Every secret name this project's commerce configuration needs, deduplicated
72
+ * and in a stable order.
73
+ *
74
+ * Deduplication is the point: a site running Square in both roles has one
75
+ * access token, not two, and seeding `SQUARE_ACCESS_TOKEN` twice into a
76
+ * `.dev.vars` would be a bug rather than a redundancy.
77
+ */
78
+ export function commerceSecretNames(commerce) {
79
+ const names = astroidCommerceProviders(commerce).flatMap((provider) => [
80
+ ...COMMERCE_PROVIDER_SECRETS[provider].credentials,
81
+ COMMERCE_PROVIDER_SECRETS[provider].webhook,
82
+ ]);
83
+ return [...new Set(names)];
84
+ }
85
+ /** Read one provider's secrets off an env-shaped record. */
86
+ async function resolveProvider(provider, roles, env) {
87
+ const spec = COMMERCE_PROVIDER_SECRETS[provider];
88
+ const pick = (names) => Object.fromEntries(names.map((n) => [n, env[n]]));
89
+ const [credentials, webhook] = await Promise.all([
90
+ resolveModuleSecrets(pick(spec.credentials)),
91
+ resolveModuleSecrets(pick([spec.webhook])),
92
+ ]);
93
+ return {
94
+ provider,
95
+ roles,
96
+ credentials,
97
+ webhook,
98
+ configured: credentials.configured && webhook.configured,
99
+ };
100
+ }
101
+ /**
102
+ * Resolve the commerce module's dormancy from the runtime env.
103
+ *
104
+ * ```ts
105
+ * const status = await resolveCommerceStatus(config.commerce, env);
106
+ * const products = status.configured
107
+ * ? await syncThenRead(env) // live
108
+ * : await readCatalog({ db: env.DB, table }); // whatever the mirror already holds
109
+ * ```
110
+ *
111
+ * Note what "dormant" means for commerce specifically: the D1 mirror is still
112
+ * readable, so an unprovisioned storefront serves the catalog it last synced
113
+ * (usually the seeded sample rows) rather than an error page. That is the whole
114
+ * reason the mirror exists as a separate layer from the provider client.
115
+ */
116
+ export async function resolveCommerceStatus(commerce, env) {
117
+ const roles = astroidCommerceRoles(commerce);
118
+ const providers = astroidCommerceProviders(commerce);
119
+ if (providers.length === 0) {
120
+ return { configured: false, enabled: false, providers: [], missing: [] };
121
+ }
122
+ const resolved = await Promise.all(providers.map((provider) => resolveProvider(provider, ["storefront", "invoicing"].filter((r) => roles[r] === provider), env)));
123
+ return {
124
+ configured: resolved.every((p) => p.configured),
125
+ enabled: true,
126
+ providers: resolved,
127
+ missing: resolved.flatMap((p) => [...p.credentials.missing, ...p.webhook.missing]),
128
+ };
129
+ }
@@ -0,0 +1,86 @@
1
+ /** The provider-agnostic shape an adapter normalizes a product into. */
2
+ export interface CatalogItem {
3
+ /** The provider's id. Unique, stable, and the sync's idempotency key. */
4
+ externalId: string;
5
+ name: string;
6
+ /** Major units (dollars), matching the mirror's `price` column. */
7
+ price: number;
8
+ images?: string[];
9
+ variants?: unknown;
10
+ /** The provider's own slug, when it has one — for re-fetching a single item. */
11
+ externalSlug?: string;
12
+ }
13
+ /** The D1 surface the sync needs. Structural, so a real `D1Database` fits. */
14
+ export interface SyncDatabase {
15
+ prepare(query: string): {
16
+ bind(...values: unknown[]): {
17
+ run(): Promise<unknown>;
18
+ first<T = Record<string, unknown>>(): Promise<T | null>;
19
+ };
20
+ };
21
+ }
22
+ export interface CatalogSyncOptions {
23
+ db: SyncDatabase;
24
+ /** Mirror table name — must match the generated schema. */
25
+ table: string;
26
+ /**
27
+ * `overlay` mode writes only the key and `synced_at`: the catalog fields live
28
+ * at the provider, and there are no pulled columns to write.
29
+ */
30
+ mode?: "mirror" | "overlay";
31
+ /** Build the public slug for a NEW row. Defaults to a slugified name. */
32
+ slugify?: (item: CatalogItem) => string;
33
+ }
34
+ export interface CatalogSyncResult {
35
+ /** Rows created — new products, landing as `draft`. */
36
+ created: number;
37
+ /** Rows whose pulled fields were refreshed. */
38
+ updated: number;
39
+ /** Items that threw and were skipped. The next run retries them. */
40
+ failed: number;
41
+ /**
42
+ * One error per failed item, in order, for logging.
43
+ *
44
+ * Capped — a catalog-wide failure would otherwise build an array as long as
45
+ * the catalog just to describe the same fault N times.
46
+ */
47
+ errors: {
48
+ externalId: string;
49
+ message: string;
50
+ }[];
51
+ }
52
+ /** Lowercase, dash-separated, punctuation stripped. */
53
+ export declare function defaultSlug(value: string): string;
54
+ /**
55
+ * Upsert one item. Returns whether it created a row.
56
+ *
57
+ * The UPDATE branch lists pulled columns ONLY — see the note at the top of this
58
+ * file. In `overlay` mode there are no pulled columns, so an existing row is
59
+ * touched only to stamp `synced_at`.
60
+ */
61
+ export declare function astroidCatalogUpsert(item: CatalogItem, options: CatalogSyncOptions): Promise<{
62
+ created: boolean;
63
+ }>;
64
+ /**
65
+ * Sync a whole catalog snapshot.
66
+ *
67
+ * Items are written one at a time and a single failure doesn't abandon the rest:
68
+ * a partial catalog is strictly better than a stale one, and the next cron tick
69
+ * retries whatever didn't land. Rows whose product has vanished upstream are
70
+ * left alone — deciding whether a missing item is delisted or just a failed page
71
+ * of an API response is the project's call, not the sync's, and unpublishing
72
+ * someone's whole catalog on a bad response is unrecoverable.
73
+ *
74
+ * **A TOTAL failure throws.** Tolerating individual items is the point; silently
75
+ * reporting `{ created: 0, updated: 0 }` when every write failed is not. The
76
+ * result carried no failure count, so an unapplied migration or an unavailable
77
+ * D1 looked exactly like an empty catalog: the queue consumer acked the message,
78
+ * the cron re-sync acked too, and the site served a frozen catalog indefinitely
79
+ * with nothing in `wrangler tail`. Throwing when nothing at all landed is what
80
+ * makes the queue's retry and DLQ do their job.
81
+ *
82
+ * Partial failures do NOT throw — they come back in `failed`/`errors` for the
83
+ * caller to log, because retrying the whole batch to re-attempt a few rows would
84
+ * undo the tolerance this loop exists to provide.
85
+ */
86
+ export declare function astroidCatalogSync(items: CatalogItem[], options: CatalogSyncOptions): Promise<CatalogSyncResult>;
@@ -0,0 +1,154 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The sync: pull the provider's catalog, write it into the mirror without
4
+ // touching a single thing the owner edited.
5
+ //
6
+ // Two invariants do all the work here.
7
+ //
8
+ // **Idempotency.** The sync runs from a cron AND from webhooks, so the same
9
+ // product arrives repeatedly and sometimes concurrently. Keying every write on
10
+ // the provider's own id (unique in the table) means a re-run is a no-op update
11
+ // rather than a duplicate row.
12
+ //
13
+ // **The owned set is never in an UPDATE.** Not "usually not" — never. A sync
14
+ // that writes an owned column is a sync that silently reverts the owner's work,
15
+ // and they find out days later when someone notices the description is back to
16
+ // the vendor's default. So the update statement is built from the pulled set
17
+ // alone, and owned columns only appear in the INSERT, as defaults for a row
18
+ // that didn't exist yet.
19
+ import { AstroidUsageError } from "../errors.js";
20
+ /** Lowercase, dash-separated, punctuation stripped. */
21
+ export function defaultSlug(value) {
22
+ return (value
23
+ .normalize("NFKD")
24
+ .replace(/[̀-ͯ]/g, "")
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/^-+|-+$/g, "")
28
+ .slice(0, 80) || "item");
29
+ }
30
+ /**
31
+ * Allocate a slug that isn't taken, by suffixing `-2`, `-3`, … Two different
32
+ * provider items genuinely can share a name ("Original" in two collections), and
33
+ * the slug column is unique, so an unguarded insert would fail the whole sync
34
+ * over a naming coincidence.
35
+ */
36
+ async function allocateSlug(db, table, base, externalId) {
37
+ for (let n = 1; n < 50; n++) {
38
+ const candidate = n === 1 ? base : `${base}-${n}`;
39
+ const clash = await db
40
+ .prepare(`SELECT external_id FROM ${table} WHERE slug = ?`)
41
+ .bind(candidate)
42
+ .first();
43
+ // Free, or already ours (a re-run reaching the same row).
44
+ if (!clash || clash.external_id === externalId)
45
+ return candidate;
46
+ }
47
+ // Pathological: 50 items with one name. Fall back to something unique by
48
+ // construction rather than failing the sync.
49
+ return `${base}-${externalId.slice(-8).toLowerCase()}`;
50
+ }
51
+ /**
52
+ * Upsert one item. Returns whether it created a row.
53
+ *
54
+ * The UPDATE branch lists pulled columns ONLY — see the note at the top of this
55
+ * file. In `overlay` mode there are no pulled columns, so an existing row is
56
+ * touched only to stamp `synced_at`.
57
+ */
58
+ export async function astroidCatalogUpsert(item, options) {
59
+ const { db, table } = options;
60
+ const mode = options.mode ?? "mirror";
61
+ const now = Math.floor(Date.now() / 1000);
62
+ const existing = await db
63
+ .prepare(`SELECT id FROM ${table} WHERE external_id = ?`)
64
+ .bind(item.externalId)
65
+ .first();
66
+ if (existing) {
67
+ if (mode === "overlay") {
68
+ await db
69
+ .prepare(`UPDATE ${table} SET synced_at = ? WHERE external_id = ?`)
70
+ .bind(now, item.externalId)
71
+ .run();
72
+ }
73
+ else {
74
+ await db
75
+ .prepare(`UPDATE ${table}
76
+ SET name = ?, price = ?, images = ?, variants = ?, external_slug = ?, synced_at = ?
77
+ WHERE external_id = ?`)
78
+ .bind(item.name, item.price, JSON.stringify(item.images ?? []), JSON.stringify(item.variants ?? null), item.externalSlug ?? null, now, item.externalId)
79
+ .run();
80
+ }
81
+ return { created: false };
82
+ }
83
+ const slug = await allocateSlug(db, table, options.slugify ? options.slugify(item) : defaultSlug(item.name), item.externalId);
84
+ if (mode === "overlay") {
85
+ await db
86
+ .prepare(`INSERT INTO ${table} (external_id, slug, synced_at) VALUES (?, ?, ?)`)
87
+ .bind(item.externalId, slug, now)
88
+ .run();
89
+ }
90
+ else {
91
+ await db
92
+ .prepare(`INSERT INTO ${table}
93
+ (external_id, name, price, images, variants, external_slug, synced_at, slug)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
95
+ .bind(item.externalId, item.name, item.price, JSON.stringify(item.images ?? []), JSON.stringify(item.variants ?? null), item.externalSlug ?? null, now, slug)
96
+ .run();
97
+ }
98
+ return { created: true };
99
+ }
100
+ /** How many per-item errors to keep. Enough to spot a pattern, bounded so a
101
+ * catalog-wide fault doesn't allocate one message per product. */
102
+ const MAX_RECORDED_ERRORS = 10;
103
+ /**
104
+ * Sync a whole catalog snapshot.
105
+ *
106
+ * Items are written one at a time and a single failure doesn't abandon the rest:
107
+ * a partial catalog is strictly better than a stale one, and the next cron tick
108
+ * retries whatever didn't land. Rows whose product has vanished upstream are
109
+ * left alone — deciding whether a missing item is delisted or just a failed page
110
+ * of an API response is the project's call, not the sync's, and unpublishing
111
+ * someone's whole catalog on a bad response is unrecoverable.
112
+ *
113
+ * **A TOTAL failure throws.** Tolerating individual items is the point; silently
114
+ * reporting `{ created: 0, updated: 0 }` when every write failed is not. The
115
+ * result carried no failure count, so an unapplied migration or an unavailable
116
+ * D1 looked exactly like an empty catalog: the queue consumer acked the message,
117
+ * the cron re-sync acked too, and the site served a frozen catalog indefinitely
118
+ * with nothing in `wrangler tail`. Throwing when nothing at all landed is what
119
+ * makes the queue's retry and DLQ do their job.
120
+ *
121
+ * Partial failures do NOT throw — they come back in `failed`/`errors` for the
122
+ * caller to log, because retrying the whole batch to re-attempt a few rows would
123
+ * undo the tolerance this loop exists to provide.
124
+ */
125
+ export async function astroidCatalogSync(items, options) {
126
+ const result = { created: 0, updated: 0, failed: 0, errors: [] };
127
+ for (const item of items) {
128
+ try {
129
+ const { created } = await astroidCatalogUpsert(item, options);
130
+ if (created)
131
+ result.created++;
132
+ else
133
+ result.updated++;
134
+ }
135
+ catch (cause) {
136
+ // Skip this item; the next run retries it — but record it, so "nothing
137
+ // synced" can be told apart from "nothing to sync".
138
+ result.failed++;
139
+ if (result.errors.length < MAX_RECORDED_ERRORS) {
140
+ result.errors.push({
141
+ externalId: item.externalId,
142
+ message: cause instanceof Error ? cause.message : String(cause),
143
+ });
144
+ }
145
+ }
146
+ }
147
+ if (items.length > 0 && result.failed === items.length) {
148
+ throw new AstroidUsageError(`Catalog sync failed for all ${items.length} item(s) — nothing was written. ` +
149
+ `First error: ${result.errors[0]?.message ?? "unknown"}. ` +
150
+ "This is usually an unapplied migration (the catalog table doesn't exist yet) " +
151
+ "or an unavailable D1 binding.");
152
+ }
153
+ return result;
154
+ }