@zackbart/connecta 0.28.1 → 0.30.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 (332) hide show
  1. package/CHANGELOG.md +855 -5
  2. package/README.md +125 -116
  3. package/bin/connecta.mjs +121 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/argument-redaction.js +230 -0
  12. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  13. package/dist/auth/clerk-sdk/client.js +5 -0
  14. package/dist/auth/clerk-sdk/generated.js +1845 -0
  15. package/dist/auth/clerk-transport.js +19 -0
  16. package/dist/auth/clerk.d.ts +17 -7
  17. package/dist/auth/clerk.js +189 -107
  18. package/dist/auth/cloudflare-access.js +25 -26
  19. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  20. package/dist/auth/downstream-client-metadata.js +133 -0
  21. package/dist/auth/downstream-oauth.js +1584 -1580
  22. package/dist/auth/downstream-revocation.js +106 -0
  23. package/dist/auth/oauth-token-response.js +231 -0
  24. package/dist/auth/oauth-v2-migration.js +116 -0
  25. package/dist/auth/static-oauth.js +93 -71
  26. package/dist/auth-elicitation.js +288 -0
  27. package/dist/authorization-handoff.js +111 -0
  28. package/dist/branding.d.ts +8 -16
  29. package/dist/branding.js +26 -59
  30. package/dist/byte-read-response.js +39 -0
  31. package/dist/call-admission.js +12 -16
  32. package/dist/catalog-cache.js +739 -0
  33. package/dist/catalog-drift.d.ts +50 -15
  34. package/dist/catalog-drift.js +365 -128
  35. package/dist/catalog-fingerprint.js +2 -4
  36. package/dist/catalog-freshness.js +13 -0
  37. package/dist/catalog-limits.js +2 -2
  38. package/dist/catalog-service.js +409 -225
  39. package/dist/catalog.js +43 -84
  40. package/dist/concurrency.js +3 -4
  41. package/dist/config-defaults.d.ts +39 -0
  42. package/dist/config-defaults.js +46 -0
  43. package/dist/config-schema.d.ts +181 -0
  44. package/dist/config-schema.js +435 -0
  45. package/dist/config-value-sources.js +102 -0
  46. package/dist/config.d.ts +633 -0
  47. package/dist/config.js +508 -0
  48. package/dist/connector-access.d.ts +1 -0
  49. package/dist/connector-access.js +13 -15
  50. package/dist/connector-caller.d.ts +8 -0
  51. package/dist/connector-caller.js +15 -6
  52. package/dist/connector-scope.js +1 -1
  53. package/dist/connectors/api-connector.d.ts +59 -7
  54. package/dist/connectors/api-connector.js +106 -19
  55. package/dist/connectors/api.js +4 -1
  56. package/dist/connectors/guarded-fetch.d.ts +144 -0
  57. package/dist/connectors/guarded-fetch.js +87 -38
  58. package/dist/connectors/negotiation-cache.js +46 -0
  59. package/dist/connectors/option-shapes.js +27 -0
  60. package/dist/connectors/remote-mcp.d.ts +30 -4
  61. package/dist/connectors/remote-mcp.js +1774 -320
  62. package/dist/connectors/resource-uri.js +335 -0
  63. package/dist/credential-contract.d.ts +10 -0
  64. package/dist/credential-rules.js +1 -3
  65. package/dist/credential-url.js +108 -0
  66. package/dist/credentials.d.ts +6 -1
  67. package/dist/credentials.js +27 -18
  68. package/dist/d1.d.ts +130 -0
  69. package/dist/d1.js +290 -0
  70. package/dist/describe-config.d.ts +165 -0
  71. package/dist/describe-config.js +315 -0
  72. package/dist/described.js +106 -0
  73. package/dist/downstream-input-context.js +128 -0
  74. package/dist/downstream-input.js +325 -0
  75. package/dist/errors.d.ts +132 -31
  76. package/dist/errors.js +175 -87
  77. package/dist/execute.js +798 -318
  78. package/dist/executor-admission.d.ts +1 -0
  79. package/dist/executor-admission.js +11 -15
  80. package/dist/executor-contract.d.ts +20 -0
  81. package/dist/executor-contract.js +48 -0
  82. package/dist/executor-result.js +30 -13
  83. package/dist/executors/quickjs-child.js +15 -9
  84. package/dist/executors/quickjs-protocol.js +5 -6
  85. package/dist/executors/quickjs-runtime.js +152 -59
  86. package/dist/executors/quickjs.js +49 -39
  87. package/dist/favicon.js +1 -2
  88. package/dist/fix-prompt.js +1 -3
  89. package/dist/guest-runtime.js +238 -0
  90. package/dist/guest-types.d.ts +218 -0
  91. package/dist/guest-types.js +1 -0
  92. package/dist/html-security.js +24 -0
  93. package/dist/identity.js +1 -3
  94. package/dist/inbound-credential.js +52 -0
  95. package/dist/index.d.ts +24 -256
  96. package/dist/index.js +65 -478
  97. package/dist/invocation-auth.js +69 -0
  98. package/dist/invocation.js +262 -79
  99. package/dist/json-schema.d.ts +1 -1
  100. package/dist/mcp-client-context.js +35 -0
  101. package/dist/mcp-result.js +11 -7
  102. package/dist/meta-output.js +61 -0
  103. package/dist/meta-tool-names.d.ts +2 -0
  104. package/dist/meta-tool-names.js +3 -0
  105. package/dist/meta-tools.js +419 -272
  106. package/dist/module-contracts.d.ts +8 -23
  107. package/dist/node.d.ts +0 -2
  108. package/dist/node.js +2 -2
  109. package/dist/oauth-callback-outcome.js +1 -3
  110. package/dist/oauth-handoff.js +194 -0
  111. package/dist/oauth-sealing.js +20 -0
  112. package/dist/operator-record.d.ts +137 -0
  113. package/dist/operator-record.js +420 -0
  114. package/dist/operator-ui/assets.js +31 -0
  115. package/dist/operator-ui/contract.d.ts +72 -0
  116. package/dist/operator-ui/contract.js +1 -0
  117. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  118. package/dist/operator-ui/generated.js +5 -4
  119. package/dist/operator-ui/model.d.ts +12 -87
  120. package/dist/operator-ui/model.js +2 -9
  121. package/dist/operator-ui/setup-commands.js +1 -3
  122. package/dist/operator-ui/view.d.ts +323 -0
  123. package/dist/operator-ui/view.js +62 -189
  124. package/dist/page-styles.js +2 -3
  125. package/dist/program-result.js +5 -0
  126. package/dist/program-source.js +14 -2
  127. package/dist/{exempt-writes.js → program-writes.js} +60 -19
  128. package/dist/provider.d.ts +142 -0
  129. package/dist/provider.js +187 -0
  130. package/dist/providers/_shared/google/result-size.js +57 -0
  131. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  132. package/dist/providers/_shared/google/service-account.js +500 -0
  133. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  134. package/dist/providers/_shared/google/workspace.js +518 -0
  135. package/dist/providers/_shared/request-token.js +83 -0
  136. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  137. package/dist/providers/basecamp/index.js +997 -0
  138. package/dist/providers/basecamp/skill.generated.js +9 -0
  139. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  140. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  141. package/dist/providers/breeze/skill.generated.js +9 -0
  142. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  143. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  144. package/dist/providers/ccb/skill.generated.js +9 -0
  145. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  146. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  147. package/dist/providers/cloudflare/skill.generated.js +10 -0
  148. package/dist/providers/docs/index.d.ts +54 -0
  149. package/dist/providers/docs/index.js +1237 -0
  150. package/dist/providers/docs/skill.generated.js +9 -0
  151. package/dist/providers/drive/index.d.ts +53 -0
  152. package/dist/providers/drive/index.js +1523 -0
  153. package/dist/providers/drive/skill.generated.js +9 -0
  154. package/dist/providers/forms/index.d.ts +54 -0
  155. package/dist/providers/forms/index.js +1217 -0
  156. package/dist/providers/forms/skill.generated.js +11 -0
  157. package/dist/providers/github/auth.d.ts +32 -0
  158. package/dist/providers/github/auth.js +361 -0
  159. package/dist/providers/github/hosted.js +218 -0
  160. package/dist/providers/github/index.d.ts +13 -0
  161. package/dist/providers/github/index.js +140 -0
  162. package/dist/providers/github/key.js +100 -0
  163. package/dist/providers/github/rest.js +321 -0
  164. package/dist/providers/github/scope.d.ts +29 -0
  165. package/dist/providers/github/scope.js +124 -0
  166. package/dist/providers/github/skill.generated.js +8 -0
  167. package/dist/providers/gmail/index.d.ts +51 -0
  168. package/dist/providers/gmail/index.js +1436 -0
  169. package/dist/providers/gmail/skill.generated.js +9 -0
  170. package/dist/providers/infisical/index.d.ts +7 -0
  171. package/dist/providers/infisical/index.js +713 -0
  172. package/dist/providers/infisical/skill.generated.js +8 -0
  173. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  174. package/dist/providers/linear/index.js +352 -0
  175. package/dist/providers/linear/skill.generated.js +8 -0
  176. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  177. package/dist/providers/mixpanel/index.js +416 -0
  178. package/dist/providers/mixpanel/skill.generated.js +9 -0
  179. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  180. package/dist/providers/{notion.js → notion/index.js} +226 -239
  181. package/dist/providers/notion/skill.generated.js +10 -0
  182. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  183. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  184. package/dist/providers/overflow/skill.generated.js +8 -0
  185. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  186. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  187. package/dist/providers/planning-center/skill.generated.js +10 -0
  188. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  189. package/dist/providers/revenuecat/index.js +557 -0
  190. package/dist/providers/revenuecat/skill.generated.js +11 -0
  191. package/dist/providers/sheets/index.d.ts +54 -0
  192. package/dist/providers/sheets/index.js +1237 -0
  193. package/dist/providers/sheets/skill.generated.js +10 -0
  194. package/dist/providers/slides/index.d.ts +53 -0
  195. package/dist/providers/slides/index.js +2645 -0
  196. package/dist/providers/slides/skill.generated.js +9 -0
  197. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  198. package/dist/providers/stripe/index.js +266 -0
  199. package/dist/providers/stripe/skill.generated.js +11 -0
  200. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  201. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  202. package/dist/providers/tithely/skill.generated.js +8 -0
  203. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  204. package/dist/providers/vercel/index.js +1038 -0
  205. package/dist/providers/vercel/mcp-ownership.js +212 -0
  206. package/dist/providers/vercel/skill.generated.js +11 -0
  207. package/dist/registry.d.ts +110 -171
  208. package/dist/registry.js +588 -927
  209. package/dist/request-state.js +21 -0
  210. package/dist/resource-read.js +103 -0
  211. package/dist/result-shapes.js +17 -49
  212. package/dist/retired-toolkits.js +1 -1
  213. package/dist/routes/access-tokens.js +3 -6
  214. package/dist/routes/activity-context.js +21 -0
  215. package/dist/routes/activity-disclosure.js +40 -0
  216. package/dist/routes/activity.js +59 -21
  217. package/dist/routes/actor-labels.js +3 -50
  218. package/dist/routes/connect.js +221 -0
  219. package/dist/routes/credentials.js +15 -27
  220. package/dist/routes/mcp.js +365 -130
  221. package/dist/routes/oauth-client-metadata.js +15 -0
  222. package/dist/routes/oauth-management.js +45 -107
  223. package/dist/routes/oauth.js +110 -57
  224. package/dist/routes/operator.js +3 -3
  225. package/dist/routes/shared.d.ts +25 -64
  226. package/dist/routes/shared.js +160 -80
  227. package/dist/routes/skills.js +30 -0
  228. package/dist/routes/ui-config.js +225 -0
  229. package/dist/routes/ui-data.js +40 -35
  230. package/dist/routes/ui.js +66 -56
  231. package/dist/runtime/admission.js +4 -2
  232. package/dist/runtime/call-admission.js +3 -1
  233. package/dist/runtime/connector-scope.js +1 -4
  234. package/dist/runtime/run.js +10 -39
  235. package/dist/runtime/services.js +12 -117
  236. package/dist/runtime/shared-read.js +1 -3
  237. package/dist/runtime/storage.js +8 -1
  238. package/dist/sent-secrets.d.ts +93 -0
  239. package/dist/sent-secrets.js +1013 -0
  240. package/dist/server.js +28 -71
  241. package/dist/skills.js +416 -174
  242. package/dist/sqlite.d.ts +48 -0
  243. package/dist/sqlite.js +181 -0
  244. package/dist/storage/keys.js +443 -0
  245. package/dist/storage/memory.js +16 -4
  246. package/dist/storage/sql.d.ts +66 -0
  247. package/dist/storage/sql.js +543 -0
  248. package/dist/timeout.js +4 -2
  249. package/dist/tool-name.js +17 -0
  250. package/dist/tool-safety.d.ts +9 -35
  251. package/dist/tool-safety.js +20 -40
  252. package/dist/types.d.ts +336 -56
  253. package/dist/types.js +0 -1
  254. package/dist/ui.d.ts +7 -19
  255. package/dist/ui.js +76 -90
  256. package/dist/url-safety.js +3 -6
  257. package/dist/usage-guide.js +146 -0
  258. package/dist/validate.d.ts +7 -0
  259. package/dist/validate.js +309 -47
  260. package/dist/version.d.ts +1 -7
  261. package/dist/version.js +3 -7
  262. package/dist/worker.d.ts +11 -0
  263. package/dist/worker.js +435 -0
  264. package/documentation/README.md +63 -0
  265. package/documentation/architecture.md +696 -231
  266. package/documentation/auth.md +1069 -546
  267. package/documentation/code-mode.md +662 -356
  268. package/documentation/deploying.md +146 -0
  269. package/documentation/integrating.md +159 -0
  270. package/documentation/meta-tools.md +570 -216
  271. package/documentation/operating.md +135 -0
  272. package/documentation/operator-tests.md +97 -0
  273. package/documentation/operator-ui.md +132 -0
  274. package/documentation/provider-migration-0.29.md +65 -0
  275. package/examples/worker/AGENTS.md +34 -12
  276. package/examples/worker/README.md +293 -255
  277. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  278. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  279. package/examples/worker/src/connecta.config.ts +151 -0
  280. package/examples/worker/src/index.ts +14 -197
  281. package/examples/worker/wrangler.jsonc +15 -35
  282. package/package.json +126 -54
  283. package/templates/node/.dockerignore +1 -2
  284. package/templates/node/.env.example +20 -21
  285. package/templates/node/AGENTS.md +21 -17
  286. package/templates/node/Dockerfile +3 -5
  287. package/templates/node/README.md +140 -58
  288. package/templates/node/docker-compose.yml +11 -12
  289. package/templates/node/package.json +4 -2
  290. package/templates/node/src/connecta.config.ts +119 -0
  291. package/templates/node/src/index.ts +9 -154
  292. package/templates/node/src/provision-token.ts +17 -0
  293. package/AGENTS.md +0 -170
  294. package/dist/artifacts/connector.d.ts +0 -58
  295. package/dist/artifacts/connector.js +0 -730
  296. package/dist/artifacts/document.js +0 -86
  297. package/dist/artifacts/guide.js +0 -140
  298. package/dist/artifacts/html-scan.js +0 -329
  299. package/dist/artifacts/json.js +0 -78
  300. package/dist/artifacts/kv-store.d.ts +0 -20
  301. package/dist/artifacts/kv-store.js +0 -140
  302. package/dist/artifacts/markdown.js +0 -478
  303. package/dist/artifacts/module.d.ts +0 -37
  304. package/dist/artifacts/module.js +0 -66
  305. package/dist/artifacts/operations.d.ts +0 -197
  306. package/dist/artifacts/operations.js +0 -1002
  307. package/dist/artifacts/refresh.d.ts +0 -55
  308. package/dist/artifacts/refresh.js +0 -236
  309. package/dist/artifacts/routes.js +0 -206
  310. package/dist/artifacts/types.d.ts +0 -214
  311. package/dist/artifacts/types.js +0 -22
  312. package/dist/artifacts/validate.d.ts +0 -62
  313. package/dist/artifacts/validate.js +0 -651
  314. package/dist/artifacts.d.ts +0 -6
  315. package/dist/artifacts.js +0 -9
  316. package/dist/auth/bearer.d.ts +0 -11
  317. package/dist/auth/bearer.js +0 -50
  318. package/dist/providers/basecamp.js +0 -445
  319. package/dist/providers/linear.js +0 -217
  320. package/dist/providers/mixpanel.js +0 -247
  321. package/dist/providers/revenuecat.js +0 -317
  322. package/dist/providers/stripe.js +0 -253
  323. package/dist/providers/vercel.js +0 -1253
  324. package/dist/storage/file.d.ts +0 -15
  325. package/dist/storage/file.js +0 -455
  326. package/ethos.md +0 -93
  327. package/examples/worker/src/cloudflare-kv.ts +0 -43
  328. package/examples/worker/src/d1-activity-row.ts +0 -155
  329. package/examples/worker/src/d1-activity.ts +0 -147
  330. package/examples/worker/src/d1-storage.ts +0 -138
  331. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  332. package/templates/node/src/file-activity.ts +0 -182
