@dragonmastery/tamer 0.1.2 → 0.29.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 (89) hide show
  1. package/README.md +570 -18
  2. package/dist/CFApiClient-DhbyyV71.mjs +868 -0
  3. package/dist/CFApiClient-DhbyyV71.mjs.map +1 -0
  4. package/dist/StateManager-DTqtLLVX.mjs +760 -0
  5. package/dist/StateManager-DTqtLLVX.mjs.map +1 -0
  6. package/dist/apply-BOABC3UB.mjs +423 -0
  7. package/dist/apply-BOABC3UB.mjs.map +1 -0
  8. package/dist/applyTarget-GWDEOXeY.mjs +152 -0
  9. package/dist/applyTarget-GWDEOXeY.mjs.map +1 -0
  10. package/dist/bootstrap-BxwxC_2Z.mjs +33 -0
  11. package/dist/bootstrap-BxwxC_2Z.mjs.map +1 -0
  12. package/dist/buildDispatchUploadForm-BoUB93b3.mjs +38 -0
  13. package/dist/buildDispatchUploadForm-BoUB93b3.mjs.map +1 -0
  14. package/dist/cloudflareSnapshot-DzPuCRTh.mjs +163 -0
  15. package/dist/cloudflareSnapshot-DzPuCRTh.mjs.map +1 -0
  16. package/dist/deploy-C0edCpn9.mjs +119 -0
  17. package/dist/deploy-C0edCpn9.mjs.map +1 -0
  18. package/dist/destroy-DzgA4lCA.mjs +215 -0
  19. package/dist/destroy-DzgA4lCA.mjs.map +1 -0
  20. package/dist/destroy-tenant-U0t7BeJ0.mjs +103 -0
  21. package/dist/destroy-tenant-U0t7BeJ0.mjs.map +1 -0
  22. package/dist/dev-CZbKfdFw.mjs +103 -0
  23. package/dist/dev-CZbKfdFw.mjs.map +1 -0
  24. package/dist/dns-records.resolve-C2T0m4NG.mjs +3 -0
  25. package/dist/dns-records.resolve-DwBR_1WI.mjs +47 -0
  26. package/dist/dns-records.resolve-DwBR_1WI.mjs.map +1 -0
  27. package/dist/dns-records.sync-Bpzz9H0s.mjs +75 -0
  28. package/dist/dns-records.sync-Bpzz9H0s.mjs.map +1 -0
  29. package/dist/doctor-C_hs7k2D.mjs +34 -0
  30. package/dist/doctor-C_hs7k2D.mjs.map +1 -0
  31. package/dist/drift-B5bpkI0i.mjs +323 -0
  32. package/dist/drift-B5bpkI0i.mjs.map +1 -0
  33. package/dist/drift-BNa92AK5.mjs +10 -0
  34. package/dist/events-BIznt8Sj.mjs +68 -0
  35. package/dist/events-BIznt8Sj.mjs.map +1 -0
  36. package/dist/fetchStackImports-C-1THPYL.mjs +3826 -0
  37. package/dist/fetchStackImports-C-1THPYL.mjs.map +1 -0
  38. package/dist/generator-Ba-vqyBG.mjs +77 -0
  39. package/dist/generator-Ba-vqyBG.mjs.map +1 -0
  40. package/dist/import-B0dlwKoQ.mjs +164 -0
  41. package/dist/import-B0dlwKoQ.mjs.map +1 -0
  42. package/dist/index.d.mts +5673 -1290
  43. package/dist/index.d.mts.map +1 -1
  44. package/dist/index.mjs +18 -1
  45. package/dist/index.mjs.map +1 -0
  46. package/dist/loader-DAvCKLTT.mjs +518 -0
  47. package/dist/loader-DAvCKLTT.mjs.map +1 -0
  48. package/dist/logpush-job-DsRkOORJ.mjs +1106 -0
  49. package/dist/logpush-job-DsRkOORJ.mjs.map +1 -0
  50. package/dist/migrate-BpW6JkIg.mjs +87 -0
  51. package/dist/migrate-BpW6JkIg.mjs.map +1 -0
  52. package/dist/normalize-DVSTRZhO.mjs +253 -0
  53. package/dist/normalize-DVSTRZhO.mjs.map +1 -0
  54. package/dist/plan-Do5rE-c5.mjs +453 -0
  55. package/dist/plan-Do5rE-c5.mjs.map +1 -0
  56. package/dist/planFormat-CJw8Kq2s.mjs +119 -0
  57. package/dist/planFormat-CJw8Kq2s.mjs.map +1 -0
  58. package/dist/provision-tenant-Wfck-2Oa.mjs +192 -0
  59. package/dist/provision-tenant-Wfck-2Oa.mjs.map +1 -0
  60. package/dist/r2S3EmptyBucket-DD81ZWQ7.mjs +92 -0
  61. package/dist/r2S3EmptyBucket-DD81ZWQ7.mjs.map +1 -0
  62. package/dist/stackOutputs-CQQHtdPA.mjs +69 -0
  63. package/dist/stackOutputs-CQQHtdPA.mjs.map +1 -0
  64. package/dist/status-D5GLpWyn.mjs +198 -0
  65. package/dist/status-D5GLpWyn.mjs.map +1 -0
  66. package/dist/sync-B_pyPi7Z.mjs +90 -0
  67. package/dist/sync-B_pyPi7Z.mjs.map +1 -0
  68. package/dist/tamer.d.mts +1 -0
  69. package/dist/tamer.mjs +4553 -0
  70. package/dist/tamer.mjs.map +1 -0
  71. package/dist/tamerArtifactsR2-Ccgplu2Q.mjs +52 -0
  72. package/dist/tamerArtifactsR2-Ccgplu2Q.mjs.map +1 -0
  73. package/dist/types-JrdlG7Dy.mjs +44 -0
  74. package/dist/types-JrdlG7Dy.mjs.map +1 -0
  75. package/dist/verifyPlanFile-ah_4tvTu.mjs +33 -0
  76. package/dist/verifyPlanFile-ah_4tvTu.mjs.map +1 -0
  77. package/dist/wfp-delete-BhuUrBUA.mjs +36 -0
  78. package/dist/wfp-delete-BhuUrBUA.mjs.map +1 -0
  79. package/dist/wfp-put-DL0mJNNz.mjs +52 -0
  80. package/dist/wfp-put-DL0mJNNz.mjs.map +1 -0
  81. package/dist/worker-route-CMbtozNa.mjs +263 -0
  82. package/dist/worker-route-CMbtozNa.mjs.map +1 -0
  83. package/dist/workers-C-oeZhdD.mjs +87 -0
  84. package/dist/workers-C-oeZhdD.mjs.map +1 -0
  85. package/dist/wranglerSpawn-DmEz0ldT.mjs +24 -0
  86. package/dist/wranglerSpawn-DmEz0ldT.mjs.map +1 -0
  87. package/dist/zoneResolver-VoxLHM4N.mjs +32 -0
  88. package/dist/zoneResolver-VoxLHM4N.mjs.map +1 -0
  89. package/package.json +38 -3
