@lanes-sh/link 0.6.10 → 0.7.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 (202) hide show
  1. package/.gcloudignore +25 -15
  2. package/README.md +62 -45
  3. package/instructions/skills/lanes-link/SKILL.md +25 -0
  4. package/package.json +3 -2
  5. package/src/audit/conformance.ts +1 -1
  6. package/src/cli/argv.ts +14 -1
  7. package/src/cli/commands/connect/acquire.ts +100 -0
  8. package/src/cli/commands/connect/custom/index.ts +1 -1
  9. package/src/cli/commands/connect/declare.ts +19 -0
  10. package/src/cli/commands/connect/index.ts +54 -56
  11. package/src/cli/commands/connect/requirements.ts +23 -1
  12. package/src/cli/commands/connect/setup.ts +1 -1
  13. package/src/cli/commands/connect/variables.ts +220 -0
  14. package/src/cli/commands/knowledge/index.ts +22 -4
  15. package/src/cli/commands/knowledge/migrate.ts +61 -43
  16. package/src/cli/commands/operate/tools.ts +1 -1
  17. package/src/cli/commands/owner/entities.ts +244 -0
  18. package/src/cli/commands/owner/shared.ts +14 -0
  19. package/src/cli/commands/owner/vault.ts +2 -2
  20. package/src/cli/commands/owner.ts +13 -3
  21. package/src/cli/commands/setup.ts +12 -0
  22. package/src/cli/config-edit.ts +16 -6
  23. package/src/cli/config-repair.ts +10 -0
  24. package/src/cli/dispatch-owner.ts +30 -1
  25. package/src/cli/main.ts +2 -1
  26. package/src/cli/runtime/open.ts +14 -7
  27. package/src/cli/runtime/registry.ts +6 -1
  28. package/src/cli/selection.ts +11 -0
  29. package/src/cli/usage.ts +13 -2
  30. package/src/connectivity/auth/strategy/index.ts +1 -1
  31. package/src/connectivity/capability.ts +1 -1
  32. package/src/connectivity/context.ts +1 -1
  33. package/src/connectivity/index.ts +1 -1
  34. package/src/connectivity/mail/attachments.ts +42 -73
  35. package/src/connectivity/mail/content-type.ts +71 -0
  36. package/src/connectivity/mail/index.ts +1 -1
  37. package/src/connectivity/manifest/provider.ts +50 -0
  38. package/src/connectivity/manifest/variables.ts +173 -0
  39. package/src/connectivity/transports/factory.ts +74 -1
  40. package/src/connectivity/transports/http/index.ts +8 -2
  41. package/src/connectivity/transports/http/keys.ts +83 -0
  42. package/src/connectivity/transports/imap/attachment.ts +53 -10
  43. package/src/connectivity/transports/imap/capabilities.ts +28 -0
  44. package/src/connectivity/transports/imap/download.ts +111 -0
  45. package/src/connectivity/transports/imap/index.ts +13 -0
  46. package/src/connectivity/transports/imap/operations.ts +1 -0
  47. package/src/deployments/adapters/s3.ts +1 -1
  48. package/src/deployments/gcp/provision.ts +1 -1
  49. package/src/deployments/knowledge.ts +44 -18
  50. package/src/deployments/prepare.ts +1 -1
  51. package/src/deployments/serving.ts +1 -1
  52. package/src/dispatch/dispatch.ts +17 -10
  53. package/src/dispatch/staging.ts +76 -1
  54. package/src/profile/knowledge.ts +19 -11
  55. package/src/profile/schema.ts +2 -2
  56. package/src/providers/README.md +189 -0
  57. package/src/providers/airtable/index.ts +10 -0
  58. package/src/providers/algolia/index.ts +10 -0
  59. package/src/providers/amplitude/index.ts +10 -0
  60. package/src/providers/apify/index.ts +10 -0
  61. package/src/providers/asana/index.ts +10 -0
  62. package/src/providers/assets/provider.ts +11 -4
  63. package/src/providers/atlassian/index.ts +19 -0
  64. package/src/providers/attio/index.ts +10 -0
  65. package/src/providers/betterstack/index.ts +10 -0
  66. package/src/providers/box/index.ts +64 -0
  67. package/src/providers/brightdata/index.ts +10 -0
  68. package/src/providers/buildkite/index.ts +10 -0
  69. package/src/providers/bunq/index.ts +2 -2
  70. package/src/providers/calendly/index.ts +10 -0
  71. package/src/providers/canva/index.ts +10 -0
  72. package/src/providers/circleci/index.ts +10 -0
  73. package/src/providers/clickup/index.ts +10 -0
  74. package/src/providers/close/index.ts +10 -0
  75. package/src/providers/cloudflare_bindings/index.ts +14 -0
  76. package/src/providers/cloudflare_observability/index.ts +10 -0
  77. package/src/providers/contentful/index.ts +10 -0
  78. package/src/providers/custom/load.ts +1 -1
  79. package/src/providers/datadog/index.ts +10 -0
  80. package/src/providers/discord/index.ts +1 -1
  81. package/src/providers/discord/specs/discord.v10.json +0 -88
  82. package/src/providers/discord/specs/vendor.ts +1 -1
  83. package/src/providers/dropbox/index.ts +10 -0
  84. package/src/providers/entities/catalogue.ts +366 -0
  85. package/src/providers/entities/find.ts +279 -0
  86. package/src/providers/entities/provider.ts +334 -0
  87. package/src/providers/entities/render.ts +142 -0
  88. package/src/providers/entities/store.ts +276 -0
  89. package/src/providers/entities/writes.ts +279 -0
  90. package/src/providers/example/provider.ts +1 -1
  91. package/src/providers/expensify/index.ts +10 -0
  92. package/src/providers/fastmail/calendar/index.ts +21 -0
  93. package/src/providers/fastmail/contacts/index.ts +17 -0
  94. package/src/providers/fastmail/index.ts +4 -0
  95. package/src/providers/fastmail/mail/index.ts +38 -0
  96. package/src/providers/fastmail/shared/setup.ts +52 -0
  97. package/src/providers/figma/index.ts +10 -0
  98. package/src/providers/fireflies/index.ts +10 -0
  99. package/src/providers/flagsmith/index.ts +10 -0
  100. package/src/providers/gamma/index.ts +10 -0
  101. package/src/providers/github/index.ts +2 -2
  102. package/src/providers/google/gmail/attachment.ts +16 -1
  103. package/src/providers/google/gmail-imap/index.ts +7 -2
  104. package/src/providers/google/shared/oauth.ts +1 -1
  105. package/src/providers/google/shared/service-account.ts +1 -1
  106. package/src/providers/google/shared/setup.ts +1 -1
  107. package/src/providers/google/specs/calendar.v3.json +0 -32
  108. package/src/providers/google/specs/docs.v1.json +0 -76
  109. package/src/providers/google/specs/drive.v3.json +0 -32
  110. package/src/providers/google/specs/gmail.v1.json +0 -76
  111. package/src/providers/google/specs/people.v1.json +0 -77
  112. package/src/providers/google/specs/sheets.v4.json +0 -76
  113. package/src/providers/google/specs/tasks.v1.json +0 -76
  114. package/src/providers/grafana/index.ts +10 -0
  115. package/src/providers/heroku/index.ts +10 -0
  116. package/src/providers/hubspot/index.ts +75 -0
  117. package/src/providers/hygraph/index.ts +10 -0
  118. package/src/providers/icloud/mail/index.ts +5 -0
  119. package/src/providers/icloud/shared/setup.ts +1 -1
  120. package/src/providers/index.ts +255 -0
  121. package/src/providers/insightly/index.ts +10 -0
  122. package/src/providers/jam/index.ts +10 -0
  123. package/src/providers/klaviyo/index.ts +10 -0
  124. package/src/providers/mailbox/index.ts +87 -0
  125. package/src/providers/memory/provider.ts +3 -8
  126. package/src/providers/mercury/index.ts +10 -0
  127. package/src/providers/microsoft/calendar/index.ts +49 -0
  128. package/src/providers/microsoft/contacts/index.ts +50 -0
  129. package/src/providers/microsoft/drive/index.ts +62 -0
  130. package/src/providers/microsoft/index.ts +12 -0
  131. package/src/providers/microsoft/mail/index.ts +76 -0
  132. package/src/providers/microsoft/shared/oauth.ts +58 -0
  133. package/src/providers/microsoft/shared/scopes.ts +56 -0
  134. package/src/providers/microsoft/shared/setup.ts +45 -0
  135. package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
  136. package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
  137. package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
  138. package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
  139. package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
  140. package/src/providers/microsoft/specs/vendor.ts +232 -0
  141. package/src/providers/microsoft/todo/index.ts +54 -0
  142. package/src/providers/miro/index.ts +10 -0
  143. package/src/providers/mixpanel/index.ts +10 -0
  144. package/src/providers/monday/index.ts +10 -0
  145. package/src/providers/mux/index.ts +10 -0
  146. package/src/providers/navan/index.ts +10 -0
  147. package/src/providers/neon/index.ts +10 -0
  148. package/src/providers/netlify/index.ts +10 -0
  149. package/src/providers/nextcloud/calendar/index.ts +28 -0
  150. package/src/providers/nextcloud/contacts/index.ts +18 -0
  151. package/src/providers/nextcloud/index.ts +3 -0
  152. package/src/providers/nextcloud/shared/setup.ts +59 -0
  153. package/src/providers/owner.ts +15 -3
  154. package/src/providers/paddle/index.ts +10 -0
  155. package/src/providers/paypal/index.ts +10 -0
  156. package/src/providers/posthog/index.ts +10 -0
  157. package/src/providers/prisma/index.ts +10 -0
  158. package/src/providers/ramp/index.ts +10 -0
  159. package/src/providers/recurly/index.ts +10 -0
  160. package/src/providers/reddit/index.ts +1 -1
  161. package/src/providers/remote/index.ts +10 -0
  162. package/src/providers/render/index.ts +35 -0
  163. package/src/providers/replicate/index.ts +10 -0
  164. package/src/providers/resend/index.ts +10 -0
  165. package/src/providers/riverside/index.ts +10 -0
  166. package/src/providers/rootly/index.ts +10 -0
  167. package/src/providers/rudderstack/index.ts +10 -0
  168. package/src/providers/salesloft/index.ts +10 -0
  169. package/src/providers/sanity/index.ts +10 -0
  170. package/src/providers/scopes.ts +2 -0
  171. package/src/providers/sentry/index.ts +10 -0
  172. package/src/providers/setup/plan.ts +11 -0
  173. package/src/providers/setup/provider.ts +12 -0
  174. package/src/providers/shared/openapi.ts +70 -0
  175. package/src/providers/shared/slug.ts +28 -0
  176. package/src/providers/shared/vendor-operations.ts +43 -1
  177. package/src/providers/shared/vendor-spec.ts +98 -17
  178. package/src/providers/shortcut/index.ts +10 -0
  179. package/src/providers/slack/index.ts +2 -2
  180. package/src/providers/square/index.ts +10 -0
  181. package/src/providers/storyblok/index.ts +10 -0
  182. package/src/providers/stripe/index.ts +10 -0
  183. package/src/providers/supabase/index.ts +10 -0
  184. package/src/providers/tasks/store.ts +2 -7
  185. package/src/providers/tavily/index.ts +10 -0
  186. package/src/providers/todoist/index.ts +10 -0
  187. package/src/providers/untested.ts +105 -0
  188. package/src/providers/vault/provider.ts +1 -1
  189. package/src/providers/vercel/index.ts +10 -0
  190. package/src/providers/vimeo/index.ts +10 -0
  191. package/src/providers/webflow/index.ts +10 -0
  192. package/src/providers/whimsical/index.ts +10 -0
  193. package/src/providers/wix/index.ts +10 -0
  194. package/src/providers/workable/index.ts +10 -0
  195. package/src/providers/yahoo_mail/index.ts +73 -0
  196. package/src/providers/zapier/index.ts +10 -0
  197. package/src/providers/zoho_mail/index.ts +79 -0
  198. package/src/secrets/document.ts +1 -1
  199. package/src/server/attachments.ts +129 -5
  200. package/src/server/index.ts +3 -3
  201. package/src/server/mcp/instructions.ts +88 -18
  202. package/src/server/stdio.ts +1 -1
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Close registers us at connect time — nothing for an operator to set up. */
4
+ export const close = defineProvider({
5
+ id: 'close',
6
+ name: 'Close',
7
+ description: 'Leads, contacts, opportunities, and activities in the CRM, via Close\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.close.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,14 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /**
4
+ * Cloudflare runs one MCP server per product area rather than one for the
5
+ * account, so this is a sibling of `cloudflare_observability` and not a
6
+ * duplicate of it — bindings and logs are separate grants.
7
+ */
8
+ export const cloudflareBindings = defineProvider({
9
+ id: 'cloudflare_bindings',
10
+ name: 'Cloudflare Bindings',
11
+ description: 'Workers KV, R2, D1, and Durable Objects, via Cloudflare\'s official MCP server.',
12
+ connector: { kind: 'mcp', endpoint: 'https://bindings.mcp.cloudflare.com/mcp' },
13
+ auth: { kind: 'oauth', registration: 'dynamic' },
14
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Cloudflare registers us at connect time — nothing for an operator to set up. */
4
+ export const cloudflareObservability = defineProvider({
5
+ id: 'cloudflare_observability',
6
+ name: 'Cloudflare Observability',
7
+ description: 'Workers logs, analytics, and traces, via Cloudflare\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://observability.mcp.cloudflare.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Contentful registers us at connect time — nothing for an operator to set up. */
4
+ export const contentful = defineProvider({
5
+ id: 'contentful',
6
+ name: 'Contentful',
7
+ description: 'Entries, assets, content types, and spaces, via Contentful\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.contentful.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -86,7 +86,7 @@ function describe(workspaceRoot: string, key: string): string {
86
86
  /**
87
87
  * Make a relative `openapi` path mean what the operator meant.
88
88
  *
89
- * `docs/detailed/creating-a-provider.md` tells people to write `openapi: ./mything.json`,
89
+ * `https://lanes.sh/docs/link/creating-a-provider` tells people to write `openapi: ./mything.json`,
90
90
  * and until now that resolved against the *process* working directory — so it
91
91
  * worked when you happened to run `lanes` from the right folder and failed
92
92
  * with a confusing ENOENT everywhere else. The built-ins never noticed because
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Datadog registers us at connect time — nothing for an operator to set up. */
4
+ export const datadog = defineProvider({
5
+ id: 'datadog',
6
+ name: 'Datadog',
7
+ description: 'Metrics, logs, monitors, incidents, and dashboards, via Datadog\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -87,7 +87,7 @@ export const discord = defineProvider({
87
87
  'their developer portal, copy its bot token, and invite it to the servers you want ' +
88
88
  'reachable. Posts will carry an APP badge; the name and avatar on them are yours to set. ' +
89
89
  'You are asked for the token once.',
90
- docs: 'docs/detailed/setup/discord.md',
90
+ docs: 'https://lanes.sh/docs/link/discord',
91
91
  docs_url: 'https://discord.com/developers/applications',
92
92
  steps: [
93
93
  'Open https://discord.com/developers/applications and choose "New Application". Name it whatever you want the posts to read as — this is the name people will see.',
@@ -2240,94 +2240,6 @@
2240
2240
  }
2241
2241
  }
2242
2242
  }
2243
- },
2244
- "responses": {
2245
- "ClientErrorResponse": {
2246
- "description": "Client error response",
2247
- "headers": {
2248
- "X-RateLimit-Limit": {
2249
- "$ref": "#/components/headers/X-RateLimit-Limit"
2250
- },
2251
- "X-RateLimit-Remaining": {
2252
- "$ref": "#/components/headers/X-RateLimit-Remaining"
2253
- },
2254
- "X-RateLimit-Reset": {
2255
- "$ref": "#/components/headers/X-RateLimit-Reset"
2256
- },
2257
- "X-RateLimit-Reset-After": {
2258
- "$ref": "#/components/headers/X-RateLimit-Reset-After"
2259
- },
2260
- "X-RateLimit-Bucket": {
2261
- "$ref": "#/components/headers/X-RateLimit-Bucket"
2262
- }
2263
- },
2264
- "content": {
2265
- "application/json": {
2266
- "schema": {
2267
- "$ref": "#/components/schemas/ErrorResponse"
2268
- }
2269
- }
2270
- }
2271
- },
2272
- "ClientRatelimitedResponse": {
2273
- "description": "Client ratelimited response",
2274
- "headers": {
2275
- "X-RateLimit-Limit": {
2276
- "$ref": "#/components/headers/X-RateLimit-Limit"
2277
- },
2278
- "X-RateLimit-Remaining": {
2279
- "$ref": "#/components/headers/X-RateLimit-Remaining"
2280
- },
2281
- "X-RateLimit-Reset": {
2282
- "$ref": "#/components/headers/X-RateLimit-Reset"
2283
- },
2284
- "X-RateLimit-Reset-After": {
2285
- "$ref": "#/components/headers/X-RateLimit-Reset-After"
2286
- },
2287
- "X-RateLimit-Bucket": {
2288
- "$ref": "#/components/headers/X-RateLimit-Bucket"
2289
- }
2290
- },
2291
- "content": {
2292
- "application/json": {
2293
- "schema": {
2294
- "$ref": "#/components/schemas/RatelimitedResponse"
2295
- }
2296
- }
2297
- }
2298
- }
2299
- },
2300
- "headers": {
2301
- "X-RateLimit-Limit": {
2302
- "schema": {
2303
- "type": "integer"
2304
- },
2305
- "description": "The maximum number of requests that can be made in the current ratelimit window"
2306
- },
2307
- "X-RateLimit-Remaining": {
2308
- "schema": {
2309
- "type": "integer"
2310
- },
2311
- "description": "The number of requests remaining in the current ratelimit window"
2312
- },
2313
- "X-RateLimit-Reset": {
2314
- "schema": {
2315
- "type": "number"
2316
- },
2317
- "description": "A unix timestamp in seconds at which the current ratelimit window resets"
2318
- },
2319
- "X-RateLimit-Reset-After": {
2320
- "schema": {
2321
- "type": "number"
2322
- },
2323
- "description": "The duration in seconds until the current ratelimit window resets"
2324
- },
2325
- "X-RateLimit-Bucket": {
2326
- "schema": {
2327
- "type": "string"
2328
- },
2329
- "description": "The bucket that the request belongs to"
2330
- }
2331
2243
  }
2332
2244
  }
2333
2245
  }
@@ -83,7 +83,7 @@ const WRITES = [
83
83
  * announcement can read as the operator while an ordinary reply reads as the
84
84
  * integration, from one bot token.
85
85
  *
86
- * The cost is stated in `docs/detailed/security.md`: `create_webhook` and
86
+ * The cost is stated in `https://lanes.sh/docs/link/security`: `create_webhook` and
87
87
  * `list_channel_webhooks` return the webhook's token in their response, and a
88
88
  * webhook token is a standalone credential for posting to that channel.
89
89
  */
@@ -0,0 +1,10 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /** Dropbox registers us at connect time — nothing for an operator to set up. */
4
+ export const dropbox = defineProvider({
5
+ id: 'dropbox',
6
+ name: 'Dropbox',
7
+ description: 'Files, folders, shared links, and file requests, via Dropbox\'s official MCP server.',
8
+ connector: { kind: 'mcp', endpoint: 'https://mcp.dropbox.com/mcp' },
9
+ auth: { kind: 'oauth', registration: 'dynamic' },
10
+ });
@@ -0,0 +1,366 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { BlobMetadata, BlobStore } from '#connectivity';
3
+ import { allEntities, idFromKey, type Attribute, type Entity, type Relation } from './store.ts';
4
+
5
+ /**
6
+ * The derived index, and the rules that keep it from becoming a second source
7
+ * of truth.
8
+ *
9
+ * `_index.json` sits beside the entity files and holds what a lookup needs, so
10
+ * a read that finds it valid opens no entity file at all. It is a **cache**:
11
+ * absent, truncated, wrong-version and fingerprint-mismatched are all one case
12
+ * here, and all four rebuild from the files without throwing. The precedent is
13
+ * `openRuntime`'s treatment of a corrupt discovery cache — never a reason to
14
+ * fail startup.
15
+ *
16
+ * ADR-014 removed an index from memory because it could disagree with the file
17
+ * it described. What makes this one different is the fingerprint: it is a stamp
18
+ * of the exact listing the index was built from, so a file edited in an editor
19
+ * or on GitHub invalidates it structurally rather than by anybody remembering
20
+ * to. The hole that remains — a hand-edited *index* whose fingerprint still
21
+ * matches untouched entity files — is closed on the path that matters by the
22
+ * confirm-on-read rule in `find.ts`, and is pinned by a test rather than
23
+ * pretended away.
24
+ *
25
+ * **Why the fingerprint is `key:size` and not `key:size:mtime`.**
26
+ * `skillFingerprint` includes mtime and is right to: it is a change detector
27
+ * for a two-second poll, where a false positive costs a reload. This is a
28
+ * validity stamp, where a false positive costs a full rebuild and, on a
29
+ * knowledge repository, a commit. The GitHub adapter reports the *branch tip*
30
+ * as `modifiedAt` for every file, so on the one backend where this index is
31
+ * worth the most, an mtime-bearing fingerprint would never match twice. Sizes
32
+ * are per-file on every adapter.
33
+ *
34
+ * That choice buys a second property the write path depends on: because size is
35
+ * known before a put, a writer can compute the fingerprint of the state it is
36
+ * *about to create* (`fingerprintAfter`) rather than listing the store again
37
+ * afterwards. No read-back, no second `list()`, no ordering hazard.
38
+ *
39
+ * **No read ever writes this file.** A read that finds it stale rebuilds in
40
+ * memory and serves the right answer. Only `write`, `link`, `forget` and
41
+ * `entities reindex` persist. So no read can move a branch tip, and therefore
42
+ * no read can invalidate the next read.
43
+ *
44
+ * **What it costs, at scale.** Steady state on a bucket is one `list()` and one
45
+ * GET, whatever the entity count — a listing carries key and size only, so no
46
+ * body is transferred on that path. A rebuild is one read per entity, bounded
47
+ * 16 at a time.
48
+ *
49
+ * entities list() requests index steady-state read rebuild
50
+ * 100 1 ~35 KB 2 requests 100 GETs
51
+ * 1,000 1-2 ~340 KB 3 requests 1,000 GETs
52
+ * 10,000 11 ~3.4 MB 12 requests 10,000 GETs
53
+ *
54
+ * A thousand is comfortably inside the design. Ten thousand is where it is the
55
+ * wrong design, and the answer then is not a larger index but a validity check
56
+ * that needs no listing — which needs an adapter reporting a per-object etag,
57
+ * and `BlobMetadata` carries none today. On a local directory none of this is
58
+ * worth anything measurable; it is carried for the deployed and repository
59
+ * paths.
60
+ */
61
+
62
+ export const INDEX_KEY = '_index.json';
63
+
64
+ /**
65
+ * Bumping this invalidates every stored index without anyone remembering to.
66
+ *
67
+ * It is in the fingerprint's own prefix rather than checked separately, so a
68
+ * format change and a content change are one comparison.
69
+ */
70
+ const INDEX_VERSION = 1;
71
+
72
+ /**
73
+ * An entity as the index holds it: everything that matches or distinguishes,
74
+ * and nothing that only renders.
75
+ *
76
+ * No body, and no body summary. A summary would be the largest field per row
77
+ * and nothing consults it — a single match reads the file anyway under
78
+ * confirm-on-read, and several matches render the fields that *differ*, not
79
+ * prose. Dropping it is about a third of the file for no loss.
80
+ */
81
+ export type CatalogueEntity = Omit<Entity, 'body' | 'bytes'>;
82
+
83
+ /** An edge as the entity it points *at* sees it. Derived, never stored. */
84
+ export interface Backlink {
85
+ readonly from: string;
86
+ readonly predicate: string;
87
+ readonly note?: string;
88
+ }
89
+
90
+ export interface Catalogue {
91
+ readonly entities: readonly CatalogueEntity[];
92
+ readonly byId: ReadonlyMap<string, CatalogueEntity>;
93
+ /** Reverse edges, derived in one pass. Keyed by the entity pointed at. */
94
+ readonly backlinks: ReadonlyMap<string, readonly Backlink[]>;
95
+ readonly fingerprint: string;
96
+ /**
97
+ * Whether this came from a valid index rather than from the files.
98
+ *
99
+ * `find` reads it to decide whether a single match needs confirming against
100
+ * its file: an answer rebuilt from the files is already confirmed.
101
+ */
102
+ readonly fromIndex: boolean;
103
+ /**
104
+ * The listing this was opened against.
105
+ *
106
+ * Carried so a write can compute `fingerprintAfter` without listing twice.
107
+ * It is the one piece of storage detail on this type and it is here rather
108
+ * than fetched again because the two calls could otherwise see different
109
+ * states.
110
+ */
111
+ readonly listing: readonly BlobMetadata[];
112
+ }
113
+
114
+ /**
115
+ * A stamp of exactly which entity files existed and how large each was.
116
+ *
117
+ * Filtered by `idFromKey`, which is what keeps `_index.json` out of its own
118
+ * fingerprint. Without that filter, writing the index would change the listing,
119
+ * which would invalidate the index the instant it was written.
120
+ */
121
+ export function fingerprintOf(blobs: readonly BlobMetadata[]): string {
122
+ return hashLines(
123
+ blobs.flatMap((blob) => (idFromKey(blob.key) === null ? [] : [`${blob.key}:${blob.size}`])),
124
+ );
125
+ }
126
+
127
+ /** What `fingerprintOf` will return after this put or delete lands. */
128
+ export function fingerprintAfter(
129
+ blobs: readonly BlobMetadata[],
130
+ change: { key: string; size: number } | { key: string; deleted: true },
131
+ ): string {
132
+ const lines = blobs.flatMap((blob) =>
133
+ idFromKey(blob.key) === null || blob.key === change.key ? [] : [`${blob.key}:${blob.size}`],
134
+ );
135
+
136
+ if (!('deleted' in change) && idFromKey(change.key) !== null) {
137
+ lines.push(`${change.key}:${change.size}`);
138
+ }
139
+
140
+ return hashLines(lines);
141
+ }
142
+
143
+ function hashLines(lines: readonly string[]): string {
144
+ return createHash('sha256')
145
+ .update(`entities/${INDEX_VERSION}\n${[...lines].sort().join('\n')}`)
146
+ .digest('hex');
147
+ }
148
+
149
+ /**
150
+ * The catalogue, from the index when it is valid and from the files when it is
151
+ * not.
152
+ *
153
+ * Never throws for a storage-shaped reason. The only thing that propagates is a
154
+ * failure to `list()` at all, which is the store being unreachable rather than
155
+ * this cache being wrong.
156
+ */
157
+ export async function openCatalogue(storage: BlobStore): Promise<Catalogue> {
158
+ const listing = await storage.list();
159
+ const fingerprint = fingerprintOf(listing);
160
+
161
+ const indexed = await readIndex(storage, fingerprint);
162
+ if (indexed !== null) return assemble(indexed, fingerprint, true, listing);
163
+
164
+ const entities = (await allEntities(storage)).map(strip);
165
+ return assemble(entities, fingerprint, false, listing);
166
+ }
167
+
168
+ /** Rebuild from the files regardless of what the index says. */
169
+ export async function rebuildCatalogue(storage: BlobStore): Promise<Catalogue> {
170
+ const listing = await storage.list();
171
+ const entities = (await allEntities(storage)).map(strip);
172
+
173
+ return assemble(entities, fingerprintOf(listing), false, listing);
174
+ }
175
+
176
+ export async function writeCatalogue(
177
+ storage: BlobStore,
178
+ entities: readonly CatalogueEntity[],
179
+ fingerprint: string,
180
+ now: string,
181
+ ): Promise<void> {
182
+ await storage.put(INDEX_KEY, new TextEncoder().encode(serialiseIndex(entities, fingerprint, now)), {
183
+ contentType: 'application/json',
184
+ });
185
+ }
186
+
187
+ /** Why a read had to rebuild, for `entities reindex` and a debug log line. */
188
+ export async function indexState(
189
+ storage: BlobStore,
190
+ ): Promise<{ current: boolean; reason: string }> {
191
+ const fingerprint = fingerprintOf(await storage.list());
192
+ const bytes = await storage.get(INDEX_KEY);
193
+
194
+ if (bytes === null) return { current: false, reason: 'no index file' };
195
+
196
+ const parsed = parseIndex(bytes);
197
+ if (parsed === null) return { current: false, reason: 'the index file could not be read' };
198
+ if (parsed.fingerprint !== fingerprint) {
199
+ return { current: false, reason: 'the entity files changed since the index was built' };
200
+ }
201
+
202
+ return { current: true, reason: 'the index matches the entity files' };
203
+ }
204
+
205
+ function strip(entity: Entity): CatalogueEntity {
206
+ const { body: _body, bytes: _bytes, ...rest } = entity;
207
+ return rest;
208
+ }
209
+
210
+ /**
211
+ * A catalogue over entities already in hand, deriving the backlinks.
212
+ *
213
+ * The same assembly both open paths use, exposed so `find` can be exercised
214
+ * against literal arrays with no store at all — which is the point of keeping
215
+ * matching pure.
216
+ */
217
+ export function catalogueFrom(entities: readonly CatalogueEntity[]): Catalogue {
218
+ return assemble(entities, '', false, []);
219
+ }
220
+
221
+ function assemble(
222
+ entities: readonly CatalogueEntity[],
223
+ fingerprint: string,
224
+ fromIndex: boolean,
225
+ listing: readonly BlobMetadata[],
226
+ ): Catalogue {
227
+ const byId = new Map(entities.map((one) => [one.id, one]));
228
+ const backlinks = new Map<string, Backlink[]>();
229
+
230
+ // One pass over the forward edges already loaded. Reverse edges are never
231
+ // stored: a second copy inside the same file could disagree with the first
232
+ // half of it, and this derivation is shared by both open paths so there is
233
+ // one implementation of "who points at this".
234
+ for (const entity of entities) {
235
+ for (const relation of entity.relations) {
236
+ const into = backlinks.get(relation.entity) ?? [];
237
+ into.push({
238
+ from: entity.id,
239
+ predicate: relation.predicate,
240
+ ...(relation.note === undefined ? {} : { note: relation.note }),
241
+ });
242
+ backlinks.set(relation.entity, into);
243
+ }
244
+ }
245
+
246
+ return { entities, byId, backlinks, fingerprint, fromIndex, listing };
247
+ }
248
+
249
+ /** The stored rows, or null for any reason at all. Every reason rebuilds. */
250
+ async function readIndex(
251
+ storage: BlobStore,
252
+ fingerprint: string,
253
+ ): Promise<CatalogueEntity[] | null> {
254
+ const bytes = await storage.get(INDEX_KEY);
255
+ if (bytes === null) return null;
256
+
257
+ const parsed = parseIndex(bytes);
258
+ if (parsed === null || parsed.fingerprint !== fingerprint) return null;
259
+
260
+ return parsed.entities;
261
+ }
262
+
263
+ function parseIndex(
264
+ bytes: Uint8Array,
265
+ ): { fingerprint: string; entities: CatalogueEntity[] } | null {
266
+ let raw: unknown;
267
+ try {
268
+ raw = JSON.parse(new TextDecoder().decode(bytes));
269
+ } catch {
270
+ return null;
271
+ }
272
+
273
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
274
+ const document = raw as Record<string, unknown>;
275
+
276
+ if (document['v'] !== INDEX_VERSION) return null;
277
+ if (typeof document['fingerprint'] !== 'string') return null;
278
+ if (!Array.isArray(document['entities'])) return null;
279
+
280
+ const entities: CatalogueEntity[] = [];
281
+ for (const row of document['entities']) {
282
+ const entity = parseRow(row);
283
+ // One unreadable row is the whole file: unlike an entity document, this is
284
+ // machine-written and a partial one means a partial answer with nothing
285
+ // saying so. Rebuilding is cheap and correct.
286
+ if (entity === null) return null;
287
+ entities.push(entity);
288
+ }
289
+
290
+ return { fingerprint: document['fingerprint'], entities };
291
+ }
292
+
293
+ function parseRow(raw: unknown): CatalogueEntity | null {
294
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
295
+ const row = raw as Record<string, unknown>;
296
+
297
+ const id = row['id'];
298
+ const updatedAt = row['updated_at'];
299
+ if (typeof id !== 'string' || typeof updatedAt !== 'string') return null;
300
+
301
+ return {
302
+ id,
303
+ type: typeof row['type'] === 'string' ? row['type'] : '',
304
+ name: typeof row['name'] === 'string' ? row['name'] : id,
305
+ aliases: strings(row['aliases']),
306
+ tags: strings(row['tags']),
307
+ attributes: rows<Attribute>(row['attributes'], ['kind', 'value']),
308
+ relations: rows<Relation>(row['relations'], ['predicate', 'entity']),
309
+ updatedAt,
310
+ };
311
+ }
312
+
313
+ function strings(raw: unknown): string[] {
314
+ return Array.isArray(raw) ? raw.filter((one): one is string => typeof one === 'string') : [];
315
+ }
316
+
317
+ function rows<T>(raw: unknown, required: readonly [string, string]): T[] {
318
+ if (!Array.isArray(raw)) return [];
319
+
320
+ return raw.flatMap((item) => {
321
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return [];
322
+ const one = item as Record<string, unknown>;
323
+ if (required.some((key) => typeof one[key] !== 'string')) return [];
324
+
325
+ const note = one['note'];
326
+ return [
327
+ {
328
+ [required[0]]: one[required[0]],
329
+ [required[1]]: one[required[1]],
330
+ ...(typeof note === 'string' ? { note } : {}),
331
+ } as T,
332
+ ];
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Sorted by id, fixed key order, two-space indent.
338
+ *
339
+ * Determinism is not cosmetic: this lands in a git diff on the owner's own
340
+ * notes repository, and a file that reorders itself makes a one-attribute
341
+ * change unreviewable.
342
+ */
343
+ function serialiseIndex(
344
+ entities: readonly CatalogueEntity[],
345
+ fingerprint: string,
346
+ now: string,
347
+ ): string {
348
+ const rows = [...entities]
349
+ .sort((a, b) => a.id.localeCompare(b.id))
350
+ .map((entity) => ({
351
+ id: entity.id,
352
+ ...(entity.type ? { type: entity.type } : {}),
353
+ name: entity.name,
354
+ ...(entity.aliases.length > 0 ? { aliases: [...entity.aliases] } : {}),
355
+ ...(entity.tags.length > 0 ? { tags: [...entity.tags] } : {}),
356
+ ...(entity.attributes.length > 0
357
+ ? { attributes: entity.attributes.map((one) => ({ ...one })) }
358
+ : {}),
359
+ ...(entity.relations.length > 0
360
+ ? { relations: entity.relations.map((one) => ({ ...one })) }
361
+ : {}),
362
+ updated_at: entity.updatedAt,
363
+ }));
364
+
365
+ return `${JSON.stringify({ v: INDEX_VERSION, fingerprint, built_at: now, entities: rows }, null, 2)}\n`;
366
+ }