@rexezuge/tooling 0.0.0-stage → 1.0.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 (160) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint.d.ts +120 -0
  4. package/dist/eslint.d.ts.map +1 -0
  5. package/dist/eslint.js +551 -0
  6. package/dist/eslint.js.map +1 -0
  7. package/dist/functions/pages-proxy.d.ts +113 -0
  8. package/dist/functions/pages-proxy.d.ts.map +1 -0
  9. package/dist/functions/pages-proxy.js +131 -0
  10. package/dist/functions/pages-proxy.js.map +1 -0
  11. package/dist/index.d.ts +31 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +28 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/scripts/backup/d1-target.d.ts +35 -0
  16. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  17. package/dist/scripts/backup/d1-target.js +31 -0
  18. package/dist/scripts/backup/d1-target.js.map +1 -0
  19. package/dist/scripts/backup/destination-config.d.ts +61 -0
  20. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  21. package/dist/scripts/backup/destination-config.js +57 -0
  22. package/dist/scripts/backup/destination-config.js.map +1 -0
  23. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  24. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  25. package/dist/scripts/backup/encrypt-backup.js +98 -0
  26. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  27. package/dist/scripts/backup/naming.d.ts +44 -0
  28. package/dist/scripts/backup/naming.d.ts.map +1 -0
  29. package/dist/scripts/backup/naming.js +58 -0
  30. package/dist/scripts/backup/naming.js.map +1 -0
  31. package/dist/scripts/check-god-files.d.ts +164 -0
  32. package/dist/scripts/check-god-files.d.ts.map +1 -0
  33. package/dist/scripts/check-god-files.js +271 -0
  34. package/dist/scripts/check-god-files.js.map +1 -0
  35. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  36. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  37. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  38. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  39. package/dist/scripts/init-secrets.d.ts +63 -0
  40. package/dist/scripts/init-secrets.d.ts.map +1 -0
  41. package/dist/scripts/init-secrets.js +240 -0
  42. package/dist/scripts/init-secrets.js.map +1 -0
  43. package/dist/scripts/lib/cli-args.d.ts +78 -0
  44. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  45. package/dist/scripts/lib/cli-args.js +116 -0
  46. package/dist/scripts/lib/cli-args.js.map +1 -0
  47. package/dist/scripts/lib/github-actions.d.ts +26 -0
  48. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  49. package/dist/scripts/lib/github-actions.js +38 -0
  50. package/dist/scripts/lib/github-actions.js.map +1 -0
  51. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  52. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  53. package/dist/scripts/lib/wrangler-table.js +99 -0
  54. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  55. package/dist/scripts/migrations-lock.d.ts +3 -0
  56. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  57. package/dist/scripts/migrations-lock.js +46 -0
  58. package/dist/scripts/migrations-lock.js.map +1 -0
  59. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  60. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  61. package/dist/scripts/prepare-wrangler-config.js +50 -0
  62. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  63. package/dist/scripts/spa-shell.d.ts +41 -0
  64. package/dist/scripts/spa-shell.d.ts.map +1 -0
  65. package/dist/scripts/spa-shell.js +155 -0
  66. package/dist/scripts/spa-shell.js.map +1 -0
  67. package/dist/scripts/validate-locales.d.ts +26 -0
  68. package/dist/scripts/validate-locales.d.ts.map +1 -0
  69. package/dist/scripts/validate-locales.js +350 -0
  70. package/dist/scripts/validate-locales.js.map +1 -0
  71. package/dist/scripts/verify-migrations.d.ts +62 -0
  72. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  73. package/dist/scripts/verify-migrations.js +302 -0
  74. package/dist/scripts/verify-migrations.js.map +1 -0
  75. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  76. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  77. package/dist/scripts/verify-spa-shell.js +53 -0
  78. package/dist/scripts/verify-spa-shell.js.map +1 -0
  79. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  80. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  81. package/dist/scripts/wrangler-config/cli.js +51 -0
  82. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  83. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  84. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  85. package/dist/scripts/wrangler-config/patches.js +140 -0
  86. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  87. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  88. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/resources.js +290 -0
  90. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  91. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  92. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/types.js +49 -0
  94. package/dist/scripts/wrangler-config/types.js.map +1 -0
  95. package/dist/test/integration-migrations.d.ts +167 -0
  96. package/dist/test/integration-migrations.d.ts.map +1 -0
  97. package/dist/test/integration-migrations.js +171 -0
  98. package/dist/test/integration-migrations.js.map +1 -0
  99. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  100. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  101. package/dist/test/mocks/cloudflare-workers.js +90 -0
  102. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  103. package/dist/vite.d.ts +117 -0
  104. package/dist/vite.d.ts.map +1 -0
  105. package/dist/vite.js +125 -0
  106. package/dist/vite.js.map +1 -0
  107. package/dist/vitest-web.d.ts +73 -0
  108. package/dist/vitest-web.d.ts.map +1 -0
  109. package/dist/vitest-web.js +72 -0
  110. package/dist/vitest-web.js.map +1 -0
  111. package/dist/vitest.d.ts +92 -0
  112. package/dist/vitest.d.ts.map +1 -0
  113. package/dist/vitest.js +128 -0
  114. package/dist/vitest.js.map +1 -0
  115. package/package.json +58 -3
  116. package/src/eslint.test.ts +175 -0
  117. package/src/eslint.ts +640 -0
  118. package/src/functions/pages-proxy.test.ts +72 -0
  119. package/src/functions/pages-proxy.ts +187 -0
  120. package/src/github/actions/retry-step/action.yml +39 -0
  121. package/src/github/actions/setup-env/action.yml +20 -0
  122. package/src/github/dependabot.yml +30 -0
  123. package/src/github/workflows/backup-main.yml +46 -0
  124. package/src/github/workflows/continuous-deployment.yml +188 -0
  125. package/src/github/workflows/continuous-integration.yml +259 -0
  126. package/src/github/workflows/scheduled-version-update.yml +38 -0
  127. package/src/github/workflows/upstream-sync.yml +56 -0
  128. package/src/index.ts +42 -0
  129. package/src/scripts/backup/backup-rules.test.ts +105 -0
  130. package/src/scripts/backup/d1-target.ts +54 -0
  131. package/src/scripts/backup/destination-config.ts +92 -0
  132. package/src/scripts/backup/encrypt-backup.ts +107 -0
  133. package/src/scripts/backup/naming.ts +62 -0
  134. package/src/scripts/check-god-files.test.ts +131 -0
  135. package/src/scripts/check-god-files.ts +327 -0
  136. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  137. package/src/scripts/init-secrets.ts +265 -0
  138. package/src/scripts/lib/cli-args.ts +154 -0
  139. package/src/scripts/lib/github-actions.ts +41 -0
  140. package/src/scripts/lib/wrangler-table.ts +105 -0
  141. package/src/scripts/migrations-lock.ts +52 -0
  142. package/src/scripts/prepare-wrangler-config.ts +51 -0
  143. package/src/scripts/spa-shell.test.ts +91 -0
  144. package/src/scripts/spa-shell.ts +179 -0
  145. package/src/scripts/validate-locales.test.ts +89 -0
  146. package/src/scripts/validate-locales.ts +380 -0
  147. package/src/scripts/verify-migrations.test.ts +71 -0
  148. package/src/scripts/verify-migrations.ts +364 -0
  149. package/src/scripts/verify-spa-shell.ts +56 -0
  150. package/src/scripts/wrangler-config/cli.ts +51 -0
  151. package/src/scripts/wrangler-config/patches.ts +157 -0
  152. package/src/scripts/wrangler-config/resources.ts +330 -0
  153. package/src/scripts/wrangler-config/types.ts +113 -0
  154. package/src/test/integration-migrations.test.ts +169 -0
  155. package/src/test/integration-migrations.ts +267 -0
  156. package/src/test/mocks/cloudflare-workers.ts +115 -0
  157. package/src/vite.test.ts +83 -0
  158. package/src/vite.ts +202 -0
  159. package/src/vitest-web.ts +109 -0
  160. package/src/vitest.ts +185 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Creating the resources `wrangler.jsonc` names, and writing their real ids over
