@businessdash/sdk 0.9.60 → 0.9.80

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 (239) hide show
  1. package/README.md +991 -295
  2. package/assets/biab-sw.js +92 -0
  3. package/dist/account-core/index.d.ts +126 -0
  4. package/dist/account-core/index.d.ts.map +1 -0
  5. package/dist/account-core/index.js +191 -0
  6. package/dist/account-core/index.js.map +1 -0
  7. package/dist/account.d.ts +11 -0
  8. package/dist/account.d.ts.map +1 -0
  9. package/dist/account.js +11 -0
  10. package/dist/account.js.map +1 -0
  11. package/dist/auth-handler.d.ts +1 -1
  12. package/dist/auth-links.d.ts +33 -0
  13. package/dist/auth-links.d.ts.map +1 -0
  14. package/dist/auth-links.js +64 -0
  15. package/dist/auth-links.js.map +1 -0
  16. package/dist/blog-core/index.d.ts +112 -0
  17. package/dist/blog-core/index.d.ts.map +1 -0
  18. package/dist/blog-core/index.js +330 -0
  19. package/dist/blog-core/index.js.map +1 -0
  20. package/dist/blog.d.ts +11 -0
  21. package/dist/blog.d.ts.map +1 -0
  22. package/dist/blog.js +11 -0
  23. package/dist/blog.js.map +1 -0
  24. package/dist/chat-core/index.d.ts +97 -0
  25. package/dist/chat-core/index.d.ts.map +1 -0
  26. package/dist/chat-core/index.js +237 -0
  27. package/dist/chat-core/index.js.map +1 -0
  28. package/dist/chat.d.ts +11 -0
  29. package/dist/chat.d.ts.map +1 -0
  30. package/dist/chat.js +11 -0
  31. package/dist/chat.js.map +1 -0
  32. package/dist/checkout-core/index.d.ts +65 -0
  33. package/dist/checkout-core/index.d.ts.map +1 -0
  34. package/dist/checkout-core/index.js +159 -0
  35. package/dist/checkout-core/index.js.map +1 -0
  36. package/dist/checkout.d.ts +10 -0
  37. package/dist/checkout.d.ts.map +1 -0
  38. package/dist/checkout.js +10 -0
  39. package/dist/checkout.js.map +1 -0
  40. package/dist/cli.js +133 -25
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +622 -1
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +888 -1
  45. package/dist/client.js.map +1 -1
  46. package/dist/consent-core/index.cjs +107 -0
  47. package/dist/consent-core/index.d.ts +103 -0
  48. package/dist/consent-core/index.d.ts.map +1 -0
  49. package/dist/consent-core/index.js +157 -0
  50. package/dist/consent-core/index.js.map +1 -0
  51. package/dist/contracts.d.ts +3526 -767
  52. package/dist/contracts.d.ts.map +1 -1
  53. package/dist/contracts.js +571 -4
  54. package/dist/contracts.js.map +1 -1
  55. package/dist/data-model-schema.d.ts +1 -1
  56. package/dist/data-model-schema.d.ts.map +1 -1
  57. package/dist/element.d.ts +30 -0
  58. package/dist/element.d.ts.map +1 -1
  59. package/dist/element.js +56 -0
  60. package/dist/element.js.map +1 -1
  61. package/dist/embed-protocol.d.ts +86 -0
  62. package/dist/embed-protocol.d.ts.map +1 -0
  63. package/dist/embed-protocol.js +60 -0
  64. package/dist/embed-protocol.js.map +1 -0
  65. package/dist/framework-support.d.ts +122 -0
  66. package/dist/framework-support.d.ts.map +1 -0
  67. package/dist/framework-support.js +323 -0
  68. package/dist/framework-support.js.map +1 -0
  69. package/dist/index.cjs +1355 -24
  70. package/dist/legal-core/index.cjs +115 -0
  71. package/dist/legal-core/index.d.ts +160 -0
  72. package/dist/legal-core/index.d.ts.map +1 -0
  73. package/dist/legal-core/index.js +220 -0
  74. package/dist/legal-core/index.js.map +1 -0
  75. package/dist/marketing-core/index.d.ts +66 -0
  76. package/dist/marketing-core/index.d.ts.map +1 -0
  77. package/dist/marketing-core/index.js +154 -0
  78. package/dist/marketing-core/index.js.map +1 -0
  79. package/dist/marketing.d.ts +7 -0
  80. package/dist/marketing.d.ts.map +1 -0
  81. package/dist/marketing.js +7 -0
  82. package/dist/marketing.js.map +1 -0
  83. package/dist/mcp-adapters/index.d.ts +135 -0
  84. package/dist/mcp-adapters/index.d.ts.map +1 -0
  85. package/dist/mcp-adapters/index.js +187 -0
  86. package/dist/mcp-adapters/index.js.map +1 -0
  87. package/dist/mcp-adapters.d.ts +7 -0
  88. package/dist/mcp-adapters.d.ts.map +1 -0
  89. package/dist/mcp-adapters.js +7 -0
  90. package/dist/mcp-adapters.js.map +1 -0
  91. package/dist/model3d-core/index.cjs +95 -0
  92. package/dist/model3d-core/index.d.ts +167 -0
  93. package/dist/model3d-core/index.d.ts.map +1 -0
  94. package/dist/model3d-core/index.js +158 -0
  95. package/dist/model3d-core/index.js.map +1 -0
  96. package/dist/notifications-core/index.cjs +315 -0
  97. package/dist/notifications-core/index.d.ts +229 -0
  98. package/dist/notifications-core/index.d.ts.map +1 -0
  99. package/dist/notifications-core/index.js +445 -0
  100. package/dist/notifications-core/index.js.map +1 -0
  101. package/dist/portal-core/index.d.ts +451 -0
  102. package/dist/portal-core/index.d.ts.map +1 -0
  103. package/dist/portal-core/index.js +819 -0
  104. package/dist/portal-core/index.js.map +1 -0
  105. package/dist/portal.d.ts +7 -0
  106. package/dist/portal.d.ts.map +1 -0
  107. package/dist/portal.js +7 -0
  108. package/dist/portal.js.map +1 -0
  109. package/dist/push-core/index.cjs +171 -0
  110. package/dist/push-core/index.d.ts +149 -0
  111. package/dist/push-core/index.d.ts.map +1 -0
  112. package/dist/push-core/index.js +231 -0
  113. package/dist/push-core/index.js.map +1 -0
  114. package/dist/react-marketing.d.ts +1 -17
  115. package/dist/react-marketing.d.ts.map +1 -1
  116. package/dist/react-marketing.js +4 -0
  117. package/dist/react-marketing.js.map +1 -1
  118. package/dist/react.d.ts +294 -101
  119. package/dist/react.d.ts.map +1 -1
  120. package/dist/react.js +302 -246
  121. package/dist/react.js.map +1 -1
  122. package/dist/seed-core/index.d.ts +354 -0
  123. package/dist/seed-core/index.d.ts.map +1 -0
  124. package/dist/seed-core/index.js +420 -0
  125. package/dist/seed-core/index.js.map +1 -0
  126. package/dist/seed.d.ts +7 -0
  127. package/dist/seed.d.ts.map +1 -0
  128. package/dist/seed.js +7 -0
  129. package/dist/seed.js.map +1 -0
  130. package/dist/seo-core/blog.d.ts +94 -0
  131. package/dist/seo-core/blog.d.ts.map +1 -0
  132. package/dist/seo-core/blog.js +149 -0
  133. package/dist/seo-core/blog.js.map +1 -0
  134. package/dist/seo-core/commerce.d.ts +64 -0
  135. package/dist/seo-core/commerce.d.ts.map +1 -0
  136. package/dist/seo-core/commerce.js +87 -0
  137. package/dist/seo-core/commerce.js.map +1 -0
  138. package/dist/seo-core/index.cjs +751 -0
  139. package/dist/seo-core/index.d.ts +226 -0
  140. package/dist/seo-core/index.d.ts.map +1 -0
  141. package/dist/seo-core/index.js +455 -0
  142. package/dist/seo-core/index.js.map +1 -0
  143. package/dist/seo-core/local.d.ts +181 -0
  144. package/dist/seo-core/local.d.ts.map +1 -0
  145. package/dist/seo-core/local.js +193 -0
  146. package/dist/seo-core/local.js.map +1 -0
  147. package/dist/sitemap-core/index.cjs +167 -0
  148. package/dist/sitemap-core/index.d.ts +155 -0
  149. package/dist/sitemap-core/index.d.ts.map +1 -0
  150. package/dist/sitemap-core/index.js +195 -0
  151. package/dist/sitemap-core/index.js.map +1 -0
  152. package/dist/social-core/index.d.ts +74 -0
  153. package/dist/social-core/index.d.ts.map +1 -0
  154. package/dist/social-core/index.js +72 -0
  155. package/dist/social-core/index.js.map +1 -0
  156. package/dist/social.d.ts +7 -0
  157. package/dist/social.d.ts.map +1 -0
  158. package/dist/social.js +7 -0
  159. package/dist/social.js.map +1 -0
  160. package/dist/solid-compat.d.ts +204 -0
  161. package/dist/solid-compat.d.ts.map +1 -0
  162. package/dist/solid-compat.js +221 -0
  163. package/dist/solid-compat.js.map +1 -0
  164. package/dist/store-core/index.d.ts +141 -0
  165. package/dist/store-core/index.d.ts.map +1 -0
  166. package/dist/store-core/index.js +277 -0
  167. package/dist/store-core/index.js.map +1 -0
  168. package/dist/store.d.ts +12 -0
  169. package/dist/store.d.ts.map +1 -0
  170. package/dist/store.js +12 -0
  171. package/dist/store.js.map +1 -0
  172. package/dist/vanilla.d.ts +111 -0
  173. package/dist/vanilla.d.ts.map +1 -1
  174. package/dist/vanilla.js +206 -0
  175. package/dist/vanilla.js.map +1 -1
  176. package/dist/verify-core/index.d.ts +98 -0
  177. package/dist/verify-core/index.d.ts.map +1 -0
  178. package/dist/verify-core/index.js +186 -0
  179. package/dist/verify-core/index.js.map +1 -0
  180. package/dist/verify.d.ts +8 -0
  181. package/dist/verify.d.ts.map +1 -0
  182. package/dist/verify.js +8 -0
  183. package/dist/verify.js.map +1 -0
  184. package/openapi.json +4516 -1311
  185. package/package.json +128 -11
  186. package/src/account-core/index.ts +309 -0
  187. package/src/account.ts +11 -0
  188. package/src/angular/headless.ts +519 -0
  189. package/src/angular/index.ts +3 -0
  190. package/src/auth-links.ts +76 -0
  191. package/src/blog-core/index.ts +423 -0
  192. package/src/blog.ts +11 -0
  193. package/src/chat-core/index.ts +335 -0
  194. package/src/chat.ts +11 -0
  195. package/src/checkout-core/index.ts +225 -0
  196. package/src/checkout.ts +10 -0
  197. package/src/cli.ts +184 -28
  198. package/src/client.ts +1097 -0
  199. package/src/consent-core/index.ts +213 -0
  200. package/src/contracts.ts +768 -11
  201. package/src/data-model-schema.ts +1 -1
  202. package/src/element.ts +102 -0
  203. package/src/embed-protocol.ts +100 -0
  204. package/src/framework-support.ts +440 -0
  205. package/src/legal-core/index.ts +276 -0
  206. package/src/marketing-core/index.ts +209 -0
  207. package/src/marketing.ts +7 -0
  208. package/src/mcp-adapters/index.ts +264 -0
  209. package/src/mcp-adapters.ts +7 -0
  210. package/src/model3d-core/index.ts +248 -0
  211. package/src/notifications-core/index.ts +650 -0
  212. package/src/portal-core/index.ts +1564 -0
  213. package/src/portal.ts +7 -0
  214. package/src/push-core/index.ts +335 -0
  215. package/src/qwik.tsx +602 -2
  216. package/src/react-marketing.tsx +5 -0
  217. package/src/react.tsx +434 -319
  218. package/src/seed-core/index.ts +735 -0
  219. package/src/seed.ts +7 -0
  220. package/src/seo-core/blog.ts +216 -0
  221. package/src/seo-core/commerce.ts +138 -0
  222. package/src/seo-core/index.ts +605 -0
  223. package/src/seo-core/local.ts +333 -0
  224. package/src/sitemap-core/index.ts +296 -0
  225. package/src/social-core/index.ts +103 -0
  226. package/src/social.ts +7 -0
  227. package/src/solid-compat.tsx +347 -0
  228. package/src/solid-surfaces.tsx +1311 -0
  229. package/src/solid.tsx +82 -60
  230. package/src/store-core/index.ts +426 -0
  231. package/src/store.ts +12 -0
  232. package/src/svelte/SocialLinks.svelte +84 -0
  233. package/src/svelte/headless.ts +435 -0
  234. package/src/svelte/index.ts +9 -0
  235. package/src/vanilla.ts +367 -0
  236. package/src/verify-core/index.ts +288 -0
  237. package/src/verify.ts +8 -0
  238. package/src/vue/headless.ts +527 -0
  239. package/src/vue/index.ts +3 -0
