@zackbart/connecta 0.37.1 → 0.38.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 (168) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +2 -2
  3. package/bin/connecta.mjs +1 -39
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +2 -4
  6. package/dist/agent-identity.js +1 -1
  7. package/dist/auth/clerk-sdk/generated.js +2 -1
  8. package/dist/auth/downstream-oauth.js +19 -153
  9. package/dist/auth/downstream-revocation.js +1 -1
  10. package/dist/auth/oauth-grant.js +11 -0
  11. package/dist/auth/oauth-token-response.js +1 -1
  12. package/dist/auth/vercel-agent.js +2 -1
  13. package/dist/auth-elicitation.js +2 -2
  14. package/dist/branding.d.ts +1 -15
  15. package/dist/branding.js +2 -2
  16. package/dist/bytes.js +42 -0
  17. package/dist/call-admission-context.js +2 -5
  18. package/dist/call-recovery.js +0 -3
  19. package/dist/catalog-fingerprint.js +2 -18
  20. package/dist/catalog-service.js +1 -1
  21. package/dist/config-defaults.d.ts +1 -0
  22. package/dist/config-defaults.js +1 -0
  23. package/dist/config-schema.d.ts +1 -1
  24. package/dist/config-schema.js +6 -2
  25. package/dist/config.d.ts +41 -14
  26. package/dist/config.js +21 -71
  27. package/dist/connectors/api-connector.js +4 -3
  28. package/dist/connectors/guarded-fetch.d.ts +0 -6
  29. package/dist/connectors/guarded-fetch.js +1 -11
  30. package/dist/connectors/remote-mcp.js +10 -43
  31. package/dist/connectors/resource-uri.js +0 -4
  32. package/dist/d1.d.ts +0 -89
  33. package/dist/d1.js +0 -34
  34. package/dist/downstream-input.js +2 -2
  35. package/dist/execute.js +22 -13
  36. package/dist/executor-result.js +2 -1
  37. package/dist/executors/quickjs-runtime.js +2 -25
  38. package/dist/executors/quickjs.js +1 -1
  39. package/dist/guest-runtime.js +5 -3
  40. package/dist/guest-types.d.ts +5 -5
  41. package/dist/http/client.js +1 -1
  42. package/dist/http/errors.js +2 -2
  43. package/dist/http/json-schema.js +1 -1
  44. package/dist/http/node-transport.js +0 -1
  45. package/dist/http/oauth.js +1 -1
  46. package/dist/http/paging.js +6 -9
  47. package/dist/http/provider.js +1 -1
  48. package/dist/http/safety.js +1 -1
  49. package/dist/http/services.js +1 -8
  50. package/dist/http/tools.js +1 -7
  51. package/dist/http/vendor.js +7 -7
  52. package/dist/identity.js +2 -6
  53. package/dist/invocation-auth.js +2 -1
  54. package/dist/meta-tools.js +27 -27
  55. package/dist/native-tools.js +2 -1
  56. package/dist/oauth-handoff.js +2 -2
  57. package/dist/operation-vocabulary.js +1 -1
  58. package/dist/operator-record.js +1 -49
  59. package/dist/operator-ui/contract.d.ts +2 -2
  60. package/dist/operator-ui/generated.js +3 -3
  61. package/dist/program-writes.js +3 -8
  62. package/dist/provider-call.js +0 -7
  63. package/dist/provider.d.ts +1 -1
  64. package/dist/providers/_shared/google/kit.js +1 -6
  65. package/dist/providers/_shared/google/service-account.js +1 -3
  66. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  67. package/dist/providers/_shared/guide.js +3 -0
  68. package/dist/providers/_shared/json.js +12 -0
  69. package/dist/providers/_shared/rest/compact-index.js +1 -1
  70. package/dist/providers/_shared/rest/kit.js +1 -3
  71. package/dist/providers/_shared/rest/operation-index.js +1 -3
  72. package/dist/providers/_shared/rest/value-safety.js +26 -28
  73. package/dist/providers/breeze/api.js +2 -7
  74. package/dist/providers/breeze/index.js +4 -2
  75. package/dist/providers/breeze/tools.js +3 -4
  76. package/dist/providers/ccb/api.js +1 -6
  77. package/dist/providers/ccb/index.js +2 -1
  78. package/dist/providers/ccb/tools.js +1 -6
  79. package/dist/providers/cloudflare/api.js +2 -2
  80. package/dist/providers/cloudflare/index.js +2 -1
  81. package/dist/providers/cloudflare/safety.js +3 -3
  82. package/dist/providers/docs/tools.js +2 -10
  83. package/dist/providers/drive/tools.js +12 -25
  84. package/dist/providers/forms/tools.js +1 -9
  85. package/dist/providers/gmail/tools.js +5 -21
  86. package/dist/providers/overflow/api.js +1 -3
  87. package/dist/providers/overflow/index.js +2 -1
  88. package/dist/providers/overflow/tools.js +3 -7
  89. package/dist/providers/planning-center/api.js +2 -1
  90. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  91. package/dist/providers/planning-center/lib/json-api.js +1 -9
  92. package/dist/providers/planning-center/tools.js +2 -1
  93. package/dist/providers/sheets/tools.js +1 -9
  94. package/dist/providers/slides/tools.js +4 -13
  95. package/dist/providers/stripe/index.js +2 -1
  96. package/dist/providers/stripe/safety.js +2 -2
  97. package/dist/providers/tithely/api.js +2 -7
  98. package/dist/providers/tithely/index.js +3 -2
  99. package/dist/providers/tithely/tools.js +7 -5
  100. package/dist/providers/vercel/api.js +1 -6
  101. package/dist/providers/vercel/lib/pin.js +1 -1
  102. package/dist/providers/vercel/safety.js +1 -1
  103. package/dist/providers/vercel/tools.js +2 -1
  104. package/dist/redaction-marker.js +2 -2
  105. package/dist/registry.d.ts +1 -1
  106. package/dist/registry.js +5 -7
  107. package/dist/request-principal.js +1 -1
  108. package/dist/request-state.js +2 -2
  109. package/dist/routes/access-tokens.js +1 -1
  110. package/dist/routes/activity-disclosure.js +1 -1
  111. package/dist/routes/connect.js +2 -2
  112. package/dist/routes/credentials.js +1 -1
  113. package/dist/routes/mcp.js +4 -3
  114. package/dist/routes/oauth-management.js +1 -1
  115. package/dist/routes/operator.js +4 -3
  116. package/dist/routes/shared.d.ts +8 -8
  117. package/dist/routes/shared.js +9 -9
  118. package/dist/routes/ui-config.js +1 -1
  119. package/dist/routes/ui.js +1 -1
  120. package/dist/runtime/call-admission.js +1 -1
  121. package/dist/runtime/call.js +1 -1
  122. package/dist/runtime/settlement.js +1 -1
  123. package/dist/runtime/shared-read.js +2 -15
  124. package/dist/sent-secrets.d.ts +1 -2
  125. package/dist/sent-secrets.js +3 -8
  126. package/dist/server.js +4 -4
  127. package/dist/skills.js +8 -35
  128. package/dist/sqlite.d.ts +0 -19
  129. package/dist/sqlite.js +2 -83
  130. package/dist/storage/keys.js +16 -125
  131. package/dist/storage/sql-native.js +0 -101
  132. package/dist/storage/sql.d.ts +1 -24
  133. package/dist/storage/sql.js +1 -12
  134. package/dist/tool-error.js +4 -0
  135. package/dist/url-safety.js +0 -4
  136. package/dist/usage-guide.js +2 -2
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/worker.js +1 -1
  140. package/documentation/README.md +6 -42
  141. package/documentation/architecture.md +86 -496
  142. package/documentation/auth.md +12 -25
  143. package/documentation/code-mode.md +90 -66
  144. package/documentation/deploying.md +19 -40
  145. package/documentation/integrating.md +4 -19
  146. package/documentation/meta-tools.md +6 -6
  147. package/documentation/migration-0.33.md +5 -5
  148. package/documentation/migration-0.34.md +2 -4
  149. package/documentation/migration-retired-layouts.md +74 -0
  150. package/documentation/operator-tests.md +19 -119
  151. package/documentation/operator-ui.md +3 -4
  152. package/documentation/provider-auth-migration.md +4 -10
  153. package/documentation/provider-migration-0.29.md +4 -15
  154. package/examples/worker/AGENTS.md +4 -6
  155. package/examples/worker/README.md +20 -115
  156. package/guides/connecting-agents.md +7 -30
  157. package/guides/how-it-works.md +2 -2
  158. package/guides/safety-and-trust.md +4 -1
  159. package/package.json +19 -19
  160. package/templates/node/README.md +9 -26
  161. package/templates/node/package.json +1 -1
  162. package/dist/auth/oauth-v2-migration.js +0 -135
  163. package/dist/provider-call-facts.d.ts +0 -115
  164. package/dist/provider-call.d.ts +0 -112
  165. package/dist/storage/d1-copy.js +0 -255
  166. package/dist/storage/sql-schema.d.ts +0 -6
  167. package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
  168. package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
