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,90 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The catalog read, and the Live Content Collection loader over it.
4
+ //
5
+ // `defineCatalogLoader` (louise-toolkit/astro) already owns the Astro-facing
6
+ // plumbing. What each site then hand-wrote was the layer underneath — "read my
7
+ // catalog out of D1" — and because that layer was per-site, so was the drift.
8
+ // It doesn't need to be: once the mirror table's shape is fixed (see mirror.ts),
9
+ // reading it is the same query whatever provider filled it in.
10
+ //
11
+ // That's what makes one loader definition serve a Square site and a Fourthwall
12
+ // site: the loader never learns which provider it is. The sync normalized that
13
+ // away before the row was written.
14
+ /** JSON column → value, tolerating both a decoded object and a raw string. */
15
+ function decodeJson(value) {
16
+ if (typeof value !== "string")
17
+ return value ?? null;
18
+ try {
19
+ return JSON.parse(value);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Mirror row → `CatalogProduct`. */
26
+ function toProduct(row) {
27
+ const { images, variants, external_id, external_slug, sort_order, synced_at, ...rest } = row;
28
+ return {
29
+ ...rest,
30
+ externalId: String(external_id ?? ""),
31
+ externalSlug: typeof external_slug === "string" ? external_slug : undefined,
32
+ slug: String(row.slug ?? ""),
33
+ name: String(row.name ?? ""),
34
+ price: Number(row.price ?? 0),
35
+ images: decodeJson(images) ?? [],
36
+ variants: decodeJson(variants),
37
+ status: row.status === "published" ? "published" : "draft",
38
+ sortOrder: Number(sort_order ?? 0),
39
+ featured: Boolean(row.featured),
40
+ };
41
+ }
42
+ /**
43
+ * Read the catalog. Ordered by `sortOrder` then name, so an owner's manual
44
+ * ordering wins and everything they haven't ordered stays stable rather than
45
+ * shuffling per query.
46
+ */
47
+ export async function readCatalog(options) {
48
+ const where = options.includeDrafts ? "" : " WHERE status = 'published'";
49
+ const { results } = await options.db
50
+ .prepare(`SELECT * FROM ${options.table}${where} ORDER BY sort_order ASC, name ASC`)
51
+ .all();
52
+ return (results ?? []).map(toProduct);
53
+ }
54
+ /** Read one product by its public slug. Null when absent or still a draft. */
55
+ export async function readCatalogItem(slug, options) {
56
+ const where = options.includeDrafts ? "" : " AND status = 'published'";
57
+ const row = await options.db
58
+ .prepare(`SELECT * FROM ${options.table} WHERE slug = ?${where}`)
59
+ .bind(slug)
60
+ .first();
61
+ return row ? toProduct(row) : null;
62
+ }
63
+ /**
64
+ * The config to hand `defineCatalogLoader`, wired to the mirror.
65
+ *
66
+ * ```ts
67
+ * // src/loaders/catalog.ts
68
+ * import { defineCatalogLoader } from "louise-toolkit/astro";
69
+ * import { astroidCatalogLoaderConfig } from "astroidjs";
70
+ *
71
+ * export const catalogLoader = defineCatalogLoader(
72
+ * astroidCatalogLoaderConfig({ db: env.DB, table: "products" }),
73
+ * );
74
+ * ```
75
+ *
76
+ * Identical for every provider — which is the whole point.
77
+ */
78
+ export function astroidCatalogLoaderConfig(options) {
79
+ return {
80
+ name: options.name ?? "astroid-catalog",
81
+ loadCatalog: async (filter) => {
82
+ const items = await readCatalog(options);
83
+ return {
84
+ items: filter?.featured ? items.filter((i) => i.featured) : items,
85
+ };
86
+ },
87
+ loadItem: (id) => readCatalogItem(id, options),
88
+ idOf: (item) => item.slug,
89
+ };
90
+ }
@@ -0,0 +1,67 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** A column the OWNER edits. Preserved verbatim across every sync. */
3
+ export interface OwnedColumn {
4
+ type: "text" | "integer" | "real" | "boolean" | "json";
5
+ /** Restrict a text column to a fixed set (emits a Drizzle `enum`). */
6
+ values?: string[];
7
+ /** Default for new rows. Strings are quoted; others are emitted as literals. */
8
+ default?: string | number | boolean;
9
+ /** Doc comment on the generated column. */
10
+ note?: string;
11
+ }
12
+ export interface CatalogMirrorConfig {
13
+ /**
14
+ * `mirror` keeps the provider's catalog fields in D1 (fast reads, briefly
15
+ * stale); `overlay` keeps only the owner's fields and reads the catalog live.
16
+ * Default `mirror`.
17
+ */
18
+ mode?: "mirror" | "overlay";
19
+ /** Table name. Default `products`. */
20
+ table?: string;
21
+ /** The owner-editable columns, on top of the built-ins below. */
22
+ owned?: Record<string, OwnedColumn>;
23
+ }
24
+ /**
25
+ * Columns Astroid always PULLS, overwriting each sync. Fixed rather than
26
+ * configurable because they're the intersection of what every provider returns —
27
+ * a project that wants a provider-specific field puts it in `owned` and fills it
28
+ * itself, which also stops the sync from clobbering it.
29
+ */
30
+ export declare const PULLED_COLUMNS: readonly ["name", "price", "images", "variants", "externalSlug", "syncedAt"];
31
+ /**
32
+ * Owner columns every catalog needs, whatever the site. `slug` is deliberately
33
+ * owned, not pulled: it's the public URL, so a provider renaming a product must
34
+ * not silently break links and SEO.
35
+ */
36
+ export declare const BUILT_IN_OWNED: Record<string, OwnedColumn>;
37
+ /** The mirror config for a project, with defaults applied. Null when the project
38
+ * has no storefront to mirror. */
39
+ export declare function astroidCatalogMirror(config: AstroidConfig): Required<Pick<CatalogMirrorConfig, "mode" | "table">> & {
40
+ owned: Record<string, OwnedColumn>;
41
+ };
42
+ /**
43
+ * Drizzle source for the catalog table.
44
+ *
45
+ * `externalId` is unique — it's the sync's idempotency key, so a webhook and the
46
+ * cron re-sync racing on the same product can only ever collide into one row.
47
+ */
48
+ export declare function generateCatalogTable(config: AstroidConfig): string | null;
49
+ /**
50
+ * The `CREATE TABLE` for the catalog mirror, as a D1 migration.
51
+ *
52
+ * Derived from the SAME `astroidCatalogMirror(config)` declaration as
53
+ * {@link generateCatalogTable}, so the Drizzle schema and the table that
54
+ * actually exists cannot describe different shapes.
55
+ *
56
+ * It has to exist at all because nothing else creates this table. `--commerce`
57
+ * put `products` in `src/schema.ts` and the queue seam told you to sync into it,
58
+ * but no migration anywhere in the toolkit created it — so the first catalog
59
+ * sync hit a missing table, and (because `astroidCatalogSync` swallows per-item
60
+ * errors) reported success while writing nothing. The documented fallback,
61
+ * `drizzle-kit generate`, could not help: the template ships a hand-authored
62
+ * `0000_content.sql` with no drizzle journal, so drizzle-kit has no baseline and
63
+ * emits a duplicate `0000_` full-CREATE that collides on the next apply.
64
+ *
65
+ * Returns null when the project has no commerce.
66
+ */
67
+ export declare function generateCatalogMigrationSql(config: AstroidConfig): string | null;
@@ -0,0 +1,203 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The catalog mirror: an external provider is the source of truth, D1 is the
4
+ // editable overlay on top of it.
5
+ //
6
+ // Every consuming site landed on the same split — a set of fields PULLED from
7
+ // the provider and overwritten on every sync, and a disjoint set the owner edits
8
+ // which must survive every sync. Get that boundary wrong in either direction and
9
+ // you either clobber the owner's copy on the next cron tick, or serve a price
10
+ // the provider no longer honours.
11
+ //
12
+ // What the sites did NOT agree on is how much to store, and it turns out to be
13
+ // one primitive with two settings rather than two designs:
14
+ //
15
+ // mirror — pulled + owned columns both live in D1 (themidwestartist.com).
16
+ // Reads are one local query. The catalog can be stale between syncs.
17
+ // overlay — only the owned columns live in D1, keyed by the provider's id
18
+ // (coracle.coffee's `product_display_meta`). The catalog is read
19
+ // live from the provider and joined at read time: never stale,
20
+ // but every read costs a provider round-trip (cache accordingly).
21
+ //
22
+ // `overlay` is just `mirror` with an empty pulled set, so one generator serves
23
+ // both and a project can switch by changing one word.
24
+ /**
25
+ * Columns Astroid always PULLS, overwriting each sync. Fixed rather than
26
+ * configurable because they're the intersection of what every provider returns —
27
+ * a project that wants a provider-specific field puts it in `owned` and fills it
28
+ * itself, which also stops the sync from clobbering it.
29
+ */
30
+ export const PULLED_COLUMNS = [
31
+ "name",
32
+ "price",
33
+ "images",
34
+ "variants",
35
+ "externalSlug",
36
+ "syncedAt",
37
+ ];
38
+ /**
39
+ * Owner columns every catalog needs, whatever the site. `slug` is deliberately
40
+ * owned, not pulled: it's the public URL, so a provider renaming a product must
41
+ * not silently break links and SEO.
42
+ */
43
+ export const BUILT_IN_OWNED = {
44
+ slug: {
45
+ type: "text",
46
+ note: "Public URL segment. Owner-owned so a provider rename can't break links.",
47
+ },
48
+ status: {
49
+ type: "text",
50
+ values: ["draft", "published"],
51
+ default: "draft",
52
+ note: "New remote items land as draft — nothing goes live until someone says so.",
53
+ },
54
+ sortOrder: { type: "real", default: 0 },
55
+ featured: { type: "boolean", default: false },
56
+ };
57
+ /** The mirror config for a project, with defaults applied. Null when the project
58
+ * has no storefront to mirror. */
59
+ export function astroidCatalogMirror(config) {
60
+ const mirror = config.commerce?.catalog ?? {};
61
+ return {
62
+ mode: mirror.mode ?? "mirror",
63
+ table: mirror.table ?? "products",
64
+ // Built-ins first so a project can override one (e.g. widen `status`)
65
+ // without restating the rest.
66
+ owned: { ...BUILT_IN_OWNED, ...mirror.owned },
67
+ };
68
+ }
69
+ const SQL_NAME = (key) => key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
70
+ /** Drizzle source for one owned column. */
71
+ function ownedColumnSource(key, col) {
72
+ const name = JSON.stringify(SQL_NAME(key));
73
+ const lit = (v) => typeof v === "string" ? JSON.stringify(v) : `${v}`;
74
+ let expr;
75
+ switch (col.type) {
76
+ case "boolean":
77
+ expr = `integer(${name}, { mode: "boolean" })`;
78
+ break;
79
+ case "json":
80
+ expr = `text(${name}, { mode: "json" }).$type<JsonValue>()`;
81
+ break;
82
+ case "text":
83
+ expr = col.values?.length
84
+ ? `text(${name}, { enum: ${JSON.stringify(col.values)} })`
85
+ : `text(${name})`;
86
+ break;
87
+ default:
88
+ expr = `${col.type}(${name})`;
89
+ }
90
+ if (col.default !== undefined)
91
+ expr += `.notNull().default(${lit(col.default)})`;
92
+ return ` ${key}: ${expr},`;
93
+ }
94
+ /**
95
+ * Drizzle source for the catalog table.
96
+ *
97
+ * `externalId` is unique — it's the sync's idempotency key, so a webhook and the
98
+ * cron re-sync racing on the same product can only ever collide into one row.
99
+ */
100
+ export function generateCatalogTable(config) {
101
+ if (!config.commerce)
102
+ return null;
103
+ const { mode, table, owned } = astroidCatalogMirror(config);
104
+ const lines = [];
105
+ const p = (s = "") => lines.push(s);
106
+ p(`// The catalog ${mode}. The provider is the source of truth; these rows are`);
107
+ p(mode === "mirror"
108
+ ? "// a local copy plus the owner's edits. Pulled columns are overwritten every"
109
+ : "// the owner's edits only — catalog fields are read live from the provider.");
110
+ p("// sync; owned columns are preserved. See astroidCatalogUpsert.");
111
+ p(`export const ${camel(table)} = sqliteTable(${JSON.stringify(table)}, {`);
112
+ p(' id: integer("id").primaryKey({ autoIncrement: true }),');
113
+ p(" // The provider's id for this item — the sync's idempotency key.");
114
+ p(' externalId: text("external_id").notNull().unique(),');
115
+ if (mode === "mirror") {
116
+ p(" // --- PULLED: overwritten on every sync, never hand-edit ---");
117
+ p(' name: text("name").notNull(),');
118
+ p(' price: real("price").notNull().default(0),');
119
+ p(' images: text("images", { mode: "json" }).$type<JsonValue>(),');
120
+ p(' variants: text("variants", { mode: "json" }).$type<JsonValue>(),');
121
+ p(' externalSlug: text("external_slug"),');
122
+ }
123
+ p(' syncedAt: integer("synced_at", { mode: "timestamp" }),');
124
+ p(" // --- OWNED: preserved across every sync ---");
125
+ for (const [key, col] of Object.entries(owned)) {
126
+ if (col.note)
127
+ p(` /** ${col.note} */`);
128
+ p(ownedColumnSource(key, col));
129
+ }
130
+ p(' createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),');
131
+ p("});");
132
+ p();
133
+ return lines.join("\n");
134
+ }
135
+ /** `product_display_meta` → `productDisplayMeta`. */
136
+ function camel(name) {
137
+ return name.replace(/[_-](\w)/g, (_, c) => c.toUpperCase());
138
+ }
139
+ /** SQL column type + constraints for one owned column, matching
140
+ * {@link ownedColumnSource}'s Drizzle output. */
141
+ function ownedColumnSql(key, col) {
142
+ const name = SQL_NAME(key);
143
+ // Drizzle stores booleans and timestamps as INTEGER, json as TEXT.
144
+ const type = col.type === "boolean" ? "integer" : col.type === "json" ? "text" : col.type;
145
+ let sql = ` \`${name}\` ${type}`;
146
+ if (col.default !== undefined) {
147
+ const lit = typeof col.default === "string"
148
+ ? `'${col.default.replace(/'/g, "''")}'`
149
+ : typeof col.default === "boolean"
150
+ ? col.default
151
+ ? "1"
152
+ : "0"
153
+ : `${col.default}`;
154
+ sql += ` NOT NULL DEFAULT ${lit}`;
155
+ }
156
+ return sql;
157
+ }
158
+ /**
159
+ * The `CREATE TABLE` for the catalog mirror, as a D1 migration.
160
+ *
161
+ * Derived from the SAME `astroidCatalogMirror(config)` declaration as
162
+ * {@link generateCatalogTable}, so the Drizzle schema and the table that
163
+ * actually exists cannot describe different shapes.
164
+ *
165
+ * It has to exist at all because nothing else creates this table. `--commerce`
166
+ * put `products` in `src/schema.ts` and the queue seam told you to sync into it,
167
+ * but no migration anywhere in the toolkit created it — so the first catalog
168
+ * sync hit a missing table, and (because `astroidCatalogSync` swallows per-item
169
+ * errors) reported success while writing nothing. The documented fallback,
170
+ * `drizzle-kit generate`, could not help: the template ships a hand-authored
171
+ * `0000_content.sql` with no drizzle journal, so drizzle-kit has no baseline and
172
+ * emits a duplicate `0000_` full-CREATE that collides on the next apply.
173
+ *
174
+ * Returns null when the project has no commerce.
175
+ */
176
+ export function generateCatalogMigrationSql(config) {
177
+ if (!config.commerce)
178
+ return null;
179
+ const { mode, table, owned } = astroidCatalogMirror(config);
180
+ const cols = [
181
+ " `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL",
182
+ " `external_id` text NOT NULL",
183
+ ];
184
+ if (mode === "mirror") {
185
+ cols.push(" `name` text NOT NULL", " `price` real NOT NULL DEFAULT 0", " `images` text", " `variants` text", " `external_slug` text");
186
+ }
187
+ cols.push(" `synced_at` integer");
188
+ for (const [key, col] of Object.entries(owned))
189
+ cols.push(ownedColumnSql(key, col));
190
+ cols.push(" `created_at` integer");
191
+ return [
192
+ `-- The catalog ${mode} (${table}). Generated from your Astroid commerce config;`,
193
+ "-- keep it in step with src/schema.ts, which is generated from the same declaration.",
194
+ `CREATE TABLE IF NOT EXISTS \`${table}\` (`,
195
+ cols.join(",\n"),
196
+ ");",
197
+ "",
198
+ "-- The sync's idempotency key: a webhook and the cron re-sync racing on the same",
199
+ "-- product can then only ever collide into one row.",
200
+ `CREATE UNIQUE INDEX IF NOT EXISTS \`${table}_external_id_unique\` ON \`${table}\` (\`external_id\`);`,
201
+ "",
202
+ ].join("\n");
203
+ }
@@ -0,0 +1,38 @@
1
+ import type { CommerceConfig, CommerceProvider } from "../config.js";
2
+ /** What a provider is being used FOR. */
3
+ export type CommerceRole = "storefront" | "invoicing";
4
+ /**
5
+ * Which roles each provider can serve, derived from the surface its
6
+ * `louise-toolkit/commerce/*` client actually exposes — not from what the
7
+ * vendor's full API could theoretically do.
8
+ *
9
+ * square catalog + orders + payments, and `createInvoice`/`publishInvoice`
10
+ * stripe invoices + payment intents; NO catalog
11
+ * fourthwall catalog + cart; NO invoicing
12
+ */
13
+ export declare const PROVIDER_ROLES: Record<CommerceProvider, readonly CommerceRole[]>;
14
+ /** The providers filling each role. Either may be absent. */
15
+ export interface ResolvedCommerceRoles {
16
+ storefront?: CommerceProvider;
17
+ invoicing?: CommerceProvider;
18
+ }
19
+ /**
20
+ * Resolve a `commerce` block into role assignments.
21
+ *
22
+ * The `provider` shorthand assigns to the provider's *natural* role — the one it
23
+ * can serve — so `{ provider: "square" }` is a storefront and
24
+ * `{ provider: "stripe" }` is invoicing. Guessing "storefront" for both would
25
+ * produce a storefront with no catalog API behind it.
26
+ */
27
+ export declare function astroidCommerceRoles(commerce: CommerceConfig | undefined): ResolvedCommerceRoles;
28
+ /** Every distinct provider this project talks to, in a stable order. */
29
+ export declare function astroidCommerceProviders(commerce: CommerceConfig | undefined): CommerceProvider[];
30
+ /** True when this project sells anything at all. */
31
+ export declare const hasStorefront: (commerce: CommerceConfig | undefined) => boolean;
32
+ /**
33
+ * Reject a role assignment the provider's client cannot serve. Called from
34
+ * `defineAstroid`, so `invoicing: "fourthwall"` fails at config load with a
35
+ * message naming the alternatives — rather than at runtime, on the first
36
+ * invoice, as a missing function.
37
+ */
38
+ export declare function assertCommerceRoles(commerce: CommerceConfig | undefined): void;
@@ -0,0 +1,93 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Provider ROLES, not "the provider".
4
+ //
5
+ // The obvious model is one commerce provider per site. It's wrong, and the
6
+ // toolkit's own clients prove it: `louise-toolkit/commerce/stripe` has no
7
+ // catalog API whatsoever — it exposes invoices, customers, and payment intents.
8
+ // `fourthwall` is the mirror image: a catalog and a cart, no invoicing. Square
9
+ // happens to do both. A single `CommerceProvider` abstraction that assumed
10
+ // catalog + checkout would therefore have a permanent hole wherever Stripe sits.
11
+ //
12
+ // That's not hypothetical. themidwestartist.com runs Stripe for **invoicing**
13
+ // (commissions, originals) alongside Fourthwall for the **storefront** (merch) —
14
+ // two providers, one site, each doing the half it can do.
15
+ //
16
+ // So a project assigns providers to roles, and Astroid validates the assignment
17
+ // against what each provider's client can actually serve.
18
+ import { AstroidConfigError } from "../errors.js";
19
+ /**
20
+ * Which roles each provider can serve, derived from the surface its
21
+ * `louise-toolkit/commerce/*` client actually exposes — not from what the
22
+ * vendor's full API could theoretically do.
23
+ *
24
+ * square catalog + orders + payments, and `createInvoice`/`publishInvoice`
25
+ * stripe invoices + payment intents; NO catalog
26
+ * fourthwall catalog + cart; NO invoicing
27
+ */
28
+ export const PROVIDER_ROLES = {
29
+ square: ["storefront", "invoicing"],
30
+ stripe: ["invoicing"],
31
+ fourthwall: ["storefront"],
32
+ };
33
+ /**
34
+ * Resolve a `commerce` block into role assignments.
35
+ *
36
+ * The `provider` shorthand assigns to the provider's *natural* role — the one it
37
+ * can serve — so `{ provider: "square" }` is a storefront and
38
+ * `{ provider: "stripe" }` is invoicing. Guessing "storefront" for both would
39
+ * produce a storefront with no catalog API behind it.
40
+ */
41
+ export function astroidCommerceRoles(commerce) {
42
+ if (!commerce)
43
+ return {};
44
+ const roles = {};
45
+ if (commerce.provider) {
46
+ const natural = PROVIDER_ROLES[commerce.provider];
47
+ // Square serves both; the shorthand means the storefront, since that's the
48
+ // role that shapes the site (a catalog, a cart, product pages).
49
+ roles[natural.includes("storefront") ? "storefront" : "invoicing"] = commerce.provider;
50
+ }
51
+ if (commerce.storefront)
52
+ roles.storefront = commerce.storefront;
53
+ if (commerce.invoicing)
54
+ roles.invoicing = commerce.invoicing;
55
+ return roles;
56
+ }
57
+ /** Every distinct provider this project talks to, in a stable order. */
58
+ export function astroidCommerceProviders(commerce) {
59
+ const { storefront, invoicing } = astroidCommerceRoles(commerce);
60
+ return [...new Set([storefront, invoicing].filter((p) => !!p))];
61
+ }
62
+ /** True when this project sells anything at all. */
63
+ export const hasStorefront = (commerce) => Boolean(astroidCommerceRoles(commerce).storefront);
64
+ /**
65
+ * Reject a role assignment the provider's client cannot serve. Called from
66
+ * `defineAstroid`, so `invoicing: "fourthwall"` fails at config load with a
67
+ * message naming the alternatives — rather than at runtime, on the first
68
+ * invoice, as a missing function.
69
+ */
70
+ export function assertCommerceRoles(commerce) {
71
+ if (!commerce)
72
+ return;
73
+ const known = (provider) => {
74
+ if (!PROVIDER_ROLES[provider]) {
75
+ throw new AstroidConfigError(`Unknown commerce provider ${JSON.stringify(provider)} (expected ${Object.keys(PROVIDER_ROLES).join(" | ")})`);
76
+ }
77
+ };
78
+ const check = (role, provider) => {
79
+ if (!provider)
80
+ return;
81
+ known(provider);
82
+ if (!PROVIDER_ROLES[provider].includes(role)) {
83
+ const able = Object.keys(PROVIDER_ROLES).filter((p) => PROVIDER_ROLES[p].includes(role));
84
+ throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role — its louise-toolkit client has no ${role === "storefront" ? "catalog" : "invoicing"} API. Providers that can: ${able.join(", ")}.`);
85
+ }
86
+ };
87
+ // The shorthand assigns itself to a role it can serve, so it only has to be a
88
+ // provider we know about.
89
+ if (commerce.provider)
90
+ known(commerce.provider);
91
+ check("storefront", commerce.storefront);
92
+ check("invoicing", commerce.invoicing);
93
+ }
@@ -0,0 +1,74 @@
1
+ import type { CommerceConfig, CommerceProvider } from "../config.js";
2
+ import { type ModuleSecrets, type SecretSource } from "../secrets.js";
3
+ /**
4
+ * Per-provider secret names, split by what they gate.
5
+ *
6
+ * `credentials` is what the provider's `louise-toolkit/commerce/*` client needs
7
+ * to make a call at all; `webhook` is the signing secret its receiver verifies
8
+ * with. They're separable on purpose — a site can receive verified webhooks
9
+ * before it has finished provisioning API access, and the reverse is the normal
10
+ * state of a brand-new integration.
11
+ *
12
+ * Names match what the scaffolded `env.d.ts` declares, so a project can read
13
+ * `env.SQUARE_ACCESS_TOKEN` and have it typed.
14
+ */
15
+ export declare const COMMERCE_PROVIDER_SECRETS: Record<CommerceProvider, {
16
+ readonly credentials: readonly string[];
17
+ readonly webhook: string;
18
+ }>;
19
+ /**
20
+ * Where a developer actually gets each provider's credentials.
21
+ *
22
+ * Carried next to the names because the scaffold's job is to leave someone able
23
+ * to finish provisioning without a search: a `.env.example` line that says
24
+ * `SQUARE_ACCESS_TOKEN=DUMMY_REPLACE_ME` and nothing else has told them what to
25
+ * do, only that something is missing.
26
+ */
27
+ export declare const COMMERCE_PROVIDER_SETUP: Record<CommerceProvider, string>;
28
+ /**
29
+ * Every secret name this project's commerce configuration needs, deduplicated
30
+ * and in a stable order.
31
+ *
32
+ * Deduplication is the point: a site running Square in both roles has one
33
+ * access token, not two, and seeding `SQUARE_ACCESS_TOKEN` twice into a
34
+ * `.dev.vars` would be a bug rather than a redundancy.
35
+ */
36
+ export declare function commerceSecretNames(commerce: CommerceConfig | undefined): string[];
37
+ /** One provider's resolved gate. */
38
+ export interface ProviderStatus {
39
+ provider: CommerceProvider;
40
+ /** Which role(s) this provider fills for the project. */
41
+ roles: ("storefront" | "invoicing")[];
42
+ /** API credentials — false means no live call can be made. */
43
+ credentials: ModuleSecrets<string>;
44
+ /** Webhook signing secret — false means the receiver answers 503. */
45
+ webhook: ModuleSecrets<string>;
46
+ /** True only when both halves resolved. */
47
+ configured: boolean;
48
+ }
49
+ /** The whole commerce module's gate, per provider. */
50
+ export interface CommerceStatus {
51
+ /** True when the project has commerce configured AND every provider is live. */
52
+ configured: boolean;
53
+ /** True when the project declares no commerce at all — not the same as dormant. */
54
+ enabled: boolean;
55
+ providers: ProviderStatus[];
56
+ /** Every unprovisioned secret name across all providers, in declaration order. */
57
+ missing: string[];
58
+ }
59
+ /**
60
+ * Resolve the commerce module's dormancy from the runtime env.
61
+ *
62
+ * ```ts
63
+ * const status = await resolveCommerceStatus(config.commerce, env);
64
+ * const products = status.configured
65
+ * ? await syncThenRead(env) // live
66
+ * : await readCatalog({ db: env.DB, table }); // whatever the mirror already holds
67
+ * ```
68
+ *
69
+ * Note what "dormant" means for commerce specifically: the D1 mirror is still
70
+ * readable, so an unprovisioned storefront serves the catalog it last synced
71
+ * (usually the seeded sample rows) rather than an error page. That is the whole
72
+ * reason the mirror exists as a separate layer from the provider client.
73
+ */
74
+ export declare function resolveCommerceStatus(commerce: CommerceConfig | undefined, env: Record<string, SecretSource>): Promise<CommerceStatus>;