@@ -0,0 +1,735 @@
1
+ /**
2
+ * `@businessdash/sdk/seed` — define a schema, push it, and seed data, from any
3
+ * framework's build step.
4
+ *
5
+ * ## Why this exists alongside the CLI
6
+ *
7
+ * `businessdash sync-data-model` and friends already do this, and they are the
8
+ * right tool at a terminal. They are the wrong tool everywhere else: a Nuxt
9
+ * module, a SvelteKit prerender hook, an Angular builder, a Vite plugin, a
10
+ * Next `instrumentation.ts` or a CI step that already has the credentials
11
+ * loaded cannot shell out to an interactive command and read its output.
12
+ *
13
+ * So the same operations are exposed as plain async functions. Nothing here is
14
+ * framework-specific, because seeding happens at BUILD time — there is no
15
+ * reactivity to bridge, only an integration point per framework, and every
16
+ * framework has one that can call a function.
17
+ *
18
+ * The CLI imports this module rather than the other way round, so there is one
19
+ * implementation of the transport, the auth header and the error handling.
20
+ *
21
+ * ## What can and cannot be seeded
22
+ *
23
+ * The package API decides this, not the SDK:
24
+ *
25
+ * | Target | Seedable | Endpoint |
26
+ * |-----------------------|----------|----------|
27
+ * | Custom-database schema| yes | `data-model/plan` + `draft-schema` |
28
+ * | Custom-database records| yes | `data-model/records` |
29
+ * | Static collections | yes | `collections`, `collections/{slug}/rows` |
30
+ * | Coupons | yes | `coupons` |
31
+ * | Products (+ variants) | yes | `storefront/products` |
32
+ * | Blog posts | yes | `blog/posts` |
33
+ *
34
+ * Products and blog posts were absent from the first version of this module
35
+ * because the package API had no create route for either — the dashboard could
36
+ * do it and the SDK could not. Both routes now exist, so a site can be seeded
37
+ * whole: schema, records, collections, catalogue, content and coupons.
38
+ *
39
+ * ## Pushing a schema does not apply it
40
+ *
41
+ * `pushDataModel` writes the DRAFT slot and returns the plan describing what
42
+ * promoting it would do. Promotion is a separate, deliberate step in the
43
+ * dashboard. That is the platform's design and this module does not route
44
+ * around it: an automated deploy that silently altered a live customer
45
+ * database would be the wrong default.
46
+ *
47
+ * @module
48
+ */
49
+
50
+ import type { CollectionDefinition } from "../data-model-schema.js";
51
+
52
+ /** Where to seed, and with what credentials. */
53
+ export type SeedTarget = {
54
+ /** Package API key. Needs the data-model and storefront write scopes. */
55
+ apiKey: string;
56
+ /** Site id, from Site Builder → Developer. */
57
+ siteId: string;
58
+ /** Package-API base URL, e.g. `https://businessdash.us/api/package/v1`. */
59
+ baseUrl: string;
60
+ };
61
+
62
+ /** A single change promoting a draft schema would make. */
63
+ export type MigrationPlanChange = {
64
+ class: "safe" | "backfill" | "destructive" | "exposure";
65
+ kind: string;
66
+ object: string;
67
+ field?: string;
68
+ option?: string;
69
+ summary: string;
70
+ };
71
+
72
+ export type MigrationPlan = {
73
+ changes: MigrationPlanChange[];
74
+ [key: string]: unknown;
75
+ };
76
+
77
+ function normalizeBaseUrl(raw: string): string {
78
+ return raw.replace(/\/+$/, "");
79
+ }
80
+
81
+ /**
82
+ * One request to the package API, shared by every function here AND by the CLI.
83
+ *
84
+ * Errors carry the server's own message when it sent one. A seeder that failed
85
+ * with `HTTP 403` and nothing else would send someone hunting through their
86
+ * own code for a problem the API already described.
87
+ */
88
+ export async function callPackageApi(
89
+ target: SeedTarget,
90
+ input: {
91
+ path: string;
92
+ method: "GET" | "POST" | "PATCH";
93
+ body?: unknown;
94
+ },
95
+ ): Promise<Record<string, unknown>> {
96
+ const url = `${normalizeBaseUrl(target.baseUrl)}/${input.path}`;
97
+ const res = await fetch(url, {
98
+ method: input.method,
99
+ headers: {
100
+ "content-type": "application/json",
101
+ authorization: `Bearer ${target.apiKey}`,
102
+ },
103
+ ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
104
+ });
105
+
106
+ const text = await res.text();
107
+ let parsed: Record<string, unknown> | null = null;
108
+ try {
109
+ parsed = JSON.parse(text) as Record<string, unknown>;
110
+ } catch {
111
+ parsed = null;
112
+ }
113
+
114
+ if (!res.ok) {
115
+ const message =
116
+ typeof parsed?.message === "string"
117
+ ? parsed.message
118
+ : `HTTP ${res.status} ${res.statusText} — ${text.slice(0, 240)}`;
119
+ throw new Error(`${input.method} ${input.path}: ${message}`);
120
+ }
121
+ if (!parsed) {
122
+ throw new Error(
123
+ `${input.method} ${input.path}: server replied ${res.status} but the body wasn't JSON — first 240 chars: ${text.slice(0, 240)}`,
124
+ );
125
+ }
126
+ return parsed;
127
+ }
128
+
129
+ /** Data-model routes are all under the site's data-model namespace. */
130
+ function dataModelPath(target: SeedTarget, suffix: string): string {
131
+ return `sites/${encodeURIComponent(target.siteId)}/data-model/${suffix}`;
132
+ }
133
+
134
+ /** A schema artifact — the output of `createSchema(...).toJSONSchema()`. */
135
+ export type DataModelArtifact = Record<string, unknown>;
136
+
137
+ export type PushDataModelResult = {
138
+ /** What promoting this draft WOULD do. Read it before promoting. */
139
+ plan: MigrationPlan;
140
+ /** Draft version now in the slot, or the existing one when unchanged. */
141
+ version: number | null;
142
+ /** True when the draft already matched — nothing was written. */
143
+ noop: boolean;
144
+ /** True when `dryRun` was set: the plan was computed, nothing uploaded. */
145
+ planOnly: boolean;
146
+ };
147
+
148
+ export type PushDataModelOptions = {
149
+ /**
150
+ * Compute the plan and stop. Use this in CI on a pull request: it tells you
151
+ * what a deploy would change without changing anything.
152
+ */
153
+ dryRun?: boolean;
154
+ /** Ask the plan to include dropping objects/fields absent from the schema. */
155
+ prune?: boolean;
156
+ /** Free-text note stored with the draft. */
157
+ note?: string | null;
158
+ /** Optional checksum; the server uses it to detect an unchanged draft. */
159
+ checksum?: string;
160
+ };
161
+
162
+ /**
163
+ * Plan, then push a schema into the DRAFT slot.
164
+ *
165
+ * The plan is always computed, even when pushing, because a push whose
166
+ * consequences nobody looked at is how a destructive migration reaches a live
167
+ * database. The returned `plan.changes` carry a `class` — `safe`, `backfill`,
168
+ * `destructive` or `exposure` — which is what a CI gate should branch on.
169
+ */
170
+ export async function pushDataModel(
171
+ target: SeedTarget,
172
+ schema: DataModelArtifact,
173
+ options: PushDataModelOptions = {},
174
+ ): Promise<PushDataModelResult> {
175
+ const planRes = await callPackageApi(target, {
176
+ path: dataModelPath(target, "plan"),
177
+ method: "POST",
178
+ body: { schema, prune: options.prune ?? false },
179
+ });
180
+ const plan = (planRes.plan ?? { changes: [] }) as MigrationPlan;
181
+
182
+ if (options.dryRun) {
183
+ return { plan, version: null, noop: false, planOnly: true };
184
+ }
185
+
186
+ const res = await callPackageApi(target, {
187
+ path: dataModelPath(target, "draft-schema"),
188
+ method: "POST",
189
+ body: {
190
+ schema,
191
+ ...(options.checksum ? { checksum: options.checksum } : {}),
192
+ note: options.note ?? null,
193
+ },
194
+ });
195
+
196
+ return {
197
+ plan,
198
+ version: typeof res.version === "number" ? res.version : null,
199
+ noop: res.noop === true,
200
+ planOnly: false,
201
+ };
202
+ }
203
+
204
+ /** Does this plan contain anything that would lose or expose data? */
205
+ export function planHasDangerousChanges(plan: MigrationPlan): boolean {
206
+ return (plan.changes ?? []).some(
207
+ (c) => c.class === "destructive" || c.class === "exposure",
208
+ );
209
+ }
210
+
211
+ /** Records keyed by collection name, as the data-model records route expects. */
212
+ export type SeedRecords = Record<
213
+ string,
214
+ ReadonlyArray<Record<string, unknown>>
215
+ >;
216
+
217
+ export type SeedRecordsResult = {
218
+ /** How many rows were written per collection, as the server reported. */
219
+ written: Record<string, number>;
220
+ };
221
+
222
+ /**
223
+ * Seed rows into the CUSTOM DATABASE (the metadata engine).
224
+ *
225
+ * This is not the same store as `seedCollectionRows` below. The custom database
226
+ * is the schema you defined and promoted; static collections are a simpler
227
+ * key/value-ish store on the site. Mixing them up is easy and the failure is
228
+ * silent — the rows land somewhere real, just not where they were wanted — so
229
+ * the two functions are named for the store rather than for the verb.
230
+ */
231
+ export async function seedRecords(
232
+ target: SeedTarget,
233
+ records: SeedRecords,
234
+ ): Promise<SeedRecordsResult> {
235
+ const res = await callPackageApi(target, {
236
+ path: dataModelPath(target, "records"),
237
+ method: "POST",
238
+ body: { records },
239
+ });
240
+ const written = (res.written ?? {}) as Record<string, number>;
241
+ return { written };
242
+ }
243
+
244
+ export type StaticCollectionDefinition = {
245
+ name: string;
246
+ slug?: string;
247
+ description?: string;
248
+ fields?: ReadonlyArray<Record<string, unknown>>;
249
+ };
250
+
251
+ /** Create a STATIC collection on the site. Idempotent by slug server-side. */
252
+ export async function seedCollection(
253
+ target: SeedTarget,
254
+ definition: StaticCollectionDefinition,
255
+ ): Promise<Record<string, unknown>> {
256
+ return callPackageApi(target, {
257
+ path: `sites/${encodeURIComponent(target.siteId)}/collections`,
258
+ method: "POST",
259
+ body: definition,
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Insert rows into a static collection.
265
+ *
266
+ * Rows go one request each because the route takes one row — batching would
267
+ * mean inventing a bulk endpoint that does not exist. `concurrency` keeps a
268
+ * large seed from opening hundreds of sockets at once.
269
+ */
270
+ export async function seedCollectionRows(
271
+ target: SeedTarget,
272
+ slug: string,
273
+ rows: ReadonlyArray<{ rowId?: string; data: Record<string, unknown> }>,
274
+ options: { concurrency?: number } = {},
275
+ ): Promise<{ written: number }> {
276
+ const concurrency = Math.max(1, options.concurrency ?? 4);
277
+ const path = `sites/${encodeURIComponent(target.siteId)}/collections/${encodeURIComponent(slug)}/rows`;
278
+
279
+ let written = 0;
280
+ for (let i = 0; i < rows.length; i += concurrency) {
281
+ const batch = rows.slice(i, i + concurrency);
282
+ await Promise.all(
283
+ batch.map(async (row) => {
284
+ await callPackageApi(target, { path, method: "POST", body: row });
285
+ written += 1;
286
+ }),
287
+ );
288
+ }
289
+ return { written };
290
+ }
291
+
292
+ export type SeedCrossVariantOption = {
293
+ value: string;
294
+ price: number;
295
+ stock?: number | null;
296
+ isLive?: boolean;
297
+ pricingBehavior?: "price" | "note" | null;
298
+ };
299
+
300
+ export type SeedCrossVariantTitle = {
301
+ title: string;
302
+ selectionMode?: "single" | "multiple" | null;
303
+ minSelected?: number | null;
304
+ maxSelected?: number | null;
305
+ options?: SeedCrossVariantOption[];
306
+ };
307
+
308
+ export type SeedProductVariant = {
309
+ name: string;
310
+ description?: string | null;
311
+ price?: number | null;
312
+ /** Unit COST in integer cents, for margin analytics. */
313
+ costCents?: number | null;
314
+ stock?: number | null;
315
+ isDigital?: boolean;
316
+ isPhysical?: boolean;
317
+ isLive?: boolean;
318
+ attributes?: Record<string, unknown> | null;
319
+ weightInGrams?: number | null;
320
+ requiresShipping?: boolean;
321
+ flatRateShippingCents?: number | null;
322
+ crossVariantTitles?: SeedCrossVariantTitle[];
323
+ };
324
+
325
+ export type SeedProduct = {
326
+ name: string;
327
+ description?: string | null;
328
+ categoryId?: string | null;
329
+ storefront?: "web" | "physical" | "all" | null;
330
+ /** Defaults FALSE — seeding must not publish a half-built catalogue. */
331
+ isLive?: boolean;
332
+ available?: boolean | null;
333
+ images?: string[] | null;
334
+ manufacturer?: string | null;
335
+ /** INTEGER, not text — the column is an integer. */
336
+ modelNumber?: number | null;
337
+ quantity?: number | null;
338
+ type?: string | null;
339
+ variants?: SeedProductVariant[];
340
+ crossVariantTitles?: SeedCrossVariantTitle[];
341
+ };
342
+
343
+ export type SeedProductResult = {
344
+ name: string;
345
+ id: string;
346
+ variantCount: number;
347
+ crossVariantCount: number;
348
+ };
349
+
350
+ /**
351
+ * Create products, each with its variants and cross-variants.
352
+ *
353
+ * The whole tree goes in ONE request per product, because the route writes it
354
+ * in a transaction — a seed that created a product and then failed partway
355
+ * through its variants would leave a catalogue nobody could trust.
356
+ *
357
+ * Products land unpublished unless `isLive` says otherwise, and are NOT pushed
358
+ * to Stripe: that is a separate deliberate call, because it mints objects in an
359
+ * account the org is billed against.
360
+ */
361
+ export async function seedProducts(
362
+ target: SeedTarget,
363
+ products: readonly SeedProduct[],
364
+ ): Promise<SeedProductResult[]> {
365
+ const results: SeedProductResult[] = [];
366
+ for (const product of products) {
367
+ const res = await callPackageApi(target, {
368
+ path: "storefront/products",
369
+ method: "POST",
370
+ body: product,
371
+ });
372
+ results.push({
373
+ name: product.name,
374
+ id: String(res.id ?? ""),
375
+ variantCount: Number(res.variantCount ?? 0),
376
+ crossVariantCount: Number(res.crossVariantCount ?? 0),
377
+ });
378
+ }
379
+ return results;
380
+ }
381
+
382
+ export type SeedBlogPost = {
383
+ title: string;
384
+ /** Lowercase words separated by single hyphens. Unique per org. */
385
+ slug: string;
386
+ content: string;
387
+ excerpt?: string | null;
388
+ featuredImageUrl?: string | null;
389
+ accessLevel?: "public" | "members" | "subscribers";
390
+ tags?: string[];
391
+ commentResponseType?: "admin" | "all" | "none";
392
+ authorEmail?: string | null;
393
+ categoryId?: string | null;
394
+ /** Defaults FALSE — seeding must not publish drafts to a live blog. */
395
+ publishNow?: boolean;
396
+ };
397
+
398
+ export type SeedBlogPostResult = {
399
+ slug: string;
400
+ created: boolean;
401
+ published: boolean;
402
+ message?: string;
403
+ };
404
+
405
+ /**
406
+ * Create blog posts.
407
+ *
408
+ * An existing slug is REPORTED rather than thrown, on the same reasoning as
409
+ * coupons: re-running a seed must be safe, and a conflict on the third post
410
+ * must not abandon the remaining seven.
411
+ */
412
+ export async function seedBlogPosts(
413
+ target: SeedTarget,
414
+ posts: readonly SeedBlogPost[],
415
+ ): Promise<SeedBlogPostResult[]> {
416
+ const results: SeedBlogPostResult[] = [];
417
+ for (const post of posts) {
418
+ try {
419
+ const res = await callPackageApi(target, {
420
+ path: "blog/posts",
421
+ method: "POST",
422
+ body: post,
423
+ });
424
+ results.push({
425
+ slug: post.slug,
426
+ created: true,
427
+ published: res.published === true,
428
+ });
429
+ } catch (cause) {
430
+ const message = cause instanceof Error ? cause.message : String(cause);
431
+ if (/already exists|conflict/i.test(message)) {
432
+ results.push({
433
+ slug: post.slug,
434
+ created: false,
435
+ published: false,
436
+ message: "already exists — left alone",
437
+ });
438
+ continue;
439
+ }
440
+ throw cause;
441
+ }
442
+ }
443
+ return results;
444
+ }
445
+
446
+ export type SeedCoupon = {
447
+ code: string;
448
+ discountType: "percent" | "amount";
449
+ /** `percent` takes 1–100; `amount` takes a MAJOR-unit amount (10 = $10). */
450
+ value: number;
451
+ validFrom?: Date | string;
452
+ validUntil?: Date | string | null;
453
+ maxUses?: number | null;
454
+ isActive?: boolean;
455
+ duration?: "once" | "forever" | "repeating";
456
+ durationInMonths?: number | null;
457
+ appliesTo?: "order" | "shipping" | "both";
458
+ };
459
+
460
+ export type SeedCouponResult = {
461
+ code: string;
462
+ created: boolean;
463
+ /** False when the coupon exists locally but the Stripe mirror failed. */
464
+ stripeSynced: boolean;
465
+ /** Why it was skipped or failed, when it was. */
466
+ message?: string;
467
+ };
468
+
469
+ /**
470
+ * Create coupons in the org's own Stripe account as well as locally.
471
+ *
472
+ * An existing code is REPORTED, not treated as a failure: re-running a seed
473
+ * should be safe, and a 409 on the third of ten coupons must not abandon the
474
+ * remaining seven. Anything else still throws — a bad key or a malformed
475
+ * coupon is a real problem and swallowing it would leave a half-seeded store
476
+ * looking successful.
477
+ */
478
+ export async function seedCoupons(
479
+ target: SeedTarget,
480
+ coupons: readonly SeedCoupon[],
481
+ ): Promise<SeedCouponResult[]> {
482
+ const results: SeedCouponResult[] = [];
483
+ for (const coupon of coupons) {
484
+ try {
485
+ const res = await callPackageApi(target, {
486
+ path: "coupons",
487
+ method: "POST",
488
+ body: {
489
+ ...coupon,
490
+ ...(coupon.validFrom
491
+ ? {
492
+ validFrom:
493
+ coupon.validFrom instanceof Date
494
+ ? coupon.validFrom.toISOString()
495
+ : coupon.validFrom,
496
+ }
497
+ : {}),
498
+ ...(coupon.validUntil
499
+ ? {
500
+ validUntil:
501
+ coupon.validUntil instanceof Date
502
+ ? coupon.validUntil.toISOString()
503
+ : coupon.validUntil,
504
+ }
505
+ : {}),
506
+ },
507
+ });
508
+ results.push({
509
+ code: coupon.code,
510
+ created: true,
511
+ stripeSynced: res.stripeSynced === true,
512
+ ...(typeof res.stripeError === "string" && res.stripeError
513
+ ? { message: res.stripeError }
514
+ : {}),
515
+ });
516
+ } catch (cause) {
517
+ const message = cause instanceof Error ? cause.message : String(cause);
518
+ // The route answers 409 for a code that already exists.
519
+ if (/already exists|conflict/i.test(message)) {
520
+ results.push({
521
+ code: coupon.code,
522
+ created: false,
523
+ stripeSynced: false,
524
+ message: "already exists — left alone",
525
+ });
526
+ continue;
527
+ }
528
+ throw cause;
529
+ }
530
+ }
531
+ return results;
532
+ }
533
+
534
+ /**
535
+ * A whole seed, declared in one object.
536
+ *
537
+ * The shape a `businessdash.seed.ts` exports. Every field is optional so a
538
+ * project can seed only what it has.
539
+ */
540
+ export type SeedDefinition = {
541
+ /** The schema artifact, or a `createSchema(...)` result. */
542
+ dataModel?: DataModelArtifact | { toJSONSchema(): DataModelArtifact };
543
+ dataModelOptions?: PushDataModelOptions;
544
+ /** Rows for the custom database, keyed by collection name. */
545
+ records?: SeedRecords;
546
+ /** Static collections to create, each with optional rows. */
547
+ collections?: ReadonlyArray<
548
+ StaticCollectionDefinition & {
549
+ rows?: ReadonlyArray<{ rowId?: string; data: Record<string, unknown> }>;
550
+ }
551
+ >;
552
+ /** Products, each with its variants and cross-variants. */
553
+ products?: readonly SeedProduct[];
554
+ posts?: readonly SeedBlogPost[];
555
+ coupons?: readonly SeedCoupon[];
556
+ };
557
+
558
+ export type SeedRunResult = {
559
+ dataModel?: PushDataModelResult;
560
+ records?: SeedRecordsResult;
561
+ collections?: Array<{ slug: string; rowsWritten: number }>;
562
+ products?: SeedProductResult[];
563
+ posts?: SeedBlogPostResult[];
564
+ coupons?: SeedCouponResult[];
565
+ };
566
+
567
+ export type RunSeedOptions = {
568
+ /** Called with human-readable progress. Wire it to your build's logger. */
569
+ onProgress?: (message: string) => void;
570
+ /**
571
+ * Refuse to push a schema whose plan contains destructive or exposing
572
+ * changes. Defaults to TRUE, because the common case for this function is
573
+ * an automated deploy, and an automated deploy should not be the thing that
574
+ * drops a customer's column.
575
+ */
576
+ failOnDangerousPlan?: boolean;
577
+ };
578
+
579
+ /**
580
+ * Run a whole seed, in dependency order: schema first, then the records that
581
+ * depend on it, then static collections, then products, posts and coupons.
582
+ *
583
+ * Order matters and is not configurable — seeding records into a collection
584
+ * whose schema has not been promoted yet fails in a way that reads as a
585
+ * permissions problem.
586
+ */
587
+ export async function runSeed(
588
+ target: SeedTarget,
589
+ seed: SeedDefinition,
590
+ options: RunSeedOptions = {},
591
+ ): Promise<SeedRunResult> {
592
+ const log = options.onProgress ?? (() => {});
593
+ const failOnDangerous = options.failOnDangerousPlan ?? true;
594
+ const result: SeedRunResult = {};
595
+
596
+ if (seed.dataModel) {
597
+ // `DataModelArtifact` is a Record, so `"toJSONSchema" in x` narrows the
598
+ // union to the record branch rather than the schema branch — the property
599
+ // check has to be on the VALUE being callable, not on the key existing.
600
+ const candidate = seed.dataModel as {
601
+ toJSONSchema?: () => DataModelArtifact;
602
+ };
603
+ const artifact =
604
+ typeof candidate.toJSONSchema === "function"
605
+ ? candidate.toJSONSchema()
606
+ : (seed.dataModel as DataModelArtifact);
607
+ log("Pushing data model…");
608
+ const pushed = await pushDataModel(target, artifact, seed.dataModelOptions);
609
+ result.dataModel = pushed;
610
+
611
+ if (failOnDangerous && planHasDangerousChanges(pushed.plan)) {
612
+ const summary = pushed.plan.changes
613
+ .filter((c) => c.class === "destructive" || c.class === "exposure")
614
+ .map((c) => ` ${c.class}: ${c.summary}`)
615
+ .join("\n");
616
+ throw new Error(
617
+ `The schema plan contains changes that would lose or expose data:\n${summary}\n\nThe draft was ${pushed.planOnly ? "not uploaded" : "uploaded but NOT promoted"}. Review and promote it deliberately, or pass \`failOnDangerousPlan: false\` if you have already decided.`,
618
+ );
619
+ }
620
+ log(
621
+ pushed.planOnly
622
+ ? "Plan only — nothing uploaded."
623
+ : pushed.noop
624
+ ? "Data model unchanged."
625
+ : `Draft v${pushed.version} uploaded (promote it in the dashboard to apply).`,
626
+ );
627
+ }
628
+
629
+ if (seed.records) {
630
+ log("Seeding custom-database records…");
631
+ result.records = await seedRecords(target, seed.records);
632
+ const total = Object.values(result.records.written).reduce(
633
+ (a, b) => a + b,
634
+ 0,
635
+ );
636
+ log(`Wrote ${total} record(s).`);
637
+ }
638
+
639
+ if (seed.collections?.length) {
640
+ result.collections = [];
641
+ for (const definition of seed.collections) {
642
+ const { rows, ...collectionInput } = definition;
643
+ log(`Creating static collection "${collectionInput.name}"…`);
644
+ const created = await seedCollection(target, collectionInput);
645
+ const slug =
646
+ (typeof created.slug === "string" ? created.slug : undefined) ??
647
+ collectionInput.slug ??
648
+ collectionInput.name;
649
+ let rowsWritten = 0;
650
+ if (rows?.length) {
651
+ rowsWritten = (await seedCollectionRows(target, slug, rows)).written;
652
+ log(` ${rowsWritten} row(s).`);
653
+ }
654
+ result.collections.push({ slug, rowsWritten });
655
+ }
656
+ }
657
+
658
+ if (seed.products?.length) {
659
+ log(`Seeding ${seed.products.length} product(s)…`);
660
+ result.products = await seedProducts(target, seed.products);
661
+ const variants = result.products.reduce((a, p) => a + p.variantCount, 0);
662
+ log(`${result.products.length} product(s), ${variants} variant(s).`);
663
+ }
664
+
665
+ if (seed.posts?.length) {
666
+ log(`Seeding ${seed.posts.length} blog post(s)…`);
667
+ result.posts = await seedBlogPosts(target, seed.posts);
668
+ const created = result.posts.filter((p) => p.created).length;
669
+ log(
670
+ `${created} created, ${result.posts.length - created} already existed.`,
671
+ );
672
+ }
673
+
674
+ if (seed.coupons?.length) {
675
+ log(`Seeding ${seed.coupons.length} coupon(s)…`);
676
+ result.coupons = await seedCoupons(target, seed.coupons);
677
+ const created = result.coupons.filter((c) => c.created).length;
678
+ log(
679
+ `${created} created, ${result.coupons.length - created} already existed.`,
680
+ );
681
+ }
682
+
683
+ return result;
684
+ }
685
+
686
+ /**
687
+ * Read a seed target from environment variables.
688
+ *
689
+ * The names match what the CLI already reads, so a project that has the CLI
690
+ * working needs no new configuration to call these functions from a build step.
691
+ */
692
+ export function seedTargetFromEnv(
693
+ env: Record<string, string | undefined> = typeof process === "undefined"
694
+ ? {}
695
+ : process.env,
696
+ ): SeedTarget {
697
+ // BIAB_API_KEY first, because that is the name the CLI reads and the name
698
+ // every starter's .env.example sets. Reading a different name would mean a
699
+ // project whose CLI works fine still fails here, with an error naming a
700
+ // variable the consumer has never seen.
701
+ const apiKey =
702
+ env.BIAB_API_KEY ?? env.BUSINESSDASH_API_KEY ?? env.BIAB_PACKAGE_API_KEY;
703
+ const siteId = env.BIAB_SITE_ID ?? env.BUSINESSDASH_SITE_ID;
704
+ const baseUrl =
705
+ env.BIAB_PACKAGE_API_BASE_URL ??
706
+ env.BUSINESSDASH_API_BASE_URL ??
707
+ env.NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL;
708
+
709
+ const missing = [
710
+ !apiKey && "BIAB_API_KEY",
711
+ !siteId && "BIAB_SITE_ID",
712
+ !baseUrl && "BIAB_PACKAGE_API_BASE_URL",
713
+ ].filter(Boolean);
714
+
715
+ if (missing.length > 0) {
716
+ throw new Error(
717
+ `Missing ${missing.join(", ")}. Seeding needs a package API key, a site id and the API base URL — the same three the CLI reads from .env.local.`,
718
+ );
719
+ }
720
+ return {
721
+ apiKey: apiKey as string,
722
+ siteId: siteId as string,
723
+ baseUrl: baseUrl as string,
724
+ };
725
+ }
726
+
727
+ /**
728
+ * Type-only helper for a `businessdash.seed.ts`, so a config file gets
729
+ * completion and checking without importing anything at runtime.
730
+ */
731
+ export function defineSeed(seed: SeedDefinition): SeedDefinition {
732
+ return seed;
733
+ }
734
+
735
+ export type { CollectionDefinition };