@@ -0,0 +1,760 @@
1
+ import { a as boolean, c as number, d as string, i as array, l as object, n as _enum, o as discriminatedUnion, s as literal, t as StateConflictError, u as record } from "./tamer.mjs";
2
+
3
+ //#region src/core/state/stateSchema.ts
4
+ const D1StateEntrySchema = object({
5
+ type: literal("d1_database"),
6
+ logicalName: string(),
7
+ shardDate: string().optional(),
8
+ derivedName: string(),
9
+ bindingKey: string(),
10
+ cfId: string(),
11
+ migrationsDir: string().optional(),
12
+ preserveOnDestroy: boolean().optional(),
13
+ createdAt: string(),
14
+ updatedAt: string()
15
+ });
16
+ const R2StateEntrySchema = object({
17
+ type: literal("r2_bucket"),
18
+ logicalName: string(),
19
+ createdDate: string(),
20
+ derivedName: string(),
21
+ bindingKey: string(),
22
+ createdAt: string(),
23
+ updatedAt: string()
24
+ });
25
+ const KVStateEntrySchema = object({
26
+ type: literal("kv_namespace"),
27
+ logicalName: string(),
28
+ derivedName: string(),
29
+ bindingKey: string(),
30
+ cfId: string(),
31
+ createdAt: string(),
32
+ updatedAt: string()
33
+ });
34
+ const QueueStateEntrySchema = object({
35
+ type: literal("queue"),
36
+ logicalName: string(),
37
+ derivedName: string(),
38
+ bindingKey: string(),
39
+ cfId: string(),
40
+ producerBinding: boolean(),
41
+ createdAt: string(),
42
+ updatedAt: string()
43
+ });
44
+ const VectorizeStateEntrySchema = object({
45
+ type: literal("vectorize"),
46
+ logicalName: string(),
47
+ derivedName: string(),
48
+ bindingKey: string(),
49
+ cfId: string(),
50
+ dimensions: number(),
51
+ metric: _enum([
52
+ "cosine",
53
+ "euclidean",
54
+ "dot-product"
55
+ ]),
56
+ createdAt: string(),
57
+ updatedAt: string()
58
+ });
59
+ const AIGatewayStateEntrySchema = object({
60
+ type: literal("ai_gateway"),
61
+ logicalName: string(),
62
+ derivedName: string(),
63
+ bindingKey: string(),
64
+ cfId: string(),
65
+ cacheTtl: number(),
66
+ cacheInvalidateOnUpdate: boolean(),
67
+ collectLogs: boolean(),
68
+ authentication: boolean(),
69
+ rateLimitingInterval: number(),
70
+ rateLimitingLimit: number(),
71
+ rateLimitingTechnique: _enum(["fixed", "sliding"]),
72
+ createdAt: string(),
73
+ updatedAt: string()
74
+ });
75
+ const PipelineStateEntrySchema = object({
76
+ type: literal("pipeline"),
77
+ logicalName: string(),
78
+ derivedName: string(),
79
+ bindingKey: string(),
80
+ cfId: string(),
81
+ sql: string(),
82
+ status: string().optional(),
83
+ createdAt: string(),
84
+ updatedAt: string()
85
+ });
86
+ const WorkflowStateEntrySchema = object({
87
+ type: literal("workflow"),
88
+ logicalName: string(),
89
+ derivedName: string(),
90
+ bindingKey: string(),
91
+ cfId: string(),
92
+ className: string(),
93
+ scriptName: string(),
94
+ limits: object({ steps: number().int().positive().optional() }).optional(),
95
+ createdAt: string(),
96
+ updatedAt: string()
97
+ });
98
+ const SecretsStoreStateEntrySchema = object({
99
+ type: literal("secrets_store"),
100
+ logicalName: string(),
101
+ derivedName: string(),
102
+ bindingKey: string(),
103
+ cfId: string(),
104
+ createdAt: string(),
105
+ updatedAt: string()
106
+ });
107
+ const HyperdriveStateEntrySchema = object({
108
+ type: literal("hyperdrive"),
109
+ logicalName: string(),
110
+ derivedName: string(),
111
+ bindingKey: string(),
112
+ cfId: string(),
113
+ scheme: _enum([
114
+ "postgres",
115
+ "postgresql",
116
+ "mysql"
117
+ ]),
118
+ originHost: string(),
119
+ originDatabase: string(),
120
+ createdAt: string(),
121
+ updatedAt: string()
122
+ });
123
+ const DnsRecordTypeSchema = _enum([
124
+ "A",
125
+ "AAAA",
126
+ "CNAME",
127
+ "TXT",
128
+ "MX",
129
+ "NS",
130
+ "CAA",
131
+ "SRV",
132
+ "PTR",
133
+ "HTTPS",
134
+ "SVCB"
135
+ ]);
136
+ const DnsRecordStateEntrySchema = object({
137
+ type: literal("dns_record"),
138
+ logicalName: string(),
139
+ zoneId: string(),
140
+ recordType: DnsRecordTypeSchema,
141
+ name: string(),
142
+ content: string(),
143
+ ttl: number(),
144
+ proxied: boolean(),
145
+ priority: number().optional(),
146
+ comment: string(),
147
+ recordId: string(),
148
+ createdAt: string(),
149
+ updatedAt: string()
150
+ });
151
+ const DispatchNamespaceStateEntrySchema = object({
152
+ type: literal("dispatch_namespace"),
153
+ logicalName: string(),
154
+ derivedName: string(),
155
+ createdAt: string(),
156
+ updatedAt: string()
157
+ });
158
+ const LogpushJobStateEntrySchema = object({
159
+ type: literal("logpush_job"),
160
+ logicalName: string(),
161
+ derivedName: string(),
162
+ cfJobId: number(),
163
+ dataset: string(),
164
+ createdAt: string(),
165
+ updatedAt: string()
166
+ });
167
+ const LogpushPipelinesStateEntrySchema = object({
168
+ type: literal("logpush_pipelines"),
169
+ logicalName: string(),
170
+ streamId: string(),
171
+ streamIngestBaseUrl: string().optional(),
172
+ sinkId: string(),
173
+ pipelineId: string(),
174
+ streamName: string(),
175
+ sinkName: string(),
176
+ pipelineName: string(),
177
+ r2DataCatalogTableName: string().optional(),
178
+ r2DataCatalogTableNamePipelines: string().optional(),
179
+ r2DataCatalogNamespace: string().optional(),
180
+ catalogBucketDerivedName: string(),
181
+ mintedR2CatalogTokenId: string().optional(),
182
+ mintedR2CatalogTokenValue: string().optional(),
183
+ mintedPipelinesSendTokenId: string().optional(),
184
+ mintedPipelinesSendTokenValue: string().optional(),
185
+ createdAt: string(),
186
+ updatedAt: string()
187
+ });
188
+ const WorkerRouteStateEntrySchema = object({
189
+ type: literal("worker_route"),
190
+ workerKey: string(),
191
+ workerName: string(),
192
+ zoneId: string(),
193
+ zoneName: string(),
194
+ routeId: string(),
195
+ pattern: string(),
196
+ createdAt: string(),
197
+ updatedAt: string()
198
+ });
199
+ const StateEntrySchema = discriminatedUnion("type", [
200
+ D1StateEntrySchema,
201
+ R2StateEntrySchema,
202
+ KVStateEntrySchema,
203
+ QueueStateEntrySchema,
204
+ HyperdriveStateEntrySchema,
205
+ VectorizeStateEntrySchema,
206
+ AIGatewayStateEntrySchema,
207
+ PipelineStateEntrySchema,
208
+ WorkflowStateEntrySchema,
209
+ SecretsStoreStateEntrySchema,
210
+ DnsRecordStateEntrySchema,
211
+ DispatchNamespaceStateEntrySchema,
212
+ LogpushJobStateEntrySchema,
213
+ LogpushPipelinesStateEntrySchema,
214
+ WorkerRouteStateEntrySchema
215
+ ]);
216
+ const ProvisioningStatusSchema = _enum([
217
+ "pending",
218
+ "d1_created",
219
+ "migrations_applied",
220
+ "script_uploaded",
221
+ "ready",
222
+ "tombstoned"
223
+ ]);
224
+ const TenantD1ShardRefSchema = object({
225
+ role: string(),
226
+ derivedName: string(),
227
+ cfId: string()
228
+ });
229
+ const TenantStateEntrySchema = object({
230
+ product: string(),
231
+ workspace: string(),
232
+ provisioningStatus: ProvisioningStatusSchema,
233
+ dispatchNamespaceName: string(),
234
+ scriptName: string(),
235
+ d1Shards: array(TenantD1ShardRefSchema).optional(),
236
+ createdAt: string(),
237
+ updatedAt: string()
238
+ });
239
+ const CfiStackMetaSchema = object({
240
+ name: string().optional(),
241
+ owner: string().optional()
242
+ });
243
+ const CfiOperationNameSchema = _enum([
244
+ "bootstrap",
245
+ "apply",
246
+ "deploy",
247
+ "destroy",
248
+ "provision-tenant",
249
+ "destroy-tenant",
250
+ "import",
251
+ "sync"
252
+ ]);
253
+ const CfiOperationStatusSchema = _enum([
254
+ "in_progress",
255
+ "succeeded",
256
+ "failed"
257
+ ]);
258
+ const CfiStackOutputValueSchema = object({
259
+ value: string(),
260
+ source: string(),
261
+ resolvedAt: string()
262
+ });
263
+ const CfiOperationRecordSchema = object({
264
+ command: CfiOperationNameSchema,
265
+ status: CfiOperationStatusSchema,
266
+ startedAt: string(),
267
+ completedAt: string().optional(),
268
+ errorMessage: string().optional(),
269
+ detail: string().optional()
270
+ });
271
+ const CfiStateSchema = object({
272
+ tenantId: string(),
273
+ env: string(),
274
+ schemaVersion: number(),
275
+ syncedAt: string(),
276
+ resources: record(string(), StateEntrySchema),
277
+ revision: number().optional(),
278
+ tenants: record(string(), TenantStateEntrySchema).optional(),
279
+ stack: CfiStackMetaSchema.optional(),
280
+ stackOutputs: record(string(), CfiStackOutputValueSchema).optional(),
281
+ lastOperation: CfiOperationRecordSchema.optional(),
282
+ operationHistory: array(CfiOperationRecordSchema).optional()
283
+ });
284
+
285
+ //#endregion
286
+ //#region src/core/state/stackName.ts
287
+ const DEFAULT_STACK_NAME = "default";
288
+ function stackNameForConfig(config) {
289
+ return config.stack?.name ?? config.tenant.slug ?? DEFAULT_STACK_NAME;
290
+ }
291
+
292
+ //#endregion
293
+ //#region src/core/state/tamerStateDb.ts
294
+ /**
295
+ * Schema versions:
296
+ * 2: original (resources only).
297
+ * 3: + `tenants` map, + `revision` for optimistic concurrency.
298
+ * 4: + `stack` metadata, + `lastOperation` (CloudFormation-style stack info).
299
+ * All v4 additions are optional; existing v3 documents are upgraded
300
+ * in-place during parse with no data loss.
301
+ */
302
+ const STATE_SCHEMA_VERSION = 4;
303
+ /** Cloudflare D1 database that holds JSON state for an env (`tamer-state-dev`, …). */
304
+ function tamerStateDatabaseName(env) {
305
+ return `tamer-state-${env}`;
306
+ }
307
+ function createEmptyCfiState(tenantId, env) {
308
+ return {
309
+ tenantId,
310
+ env,
311
+ schemaVersion: STATE_SCHEMA_VERSION,
312
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString(),
313
+ resources: {},
314
+ revision: 0,
315
+ tenants: {}
316
+ };
317
+ }
318
+ /** In-place upgrade for parsed JSON before Zod validation. */
319
+ function migrateRawCfiStateInPlace(raw) {
320
+ const v = raw.schemaVersion;
321
+ if (typeof v !== "number") throw new Error("tamer state: schemaVersion must be a number");
322
+ if (v < 2) throw new Error(`tamer state: unsupported schemaVersion ${v}`);
323
+ if (v > STATE_SCHEMA_VERSION) throw new Error(`tamer state: unknown schemaVersion ${v} (engine supports up to ${STATE_SCHEMA_VERSION})`);
324
+ if (v === 2) {
325
+ raw.tenants = {};
326
+ raw.revision = 0;
327
+ raw.schemaVersion = 3;
328
+ }
329
+ if (!raw.tenants || typeof raw.tenants !== "object") raw.tenants = {};
330
+ if (typeof raw.revision !== "number") raw.revision = 0;
331
+ if (raw.schemaVersion === 3) raw.schemaVersion = STATE_SCHEMA_VERSION;
332
+ }
333
+ async function findTamerStateDatabaseUuid(api, env) {
334
+ const name = tamerStateDatabaseName(env);
335
+ return (await api.d1ListAll()).find((d) => d.name === name)?.uuid;
336
+ }
337
+ /**
338
+ * Create `tamer-state-{env}` if missing, ensure `tamer_kv` table, and seed an
339
+ * initial empty `cfi_state:{stackName}` row when this stack has no row yet.
340
+ * Idempotent — re-running for the same stack is a no-op; re-running for a
341
+ * different stack against the same env D1 just adds another row.
342
+ */
343
+ async function ensureTamerStateDatabase(api, tenantId, env, stackName = DEFAULT_STACK_NAME) {
344
+ let uuid = await findTamerStateDatabaseUuid(api, env);
345
+ if (!uuid) uuid = (await api.d1Create(tamerStateDatabaseName(env))).uuid;
346
+ await api.d1Query(uuid, `CREATE TABLE IF NOT EXISTS tamer_kv (
347
+ k TEXT PRIMARY KEY,
348
+ v TEXT NOT NULL
349
+ )`);
350
+ const rowKey = `cfi_state:${stackName}`;
351
+ const { rows } = await api.d1Query(uuid, `SELECT v FROM tamer_kv WHERE k = ?`, [rowKey]);
352
+ if (rows.length === 0) {
353
+ const initial = createEmptyCfiState(tenantId, env);
354
+ await api.d1Query(uuid, `INSERT INTO tamer_kv (k, v) VALUES (?, ?)`, [rowKey, JSON.stringify(initial)]);
355
+ }
356
+ return uuid;
357
+ }
358
+ function parseCfiStateJson(json) {
359
+ const raw = JSON.parse(json);
360
+ migrateRawCfiStateInPlace(raw);
361
+ const result = CfiStateSchema.safeParse(raw);
362
+ if (!result.success) throw new Error(`Invalid tamer state JSON: ${result.error.message}`);
363
+ return result.data;
364
+ }
365
+ async function destroyTamerStateDatabase(api, env) {
366
+ const uuid = await findTamerStateDatabaseUuid(api, env);
367
+ if (!uuid) return false;
368
+ await api.d1Delete(uuid);
369
+ return true;
370
+ }
371
+
372
+ //#endregion
373
+ //#region src/features/dispatch-namespace/dispatch-namespace.resolve.ts
374
+ const ephemeralPredicateCache = /* @__PURE__ */ new WeakMap();
375
+ function ephemeralPredicateFor(tenant) {
376
+ if (ephemeralPredicateCache.has(tenant)) return ephemeralPredicateCache.get(tenant) ?? null;
377
+ const pat = tenant.ephemeralEnvPattern;
378
+ const compiled = pat ? new RegExp(pat) : null;
379
+ ephemeralPredicateCache.set(tenant, compiled);
380
+ return compiled;
381
+ }
382
+ /**
383
+ * `true` when `env` matches `tenant.ephemeralEnvPattern` (e.g.
384
+ * `"^pr-"` for PR previews, `"^(pr|feature)-"` for branch previews).
385
+ *
386
+ * Ephemeral envs share **one** dispatch namespace
387
+ * (`{ns}-ephemeral`) so we don't churn dispatch-namespace creates per
388
+ * preview, and dispatch-script names carry the env suffix
389
+ * (`{product}-{workspace}-{env}`) so multiple previews can coexist
390
+ * inside that shared namespace. When the config doesn't pin a
391
+ * pattern, no env is ephemeral — every env gets its own dispatch
392
+ * namespace `{ns}-{env}`.
393
+ */
394
+ function isEphemeralEnv(env, tenant) {
395
+ const re = ephemeralPredicateFor(tenant);
396
+ if (!re) return false;
397
+ return re.test(env);
398
+ }
399
+ /** Resolved Cloudflare dispatch namespace name for the given env. */
400
+ function effectiveDispatchNamespaceName(config, env, tenant) {
401
+ if (config.envSuffix) {
402
+ if (env === "local") return config.namespace;
403
+ if (isEphemeralEnv(env, tenant)) return `${config.namespace}-ephemeral`;
404
+ return `${config.namespace}-${env}`;
405
+ }
406
+ return config.namespace;
407
+ }
408
+
409
+ //#endregion
410
+ //#region src/core/tenant/tenantKeys.ts
411
+ /** Stable map key for `CfiState.tenants` (workspace-scoped product tenant). */
412
+ function tenantStateKey(product, workspace) {
413
+ return `${product}:${workspace}`;
414
+ }
415
+ /**
416
+ * Dispatch-namespace script name per `docs/handoff.md` §6: non-ephemeral
417
+ * envs collapse to `{product}-{workspace}` (one script per workspace);
418
+ * ephemeral envs (matching `tenant.ephemeralEnvPattern`) carry the env
419
+ * in the script name (`{product}-{workspace}-{env}`) so multiple
420
+ * previews can coexist in the shared `{ns}-ephemeral` namespace.
421
+ */
422
+ function tenantDispatchScriptName(product, workspace, env, tenant) {
423
+ if (isEphemeralEnv(env, tenant)) return `${product}-${workspace}-${env}`;
424
+ return `${product}-${workspace}`;
425
+ }
426
+ const SAFE = /[^a-z0-9_-]/gi;
427
+ /**
428
+ * Per-shard D1 database name for a tenant. Stable across `provision-tenant`
429
+ * runs and across env so re-provisioning + drift detection can match by
430
+ * name. Format: `db_{role}_{w}_{p}_t_{tid}_{env}`.
431
+ *
432
+ * db_system_acme_todo_t_platform_prod
433
+ * db_app_acme_todo_t_platform_prod
434
+ *
435
+ * `role` is whatever the operator declared in `tenant.d1Shards` in
436
+ * `tamer.config.ts`. Tamer is opinion-free about the shard layout — a
437
+ * Dragoncore-style product picks `["system", "app", "history"]`, a
438
+ * single-DB tenant picks `["main"]`, an audit-only tenant picks
439
+ * `["audit"]`, etc. The role itself is validated by the loader (lowercase
440
+ * ASCII subset) so it slots cleanly into the D1 naming scheme.
441
+ *
442
+ * D1 names are length-bounded (Cloudflare currently allows up to 64
443
+ * chars), and this scheme keeps every shard well under that limit even
444
+ * for long workspace + product slugs.
445
+ */
446
+ function tenantShardDatabaseName(role, workspace, product, platformTenantId, env) {
447
+ return `db_${role}_${workspace.replace(SAFE, "_").toLowerCase()}_${product.replace(SAFE, "_").toLowerCase()}_t_${platformTenantId}_${env}`;
448
+ }
449
+ /**
450
+ * Parse + validate a `--shards a,b,c` CLI argument against the configured
451
+ * shard set in `tamer.config.ts`. The CLI flag may only **trim** the
452
+ * configured layout (e.g. `--shards system` on a stack whose config
453
+ * declares `["system","app","history"]` provisions just the system
454
+ * shard for an ephemeral preview); it cannot extend it, because the
455
+ * config is the source of truth that `apply` / `drift` / `destroy`
456
+ * other operators read.
457
+ *
458
+ * Returns the requested roles in canonical order (matches `allowed`
459
+ * order, regardless of input order) so plan/apply output is
460
+ * deterministic and partial-failure resumes pick up at the next
461
+ * canonical role.
462
+ */
463
+ function parseTenantShardRoles(raw, allowed) {
464
+ const allowedSet = new Set(allowed);
465
+ const requested = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
466
+ const unknown = requested.filter((r) => !allowedSet.has(r));
467
+ if (unknown.length > 0) {
468
+ const list = allowed.length > 0 ? allowed.join(", ") : "(none configured)";
469
+ throw new Error(`unknown tenant shard role(s) "${unknown.join(", ")}"; must be a subset of tenant.d1Shards in the Tamer project config: ${list}`);
470
+ }
471
+ const seen = new Set(requested);
472
+ return allowed.filter((r) => seen.has(r));
473
+ }
474
+
475
+ //#endregion
476
+ //#region src/core/state/StateManager.ts
477
+ /** D1 `tamer_kv.k` value for a given stack's state row. */
478
+ function stateRowKey(stackName) {
479
+ return `cfi_state:${stackName}`;
480
+ }
481
+ const OPERATION_HISTORY_CAP = 50;
482
+ /**
483
+ * Authoritative deployment state for an env.
484
+ *
485
+ * - **Non-local:** stored as JSON in Cloudflare D1 (`tamer-state-{env}`).
486
+ * Call {@link hydrate} before {@link load}, then {@link persist} after mutations.
487
+ * - **local:** in-memory only (no persistence).
488
+ */
489
+ var StateManager = class {
490
+ state = null;
491
+ dirty = false;
492
+ /** Set when {@link hydrate} loads remote state. */
493
+ tamerStateDbUuid = null;
494
+ /**
495
+ * Remote `revision` at last hydrate (or last successful persist). Used for
496
+ * optimistic concurrency on D1 persist.
497
+ */
498
+ baselineRevision = 0;
499
+ /**
500
+ * @param tenantId `config.tenant.id` — recorded on the state row for
501
+ * diagnostics; not part of the row key.
502
+ * @param env Cloudflare environment name; selects the
503
+ * `tamer-state-{env}` D1 database.
504
+ * @param stackName Stack identity (`config.stack.name ?? tenant.slug`).
505
+ * The state row in D1 is keyed `cfi_state:{stackName}`,
506
+ * so multiple stacks coexist in one env D1 without
507
+ * clobbering each other. Defaults to `"default"` —
508
+ * unit tests that synthesize a StateManager without
509
+ * a config get a stable key without extra plumbing.
510
+ */
511
+ constructor(tenantId, env, stackName = DEFAULT_STACK_NAME) {
512
+ this.tenantId = tenantId;
513
+ this.env = env;
514
+ this.stackName = stackName;
515
+ }
516
+ /**
517
+ * Load state from D1 (remote) or allocate empty state (local).
518
+ * Required before {@link load} for every command.
519
+ */
520
+ async hydrate(api) {
521
+ if (this.state) return;
522
+ if (this.env === "local") {
523
+ this.state = createEmptyCfiState(this.tenantId, this.env);
524
+ this.baselineRevision = this.state.revision ?? 0;
525
+ return;
526
+ }
527
+ const name = tamerStateDatabaseName(this.env);
528
+ const uuid = await findTamerStateDatabaseUuid(api, this.env);
529
+ if (!uuid) throw new Error(`Tamer state database "${name}" not found. Run: tamer bootstrap --env ${this.env}`);
530
+ this.tamerStateDbUuid = uuid;
531
+ const rowKey = stateRowKey(this.stackName);
532
+ const { rows } = await api.d1Query(uuid, `SELECT v FROM tamer_kv WHERE k = ?`, [rowKey]);
533
+ if (rows.length === 0) {
534
+ this.state = createEmptyCfiState(this.tenantId, this.env);
535
+ this.baselineRevision = this.state.revision ?? 0;
536
+ this.dirty = true;
537
+ return;
538
+ }
539
+ const v = rows[0]["v"];
540
+ if (typeof v !== "string") throw new Error(`tamer_kv.${rowKey} must be a string column`);
541
+ this.state = parseCfiStateJson(v);
542
+ this.baselineRevision = this.state.revision ?? 0;
543
+ }
544
+ /**
545
+ * Stack identifier this manager is bound to (the `cfi_state:{name}` row
546
+ * key suffix). Exposed so `fetchStackImports` and diagnostics can show
547
+ * the operator which row this manager owns.
548
+ */
549
+ getStackName() {
550
+ return this.stackName;
551
+ }
552
+ /**
553
+ * Allocate empty in-memory state without touching D1. Use for read-only
554
+ * "what-would-state-look-like" snapshots (e.g. drift-aware plan refresh)
555
+ * where we want to drive the module `sync` hooks against a fresh slate
556
+ * and then discard the result. {@link persist} is unsafe afterwards
557
+ * because there is no D1 baseline to compare against.
558
+ */
559
+ hydrateInMemory() {
560
+ if (this.state) return;
561
+ this.state = createEmptyCfiState(this.tenantId, this.env);
562
+ this.baselineRevision = this.state.revision ?? 0;
563
+ }
564
+ /** Clear cached state so the next {@link hydrate} reloads from D1. */
565
+ reset() {
566
+ this.state = null;
567
+ this.tamerStateDbUuid = null;
568
+ this.dirty = false;
569
+ this.baselineRevision = 0;
570
+ }
571
+ load() {
572
+ if (!this.state) throw new Error("StateManager: call await hydrate(api) before load()");
573
+ return this.state;
574
+ }
575
+ get(derivedName) {
576
+ return this.load().resources[derivedName];
577
+ }
578
+ set(derivedName, entry) {
579
+ const s = this.load();
580
+ s.resources[derivedName] = entry;
581
+ s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
582
+ this.dirty = true;
583
+ }
584
+ delete(derivedName) {
585
+ const s = this.load();
586
+ delete s.resources[derivedName];
587
+ s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
588
+ this.dirty = true;
589
+ }
590
+ getAll() {
591
+ return this.load().resources;
592
+ }
593
+ getTenant(product, workspace) {
594
+ return this.load().tenants?.[tenantStateKey(product, workspace)];
595
+ }
596
+ setTenant(entry) {
597
+ const s = this.load();
598
+ if (!s.tenants) s.tenants = {};
599
+ s.tenants[tenantStateKey(entry.product, entry.workspace)] = entry;
600
+ s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
601
+ this.dirty = true;
602
+ }
603
+ deleteTenant(product, workspace) {
604
+ const s = this.load();
605
+ if (!s.tenants) return;
606
+ delete s.tenants[tenantStateKey(product, workspace)];
607
+ s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
608
+ this.dirty = true;
609
+ }
610
+ listTenants() {
611
+ return Object.values(this.load().tenants ?? {});
612
+ }
613
+ /** CloudFormation-style stack metadata (name, owner). Returns a copy. */
614
+ getStackMeta() {
615
+ const s = this.load().stack;
616
+ return s ? { ...s } : void 0;
617
+ }
618
+ /**
619
+ * Set or merge stack metadata. Pass `undefined` fields to clear them; only
620
+ * provided keys are written, so callers can update one field at a time.
621
+ */
622
+ setStackMeta(meta) {
623
+ const s = this.load();
624
+ s.stack = {
625
+ ...s.stack ?? {},
626
+ ...meta
627
+ };
628
+ s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
629
+ this.dirty = true;
630
+ }
631
+ /**
632
+ * Resolved + persisted `outputs:` for this stack. Returns `{}` when none
633
+ * have been recorded yet (e.g. before the first successful `apply`). The
634
+ * returned object is a shallow copy — mutate via {@link replaceStackOutputs}.
635
+ */
636
+ getStackOutputs() {
637
+ return { ...this.load().stackOutputs ?? {} };
638
+ }
639
+ /**
640
+ * Replace this stack's `stackOutputs` map wholesale. Pass `{}` to clear
641
+ * (e.g. when `outputs` is removed from `tamer.config.ts`); pass a fresh
642
+ * map keyed by output name to commit a successful apply's resolved values.
643
+ * No-op when the new map is structurally identical to the existing one
644
+ * (avoids gratuitous `revision` bumps on no-op applies).
645
+ */
646
+ replaceStackOutputs(next) {
647
+ const s = this.load();
648
+ if (stackOutputsEqual(s.stackOutputs ?? {}, next)) return;
649
+ if (Object.keys(next).length === 0) delete s.stackOutputs;
650
+ else s.stackOutputs = { ...next };
651
+ s.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
652
+ this.dirty = true;
653
+ }
654
+ getLastOperation() {
655
+ return this.load().lastOperation;
656
+ }
657
+ /** Completed operations (`succeeded` / `failed` only), newest first. */
658
+ getOperationHistory() {
659
+ const h = this.load().operationHistory;
660
+ return h ? h.map((e) => ({ ...e })) : [];
661
+ }
662
+ /**
663
+ * Begin recording a CloudFormation-style operation marker. Sets `status:
664
+ * "in_progress"` and `startedAt`; pair with {@link finishOperation} on
665
+ * success or {@link failOperation} on error. Persist between calls if the
666
+ * operation may take a long time and you want concurrent operators to see
667
+ * the in-progress marker.
668
+ */
669
+ beginOperation(command, detail) {
670
+ const s = this.load();
671
+ s.lastOperation = {
672
+ command,
673
+ status: "in_progress",
674
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
675
+ detail
676
+ };
677
+ s.syncedAt = s.lastOperation.startedAt;
678
+ this.dirty = true;
679
+ }
680
+ finishOperation(detail) {
681
+ const s = this.load();
682
+ if (!s.lastOperation) return;
683
+ s.lastOperation.status = "succeeded";
684
+ s.lastOperation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
685
+ if (detail !== void 0) s.lastOperation.detail = detail;
686
+ s.syncedAt = s.lastOperation.completedAt;
687
+ this.appendTerminalOperationToHistory(s.lastOperation);
688
+ this.dirty = true;
689
+ }
690
+ failOperation(errorMessage) {
691
+ const s = this.load();
692
+ if (!s.lastOperation) return;
693
+ s.lastOperation.status = "failed";
694
+ s.lastOperation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
695
+ s.lastOperation.errorMessage = errorMessage;
696
+ s.syncedAt = s.lastOperation.completedAt;
697
+ this.appendTerminalOperationToHistory(s.lastOperation);
698
+ this.dirty = true;
699
+ }
700
+ appendTerminalOperationToHistory(op) {
701
+ if (op.status !== "succeeded" && op.status !== "failed") return;
702
+ const s = this.load();
703
+ s.operationHistory = [{
704
+ command: op.command,
705
+ status: op.status,
706
+ startedAt: op.startedAt,
707
+ completedAt: op.completedAt,
708
+ errorMessage: op.errorMessage,
709
+ detail: op.detail
710
+ }, ...s.operationHistory ?? []].slice(0, OPERATION_HISTORY_CAP);
711
+ }
712
+ /**
713
+ * Persist to D1 (no-op for local). Uses optimistic concurrency: re-reads
714
+ * `revision` before write; throws {@link StateConflictError} if another
715
+ * writer advanced the row since {@link hydrate}.
716
+ */
717
+ async persist(api) {
718
+ if (this.env === "local") {
719
+ this.dirty = false;
720
+ return;
721
+ }
722
+ if (!this.dirty || !this.state || !this.tamerStateDbUuid) return;
723
+ const rowKey = stateRowKey(this.stackName);
724
+ const { rows } = await api.d1Query(this.tamerStateDbUuid, `SELECT v FROM tamer_kv WHERE k = ?`, [rowKey]);
725
+ let remoteRev = 0;
726
+ if (rows.length > 0) {
727
+ const v = rows[0]["v"];
728
+ if (typeof v === "string") remoteRev = parseCfiStateJson(v).revision ?? 0;
729
+ }
730
+ if (remoteRev !== this.baselineRevision) throw new StateConflictError(`Tamer state conflict (stack=${this.stackName}): remote revision ${remoteRev} !== expected ${this.baselineRevision}. Re-run after refresh.`);
731
+ this.state.revision = remoteRev + 1;
732
+ this.state.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
733
+ const json = JSON.stringify(this.state);
734
+ await api.d1Query(this.tamerStateDbUuid, `INSERT INTO tamer_kv (k, v) VALUES (?, ?)
735
+ ON CONFLICT(k) DO UPDATE SET v = excluded.v`, [rowKey, json]);
736
+ this.baselineRevision = this.state.revision;
737
+ this.dirty = false;
738
+ }
739
+ /** Mark clean without writing (e.g. before deleting the state database). */
740
+ clearDirty() {
741
+ this.dirty = false;
742
+ }
743
+ };
744
+ function stackOutputsEqual(a, b) {
745
+ const ak = Object.keys(a).sort();
746
+ const bk = Object.keys(b).sort();
747
+ if (ak.length !== bk.length) return false;
748
+ for (let i = 0; i < ak.length; i++) {
749
+ if (ak[i] !== bk[i]) return false;
750
+ const k = ak[i];
751
+ const av = a[k];
752
+ const bv = b[k];
753
+ if (av.value !== bv.value || av.source !== bv.source) return false;
754
+ }
755
+ return true;
756
+ }
757
+
758
+ //#endregion
759
+ export { tenantStateKey as a, createEmptyCfiState as c, tamerStateDatabaseName as d, stackNameForConfig as f, tenantShardDatabaseName as i, destroyTamerStateDatabase as l, parseTenantShardRoles as n, effectiveDispatchNamespaceName as o, tenantDispatchScriptName as r, isEphemeralEnv as s, StateManager as t, ensureTamerStateDatabase as u };
760
+ //# sourceMappingURL=StateManager-DTqtLLVX.mjs.map