@@ -0,0 +1,48 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { ActivityStore } from "./activity.js";
3
+ import type { KVStorage } from "./types.js";
4
+ import { type SqlActivityOptions } from "./storage/sql.js";
5
+ export type SqliteActivityOptions = SqlActivityOptions;
6
+ /** A database path, or a `DatabaseSync` that `openSqlite` (or you) opened. */
7
+ export type SqliteDatabase = string | DatabaseSync;
8
+ /**
9
+ * Open (creating if needed) a SQLite database for connecta. The directory is
10
+ * created owner-only and the file is owner-only before SQLite first writes
11
+ * it, because it holds downstream OAuth state and sealed credentials; SQLite
12
+ * gives its `-wal` and `-shm` files the database file's mode. Write-ahead
13
+ * logging lets activity reads proceed beside writes, and a busy timeout lets a
14
+ * second process on the same file wait for a lock instead of failing.
15
+ *
16
+ * `":memory:"` opens a private in-memory database, for tests.
17
+ */
18
+ export declare function openSqlite(path: string): DatabaseSync;
19
+ /**
20
+ * `KVStorage` in a SQLite file, with the atomic `compareAndSet` every
21
+ * subsystem relies on. Each write commits one row; nothing rewrites the file.
22
+ */
23
+ export declare function sqliteStorage(database: SqliteDatabase): KVStorage;
24
+ /**
25
+ * Payload-free activity history in the same SQLite file, for
26
+ * `activityHistory({ store })`. Each write prunes a bounded batch of rows
27
+ * older than `retentionDays`.
28
+ */
29
+ export declare function sqliteActivityStore(database: SqliteDatabase, options?: SqliteActivityOptions): ActivityStore;
30
+ export interface StateFileImport {
31
+ /** Entries copied into the database. */
32
+ imported: number;
33
+ /** Entries left alone because the database already held their key. */
34
+ kept: number;
35
+ /** Entries whose TTL had already passed. */
36
+ expired: number;
37
+ }
38
+ /**
39
+ * One-shot migration from the 0.28 `fileStorage` JSON state file: copy every
40
+ * live entry, with its expiry, into the database's key-value table. Run it
41
+ * once, with the old deployment stopped, before the first start on SQLite.
42
+ *
43
+ * Keys the database already holds are kept, so a second run copies nothing
44
+ * twice and never overwrites state written since. The file is read, never
45
+ * changed; delete it once the deployment is verified. A file that is not a
46
+ * state file throws before anything is written.
47
+ */
48
+ export declare function importStateFile(database: SqliteDatabase, statePath: string, now?: number): StateFileImport;
package/dist/sqlite.js ADDED
@@ -0,0 +1,181 @@
1
+ // `@zackbart/connecta/sqlite`: connecta's storage on Node, in one SQLite file.
2
+ //
3
+ // Built on `node:sqlite` (Node 22.13 or later), so there is nothing to
4
+ // install. `sqliteStorage` is the deployment's `KVStorage` and
5
+ // `sqliteActivityStore` its activity history; both create their tables on
6
+ // first use. Node-only: nothing reachable from the root entry imports this.
7
+ import { chmodSync, closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import { validateStorageKey } from "./storage/keys.js";
11
+ import { KV_SCHEMA, sqlActivityStore, sqlStorage, } from "./storage/sql.js";
12
+ /** Milliseconds a write waits for another process's write lock. */
13
+ const BUSY_TIMEOUT_MS = 5_000;
14
+ /**
15
+ * Open (creating if needed) a SQLite database for connecta. The directory is
16
+ * created owner-only and the file is owner-only before SQLite first writes
17
+ * it, because it holds downstream OAuth state and sealed credentials; SQLite
18
+ * gives its `-wal` and `-shm` files the database file's mode. Write-ahead
19
+ * logging lets activity reads proceed beside writes, and a busy timeout lets a
20
+ * second process on the same file wait for a lock instead of failing.
21
+ *
22
+ * `":memory:"` opens a private in-memory database, for tests.
23
+ */
24
+ export function openSqlite(path) {
25
+ if (path !== ":memory:") {
26
+ path = resolve(path);
27
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
28
+ closeSync(openSync(path, "a", 0o600));
29
+ try {
30
+ chmodSync(path, 0o600);
31
+ }
32
+ catch {
33
+ // Non-POSIX filesystem — leave the mode as it is.
34
+ }
35
+ }
36
+ const db = new DatabaseSync(path);
37
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
38
+ if (path !== ":memory:")
39
+ db.exec("PRAGMA journal_mode = WAL");
40
+ return db;
41
+ }
42
+ const drivers = new WeakMap();
43
+ function sqliteDriver(database) {
44
+ const db = typeof database === "string" ? openSqlite(database) : database;
45
+ const existing = drivers.get(db);
46
+ if (existing)
47
+ return existing;
48
+ // The statement set is the fixed SQL in ./sql.ts, so the cache is bounded.
49
+ const statements = new Map();
50
+ const prepare = (statement) => {
51
+ let prepared = statements.get(statement.sql);
52
+ if (!prepared) {
53
+ prepared = db.prepare(statement.sql);
54
+ statements.set(statement.sql, prepared);
55
+ }
56
+ return prepared;
57
+ };
58
+ const run = (statement) => {
59
+ return Number(prepare(statement).run(...statement.params).changes);
60
+ };
61
+ const driver = {
62
+ async all(statement) {
63
+ return prepare(statement).all(...statement.params);
64
+ },
65
+ async run(statement) {
66
+ return run(statement);
67
+ },
68
+ async batch(batch) {
69
+ // Synchronous from BEGIN to COMMIT: no other call on this connection
70
+ // can interleave, and IMMEDIATE takes the write lock up front so
71
+ // another process cannot either.
72
+ db.exec("BEGIN IMMEDIATE");
73
+ try {
74
+ const changes = batch.map(run);
75
+ db.exec("COMMIT");
76
+ return changes;
77
+ }
78
+ catch (error) {
79
+ db.exec("ROLLBACK");
80
+ throw error;
81
+ }
82
+ },
83
+ };
84
+ drivers.set(db, driver);
85
+ return driver;
86
+ }
87
+ /**
88
+ * `KVStorage` in a SQLite file, with the atomic `compareAndSet` every
89
+ * subsystem relies on. Each write commits one row; nothing rewrites the file.
90
+ */
91
+ export function sqliteStorage(database) {
92
+ return sqlStorage(sqliteDriver(database), "sqlite");
93
+ }
94
+ /**
95
+ * Payload-free activity history in the same SQLite file, for
96
+ * `activityHistory({ store })`. Each write prunes a bounded batch of rows
97
+ * older than `retentionDays`.
98
+ */
99
+ export function sqliteActivityStore(database, options) {
100
+ return sqlActivityStore(sqliteDriver(database), "sqlite", options);
101
+ }
102
+ /**
103
+ * One-shot migration from the 0.28 `fileStorage` JSON state file: copy every
104
+ * live entry, with its expiry, into the database's key-value table. Run it
105
+ * once, with the old deployment stopped, before the first start on SQLite.
106
+ *
107
+ * Keys the database already holds are kept, so a second run copies nothing
108
+ * twice and never overwrites state written since. The file is read, never
109
+ * changed; delete it once the deployment is verified. A file that is not a
110
+ * state file throws before anything is written.
111
+ */
112
+ export function importStateFile(database, statePath, now = Date.now()) {
113
+ const text = readFileSync(statePath, "utf8");
114
+ // The file holds stored state (tokens, sealed credentials), and these
115
+ // errors reach the operator's terminal, so they name the file and an entry's
116
+ // position, never a key or the parser's account, which quotes the text.
117
+ let loaded;
118
+ try {
119
+ loaded = JSON.parse(text);
120
+ }
121
+ catch {
122
+ throw new TypeError(`${statePath} is not a connecta state file: it is not valid JSON`);
123
+ }
124
+ if (loaded === null || typeof loaded !== "object" || Array.isArray(loaded)) {
125
+ throw new TypeError(`${statePath} is not a connecta state file`);
126
+ }
127
+ const entries = Object.entries(loaded);
128
+ for (const [index, [key, entry]] of entries.entries()) {
129
+ validateStorageKey(key);
130
+ const valid = entry !== null &&
131
+ typeof entry === "object" &&
132
+ typeof entry.value === "string" &&
133
+ (!("exp" in entry) || Number.isFinite(entry.exp));
134
+ if (!valid) {
135
+ throw new TypeError(`${statePath} is not a connecta state file: entry ${index + 1} has no string value`);
136
+ }
137
+ }
138
+ let db;
139
+ let transaction = false;
140
+ let step = "open the SQLite database";
141
+ const result = { imported: 0, kept: 0, expired: 0 };
142
+ try {
143
+ db = typeof database === "string" ? openSqlite(database) : database;
144
+ step = "begin the import transaction";
145
+ db.exec("BEGIN IMMEDIATE");
146
+ transaction = true;
147
+ step = "prepare the key-value table";
148
+ for (const statement of KV_SCHEMA)
149
+ db.exec(statement);
150
+ const insert = db.prepare(`INSERT INTO connecta_kv (key, value, expires_at_ms) VALUES (?, ?, ?)
151
+ ON CONFLICT (key) DO NOTHING`);
152
+ for (const [index, [key, entry]] of entries.entries()) {
153
+ step = `import entry ${index + 1} into the key-value table`;
154
+ const { value, exp } = entry;
155
+ if (exp !== undefined && exp <= now) {
156
+ result.expired += 1;
157
+ continue;
158
+ }
159
+ if (Number(insert.run(key, value, exp === undefined ? null : Math.ceil(exp)).changes) > 0) {
160
+ result.imported += 1;
161
+ }
162
+ else {
163
+ result.kept += 1;
164
+ }
165
+ }
166
+ step = "commit the import transaction";
167
+ db.exec("COMMIT");
168
+ }
169
+ catch {
170
+ if (transaction) {
171
+ try {
172
+ db.exec("ROLLBACK");
173
+ }
174
+ catch {
175
+ // Rollback errors can contain stored text too. Report the failed step.
176
+ }
177
+ }
178
+ throw new Error(`State import failed: could not ${step}`);
179
+ }
180
+ return result;
181
+ }
@@ -0,0 +1,443 @@
1
+ // Every key connecta stores, in one module.
2
+ //
3
+ // A deployment has one KVStorage (D1 on Workers, SQLite on Node, memory in
4
+ // tests), and every subsystem shares it. Sharing is safe because no two
5
+ // families claim overlapping keys, and that is only checkable if every key is
6
+ // built here: `test/storage-keys.node.test.ts` fails when another source file
7
+ // spells one of these prefixes itself.
8
+ //
9
+ // Each family records:
10
+ //
11
+ // - scope: where its keys sit. `root` keys are physical. `partition` keys sit
12
+ // under a result partition (`results:`, or `subject:<key>:` for a scoped
13
+ // subject). `connector` keys sit under a connector's namespace
14
+ // (`conn:<id>:`), which a personal registry nests again under
15
+ // `principal:<key>:`.
16
+ // - version: the layout the keys and values follow. `in: "key"` families spell
17
+ // it in the key, so a new layout can be written beside the old one and
18
+ // migrated; `in: "value"` families carry it inside each record; `untagged`
19
+ // families predate tags, and their next layout change adds one. A version
20
+ // changes with a one-shot migration or with a family declared transient.
21
+ // - codec: how a value is written. `json` values are validated by the
22
+ // subsystem that reads them; nothing here trusts stored bytes.
23
+ // - ttl: `durable` (no expiry), `fixed` seconds, or `configured` by the
24
+ // deployment. Transient families can change layout freely, because their
25
+ // data expires within one TTL of a deploy.
26
+ /** Stored as given. */
27
+ const textCodec = {
28
+ name: "text",
29
+ encode: (value) => value,
30
+ decode: (raw) => raw,
31
+ };
32
+ /** JSON. Decoding returns `unknown`: the reader validates the shape. */
33
+ export const jsonCodec = {
34
+ name: "json",
35
+ encode: (value) => JSON.stringify(value),
36
+ decode: (raw) => JSON.parse(raw),
37
+ };
38
+ /** SQL TEXT keys must round-trip on every supported Node and D1 runtime. */
39
+ export function validateStorageKey(key) {
40
+ if (key.includes("\0")) {
41
+ throw new TypeError("Storage keys and list prefixes must not contain U+0000 (NUL)");
42
+ }
43
+ return key;
44
+ }
45
+ // --- scopes -------------------------------------------------------------
46
+ /** Namespaces core hands to the subsystems below. */
47
+ export const scopes = {
48
+ /** A personal registry's partition of the root store. */
49
+ principal: (principalKey) => validateStorageKey(`principal:${principalKey}:`),
50
+ /** One connector's namespace: its `ctx.storage`. */
51
+ connector: (connectorId) => validateStorageKey(`conn:${connectorId}:`),
52
+ /** The root result partition. */
53
+ results: "results:",
54
+ /** A scoped subject's result partition. */
55
+ subject: (subjectKey) => validateStorageKey(`subject:${subjectKey}:`),
56
+ };
57
+ // --- partition families ---------------------------------------------------
58
+ /** Seconds a stashed result stays readable through paging. */
59
+ export const RESULT_TTL_SECONDS = 900;
60
+ /**
61
+ * A stashed result: chunk 0 at `result:<id>`, chunk n at `result:<id>#<n>`.
62
+ * The value is the paging envelope `src/meta-tools.ts` writes and reads.
63
+ */
64
+ export const resultKeys = {
65
+ family: {
66
+ name: "result",
67
+ scope: "partition",
68
+ prefixes: ["result:"],
69
+ version: { number: 4, in: "value" },
70
+ codec: textCodec,
71
+ ttl: { kind: "fixed", seconds: RESULT_TTL_SECONDS },
72
+ durable: false,
73
+ },
74
+ chunk: (id, index) => validateStorageKey(index === 0 ? `result:${id}` : `result:${id}#${index}`),
75
+ };
76
+ // --- root families --------------------------------------------------------
77
+ /**
78
+ * The runtime-wide result stash ledger: every live stash entry's reservation
79
+ * id, byte charge, and expiry. One record, swapped by compare-and-set, so the
80
+ * stash bounds hold across isolates and processes sharing the store.
81
+ */
82
+ export const stashLedgerKeys = {
83
+ family: {
84
+ name: "result-stash-ledger",
85
+ scope: "root",
86
+ prefixes: ["result-stash:v1:"],
87
+ version: { number: 1, in: "key" },
88
+ codec: jsonCodec,
89
+ // Every reservation is finite; the row expires with its latest charge.
90
+ ttl: { kind: "configured", by: "latest live stash reservation deadline" },
91
+ durable: false,
92
+ },
93
+ ledger: "result-stash:v1:ledger",
94
+ };
95
+ /** Storage-side proof that a stash charge's absolute deadline has passed. */
96
+ export const stashExpiryKeys = {
97
+ family: {
98
+ name: "result-stash-expiry",
99
+ scope: "root",
100
+ prefixes: ["result-stash-expiry:v1:"],
101
+ version: { number: 1, in: "key" },
102
+ codec: textCodec,
103
+ ttl: { kind: "configured", by: "probed stash charge deadline" },
104
+ durable: false,
105
+ },
106
+ // Different deadlines cannot overwrite one another's proof. Repeated probes
107
+ // of the same deadline always use the same value and absolute expiry.
108
+ deadline: (expiresAtMs) => `result-stash-expiry:v1:${expiresAtMs}`,
109
+ };
110
+ /**
111
+ * A persisted downstream catalog: the manifest at `catalog:<id>`, then its
112
+ * chunks at `catalog:<id>:chunk:<revision>:<index>`.
113
+ */
114
+ export const catalogKeys = {
115
+ family: {
116
+ name: "catalog",
117
+ scope: "root",
118
+ prefixes: ["catalog:"],
119
+ // Retired v2/v3 listings are inventoried for migration; no runtime reads them.
120
+ version: { number: 3, in: "value" },
121
+ codec: textCodec,
122
+ ttl: {
123
+ kind: "configured",
124
+ by: "retired catalog layout; expires on its original TTL",
125
+ },
126
+ durable: false,
127
+ },
128
+ manifest: (connectorId) => validateStorageKey(`catalog:${connectorId}`),
129
+ chunk: (connectorId, revision, index) => validateStorageKey(`catalog:${connectorId}:chunk:${revision}:${index}`),
130
+ };
131
+ /** SDK catalog response cache, complete manifests published after their chunks. */
132
+ export const responseCacheKeys = {
133
+ family: {
134
+ name: "response-cache",
135
+ scope: "root",
136
+ prefixes: ["response-cache:v1:"],
137
+ version: { number: 1, in: "key" },
138
+ codec: textCodec,
139
+ ttl: {
140
+ kind: "configured",
141
+ by: "bounded downstream ttlMs; generations and hash refresh baselines expire after 48 hours",
142
+ },
143
+ durable: false,
144
+ },
145
+ prefix: (id) => validateStorageKey(`response-cache:v1:${id}:`),
146
+ generation: (id) => validateStorageKey(`response-cache:v1:${id}:generation`),
147
+ namespace: (id, config, generation) => validateStorageKey(`response-cache:v1:${id}:${config}:${generation}:`),
148
+ entry: (namespace, partition) => validateStorageKey(`${namespace}${partition}`),
149
+ refreshDigest: (id, config, partition) => validateStorageKey(`response-cache:v1:${id}:refresh-digest:${config}:${partition}`),
150
+ chunk: (entry, revision, index) => validateStorageKey(`${entry}:chunk:${revision}:${index}`),
151
+ };
152
+ /** Seconds an OAuth callback's owner binding stays claimable. */
153
+ export const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
154
+ /** The principal that started an OAuth flow, by connector and state digest. */
155
+ export const oauthHandoffKeys = {
156
+ family: {
157
+ name: "oauth-handoff",
158
+ scope: "root",
159
+ prefixes: ["oauth-handoff:v1:"],
160
+ version: { number: 1, in: "key" },
161
+ codec: textCodec,
162
+ ttl: { kind: "fixed", seconds: OAUTH_HANDOFF_TTL_SECONDS },
163
+ durable: false,
164
+ },
165
+ handoff: (connectorId, stateHash) => validateStorageKey(`oauth-handoff:v1:${connectorId}:${stateHash}`),
166
+ };
167
+ /** Connecta-issued `cta_` access tokens: records, digest lookups, capacity. */
168
+ export const accessTokenKeys = {
169
+ family: {
170
+ name: "access-token",
171
+ scope: "root",
172
+ prefixes: ["access-token:v1:"],
173
+ version: { number: 1, in: "key" },
174
+ codec: jsonCodec,
175
+ ttl: { kind: "durable" },
176
+ durable: true,
177
+ },
178
+ recordPrefix: "access-token:v1:record:",
179
+ record: (id) => validateStorageKey(`access-token:v1:record:${id}`),
180
+ lookup: (tokenHash) => validateStorageKey(`access-token:v1:lookup:${tokenHash}`),
181
+ active: "access-token:v1:active",
182
+ };
183
+ /**
184
+ * A vault credential, sealed: `credential:v1` inside its connector's
185
+ * namespace, under the owner's principal partition when it is personal.
186
+ */
187
+ export const credentialKeys = {
188
+ family: {
189
+ name: "credential",
190
+ scope: "connector",
191
+ prefixes: ["credential:v1"],
192
+ version: { number: 1, in: "key" },
193
+ codec: jsonCodec,
194
+ ttl: { kind: "durable" },
195
+ durable: true,
196
+ },
197
+ credential: (connectorId, owner) => `${owner ? scopes.principal(owner) : ""}${scopes.connector(connectorId)}credential:v1`,
198
+ };
199
+ /** Seconds a Workers KV → D1 copy's resume point stays usable. */
200
+ export const KV_COPY_CURSOR_TTL_SECONDS = 7 * 24 * 60 * 60;
201
+ /**
202
+ * Where a one-shot Workers KV → D1 copy (`copyKvToD1` in `src/d1.ts`) resumes,
203
+ * held in D1 under a random token. Workers KV's own list cursor can spell the
204
+ * last key it listed, so the caller holds the token and never the cursor.
205
+ */
206
+ export const kvCopyKeys = {
207
+ family: {
208
+ name: "kv-copy",
209
+ scope: "root",
210
+ prefixes: ["kv-copy:v1:"],
211
+ version: { number: 1, in: "key" },
212
+ codec: textCodec,
213
+ ttl: { kind: "fixed", seconds: KV_COPY_CURSOR_TTL_SECONDS },
214
+ durable: false,
215
+ },
216
+ cursor: (token) => validateStorageKey(`kv-copy:v1:cursor:${token}`),
217
+ };
218
+ /** Once traffic uses D1, copying stale KV state is refused. */
219
+ export const kvCutoverKeys = {
220
+ family: {
221
+ name: "kv-cutover",
222
+ scope: "root",
223
+ prefixes: ["kv-cutover:v1:"],
224
+ version: { number: 1, in: "key" },
225
+ codec: textCodec,
226
+ ttl: { kind: "durable" },
227
+ durable: true,
228
+ },
229
+ source: (source) => validateStorageKey(`kv-cutover:v1:${encodeURIComponent(source)}`),
230
+ };
231
+ // --- connector families ---------------------------------------------------
232
+ /** Seconds a consent's flow record stays claimable, the same as its link. */
233
+ export const OAUTH_FLOW_TTL_SECONDS = 15 * 60;
234
+ /**
235
+ * Downstream OAuth's grant in a connector's namespace, layout 3: one record
236
+ * per owner at `oauth:grant`, holding the epoch, the latest consent's state
237
+ * digest, and the sealed client, tokens, and discovery. Every write is a
238
+ * compare-and-set against the exact record read, so a write a restart
239
+ * overtook cannot land.
240
+ */
241
+ export const oauthGrantKeys = {
242
+ family: {
243
+ name: "oauth-grant",
244
+ scope: "connector",
245
+ prefixes: ["oauth:grant"],
246
+ version: { number: 3, in: "value" },
247
+ codec: jsonCodec,
248
+ ttl: { kind: "durable" },
249
+ durable: true,
250
+ },
251
+ grant: "oauth:grant",
252
+ };
253
+ /**
254
+ * One consent: `oauth:flow:<sha256(state)>`, holding its epoch, consent URL,
255
+ * and sealed PKCE verifier. The callback claims it by compare-and-set before
256
+ * the code leaves, so consumption is recorded on that consent alone.
257
+ */
258
+ export const oauthFlowKeys = {
259
+ family: {
260
+ name: "oauth-flow",
261
+ scope: "connector",
262
+ prefixes: ["oauth:flow:"],
263
+ version: { number: 1, in: "value" },
264
+ codec: jsonCodec,
265
+ ttl: { kind: "fixed", seconds: OAUTH_FLOW_TTL_SECONDS },
266
+ durable: false,
267
+ },
268
+ prefix: "oauth:flow:",
269
+ flow: (stateDigest) => validateStorageKey(`oauth:flow:${stateDigest}`),
270
+ };
271
+ /** Shared-storage lifetime of a refresh holder, longer than its HTTP deadline. */
272
+ export const OAUTH_REFRESH_LEASE_SECONDS = 120;
273
+ export const oauthRefreshKeys = {
274
+ family: {
275
+ name: "oauth-refresh",
276
+ scope: "connector",
277
+ prefixes: ["oauth:refresh:"],
278
+ version: { number: 1, in: "value" },
279
+ codec: jsonCodec,
280
+ ttl: { kind: "durable" },
281
+ durable: true,
282
+ },
283
+ prefix: "oauth:refresh:",
284
+ lease: (epoch, tokenDigest) => validateStorageKey(`oauth:refresh:${epoch}:${tokenDigest}`),
285
+ };
286
+ /** One fingerprint's dispatch and resolution across epochs. Never expire or delete these records. */
287
+ export const oauthRefreshSpentKeys = {
288
+ family: {
289
+ name: "oauth-refresh-spent",
290
+ scope: "connector",
291
+ prefixes: ["oauth:refresh-spent:"],
292
+ version: { number: 1, in: "value" },
293
+ codec: jsonCodec,
294
+ ttl: { kind: "durable" },
295
+ durable: true,
296
+ },
297
+ spent: (tokenDigest) => validateStorageKey(`oauth:refresh-spent:${tokenDigest}`),
298
+ };
299
+ /** Expiry is storage-owned; dispatched fingerprints themselves never expire. */
300
+ export const oauthRefreshActiveKeys = {
301
+ family: {
302
+ name: "oauth-refresh-active",
303
+ scope: "connector",
304
+ prefixes: ["oauth:refresh-active:"],
305
+ version: { number: 1, in: "value" },
306
+ codec: jsonCodec,
307
+ ttl: { kind: "fixed", seconds: OAUTH_REFRESH_LEASE_SECONDS },
308
+ durable: false,
309
+ },
310
+ prefix: "oauth:refresh-active:",
311
+ holder: (epoch, holder) => validateStorageKey(`oauth:refresh-active:${epoch}:${holder}`),
312
+ };
313
+ /** The values layout 2 stored, each under its historical key. */
314
+ const oauthV2Field = {
315
+ client: "oauth:client",
316
+ tokens: "oauth:tokens",
317
+ pending: "oauth:pending",
318
+ verifier: "oauth:verifier",
319
+ state: "oauth:state",
320
+ discovery: "oauth:discovery",
321
+ };
322
+ /**
323
+ * Layout 2, read once to migrate it into a grant record and then deleted:
324
+ * each value at its field key, suffixed `:epoch:<generation>` once a modern
325
+ * generation owned it; the active generation at `oauth:generation`; a reset's
326
+ * cleanup lineage at `oauth:cleanup:` and `oauth:cleanup-at:`.
327
+ */
328
+ export const oauthV2Keys = {
329
+ family: {
330
+ name: "oauth-v2",
331
+ scope: "connector",
332
+ prefixes: [...Object.values(oauthV2Field), "oauth:generation", "oauth:cleanup:", "oauth:cleanup-at:"],
333
+ version: { number: 2, in: "value" },
334
+ codec: textCodec,
335
+ ttl: { kind: "durable" },
336
+ durable: true,
337
+ },
338
+ field: oauthV2Field,
339
+ /** One listing covers every layout 2 key; the reader keeps those above. */
340
+ scan: "oauth:",
341
+ /** A value's physical key; `epoch` null is the historical unsuffixed name. */
342
+ value: (key, epoch) => validateStorageKey(epoch === null ? key : `${key}:epoch:${epoch}`),
343
+ generation: "oauth:generation",
344
+ };
345
+ /** Single-use browser links (`used`/`started`/`failed`/`closed`) and MRTR rounds. */
346
+ export const oauthConnectKeys = {
347
+ family: {
348
+ name: "oauth-connect",
349
+ scope: "connector",
350
+ prefixes: ["oauth:connect-used:", "oauth:request-used:"],
351
+ version: { number: 1, in: "untagged" },
352
+ codec: textCodec,
353
+ // The link's remaining lifetime, or fifteen minutes when retiring it.
354
+ ttl: { kind: "fixed", seconds: 15 * 60 },
355
+ durable: false,
356
+ },
357
+ used: (nonce) => validateStorageKey(`oauth:connect-used:${nonce}`),
358
+ retry: (nonce) => validateStorageKey(`oauth:request-used:${nonce}`),
359
+ };
360
+ /** A bounded, credential-partitioned downstream protocol verdict. */
361
+ export const NEGOTIATION_TTL_SECONDS = 300;
362
+ export const negotiationKeys = {
363
+ family: {
364
+ name: "negotiation",
365
+ scope: "connector",
366
+ prefixes: ["negotiation:v1:"],
367
+ version: { number: 1, in: "key" },
368
+ codec: jsonCodec,
369
+ ttl: { kind: "fixed", seconds: NEGOTIATION_TTL_SECONDS },
370
+ durable: false,
371
+ },
372
+ verdict: (partitionDigest) => validateStorageKey(`negotiation:v1:${partitionDigest}`),
373
+ };
374
+ /** A downstream MRTR round is consumed once before continuation dispatch. */
375
+ export const inputRetryKeys = {
376
+ family: {
377
+ name: "downstream-input-retry",
378
+ scope: "connector",
379
+ prefixes: ["input-retry:v1:"],
380
+ version: { number: 1, in: "key" },
381
+ codec: textCodec,
382
+ ttl: { kind: "fixed", seconds: 10 * 60 },
383
+ durable: false,
384
+ },
385
+ used: (nonce) => validateStorageKey(`input-retry:v1:${nonce}`),
386
+ };
387
+ /**
388
+ * Every family, for the overlap and coverage checks and for migrations that
389
+ * copy only what a deployment cannot recreate. Keys a custom connector writes
390
+ * through its own `ctx.storage` belong to that connector, inside its
391
+ * namespace, and are not listed here.
392
+ */
393
+ export const KEY_FAMILIES = [
394
+ resultKeys.family,
395
+ stashLedgerKeys.family,
396
+ stashExpiryKeys.family,
397
+ catalogKeys.family,
398
+ responseCacheKeys.family,
399
+ oauthHandoffKeys.family,
400
+ accessTokenKeys.family,
401
+ credentialKeys.family,
402
+ oauthGrantKeys.family,
403
+ oauthFlowKeys.family,
404
+ oauthRefreshKeys.family,
405
+ oauthRefreshSpentKeys.family,
406
+ oauthRefreshActiveKeys.family,
407
+ oauthV2Keys.family,
408
+ oauthConnectKeys.family,
409
+ negotiationKeys.family,
410
+ inputRetryKeys.family,
411
+ kvCopyKeys.family,
412
+ kvCutoverKeys.family,
413
+ ];
414
+ // Principal, connector, and subject segments are hex digests and connector
415
+ // ids, so none holds a colon.
416
+ const PRINCIPAL_SCOPE = /^principal:[^:]*:/;
417
+ const CONNECTOR_SCOPE = /^conn:[^:]*:/;
418
+ const PARTITION_SCOPE = /^(?:results:|subject:[^:]*:)/;
419
+ /**
420
+ * The family a physical key belongs to, for reports that count keys without
421
+ * naming them: one-shot migrations report per family, never per key. A key
422
+ * no family claims is `connector-owned` when a custom connector wrote it
423
+ * through its own `ctx.storage`, and `unclassified` otherwise (a key an older release wrote).
424
+ */
425
+ export function familyOfKey(key) {
426
+ let rest = key.replace(PRINCIPAL_SCOPE, "");
427
+ let scope = "root";
428
+ for (const [pattern, nested] of [
429
+ [CONNECTOR_SCOPE, "connector"],
430
+ [PARTITION_SCOPE, "partition"],
431
+ ]) {
432
+ const match = pattern.exec(rest);
433
+ if (match) {
434
+ rest = rest.slice(match[0].length);
435
+ scope = nested;
436
+ break;
437
+ }
438
+ }
439
+ const family = KEY_FAMILIES.find((candidate) => candidate.scope === scope && candidate.prefixes.some((prefix) => rest.startsWith(prefix)));
440
+ if (family)
441
+ return family.name;
442
+ return scope === "connector" ? "connector-owned" : "unclassified";
443
+ }