3
+ * the placeholders.
4
+ *
5
+ * Provenance: ported from Edge-Sonic's `scripts/lib/wrangler-config/resources.ts`.
6
+ *
7
+ * The placeholder rule: a template carries an all-zero id meaning "resolve me",
8
+ * and a config carrying one cannot be deployed. So provisioning walks the config,
9
+ * creates what is missing, and patches the id in place. A placeholder with nothing
10
+ * to create it *under* — a KV id with no binding, a D1 id with no name — is
11
+ * refused rather than skipped, because a silently skipped placeholder is a deploy
12
+ * that fails later with no link back to the reason.
13
+ */
14
+
15
+ import { writeFileSync } from 'node:fs';
16
+ import { parse } from 'jsonc-parser';
17
+ import { parseWranglerTableRows } from '../lib/wrangler-table';
18
+ import {
19
+ CONFIG_PATH,
20
+ DEFAULT_HEX_ID,
21
+ DEFAULT_KV_NAMESPACE_NAMES,
22
+ DEFAULT_SECRET_STORE_NAME,
23
+ DEFAULT_UUID,
24
+ DEFAULT_WORKER_NAME,
25
+ VECTORIZE_DIMENSIONS,
26
+ type D1Database,
27
+ type KVNamespace,
28
+ type SecretStore,
29
+ type WranglerConfig,
30
+ } from './types';
31
+ import { parseJsonArray, runWrangler } from './cli';
32
+ import { readConfig, writeConfig, writeConfigValue } from './patches';
33
+
34
+ /** What `ensure*` did, so a caller can act on the difference. */
35
+ export interface Provisioned {
36
+ id: string;
37
+ created: boolean;
38
+ }
39
+
40
+ /** The id a D1 database is reported under, whichever key the CLI used. */
41
+ export function getD1Id(database: D1Database): string | undefined {
42
+ return database.uuid ?? database.database_id ?? database.id;
43
+ }
44
+
45
+ export function listD1Databases(): D1Database[] {
46
+ return parseJsonArray<D1Database>(runWrangler(['d1', 'list', '--json']), 'wrangler d1 list --json');
47
+ }
48
+
49
+ /**
50
+ * The D1 database `binding` names, created when it does not exist.
51
+ *
52
+ * Whether it was created is reported, not inferred: a database created moments ago
53
+ * holds no user data, so a backup workflow must refuse to upload its empty dump.
54
+ * The caller cannot re-derive that from the config, because this very function
55
+ * patches the placeholder away before the reader ever runs.
56
+ */
57
+ export function ensureD1Database(databaseName: string): Provisioned {
58
+ let database = listD1Databases().find((candidate) => candidate.name === databaseName);
59
+ let created = false;
60
+ if (database === undefined) {
61
+ console.log(`Creating D1 database: ${databaseName}`);
62
+ runWrangler(['d1', 'create', databaseName]);
63
+ database = listD1Databases().find((candidate) => candidate.name === databaseName);
64
+ created = true;
65
+ }
66
+ const databaseId = database === undefined ? undefined : getD1Id(database);
67
+ if (databaseId === undefined || databaseId === '') {
68
+ throw new Error(`Unable to discover D1 database ID for ${databaseName}.`);
69
+ }
70
+ return { id: databaseId, created };
71
+ }
72
+
73
+ export function listKVNamespaces(): KVNamespace[] {
74
+ return parseJsonArray<KVNamespace>(runWrangler(['kv', 'namespace', 'list']), 'wrangler kv namespace list');
75
+ }
76
+
77
+ /** The namespace name a binding's namespace is created under. */
78
+ export function getKVNamespaceName(config: WranglerConfig, binding: string): string {
79
+ return DEFAULT_KV_NAMESPACE_NAMES[binding] ?? `${config.name ?? DEFAULT_WORKER_NAME}-${binding.toLowerCase()}`;
80
+ }
81
+
82
+ export function ensureKVNamespace(config: WranglerConfig, binding: string): Provisioned {
83
+ const namespaceName = getKVNamespaceName(config, binding);
84
+ const candidateNames = new Set([namespaceName, `${config.name ?? DEFAULT_WORKER_NAME}-${binding}`, binding]);
85
+ let namespace = listKVNamespaces().find((candidate) => {
86
+ const candidateName = candidate.title ?? candidate.name;
87
+ return candidate.id !== undefined && candidateName !== undefined && candidateNames.has(candidateName);
88
+ });
89
+ let created = false;
90
+ if (namespace === undefined) {
91
+ console.log(`Creating KV namespace: ${namespaceName}`);
92
+ runWrangler(['kv', 'namespace', 'create', namespaceName]);
93
+ namespace = listKVNamespaces().find(
94
+ (candidate) => candidate.id !== undefined && (candidate.title ?? candidate.name) === namespaceName,
95
+ );
96
+ created = true;
97
+ }
98
+ if (namespace?.id === undefined) {
99
+ throw new Error(`Unable to discover KV namespace ID for ${namespaceName}.`);
100
+ }
101
+ return { id: namespace.id, created };
102
+ }
103
+
104
+ /** The bindings a deployment always needs, whether or not a custom config lists them. */
105
+ export function getRequiredKvBindings(): string[] {
106
+ return Object.keys(DEFAULT_KV_NAMESPACE_NAMES);
107
+ }
108
+
109
+ /**
110
+ * Add the always-needed KV bindings a hand-written config left out.
111
+ *
112
+ * A config supplied through `WRANGLER_JSONC` is edited by hand and drifts; without
113
+ * this the worker starts with an unbound `CACHE` and fails on the first read.
114
+ */
115
+ export function ensureRequiredKvBindings(content: string, config: WranglerConfig): string {
116
+ const existing = new Set((config.kv_namespaces ?? []).map((namespace) => namespace.binding).filter((binding) => binding !== undefined));
117
+ const missing = getRequiredKvBindings().filter((binding) => !existing.has(binding));
118
+ if (missing.length === 0) {
119
+ return content;
120
+ }
121
+ const next = [...(config.kv_namespaces ?? [])];
122
+ for (const binding of missing) {
123
+ console.log(`Adding missing KV namespace binding: ${binding}`);
124
+ next.push({ binding, id: DEFAULT_HEX_ID });
125
+ }
126
+ return writeConfigValue(content, ['kv_namespaces'], next);
127
+ }
128
+
129
+ /** The stores in a `secrets-store store list` table. */
130
+ export function parseSecretStoresTable(output: string): SecretStore[] {
131
+ const stores: SecretStore[] = [];
132
+ // Through the shared table parser rather than a second inline split: `wrangler`
133
+ // renders through `cli-table3`, whose cells are separated by U+2502 and are *not*
134
+ // space-aligned, so a parser written for aligned output silently matches nothing
135
+ // — which reads as "the store does not exist" and makes provisioning create a
136
+ // duplicate.
137
+ for (const row of parseWranglerTableRows(output)) {
138
+ const name = row[0];
139
+ const id = row[1];
140
+ // Store ids are 32 hex characters; a row without one is a header, a border, or
141
+ // an unrelated column, and is skipped rather than stored with an undefined id.
142
+ if (name !== undefined && id !== undefined && /^[a-f0-9]{32}$/i.test(id)) {
143
+ stores.push({ name, id });
144
+ }
145
+ }
146
+ return stores;
147
+ }
148
+
149
+ export function listSecretStores(): SecretStore[] {
150
+ const output = runWrangler(['secrets-store', 'store', 'list', '--remote']);
151
+ try {
152
+ return parseJsonArray<SecretStore>(output, 'wrangler secrets-store store list --remote');
153
+ } catch {
154
+ // The subcommand prints a table rather than JSON on some versions.
155
+ return parseSecretStoresTable(output);
156
+ }
157
+ }
158
+
159
+ export function ensureSecretStore(): string {
160
+ const stores = listSecretStores();
161
+ const existing = stores.find((candidate) => candidate.name === DEFAULT_SECRET_STORE_NAME) ?? stores[0];
162
+ if (existing !== undefined) {
163
+ return existing.id;
164
+ }
165
+ console.log(`Creating Secrets Store: ${DEFAULT_SECRET_STORE_NAME}`);
166
+ const output = runWrangler(['secrets-store', 'store', 'create', DEFAULT_SECRET_STORE_NAME, '--remote']);
167
+ const createdStoreId = /ID:\s*([a-f0-9]{32})/i.exec(output)?.[1];
168
+ if (createdStoreId !== undefined) {
169
+ return createdStoreId;
170
+ }
171
+ // The create output did not name the id, so read it back rather than guessing.
172
+ const refreshed = listSecretStores();
173
+ const store = refreshed.find((candidate) => candidate.name === DEFAULT_SECRET_STORE_NAME) ?? refreshed[0];
174
+ if (store?.id === undefined) {
175
+ throw new Error(`Unable to discover Secrets Store ID for ${DEFAULT_SECRET_STORE_NAME}.`);
176
+ }
177
+ return store.id;
178
+ }
179
+
180
+ /** Create a queue when it is absent, reporting whether it had to be created. */
181
+ export function ensureQueue(queueName: string): boolean {
182
+ try {
183
+ runWrangler(['queues', 'info', queueName]);
184
+ console.log(`Queue ${queueName} already exists.`);
185
+ return false;
186
+ } catch {
187
+ console.log(`Creating queue: ${queueName}`);
188
+ runWrangler(['queues', 'create', queueName]);
189
+ return true;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Queue names a deployment needs, in first-seen order.
195
+ *
196
+ * Producers and consumers name the same queue twice — one queue, two roles — and a
197
+ * name appearing in both must be created once. Pure so it is testable without
198
+ * wrangler.
199
+ */
200
+ export function queueNamesIn(config: WranglerConfig): string[] {
201
+ const names: string[] = [];
202
+ const seen = new Set<string>();
203
+ for (const producer of config.queues?.producers ?? []) {
204
+ if (producer.queue === '' || seen.has(producer.queue)) continue;
205
+ seen.add(producer.queue);
206
+ names.push(producer.queue);
207
+ }
208
+ for (const consumer of config.queues?.consumers ?? []) {
209
+ if (consumer.queue === '' || seen.has(consumer.queue)) continue;
210
+ seen.add(consumer.queue);
211
+ names.push(consumer.queue);
212
+ }
213
+ return names;
214
+ }
215
+
216
+ export function ensureVectorizeIndex(indexName: string, dimensions: number): void {
217
+ try {
218
+ runWrangler(['vectorize', 'info', indexName]);
219
+ console.log(`Vectorize index ${indexName} already exists.`);
220
+ } catch {
221
+ console.log(`Creating Vectorize index: ${indexName} with ${dimensions} dimensions`);
222
+ runWrangler(['vectorize', 'create', indexName, `--dimensions=${dimensions}`, '--metric=cosine']);
223
+ }
224
+ }
225
+
226
+ /** Replace each placeholder D1 `database_id` with a real one. */
227
+ function provisionD1Databases(content: string, config: WranglerConfig, created: string[]): string {
228
+ let patched = content;
229
+ for (const [index, database] of config.d1_databases?.entries() ?? []) {
230
+ if (database.database_id !== DEFAULT_UUID) continue;
231
+ if (database.database_name === undefined || database.database_name === '') {
232
+ throw new Error(`D1 database binding ${database.binding ?? index} has a placeholder database_id but no database_name.`);
233
+ }
234
+ const resolved = ensureD1Database(database.database_name);
235
+ if (resolved.created) {
236
+ created.push(`d1:${database.database_name}`);
237
+ }
238
+ console.log(`Using D1 database ${database.database_name}: ${resolved.id}`);
239
+ patched = writeConfigValue(patched, ['d1_databases', index, 'database_id'], resolved.id);
240
+ }
241
+ return patched;
242
+ }
243
+
244
+ /** Replace each placeholder KV namespace id with a real one. */
245
+ function provisionKvNamespaces(content: string, config: WranglerConfig, created: string[]): string {
246
+ let patched = content;
247
+ for (const [index, namespace] of config.kv_namespaces?.entries() ?? []) {
248
+ if (namespace.id !== DEFAULT_HEX_ID) continue;
249
+ if (namespace.binding === undefined || namespace.binding === '') {
250
+ throw new Error(`KV namespace at index ${index} has a placeholder id but no binding.`);
251
+ }
252
+ const resolved = ensureKVNamespace(config, namespace.binding);
253
+ const namespaceName = getKVNamespaceName(config, namespace.binding);
254
+ if (resolved.created) {
255
+ created.push(`kv:${namespaceName}`);
256
+ }
257
+ console.log(`Using KV namespace ${namespaceName}: ${resolved.id}`);
258
+ patched = writeConfigValue(patched, ['kv_namespaces', index, 'id'], resolved.id);
259
+ }
260
+ return patched;
261
+ }
262
+
263
+ /**
264
+ * Point every placeholder Secrets Store `store_id` at one store, creating it if
265
+ * absent. `existed` is read **before** the create, because whether the store was
266
+ * created is what the caller reports.
267
+ */
268
+ function provisionSecretStore(content: string, config: WranglerConfig, created: string[]): string {
269
+ const placeholders = (config.secrets_store_secrets ?? [])
270
+ .map((secret, index) => ({ secret, index }))
271
+ .filter(({ secret }) => secret.store_id === DEFAULT_HEX_ID);
272
+ if (placeholders.length === 0) {
273
+ return content;
274
+ }
275
+ const existed = listSecretStores().some((store) => store.name === DEFAULT_SECRET_STORE_NAME);
276
+ const storeId = ensureSecretStore();
277
+ if (!existed) {
278
+ created.push(`secrets-store:${DEFAULT_SECRET_STORE_NAME}`);
279
+ }
280
+ console.log(`Using Secrets Store: ${storeId}`);
281
+ return placeholders.reduce((patched, { index }) => writeConfigValue(patched, ['secrets_store_secrets', index, 'store_id'], storeId), content);
282
+ }
283
+
284
+ /**
285
+ * Create every resource the config names, and write the real ids over the
286
+ * placeholders.
287
+ *
288
+ * Returns what it had to create, as `<kind>:<name>` — returned rather than logged,
289
+ * because a caller acts on it: the backup workflow refuses to export a D1 database
290
+ * that appears here, since one created moments ago holds no user data and an empty
291
+ * dump uploaded on schedule is a false sense of safety.
292
+ */
293
+ export function provisionWranglerResources(): string[] {
294
+ const created: string[] = [];
295
+ let { content, config } = readConfig();
296
+
297
+ content = ensureRequiredKvBindings(content, config);
298
+ config = parse(content) as WranglerConfig;
299
+
300
+ // Each of the three below re-parses before it patches, because each writes into
301
+ // the config text the previous one produced. They are separate functions because
302
+ // they are separate resources with separate rules, and one loop holding all
303
+ // three would read as a single resource list with the differences hidden in the
304
+ // bodies.
305
+ content = provisionD1Databases(content, config, created);
306
+ config = parse(content) as WranglerConfig;
307
+
308
+ content = provisionKvNamespaces(content, config, created);
309
+ config = parse(content) as WranglerConfig;
310
+
311
+ content = provisionSecretStore(content, config, created);
312
+
313
+ writeConfig(content);
314
+
315
+ config = parse(content) as WranglerConfig;
316
+ for (const queueName of queueNamesIn(config)) {
317
+ if (ensureQueue(queueName)) {
318
+ created.push(`queue:${queueName}`);
319
+ }
320
+ }
321
+
322
+ // Vectorize indexes are name-based, so nothing in the file is patched — but the
323
+ // created names are still reported like every other resource, so a deploy log
324
+ // shows what appeared.
325
+ for (const binding of config.vectorize ?? []) {
326
+ ensureVectorizeIndex(binding.index_name, VECTORIZE_DIMENSIONS);
327
+ }
328
+
329
+ return created;
330
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared paths, placeholders and shape definitions for the wrangler config helpers.
3
+ *
4
+ * Provenance: ported from Edge-Sonic's `scripts/lib/wrangler-config/types.ts`.
5
+ * Every script that reads or writes `wrangler.jsonc` resolves it here, so the
6
+ * backup workflow cannot end up inspecting a different file than the deploy
7
+ * materialized.
8
+ *
9
+ * The paths default to `process.cwd()`, which is the repo root in every consumer:
10
+ * the scripts run as `pnpm exec tsx scripts/deploy/…` from there. `WRANGLER_CONFIG_PATH`
11
+ * / `WRANGLER_TEMPLATE_PATH` override them for a caller that is not in the repo
12
+ * root, which is the one case the cwd default gets wrong.
13
+ */
14
+
15
+ import path from 'node:path';
16
+
17
+ /** The config the deploy targets. */
18
+ export const CONFIG_PATH = process.env['WRANGLER_CONFIG_PATH'] ?? path.join(process.cwd(), 'wrangler.jsonc');
19
+
20
+ /** The committed template the config is materialized from. */
21
+ export const TEMPLATE_PATH = process.env['WRANGLER_TEMPLATE_PATH'] ?? path.join(process.cwd(), 'apps/api/wrangler.template.jsonc');
22
+
23
+ /**
24
+ * The id a template carries for a D1 database that does not exist yet.
25
+ *
26
+ * `provisionWranglerResources` replaces it with a real id; the backup scripts
27
+ * refuse to export while it is still in place, because a database that was just
28
+ * created holds no user data.
29
+ */
30
+ export const DEFAULT_UUID = '00000000-0000-0000-0000-000000000000';
31
+
32
+ /** The placeholder for a KV namespace id and for a Secrets Store id. */
33
+ export const DEFAULT_HEX_ID = '00000000000000000000000000000000';
34
+
35
+ /** The Secrets Store name a deployment uses. */
36
+ export const DEFAULT_SECRET_STORE_NAME = 'default';
37
+
38
+ /**
39
+ * Fallback when the config declares no `name`, used to derive resource names.
40
+ *
41
+ * Overridable per consumer with `WRANGLER_WORKER_NAME`, which keeps the default
42
+ * honest rather than baking one repo's name into the kit.
43
+ */
44
+ export const DEFAULT_WORKER_NAME = process.env['WRANGLER_WORKER_NAME'] ?? 'my-worker';
45
+
46
+ /**
47
+ * KV bindings a deployment always needs, and the namespace each maps to.
48
+ *
49
+ * A consumer whose worker has no cache binding passes an empty map; a consumer
50
+ * with one names it here or leaves the derived `<worker>-<BINDING>` convention.
51
+ */
52
+ export const DEFAULT_KV_NAMESPACE_NAMES: Record<string, string> = {
53
+ CACHE: `${DEFAULT_WORKER_NAME}-cache`,
54
+ };
55
+
56
+ /** Dimensions of a Vectorize index this kit provisions. */
57
+ export const VECTORIZE_DIMENSIONS = 1024;
58
+
59
+ /**
60
+ * The parsed shape of a wrangler config.
61
+ *
62
+ * `vars` is widened with `null` on purpose: jsonc-parser yields `null` for an
63
+ * explicit `vars: null`, and the vars-patch path must be able to reject that
64
+ * rather than treat the field as absent.
65
+ */
66
+ export interface WranglerConfig {
67
+ name?: string;
68
+ $version?: number;
69
+ vars?: Record<string, unknown> | null;
70
+ d1_databases?: Array<{
71
+ binding?: string;
72
+ database_id?: string;
73
+ database_name?: string;
74
+ }>;
75
+ kv_namespaces?: Array<{
76
+ binding?: string;
77
+ id?: string;
78
+ }>;
79
+ secrets_store_secrets?: Array<{
80
+ binding?: string;
81
+ store_id?: string;
82
+ secret_name?: string;
83
+ }>;
84
+ queues?: {
85
+ producers?: Array<{ binding: string; queue: string }>;
86
+ consumers?: Array<{ queue: string }>;
87
+ };
88
+ vectorize?: Array<{
89
+ binding: string;
90
+ index_name: string;
91
+ }>;
92
+ }
93
+
94
+ /** A D1 database as `wrangler d1 list --json` reports one. */
95
+ export interface D1Database {
96
+ name?: string;
97
+ uuid?: string;
98
+ id?: string;
99
+ database_id?: string;
100
+ }
101
+
102
+ /** A KV namespace as `wrangler kv namespace list` reports one. */
103
+ export interface KVNamespace {
104
+ title?: string;
105
+ name?: string;
106
+ id?: string;
107
+ }
108
+
109
+ /** A Secrets Store as `wrangler secrets-store store list` reports one. */
110
+ export interface SecretStore {
111
+ name: string;
112
+ id: string;
113
+ }
@@ -0,0 +1,169 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterAll, describe, expect, it } from 'vitest';
5
+ import { createMigrationHelper, executableStatements, readMigrationFiles, splitSql, type D1DatabaseLike, type D1PreparedStatementLike, type MigrationFile } from './integration-migrations';
6
+
7
+ const created: string[] = [];
8
+
9
+ function tempTree(files: Record<string, string>): string {
10
+ const root = mkdtempSync(join(tmpdir(), 'migrations-'));
11
+ created.push(root);
12
+ for (const [name, content] of Object.entries(files)) {
13
+ const path = join(root, name);
14
+ mkdirSync(join(path, '..'), { recursive: true });
15
+ writeFileSync(path, content, 'utf8');
16
+ }
17
+ return root;
18
+ }
19
+
20
+ afterAll(() => {
21
+ for (const root of created) rmSync(root, { recursive: true, force: true });
22
+ });
23
+
24
+ /**
25
+ * A fake D1: records what it was asked to run, and refuses a statement it does not
26
+ * recognise, so a suite can assert the helper split the file the way SQLite would
27
+ * have to.
28
+ */
29
+ function fakeD1(options: { batch?: boolean } = {}): D1DatabaseLike & { ran: string[] } {
30
+ const ran: string[] = [];
31
+ const prepare = (sql: string): D1PreparedStatementLike => ({
32
+ run: async () => {
33
+ ran.push(sql);
34
+ },
35
+ });
36
+ const base: D1DatabaseLike & { ran: string[] } = { prepare, ran };
37
+ if (options.batch !== false) {
38
+ return {
39
+ ...base,
40
+ batch: async (statements) => {
41
+ for (const statement of statements) await statement.run();
42
+ },
43
+ };
44
+ }
45
+ return base;
46
+ }
47
+
48
+ describe('readMigrationFiles', () => {
49
+ it('reads every .sql file in apply order, and ignores the lock', () => {
50
+ const dir = tempTree({
51
+ 'migrations.lock.json': '{}',
52
+ '0002_more.sql': 'SELECT 2;',
53
+ '0001_init.sql': 'SELECT 1;',
54
+ 'notes.md': 'ignored',
55
+ });
56
+ expect(readMigrationFiles(dir).map((file) => file.name)).toStrictEqual(['0001_init.sql', '0002_more.sql']);
57
+ });
58
+ });
59
+
60
+ describe('splitSql', () => {
61
+ it('splits on semicolons outside strings and comments', () => {
62
+ expect(splitSql('SELECT 1; SELECT 2;')).toStrictEqual(['SELECT 1', 'SELECT 2']);
63
+ });
64
+
65
+ it('keeps semicolons inside quoted strings', () => {
66
+ expect(splitSql("INSERT INTO t VALUES ('a;b');")).toStrictEqual(["INSERT INTO t VALUES ('a;b')"]);
67
+ });
68
+
69
+ it('keeps doubled-quote escapes inside strings', () => {
70
+ expect(splitSql("INSERT INTO t VALUES ('it''s; fine');")).toStrictEqual(["INSERT INTO t VALUES ('it''s; fine')"]);
71
+ });
72
+
73
+ it('folds comment-only runs into one statement the executable filter drops', () => {
74
+ // The splitter does not judge executability — it only knows statement
75
+ // boundaries. Comments stay attached to the statement they sit above; a
76
+ // comment-only run comes back as its comment text, and the executable filter
77
+ // (isExecutable) is what keeps it off the wire.
78
+ const sql = '-- a note about the migration;\nCREATE TABLE a (id TEXT);\n/* trailing block */;';
79
+ expect(splitSql(sql)).toStrictEqual(['-- a note about the migration;\nCREATE TABLE a (id TEXT)', '/* trailing block */']);
80
+ expect(executableStatements(sql)).toStrictEqual(['-- a note about the migration;\nCREATE TABLE a (id TEXT)']);
81
+ });
82
+
83
+ it('does not split inside a CREATE TRIGGER body', () => {
84
+ const sql = `CREATE TRIGGER t AFTER INSERT ON x BEGIN UPDATE y SET a = 1; UPDATE y SET b = 2; END;`;
85
+ // Statements come back trimmed, without the terminating semicolon.
86
+ expect(splitSql(sql)).toStrictEqual(['CREATE TRIGGER t AFTER INSERT ON x BEGIN UPDATE y SET a = 1; UPDATE y SET b = 2; END']);
87
+ });
88
+
89
+ it('does not treat a table named trigger as a trigger body', () => {
90
+ const sql = 'CREATE TABLE trigger (id TEXT); INSERT INTO trigger VALUES (1);';
91
+ expect(splitSql(sql)).toStrictEqual(['CREATE TABLE trigger (id TEXT)', 'INSERT INTO trigger VALUES (1)']);
92
+ });
93
+ });
94
+
95
+ describe('createMigrationHelper', () => {
96
+ it('applies every file, in order, once per database', async () => {
97
+ const dir = tempTree({ '0001_init.sql': 'CREATE TABLE a (id TEXT);', '0002_more.sql': 'CREATE TABLE b (id TEXT);' });
98
+ const helper = createMigrationHelper({ migrationsDir: dir });
99
+ const db = fakeD1();
100
+
101
+ await helper.applyMigrations(db);
102
+ await helper.applyMigrations(db);
103
+
104
+ expect(db.ran).toStrictEqual(['CREATE TABLE a (id TEXT)', 'CREATE TABLE b (id TEXT)']);
105
+ });
106
+
107
+ it('applies a closed range by filename, and can start from a pre-migration shape', async () => {
108
+ const dir = tempTree({
109
+ '0001_init.sql': 'CREATE TABLE a (id TEXT);',
110
+ '0002_upgrade.sql': 'ALTER TABLE a ADD COLUMN email TEXT;',
111
+ '0003_more.sql': 'CREATE TABLE c (id TEXT);',
112
+ });
113
+ const helper = createMigrationHelper({ migrationsDir: dir });
114
+ const db = fakeD1();
115
+
116
+ await helper.applyMigrations(db, { to: '0001_init.sql' });
117
+ expect(db.ran).toStrictEqual(['CREATE TABLE a (id TEXT)']);
118
+
119
+ await helper.applyMigrations(db, { from: '0002_upgrade.sql' });
120
+ // `from` without `to` runs to the end of the set — 0002 and 0003 both.
121
+ expect(db.ran).toStrictEqual(['CREATE TABLE a (id TEXT)', 'ALTER TABLE a ADD COLUMN email TEXT', 'CREATE TABLE c (id TEXT)']);
122
+ });
123
+
124
+ it('falls back to a per-statement loop when the binding cannot batch', async () => {
125
+ const dir = tempTree({ '0001_init.sql': 'CREATE TABLE a (id TEXT); CREATE TABLE b (id TEXT);' });
126
+ const helper = createMigrationHelper({ migrationsDir: dir });
127
+ const db = fakeD1({ batch: false });
128
+
129
+ await helper.applyMigrations(db);
130
+
131
+ expect(db.ran).toStrictEqual(['CREATE TABLE a (id TEXT)', 'CREATE TABLE b (id TEXT)']);
132
+ expect('batch' in db).toBe(false);
133
+ });
134
+
135
+ it('names the file and the statement when a batch fails', async () => {
136
+ const dir = tempTree({ '0001_init.sql': 'CREATE TABLE a (id TEXT);' });
137
+ const helper = createMigrationHelper({ migrationsDir: dir });
138
+ const db: D1DatabaseLike = {
139
+ prepare: () => ({ run: async () => undefined }),
140
+ batch: async () => {
141
+ throw new Error('no such table: a');
142
+ },
143
+ };
144
+
145
+ await expect(helper.applyMigrations(db)).rejects.toThrow(/0001_init\.sql: batch of 1 statements failed/);
146
+ });
147
+
148
+ it('refuses an unknown range endpoint, naming what is available', async () => {
149
+ const dir = tempTree({ '0001_init.sql': 'SELECT 1;' });
150
+ const helper = createMigrationHelper({ migrationsDir: dir });
151
+ await expect(helper.applyMigrations(fakeD1(), { from: '0009_missing.sql' })).rejects.toThrow(/Unknown migration file: 0009_missing\.sql/);
152
+ });
153
+
154
+ it('reports one file\'s SQL without reading the others', () => {
155
+ const dir = tempTree({ '0001_init.sql': 'SELECT 1;', '0002_more.sql': 'SELECT 2;' });
156
+ const helper = createMigrationHelper({ migrationsDir: dir });
157
+ expect(helper.migrationSqlOf('0002_more.sql')).toBe('SELECT 2;');
158
+ expect(helper.migrationSql()).toBe('SELECT 1;\nSELECT 2;');
159
+ expect(helper.migrationFileNames()).toStrictEqual(['0001_init.sql', '0002_more.sql']);
160
+ });
161
+
162
+ it('accepts injected files, for a harness that embeds the SQL at build time', async () => {
163
+ const files: MigrationFile[] = [{ name: 'all.sql', sql: 'CREATE TABLE a (id TEXT);' }];
164
+ const helper = createMigrationHelper({ migrationsDir: '/nonexistent', files });
165
+ const db = fakeD1();
166
+ await helper.applyMigrations(db);
167
+ expect(db.ran).toStrictEqual(['CREATE TABLE a (id TEXT)']);
168
+ });
169
+ });