@@ -1,255 +0,0 @@
1
- // Native copy orchestration. Only Workers KV and Web Crypto are Promise leaves.
2
- import { Effect } from "effect";
3
- import { errorLabel } from "../operator-record.js";
4
- import { KvToD1CopyError } from "../d1.js";
5
- import { familyOfKey, KEY_FAMILIES, KV_COPY_CURSOR_TTL_SECONDS, kvCopyKeys, kvCutoverKeys } from "./keys.js";
6
- import { copyIntoSqlEffect, nativeSqlStorage } from "./sql-native.js";
7
- import { nativeSqlDriver } from "./sql-driver.js";
8
- import { d1Driver } from "./d1-driver.js";
9
- const leaf = (operation) => Effect.tryPromise({ try: operation, catch: (error) => error });
10
- // A native equivalent of a Promise try/catch. Synchronous parser/validation
11
- // throws keep their original identity too; interruption is never recovered.
12
- const attempt = (effect) => Effect.result(effect.pipe(Effect.catchDefect((error) => Effect.fail(error))));
13
- /** D1 string and row limit, in UTF-8 bytes.
14
- * https://developers.cloudflare.com/d1/platform/limits/
15
- */
16
- const D1_MAX_ROW_BYTES = 2000000;
17
- /** Buffered entries, excluding the single value currently being read. */
18
- const COPY_BUFFER_BYTES = 4 * 1024 * 1024;
19
- const COPY_GROUP_KEYS = 50;
20
- const KV_LIST_LIMIT = 1000;
21
- const encoder = new TextEncoder();
22
- // SQLite record header, two TEXT serial types, and an eight-byte expiry.
23
- // Reserve the worst-case header rather than accepting a row at the boundary.
24
- const ROW_OVERHEAD_BYTES = 32;
25
- const emptyCounts = () => ({
26
- copied: 0,
27
- unchanged: 0,
28
- conflicts: 0,
29
- overwritten: 0,
30
- expired: 0,
31
- invalid: 0,
32
- verified: 0,
33
- mismatches: 0,
34
- });
35
- const OUTCOME_COUNT = {
36
- copied: "copied",
37
- unchanged: "unchanged",
38
- conflict: "conflicts",
39
- overwritten: "overwritten",
40
- };
41
- function errorClass(error) {
42
- const label = errorLabel(error);
43
- return label ? ` (${label})` : "";
44
- }
45
- const valueHash = (value) => Effect.gen(function* () {
46
- const hash = yield* leaf(() => crypto.subtle.digest("SHA-256", encoder.encode(value)));
47
- return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
48
- });
49
- /** Mark cutover before reopening traffic. The marker stays in D1 permanently. */
50
- export function markKvToD1LiveEffect(db, source) {
51
- return Effect.gen(function* () {
52
- if (typeof source !== "string" || !source || source.includes("\0")) {
53
- throw new TypeError("copyKvToD1 source must be a namespace id");
54
- }
55
- const cutover = yield* attempt(nativeSqlStorage(nativeSqlDriver(d1Driver(db))).set(kvCutoverKeys.source(source), "live"));
56
- if (cutover._tag === "Failure") {
57
- const error = cutover.failure;
58
- throw new KvToD1CopyError(`Workers KV to D1 copy stopped: recording cutover failed${errorClass(error)}`, undefined);
59
- }
60
- });
61
- }
62
- /**
63
- * Copy live KV entries verbatim while all writers and traffic are stopped.
64
- * A live D1 row is kept unless its family is explicitly allowed to overwrite.
65
- * `verify` reads hashes and absolute expiries without changing copied entries.
66
- * Results contain counts only. Resume tokens are source-bound atomic claims.
67
- */
68
- export function copyKvToD1Effect(kv, db, options) {
69
- return Effect.gen(function* () {
70
- if (!options?.source || typeof options.source !== "string" || options.source.includes("\0")) {
71
- throw new TypeError("copyKvToD1 source must be a namespace id");
72
- }
73
- const maxKeys = options.maxKeys ?? 500;
74
- if (!Number.isSafeInteger(maxKeys) || maxKeys < 1) {
75
- throw new TypeError("copyKvToD1 maxKeys must be a positive integer");
76
- }
77
- const allowed = new Set([...KEY_FAMILIES.map((family) => family.name), "connector-owned", "unclassified"]);
78
- if (options.overwriteFamilies?.some((family) => !allowed.has(family))) {
79
- throw new TypeError("copyKvToD1 overwriteFamilies contains an unknown family");
80
- }
81
- const overwrite = new Set(options.overwriteFamilies ?? []);
82
- const driver = nativeSqlDriver(d1Driver(db));
83
- const storage = nativeSqlStorage(driver);
84
- const remember = (kvCursor) => Effect.gen(function* () {
85
- const token = crypto.randomUUID();
86
- yield* storage.set(kvCopyKeys.cursor(token), JSON.stringify({ source: options.source, cursor: kvCursor }), {
87
- ttlSeconds: KV_COPY_CURSOR_TTL_SECONDS,
88
- });
89
- return token;
90
- });
91
- let cursor;
92
- let pageCursor;
93
- const families = {};
94
- const count = (key, field) => {
95
- (families[familyOfKey(key)] ??= emptyCounts())[field] += 1;
96
- };
97
- const unknown = new TypeError("copyKvToD1 cursor is unknown, expired, spent, or belongs to another source; restart under maintenance");
98
- const stale = new TypeError("copyKvToD1 refuses stale KV after cutover");
99
- // Validate before any D1 access so a failing binding cannot return raw input
100
- // as an error's resume point.
101
- if (options.cursor !== undefined &&
102
- !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(options.cursor)) {
103
- throw unknown;
104
- }
105
- const failures = new WeakSet();
106
- const failure = (message) => {
107
- const error = new KvToD1CopyError(message, undefined);
108
- failures.add(error);
109
- return error;
110
- };
111
- const claim = yield* attempt(Effect.gen(function* () {
112
- if (!options.allowStale && (yield* storage.get(kvCutoverKeys.source(options.source))) !== null) {
113
- throw stale;
114
- }
115
- if (options.cursor !== undefined) {
116
- const key = kvCopyKeys.cursor(options.cursor);
117
- const stored = yield* storage.get(key);
118
- let record = null;
119
- try {
120
- record = stored === null ? null : JSON.parse(stored);
121
- }
122
- catch {
123
- /* refused below */
124
- }
125
- if (!record || record.source !== options.source || typeof record.cursor !== "string") {
126
- throw unknown;
127
- }
128
- // Delete only the exact record read: concurrent consumers cannot both win.
129
- if (!(yield* storage.compareAndSet(key, stored, null)))
130
- throw unknown;
131
- cursor = record.cursor;
132
- }
133
- }));
134
- if (claim._tag === "Failure") {
135
- const error = claim.failure;
136
- if (error === unknown || error === stale)
137
- throw error;
138
- throw new KvToD1CopyError(`Workers KV to D1 copy stopped: claiming the resume point in D1 failed${errorClass(error)}`, options.cursor);
139
- }
140
- const flush = (buffer) => Effect.gen(function* () {
141
- const entries = buffer.filter((entry) => {
142
- if (entry.expiresAtMs !== null && entry.expiresAtMs <= Date.now()) {
143
- count(entry.key, "expired");
144
- return false;
145
- }
146
- return true;
147
- });
148
- if (!entries.length)
149
- return;
150
- const flushed = yield* attempt(Effect.gen(function* () {
151
- if (options.verify) {
152
- for (const entry of entries) {
153
- const [row] = yield* driver.all({
154
- // Read bytes, including NUL, and hash in JS. D1 has no SHA-256 function.
155
- sql: "SELECT CAST(value AS BLOB) AS value_bytes, expires_at_ms FROM connecta_kv WHERE key = ?",
156
- params: [entry.key],
157
- });
158
- const bytes = row ? Uint8Array.from(row.value_bytes) : undefined;
159
- const hash = bytes ? yield* leaf(() => crypto.subtle.digest("SHA-256", bytes)) : undefined;
160
- const hex = hash
161
- ? Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("")
162
- : undefined;
163
- count(entry.key, row && row.expires_at_ms === entry.expiresAtMs && hex === (yield* valueHash(entry.value))
164
- ? "verified"
165
- : "mismatches");
166
- }
167
- }
168
- else {
169
- // Partition only this bounded buffer by overwrite permission.
170
- for (const replace of [false, true]) {
171
- const group = entries.filter((entry) => overwrite.has(familyOfKey(entry.key)) === replace);
172
- if (!group.length)
173
- continue;
174
- const outcomes = yield* copyIntoSqlEffect(driver, group, { overwrite: replace, now: Date.now() });
175
- for (const [index, outcome] of outcomes.entries())
176
- count(group[index].key, OUTCOME_COUNT[outcome]);
177
- }
178
- }
179
- }));
180
- if (flushed._tag === "Failure") {
181
- const error = flushed.failure;
182
- throw failure(`Workers KV to D1 copy stopped: ${options.verify ? "verifying" : "writing to"} D1 failed${errorClass(error)}`);
183
- }
184
- });
185
- let read = 0;
186
- const copied = yield* attempt(Effect.gen(function* () {
187
- for (;;) {
188
- pageCursor = cursor;
189
- const listed = yield* attempt(leaf(() => kv.list({ limit: Math.min(KV_LIST_LIMIT, maxKeys - read), ...(cursor ? { cursor } : {}) })));
190
- if (listed._tag === "Failure") {
191
- const error = listed.failure;
192
- throw failure(`Workers KV to D1 copy stopped: listing Workers KV failed${errorClass(error)}`);
193
- }
194
- const page = listed.success;
195
- read += page.keys.length;
196
- let entries = [];
197
- let bufferedBytes = 0;
198
- for (const key of page.keys) {
199
- const keyBytes = encoder.encode(key.name).byteLength;
200
- if (key.name.includes("\0") || keyBytes + ROW_OVERHEAD_BYTES > D1_MAX_ROW_BYTES) {
201
- count(key.name, "invalid");
202
- continue;
203
- }
204
- const expiresAtMs = key.expiration === undefined ? null : key.expiration * 1000;
205
- if (expiresAtMs !== null && expiresAtMs <= Date.now()) {
206
- count(key.name, "expired");
207
- continue;
208
- }
209
- const readValue = yield* attempt(leaf(() => kv.get(key.name, "text")));
210
- if (readValue._tag === "Failure") {
211
- const error = readValue.failure;
212
- throw failure(`Workers KV to D1 copy stopped: reading an entry of the ${familyOfKey(key.name)} family from Workers KV failed${errorClass(error)}`);
213
- }
214
- const value = readValue.success;
215
- if (value === null || (expiresAtMs !== null && expiresAtMs <= Date.now())) {
216
- count(key.name, "expired");
217
- continue;
218
- }
219
- const valueBytes = encoder.encode(value).byteLength;
220
- const bytes = keyBytes + valueBytes + ROW_OVERHEAD_BYTES;
221
- if (valueBytes > D1_MAX_ROW_BYTES || bytes > D1_MAX_ROW_BYTES) {
222
- count(key.name, "invalid");
223
- continue;
224
- }
225
- if (entries.length >= COPY_GROUP_KEYS || bufferedBytes + bytes > COPY_BUFFER_BYTES) {
226
- yield* flush(entries);
227
- entries = [];
228
- bufferedBytes = 0;
229
- }
230
- entries.push({ key: key.name, value, expiresAtMs });
231
- bufferedBytes += bytes;
232
- }
233
- yield* flush(entries);
234
- if (page.list_complete || !page.cursor)
235
- return { done: true, families };
236
- cursor = page.cursor;
237
- if (read >= maxKeys)
238
- return { done: false, cursor: yield* remember(cursor), families };
239
- }
240
- }));
241
- if (copied._tag === "Failure") {
242
- const error = copied.failure;
243
- // The old token was claimed atomically. Replace it on failure, if D1 works.
244
- const resume = pageCursor === undefined
245
- ? undefined
246
- : yield* attempt(remember(pageCursor)).pipe(Effect.map((result) => (result._tag === "Success" ? result.success : undefined)));
247
- throw new KvToD1CopyError(error instanceof KvToD1CopyError && failures.has(error)
248
- ? error.message
249
- : `Workers KV to D1 copy stopped: recording the resume point in D1 failed${errorClass(error)}`, resume);
250
- }
251
- else {
252
- return copied.success;
253
- }
254
- });
255
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * The key-value table. Its shape is the one the 0.28 Worker example's
3
- * `d1-storage.ts` documented, so a D1 database that already holds it is read
4
- * as is.
5
- */
6
- export declare const KV_SCHEMA: readonly string[];
@@ -1,26 +0,0 @@
1
- // Bindings for the one-shot Workers KV → D1 copy in scripts/copy-kv-to-d1.mjs.
2
- // Never deployed: `wrangler deploy` reads wrangler.jsonc, which binds no KV.
3
- // Both bindings are remote, so the script reads the real namespace and writes
4
- // the real database through your `wrangler login` session. Fill in the ids,
5
- // stop traffic/writers, wait for stable KV, copy and verify before deploying,
6
- // then mark cutover before reopening traffic. README.md § "Upgrading from
7
- // 0.28" has the steps.
8
- {
9
- "name": "connecta-kv-to-d1",
10
- "compatibility_date": "2025-01-01",
11
- "kv_namespaces": [
12
- {
13
- "binding": "CONNECTA_KV",
14
- "id": "replace-with-kv-namespace-id",
15
- "remote": true,
16
- },
17
- ],
18
- "d1_databases": [
19
- {
20
- "binding": "CONNECTA_DB",
21
- "database_name": "connecta",
22
- "database_id": "replace-with-d1-database-id",
23
- "remote": true,
24
- },
25
- ],
26
- }
@@ -1,162 +0,0 @@
1
- #!/usr/bin/env node
2
- // Run only during the maintenance-window cutover. See README "Upgrading from 0.28".
3
-
4
- export const HELP = `Usage: node scripts/copy-kv-to-d1.mjs [config] --maintenance [options]
5
- Config defaults to kv-to-d1.wrangler.jsonc; source is its CONNECTA_KV namespace id.
6
- 1. Bulk-export KV and export D1 with wrangler d1 export. Preserve the credentials key.
7
- 2. Block traffic and all background writers, then drain in-flight requests.
8
- 3. Wait at least 60 seconds, or the longest configured KV cacheTtl, whichever is
9
- greater. Propagation can take longer. Two consecutive listing/hash passes
10
- must match; this script checks them before copying or verifying.
11
- 4. Copy BEFORE switching traffic. Resolve invalid/conflict counts under maintenance.
12
- --overwrite-family <family> may be repeated; there is no blanket overwrite.
13
- Token/OAuth/vault families also require --confirm-stale-d1 after the operator
14
- confirms that D1's conflicting rows are stale. Run --verify for exact value
15
- hashes and expiries; it reports mismatch counts by family, never payloads.
16
- 5. Deploy D1 config under maintenance. Run doctor and check an existing OAuth
17
- connector, cta_ token, vault credential, and visible activity. Set
18
- CONNECTA_ACTIVITY="on"; config lives in src/connecta.config.ts.
19
- 6. Run --mark-live before reopening traffic. It permanently seals this source
20
- in D1. Never rerun afterward. --i-know-this-is-stale overrides the seal only
21
- after assessing stale KV state, with traffic and writers stopped again.
22
- Keep backups before deleting KV.
23
- Options: --verify, --mark-live, --overwrite-family <family>, --confirm-stale-d1,
24
- --i-know-this-is-stale, --maintenance, --help`;
25
-
26
- export class MigrationUsageError extends Error {}
27
-
28
- export function parseArgs(args) {
29
- const options = { configPath: "kv-to-d1.wrangler.jsonc", overwriteFamilies: [] };
30
- let hasConfig = false;
31
- for (let i = 0; i < args.length; i++) {
32
- const arg = args[i];
33
- if (arg === "--overwrite-family") {
34
- const family = args[++i];
35
- if (!family || family.startsWith("--")) throw new MigrationUsageError("--overwrite-family requires a family");
36
- options.overwriteFamilies.push(family);
37
- } else if (arg === "--maintenance") options.maintenance = true;
38
- else if (arg === "--verify") options.verify = true;
39
- else if (arg === "--mark-live") options.markLive = true;
40
- else if (arg === "--confirm-stale-d1") options.confirmStale = true;
41
- else if (arg === "--i-know-this-is-stale") options.allowStale = true;
42
- else if (arg === "--help") options.help = true;
43
- else if (!arg.startsWith("--") && !hasConfig) {
44
- options.configPath = arg;
45
- hasConfig = true;
46
- } else throw new MigrationUsageError("Unknown argument; use --help");
47
- }
48
- if (options.help) return options;
49
- if (!options.maintenance)
50
- throw new MigrationUsageError(
51
- "Traffic and writers must be stopped; pass --maintenance after draining and waiting for KV propagation",
52
- );
53
- if (
54
- options.overwriteFamilies.some(
55
- (family) => family === "access-token" || family === "credential" || family.startsWith("oauth"),
56
- ) &&
57
- !options.confirmStale
58
- ) {
59
- throw new MigrationUsageError(
60
- "Token/OAuth/vault overwrite requires --confirm-stale-d1 after confirming D1 rows are stale",
61
- );
62
- }
63
- if ((options.verify || options.markLive) && options.overwriteFamilies.length)
64
- throw new MigrationUsageError("Verification and cutover marking cannot overwrite families");
65
- if (options.verify && options.markLive)
66
- throw new MigrationUsageError("Verify before marking cutover in a separate command");
67
- return options;
68
- }
69
-
70
- const encode = new TextEncoder();
71
- const hash = async (value) => {
72
- const bytes = await crypto.subtle.digest("SHA-256", encode.encode(value));
73
- return Array.from(new Uint8Array(bytes), (n) => n.toString(16).padStart(2, "0")).join("");
74
- };
75
-
76
- // Keep only entry digests. Sorting makes the comparison independent of list order.
77
- async function sourceHash(kv) {
78
- const digests = [];
79
- let cursor;
80
- do {
81
- const page = await kv.list({ limit: 1000, ...(cursor ? { cursor } : {}) });
82
- for (const key of page.keys) {
83
- if (key.expiration !== undefined && key.expiration * 1000 <= Date.now()) continue;
84
- const value = await kv.get(key.name, "text");
85
- if (value !== null) digests.push(await hash(JSON.stringify([key.name, key.expiration ?? null, value])));
86
- }
87
- cursor = page.list_complete ? undefined : page.cursor;
88
- } while (cursor);
89
- return hash(digests.sort().join(""));
90
- }
91
-
92
- /** Injected operations let the Workers suite exercise the real migration flow. */
93
- export async function runMigration(kv, db, options, operations, output = console) {
94
- if (!options.maintenance) throw new MigrationUsageError("Migration requires maintenance");
95
- if (options.markLive) {
96
- await operations.markKvToD1Live(db, options.source);
97
- output.log("Cutover sealed in D1. Reopen traffic after deployment verification; never copy stale KV afterward.");
98
- return 0;
99
- }
100
- if ((await sourceHash(kv)) !== (await sourceHash(kv))) {
101
- throw new MigrationUsageError("Workers KV source is not stable; keep maintenance active, wait, and repeat");
102
- }
103
- const families = {};
104
- let cursor;
105
- do {
106
- const result = await operations.copyKvToD1(kv, db, {
107
- source: options.source,
108
- cursor,
109
- overwriteFamilies: options.overwriteFamilies,
110
- verify: options.verify,
111
- allowStale: options.allowStale,
112
- });
113
- for (const [family, counts] of Object.entries(result.families)) {
114
- const into = (families[family] ??= {});
115
- for (const [field, n] of Object.entries(counts)) into[field] = (into[field] ?? 0) + n;
116
- }
117
- cursor = result.cursor;
118
- } while (cursor);
119
- output.table(families);
120
- if (Object.values(families).some((counts) => counts.invalid > 0 || counts.conflicts > 0 || counts.mismatches > 0)) {
121
- output.error(
122
- "Invalid entries, conflicts, or verification mismatches remain. Keep maintenance active and resolve the per-family counts.",
123
- );
124
- return 1;
125
- }
126
- return 0;
127
- }
128
-
129
- async function main() {
130
- let proxy;
131
- let operations;
132
- try {
133
- const options = parseArgs(process.argv.slice(2));
134
- if (options.help) {
135
- console.log(HELP);
136
- return;
137
- }
138
- const wranglerModule = "wrangler";
139
- const d1Module = "@zackbart/connecta/d1";
140
- const { getPlatformProxy, unstable_readConfig } = await import(/* @vite-ignore */ wranglerModule);
141
- operations = await import(/* @vite-ignore */ d1Module);
142
- const config = unstable_readConfig({ config: options.configPath });
143
- const source = config.kv_namespaces?.find((binding) => binding.binding === "CONNECTA_KV")?.id;
144
- if (!source || source.startsWith("replace-"))
145
- throw new MigrationUsageError("Configure the CONNECTA_KV namespace id before copying");
146
- proxy = await getPlatformProxy({ configPath: options.configPath, persist: false });
147
- const { CONNECTA_KV: kv, CONNECTA_DB: db } = proxy.env;
148
- if (!kv || !db) throw new MigrationUsageError("Copy config must bind CONNECTA_KV and CONNECTA_DB");
149
- process.exitCode = await runMigration(kv, db, { ...options, source }, operations);
150
- } catch (error) {
151
- console.error(
152
- error instanceof MigrationUsageError || (operations && error instanceof operations.KvToD1CopyError)
153
- ? error.message
154
- : "Workers KV to D1 migration stopped; keep maintenance active and inspect the configuration",
155
- );
156
- process.exitCode = 1;
157
- } finally {
158
- await proxy?.dispose();
159
- }
160
- }
161
-
162
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv?.[1]}`) await main();