@vantageos/backend-doctor 0.1.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 (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,1031 @@
1
+ "use node";
2
+
3
+ /**
4
+ * OKF v0.1 bundle exporter — Node-runtime split (Phase 1 — T3 REVISE).
5
+ *
6
+ * Runtime split (Eta REVISE verdict on PR #846):
7
+ * - This module hosts the public Convex `action` `exportOkfBundle` together
8
+ * with the `tar-stream` packer (`packTarball`) and the Phase 1 auth helper
9
+ * `assertCanExportNamespace`. Both touch Node globals (`Buffer`, Node
10
+ * streams) and `tar-stream` is a Node-only library, so the `"use node"`
11
+ * directive at the top of this file is mandatory.
12
+ * - All non-Node primitives (internal queries, internal mutation, pure
13
+ * helpers like `assembleBundle`, `shouldIncludeFamily`, `parseSinceArg`)
14
+ * stay in `convex/okfBundle.ts` (V8 runtime). This action calls them via
15
+ * `ctx.runQuery(internal.okfBundle.*)` / `ctx.runMutation(internal.okfBundle.*)`.
16
+ *
17
+ * Eta fix-pattern reference: m9781h39qvcyy4hsphthz7eg5s88yc1f
18
+ * — "Convex action Node global ReferenceError" — co-exporting `internalQuery` /
19
+ * `internalMutation` in a `"use node"` module is forbidden by Convex, hence
20
+ * the split into two files.
21
+ *
22
+ * RFC parent: decisions/okf-bridge-phase-1-rfc-2026-06-18.md (commit 6613610).
23
+ * ADR: decisions/adr-okf-exporter-arch.md (commit 2cd357e).
24
+ *
25
+ * Orchestrator: Sigma — VantagePeers | 2026-06-19
26
+ */
27
+
28
+ import { Readable } from "node:stream";
29
+ import { v } from "convex/values";
30
+ import { extract, pack } from "tar-stream";
31
+ import { internal as generatedInternal } from "./_generated/api";
32
+ import { action } from "./_generated/server";
33
+ import {
34
+ applyMemorySubtypeFilter,
35
+ assembleBundle,
36
+ BUNDLE_HARD_CAP_BYTES,
37
+ BUNDLE_PAGE_SIZE,
38
+ BUNDLE_SOFT_CAP_BYTES,
39
+ DEFAULT_URL_TTL_SECONDS,
40
+ parseSinceArg,
41
+ shouldIncludeFamily,
42
+ } from "./okfBundle";
43
+ import {
44
+ type BriefingNoteDoc,
45
+ type MemoryDoc,
46
+ serializeBriefingNote,
47
+ serializeMemory,
48
+ serializeTask,
49
+ type TaskDoc,
50
+ } from "./okfSerializer";
51
+ import {
52
+ type BundleEntry,
53
+ type ValidationError,
54
+ validateBundle,
55
+ } from "./okfValidator";
56
+
57
+ // Codegen-lag workaround (mirrors okfBundle.ts comment). The Convex codegen
58
+ // resolves FunctionReferences by module path at runtime; widening the type
59
+ // keeps this module compilable before `npx convex dev` regenerates `_generated/`.
60
+ // biome-ignore lint/suspicious/noExplicitAny: codegen-lag workaround
61
+ const internal = generatedInternal as any;
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // Pagination driver (Eta REVISE iter 2 — fix-pattern m978c0zyjav9tjmp4aq8b04j8n88zhwm).
65
+ //
66
+ // Each V8 internal query now returns `{ page, isDone, continueCursor }`. The
67
+ // Node-runtime action loops until `isDone === true`, tracking the cumulative
68
+ // byte budget so we abort early if the bundle approaches the soft cap (ADR D4).
69
+ //
70
+ // `budgetBytes` is a heuristic ceiling on the running sum of serialized row
71
+ // lengths, NOT the final tar size — `assembleBundle()` is the authoritative
72
+ // cap enforcer. This loop guard exists solely to bound the V8→Node hop count
73
+ // when a tenant has millions of rows.
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+
76
+ interface PaginatedResult<T> {
77
+ page: T[];
78
+ isDone: boolean;
79
+ continueCursor: string;
80
+ }
81
+
82
+ async function collectAllPages<T>(
83
+ runQuery: (cursor: string | null) => Promise<PaginatedResult<T>>,
84
+ budgetBytes: number,
85
+ approxSize: (row: T) => number,
86
+ ): Promise<{ rows: T[]; truncated: boolean }> {
87
+ const rows: T[] = [];
88
+ let cursor: string | null = null;
89
+ let bytes = 0;
90
+ let truncated = false;
91
+ // Hard ceiling on hop count to avoid runaway loops in case of pathological
92
+ // cursor non-advance (Convex contract guarantees forward progress; this is
93
+ // a belt-and-braces guard). 4096 hops × 256 rows = 1 048 576 rows max.
94
+ for (let hop = 0; hop < 4096; hop++) {
95
+ const res = await runQuery(cursor);
96
+ for (const r of res.page) {
97
+ rows.push(r);
98
+ bytes += approxSize(r);
99
+ }
100
+ if (res.isDone) break;
101
+ if (bytes >= budgetBytes) {
102
+ truncated = true;
103
+ break;
104
+ }
105
+ cursor = res.continueCursor;
106
+ }
107
+ return { rows, truncated };
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Dedup lookup driver (Bug #1134 fix).
112
+ //
113
+ // Each `_find*ByX` V8 internal query returns ONE bounded page (`{ id, isDone,
114
+ // continueCursor }`). This driver loops calling the query with an advancing
115
+ // cursor and SHORT-CIRCUITS the instant a match is found, so at most one
116
+ // bounded page is read once a hit exists — and a full miss costs many
117
+ // separate bounded executions rather than a single unbounded one (see the
118
+ // comment block in `convex/okfBundle.ts` above the three helpers for why an
119
+ // in-process loop over `.paginate()` inside a single execution would NOT be
120
+ // sufficient).
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ interface DedupPageResult {
124
+ id: string | null;
125
+ isDone: boolean;
126
+ continueCursor: string;
127
+ }
128
+
129
+ async function findExistingIdByPaginating(
130
+ runQuery: (cursor: string | null) => Promise<DedupPageResult>,
131
+ ): Promise<string | null> {
132
+ let cursor: string | null = null;
133
+ // Hard ceiling on hop count — same rationale as `collectAllPages()`.
134
+ for (let hop = 0; hop < 4096; hop++) {
135
+ const res = await runQuery(cursor);
136
+ if (res.id !== null) return res.id;
137
+ if (res.isDone) return null;
138
+ cursor = res.continueCursor;
139
+ }
140
+ // Ceiling exceeded (4096 hops × 256 rows = 1 048 576 rows scanned without a
141
+ // definitive answer). Silently returning `null` here would report a
142
+ // genuine duplicate as "not found" for a target table above ~1M rows,
143
+ // causing the import to reinsert it — a correctness bug, not just a perf
144
+ // one. We THROW rather than log-and-continue: the caller
145
+ // (`importOkfBundle`) has no safe fallback once dedup truth is unknown,
146
+ // and reinserting the row silently is worse than failing the import loudly
147
+ // so the operator can re-run against a narrower namespace/window or raise
148
+ // the ceiling. Mirrors `collectAllPages()`'s `truncated` signal, but dedup
149
+ // has no partial-success mode to degrade into, so throw instead of flag.
150
+ throw new Error(
151
+ "OKF_IMPORT_DEDUP_CEILING_EXCEEDED: findExistingIdByPaginating hit the " +
152
+ "4096-hop pagination ceiling without a definitive match/no-match " +
153
+ "result. Refusing to treat this as \"not found\" to avoid silently " +
154
+ "reinserting a duplicate row.",
155
+ );
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Public types (mirror the V8 module — kept exported for the MCP wrapper)
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+
162
+ export interface ExportOkfBundleResult {
163
+ bundleUrl: string;
164
+ storageId: string;
165
+ size: number;
166
+ fileCount: number;
167
+ manifest: {
168
+ types: {
169
+ memoryCount: number;
170
+ briefingCount: number;
171
+ taskCount: number;
172
+ };
173
+ truncated: boolean;
174
+ urlExpiresAt: string; // ISO 8601
175
+ };
176
+ }
177
+
178
+ // ─────────────────────────────────────────────────────────────────────────────
179
+ // Tarball packer — Node-only (uses Buffer + tar-stream)
180
+ // ─────────────────────────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Pack an in-memory bundle into a tar archive. Returns the full tarball as a
184
+ * Buffer (Phase 1 bundle ≤50 MB → fits comfortably in memory). Uses
185
+ * `tar-stream` entry-by-entry so we never hold two full copies in RAM.
186
+ */
187
+ export async function packTarball(
188
+ entries: readonly BundleEntry[],
189
+ ): Promise<Buffer> {
190
+ const tar = pack();
191
+ const chunks: Buffer[] = [];
192
+ const flushed = new Promise<void>((resolve, reject) => {
193
+ tar.on("data", (c: Buffer) => chunks.push(c));
194
+ tar.on("end", () => resolve());
195
+ tar.on("error", reject);
196
+ });
197
+
198
+ for (const e of entries) {
199
+ await new Promise<void>((resolve, reject) => {
200
+ tar.entry(
201
+ { name: e.path, size: Buffer.byteLength(e.content, "utf8") },
202
+ e.content,
203
+ (err) => (err ? reject(err) : resolve()),
204
+ );
205
+ });
206
+ }
207
+ tar.finalize();
208
+ await flushed;
209
+ return Buffer.concat(chunks);
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ // Auth helper (RFC §4) — kept here because the action invokes it and it has
214
+ // no DB dependency. Pure check against ctx.auth + Phase 1 namespace constant.
215
+ // ─────────────────────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Lightweight namespace authorization (Phase 2 — B3 generalize).
219
+ *
220
+ * Rules:
221
+ * - Namespace prefix is accepted as long as it is a non-empty string with no
222
+ * path-traversal segments (`..`). The Phase 1 hard-lock to
223
+ * `project/elpi-corp` was relaxed by B3 (mission k5779qbxh, task
224
+ * k17f3407sg7cn6gswn5qs9j5b5891581) so multi-tenant `team/<orgId>/*`
225
+ * and other namespaces can export their own bundles.
226
+ * - Identity is resolved via `ctx.auth.getUserIdentity()`. Absence is
227
+ * permitted when running through the Convex CLI / deploy key (mirrors
228
+ * `lib/auth.withOrgScope` master-scope behaviour).
229
+ * - When an org slug is attached, it MUST match the tail of the requested
230
+ * namespace (e.g. `team/abc-123` → org `abc-123`; `project/elpi-corp` →
231
+ * org `elpi-corp`). Mismatch → `AUTH_NAMESPACE_DENIED`. Cross-tenant
232
+ * export remains forbidden.
233
+ */
234
+ export async function assertCanExportNamespace(
235
+ ctx: { auth: { getUserIdentity: () => Promise<unknown> } },
236
+ namespace: string,
237
+ ): Promise<void> {
238
+ if (typeof namespace !== "string" || namespace.length === 0) {
239
+ throw new Error(
240
+ `OKF_NAMESPACE_INVALID: namespace must be a non-empty string, got "${namespace}".`,
241
+ );
242
+ }
243
+ // Reject path-traversal sequences as a defence-in-depth — the V8 internal
244
+ // queries filter by prefix and would not surface "../foo" rows anyway, but
245
+ // rejecting here keeps the error surface localised.
246
+ if (namespace.includes("..")) {
247
+ throw new Error(
248
+ `OKF_NAMESPACE_INVALID: namespace "${namespace}" contains a path-traversal segment.`,
249
+ );
250
+ }
251
+
252
+ const identity = (await ctx.auth.getUserIdentity()) as Record<
253
+ string,
254
+ unknown
255
+ > | null;
256
+
257
+ // Master-bypass allowlist. In production the MCP Cloud surface never calls
258
+ // setAuth on the Convex client, so `getUserIdentity()` is always null on the
259
+ // hot path. Eta REVISE iter-2 on PR #888 flagged this as a CRITICAL
260
+ // cross-tenant bypass: prior to this guard, a null identity authorized ANY
261
+ // namespace, so any tenant token could export any other tenant's data.
262
+ //
263
+ // The fix is fail-CLOSED on null/no-org identities for all but the legacy
264
+ // "self" namespace. CLI / deploy-key callers still get through because their
265
+ // invocations carry no identity AND target `project/elpi-corp` — which is
266
+ // what the Phase 1 contract (PR #850) actually meant by "master".
267
+ const isMasterNamespace = namespace === "project/elpi-corp";
268
+
269
+ if (identity === null || identity === undefined) {
270
+ if (isMasterNamespace) {
271
+ return;
272
+ }
273
+ throw new Error(
274
+ `AUTH_NO_IDENTITY: anonymous caller cannot export non-master namespace "${namespace}".`,
275
+ );
276
+ }
277
+ const orgSlug =
278
+ (identity.organizationId as string | undefined) ??
279
+ (identity.organizationSlug as string | undefined) ??
280
+ null;
281
+ if (orgSlug === null) {
282
+ // Identity attached but carries no org affiliation — same fail-closed
283
+ // posture as the null branch above. Only the legacy master namespace is
284
+ // allowed through (system:cron, deploy key with metadata, etc.).
285
+ if (isMasterNamespace) {
286
+ return;
287
+ }
288
+ throw new Error(
289
+ `AUTH_NO_ORG: caller without org affiliation cannot export non-master namespace "${namespace}".`,
290
+ );
291
+ }
292
+ const expectedSuffix = namespace.split("/").slice(1).join("/");
293
+ if (orgSlug !== expectedSuffix) {
294
+ throw new Error(
295
+ `AUTH_NAMESPACE_DENIED: caller org "${orgSlug}" cannot export namespace "${namespace}".`,
296
+ );
297
+ }
298
+ }
299
+
300
+ // ─────────────────────────────────────────────────────────────────────────────
301
+ // Public action — Node-runtime entry point invoked by MCP / Cloud surface.
302
+ // ─────────────────────────────────────────────────────────────────────────────
303
+
304
+ export const exportOkfBundle = action({
305
+ args: {
306
+ namespace: v.string(),
307
+ types: v.optional(v.union(v.array(v.string()), v.null())),
308
+ format: v.union(v.literal("tarball"), v.literal("tree")),
309
+ since: v.optional(v.union(v.string(), v.null())),
310
+ urlTtl: v.optional(v.number()),
311
+ },
312
+ handler: async (ctx, args): Promise<ExportOkfBundleResult> => {
313
+ // 1. Auth.
314
+ await assertCanExportNamespace(ctx, args.namespace);
315
+
316
+ // 2. Format gate — Phase 1 supports tarball only.
317
+ if (args.format !== "tarball") {
318
+ throw new Error(
319
+ `OKF_FORMAT_UNSUPPORTED: Phase 1 supports "tarball" only, got "${args.format}".`,
320
+ );
321
+ }
322
+
323
+ const sinceMs = parseSinceArg(args.since ?? undefined);
324
+ const typeFilter = args.types ?? null;
325
+
326
+ // 3. Fetch entries (only the families requested) — via V8 internal queries.
327
+ // Each internal query returns a paginated `{ page, isDone, continueCursor }`
328
+ // shape; the action drives the cursor loop via `collectAllPages()` so we
329
+ // never exceed the Convex 16 MB function-return cap (Eta REVISE iter 2).
330
+ const FETCH_BUDGET_BYTES = BUNDLE_SOFT_CAP_BYTES; // 50 MB pre-tar budget
331
+ // Cheap byte estimator — `content` dominates row size for all three
332
+ // families. Using `JSON.stringify` here would double-count for nested
333
+ // objects; reading `.content?.length` is a safe lower bound.
334
+ const approx = (r: unknown): number => {
335
+ const row = r as {
336
+ content?: string;
337
+ description?: string;
338
+ title?: string;
339
+ };
340
+ return (
341
+ (row.content?.length ?? 0) +
342
+ (row.description?.length ?? 0) +
343
+ (row.title?.length ?? 0) +
344
+ 256
345
+ );
346
+ };
347
+
348
+ const memoriesResult = shouldIncludeFamily("memory", typeFilter)
349
+ ? await collectAllPages<MemoryDoc>(
350
+ async (cursor) =>
351
+ (await ctx.runQuery(internal.okfBundle._fetchMemoriesForBundle, {
352
+ namespace: args.namespace,
353
+ sinceMs,
354
+ paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
355
+ })) as PaginatedResult<MemoryDoc>,
356
+ FETCH_BUDGET_BYTES,
357
+ approx,
358
+ )
359
+ : { rows: [], truncated: false };
360
+ const briefingsResult = shouldIncludeFamily("briefing", typeFilter)
361
+ ? await collectAllPages<BriefingNoteDoc>(
362
+ async (cursor) =>
363
+ (await ctx.runQuery(
364
+ internal.okfBundle._fetchBriefingNotesForBundle,
365
+ {
366
+ namespace: args.namespace,
367
+ sinceMs,
368
+ paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
369
+ },
370
+ )) as PaginatedResult<BriefingNoteDoc>,
371
+ FETCH_BUDGET_BYTES,
372
+ approx,
373
+ )
374
+ : { rows: [], truncated: false };
375
+ const tasksResult = shouldIncludeFamily("task", typeFilter)
376
+ ? await collectAllPages<TaskDoc>(
377
+ async (cursor) =>
378
+ (await ctx.runQuery(internal.okfBundle._fetchTasksForBundle, {
379
+ namespace: args.namespace,
380
+ sinceMs,
381
+ paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
382
+ })) as PaginatedResult<TaskDoc>,
383
+ FETCH_BUDGET_BYTES,
384
+ approx,
385
+ )
386
+ : { rows: [], truncated: false };
387
+ const memories = memoriesResult.rows;
388
+ const briefings = briefingsResult.rows;
389
+ const tasks = tasksResult.rows;
390
+ const fetchTruncated =
391
+ memoriesResult.truncated ||
392
+ briefingsResult.truncated ||
393
+ tasksResult.truncated;
394
+
395
+ // 4. Serialize.
396
+ const memoryFiles = applyMemorySubtypeFilter(
397
+ memories.map(serializeMemory),
398
+ memories,
399
+ typeFilter,
400
+ );
401
+ const briefingFiles = briefings.map(serializeBriefingNote);
402
+ const taskFiles = tasks.map(serializeTask);
403
+
404
+ // 5. Assemble + cap.
405
+ const { entries, bytes, truncated } = assembleBundle(
406
+ memoryFiles,
407
+ briefingFiles,
408
+ taskFiles,
409
+ memories,
410
+ briefings,
411
+ tasks,
412
+ { softCap: BUNDLE_SOFT_CAP_BYTES, hardCap: BUNDLE_HARD_CAP_BYTES },
413
+ );
414
+
415
+ if (truncated) {
416
+ console.warn(
417
+ `[okfBundleNode] truncated bundle for namespace=${args.namespace}: ${bytes} B > ${BUNDLE_SOFT_CAP_BYTES} B`,
418
+ );
419
+ }
420
+
421
+ // 6. Validate (in-memory, before upload).
422
+ const validation = validateBundle({ entries });
423
+ if (!validation.pass) {
424
+ throw new Error(
425
+ `OKF_BUNDLE_INVALID: ${validation.errors.length} error(s) — first: ${validation.errors[0].rule} at ${validation.errors[0].path}`,
426
+ );
427
+ }
428
+
429
+ // 7. Pack tarball (Node Buffer).
430
+ const tarball = await packTarball(entries);
431
+
432
+ // 8. Upload to Convex storage.
433
+ const tarBytes = new Uint8Array(
434
+ tarball.buffer,
435
+ tarball.byteOffset,
436
+ tarball.byteLength,
437
+ );
438
+ // Uint8Array is a valid Blob input under both Node ≥18 and the Convex
439
+ // Node runtime. Funnel through `unknown` array because the lib's
440
+ // `BlobPart` narrowing rejects `Uint8Array<ArrayBufferLike>` even though
441
+ // it is accepted at runtime.
442
+ const blob = new Blob(
443
+ [tarBytes] as unknown as ConstructorParameters<typeof Blob>[0],
444
+ { type: "application/x-tar" },
445
+ );
446
+ const storageId = await ctx.storage.store(blob);
447
+ const bundleUrl = await ctx.storage.getUrl(storageId);
448
+ if (bundleUrl === null) {
449
+ throw new Error(
450
+ `OKF_STORAGE_FAILED: storage.getUrl returned null for ${storageId}`,
451
+ );
452
+ }
453
+
454
+ // 9. Schedule TTL-bound purge — via V8 internal mutation.
455
+ const ttlSeconds = args.urlTtl ?? DEFAULT_URL_TTL_SECONDS;
456
+ if (ttlSeconds > 0) {
457
+ await ctx.scheduler.runAfter(
458
+ ttlSeconds * 1000,
459
+ internal.okfBundle._deleteBundleStorage,
460
+ { storageId },
461
+ );
462
+ }
463
+ const urlExpiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
464
+
465
+ // 10. Manifest.
466
+ return {
467
+ bundleUrl,
468
+ storageId,
469
+ size: tarball.byteLength,
470
+ fileCount: entries.length,
471
+ manifest: {
472
+ types: {
473
+ memoryCount: memoryFiles.length,
474
+ briefingCount: briefingFiles.length,
475
+ taskCount: taskFiles.length,
476
+ },
477
+ truncated: truncated || fetchTruncated,
478
+ urlExpiresAt,
479
+ },
480
+ };
481
+ },
482
+ });
483
+
484
+ // ─────────────────────────────────────────────────────────────────────────────
485
+ // OKF Phase 2 — B1 / T-OKF-PHASE2-A: validate_okf_bundle (read-only).
486
+ //
487
+ // Mission k5779qbxhwrfjmj02t31yvehns8911jp, task k1796g7g7y03gn9rd6z7psenk98910vt.
488
+ // Accepts {bundleUrl|storageId}, fetches the tarball, untar's with tar-stream,
489
+ // counts family entries by path prefix, and runs the pure `validateBundle()`
490
+ // validator from okfValidator.ts. Returns { valid, schemaVersion, errors?, stats }.
491
+ //
492
+ // Read-only — never writes to the DB. The action exists so dashboard UIs can
493
+ // preview a bundle's integrity before calling `import_okf_bundle` (B2) which
494
+ // reuses the same validator before any mutation.
495
+ // ─────────────────────────────────────────────────────────────────────────────
496
+
497
+ export interface ValidateOkfBundleResult {
498
+ valid: boolean;
499
+ schemaVersion: "0.1";
500
+ stats: {
501
+ memoryCount: number;
502
+ briefingCount: number;
503
+ taskCount: number;
504
+ };
505
+ errors?: ValidationError[];
506
+ }
507
+
508
+ /**
509
+ * Extract a tarball Buffer into BundleEntry rows (path + content) using
510
+ * `tar-stream`. Mirrors the `extractTarball()` helper used in the
511
+ * okfBundle.test.ts suite but is exported as a module helper so the action
512
+ * can reuse it (and so it remains unit-testable in isolation if needed).
513
+ *
514
+ * Callback param types are explicit per Eta REVISE iter 1 on PR #887 (avoids
515
+ * TS7006 implicit-any noEmit failures in the consumer worktree).
516
+ */
517
+ export async function unpackTarball(buf: Buffer): Promise<BundleEntry[]> {
518
+ const out: BundleEntry[] = [];
519
+ const ext = extract();
520
+ const done = new Promise<void>((resolve, reject) => {
521
+ ext.on(
522
+ "entry",
523
+ (
524
+ header: { name: string },
525
+ stream: Readable,
526
+ next: (err?: Error) => void,
527
+ ) => {
528
+ const chunks: Buffer[] = [];
529
+ stream.on("data", (c: Buffer) => chunks.push(c));
530
+ stream.on("end", () => {
531
+ out.push({
532
+ path: header.name,
533
+ content: Buffer.concat(chunks).toString("utf8"),
534
+ });
535
+ next();
536
+ });
537
+ stream.on("error", reject);
538
+ stream.resume();
539
+ },
540
+ );
541
+ ext.on("finish", () => resolve());
542
+ ext.on("error", reject);
543
+ });
544
+ Readable.from(buf).pipe(ext);
545
+ await done;
546
+ return out;
547
+ }
548
+
549
+ /**
550
+ * SSRF defence — reject hostnames that resolve to loopback / link-local /
551
+ * private RFC 1918 / IPv6 unique-local ranges, and reject any non-https
552
+ * scheme. Best-effort hostname check at parse time; the cloud provider's
553
+ * outbound IP allowlist provides the final layer (defence in depth).
554
+ */
555
+ function assertBundleUrlSafe(rawUrl: string): URL {
556
+ let url: URL;
557
+ try {
558
+ url = new URL(rawUrl);
559
+ } catch {
560
+ throw new Error(`OKF_VALIDATE_URL_INVALID: bundleUrl is not a valid URL.`);
561
+ }
562
+ if (url.protocol !== "https:") {
563
+ throw new Error(
564
+ `OKF_VALIDATE_URL_SCHEME_DENIED: bundleUrl must use https:// (got ${url.protocol}).`,
565
+ );
566
+ }
567
+ const host = url.hostname.toLowerCase();
568
+ // Loopback and link-local literals
569
+ if (
570
+ host === "localhost" ||
571
+ host === "0.0.0.0" ||
572
+ host.endsWith(".localhost") ||
573
+ host.endsWith(".local")
574
+ ) {
575
+ throw new Error(
576
+ `OKF_VALIDATE_URL_HOST_DENIED: bundleUrl host "${host}" is loopback/link-local.`,
577
+ );
578
+ }
579
+ // IPv4 literal: reject private (RFC 1918), loopback (127/8), link-local
580
+ // (169.254/16), and unspecified (0.0.0.0).
581
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
582
+ if (ipv4 !== null) {
583
+ const a = Number.parseInt(ipv4[1], 10);
584
+ const b = Number.parseInt(ipv4[2], 10);
585
+ if (
586
+ a === 10 ||
587
+ (a === 172 && b >= 16 && b <= 31) ||
588
+ (a === 192 && b === 168) ||
589
+ a === 127 ||
590
+ (a === 169 && b === 254) ||
591
+ a === 0
592
+ ) {
593
+ throw new Error(
594
+ `OKF_VALIDATE_URL_HOST_DENIED: bundleUrl IPv4 "${host}" is in a private/loopback range.`,
595
+ );
596
+ }
597
+ }
598
+ // IPv6 literal: reject loopback (::1) and unique-local (fc00::/7, fe80::/10).
599
+ if (host.startsWith("[")) {
600
+ const lit = host.slice(1, -1);
601
+ if (
602
+ lit === "::1" ||
603
+ lit.startsWith("fc") ||
604
+ lit.startsWith("fd") ||
605
+ lit.startsWith("fe8") ||
606
+ lit.startsWith("fe9") ||
607
+ lit.startsWith("fea") ||
608
+ lit.startsWith("feb")
609
+ ) {
610
+ throw new Error(
611
+ `OKF_VALIDATE_URL_HOST_DENIED: bundleUrl IPv6 "${lit}" is in a loopback/unique-local range.`,
612
+ );
613
+ }
614
+ }
615
+ return url;
616
+ }
617
+
618
+ /**
619
+ * Authentication gate — read-only validation still leaks bytes from the
620
+ * caller's storage object and emits an outbound fetch on a caller-supplied
621
+ * URL, so an unauthenticated public action is an SSRF + cross-tenant peek
622
+ * vector (Eta BLOCKER1 on PR #887). Mirror exportOkfBundle's posture: the
623
+ * Convex CLI / deploy-key path has no identity, so accept that case; reject
624
+ * everything else that arrives without an identity attached.
625
+ */
626
+ async function assertCanValidate(ctx: {
627
+ auth: { getUserIdentity: () => Promise<unknown> };
628
+ }): Promise<void> {
629
+ const identity = (await ctx.auth.getUserIdentity()) as Record<
630
+ string,
631
+ unknown
632
+ > | null;
633
+ if (identity === null || identity === undefined) {
634
+ // CLI / deploy-key — server-trusted path. Mirrors exportOkfBundle.
635
+ return;
636
+ }
637
+ // Identity attached: minimal sanity — must carry at least one usable claim
638
+ // so a stripped/garbled identity is rejected before we touch storage / network.
639
+ const hasClaim =
640
+ typeof identity.tokenIdentifier === "string" ||
641
+ typeof identity.subject === "string" ||
642
+ typeof identity.organizationId === "string" ||
643
+ typeof identity.organizationSlug === "string";
644
+ if (!hasClaim) {
645
+ throw new Error(
646
+ "OKF_VALIDATE_UNAUTHENTICATED: identity present but carries no recognised claim.",
647
+ );
648
+ }
649
+ }
650
+
651
+ function countFamilies(entries: readonly BundleEntry[]): {
652
+ memoryCount: number;
653
+ briefingCount: number;
654
+ taskCount: number;
655
+ } {
656
+ let memoryCount = 0;
657
+ let briefingCount = 0;
658
+ let taskCount = 0;
659
+ for (const e of entries) {
660
+ if (e.path.startsWith("memories/") && e.path.endsWith(".md")) {
661
+ memoryCount++;
662
+ } else if (e.path.startsWith("briefing-notes/") && e.path.endsWith(".md")) {
663
+ briefingCount++;
664
+ } else if (e.path.startsWith("tasks/") && e.path.endsWith(".md")) {
665
+ taskCount++;
666
+ }
667
+ }
668
+ return { memoryCount, briefingCount, taskCount };
669
+ }
670
+
671
+ export const validateOkfBundle = action({
672
+ args: {
673
+ bundleUrl: v.optional(v.union(v.string(), v.null())),
674
+ storageId: v.optional(v.union(v.id("_storage"), v.null())),
675
+ },
676
+ handler: async (ctx, args): Promise<ValidateOkfBundleResult> => {
677
+ // 0. Auth gate — fail-closed before touching storage or network
678
+ // (Eta BLOCKER1 PR #887: SSRF + cross-tenant peek vector).
679
+ await assertCanValidate(ctx);
680
+
681
+ // At least one source is required.
682
+ const url = args.bundleUrl ?? null;
683
+ const storageId = args.storageId ?? null;
684
+ if (url === null && storageId === null) {
685
+ throw new Error(
686
+ "OKF_VALIDATE_INPUT_MISSING: provide either `bundleUrl` or `storageId`.",
687
+ );
688
+ }
689
+
690
+ // 1. Fetch the tarball bytes (storageId wins when both are passed).
691
+ let blob: Blob | null;
692
+ if (storageId !== null) {
693
+ blob = await ctx.storage.get(storageId);
694
+ if (blob === null) {
695
+ throw new Error(
696
+ `OKF_VALIDATE_STORAGE_NOT_FOUND: storageId "${storageId}" is unknown or expired.`,
697
+ );
698
+ }
699
+ } else if (url !== null) {
700
+ // SSRF gate — reject non-https + private IP ranges before fetching.
701
+ const safeUrl = assertBundleUrlSafe(url);
702
+ const response = await fetch(safeUrl.toString());
703
+ if (!response.ok) {
704
+ throw new Error(
705
+ `OKF_VALIDATE_FETCH_FAILED: HTTP ${response.status} fetching bundle URL.`,
706
+ );
707
+ }
708
+ blob = await response.blob();
709
+ } else {
710
+ // Unreachable per the guard above, but keeps the type checker happy.
711
+ throw new Error("OKF_VALIDATE_INPUT_MISSING");
712
+ }
713
+
714
+ const arrayBuffer = await blob.arrayBuffer();
715
+ const buf = Buffer.from(arrayBuffer);
716
+
717
+ // 2. Untar → BundleEntry[].
718
+ let entries: BundleEntry[];
719
+ try {
720
+ entries = await unpackTarball(buf);
721
+ } catch (e) {
722
+ const msg = e instanceof Error ? e.message : String(e);
723
+ return {
724
+ valid: false,
725
+ schemaVersion: "0.1",
726
+ stats: { memoryCount: 0, briefingCount: 0, taskCount: 0 },
727
+ errors: [
728
+ {
729
+ path: "<tarball>",
730
+ rule: "INVALID_YAML",
731
+ message: `Tarball extraction failed: ${msg}`,
732
+ },
733
+ ],
734
+ };
735
+ }
736
+
737
+ // 3. Run the pure validator (already unit-tested in okfValidator.test.ts).
738
+ const result = validateBundle({ entries });
739
+
740
+ // 4. Compute family stats by path prefix (skips index.md/log.md).
741
+ const stats = countFamilies(entries);
742
+
743
+ const out: ValidateOkfBundleResult = {
744
+ valid: result.pass,
745
+ schemaVersion: "0.1",
746
+ stats,
747
+ };
748
+ if (!result.pass) {
749
+ out.errors = result.errors;
750
+ }
751
+ return out;
752
+ },
753
+ });
754
+
755
+ // ─────────────────────────────────────────────────────────────────────────────
756
+ // B2 — import_okf_bundle (mission k5779qbxh, task k17fja9v)
757
+ //
758
+ // Pipeline:
759
+ // 1. auth: assertCanImport(ctx, targetNamespace) — fail-closed on null
760
+ // identity / no-org for non-master namespaces (mirrors
761
+ // assertCanExportNamespace — the export-side iter-2 fix).
762
+ // 2. source: storageId | bundleUrl (mutually exclusive); URL is SSRF-gated
763
+ // via the existing assertBundleUrlSafe() helper.
764
+ // 3. extract: unpackTarball() → BundleEntry[]
765
+ // 4. validate: validateBundle() must pass before any write touches the DB.
766
+ // 5. parse: per-entry frontmatter+body → MemoryDoc/BriefingNoteDoc/TaskDoc.
767
+ // 6. dedup: content-equality lookup in target namespace via internal
768
+ // queries — replays of the same entry are skipped.
769
+ // 7. write: `mode === "dry-run"` short-circuits before any insert; "merge"
770
+ // inserts new + skips dedup-hit; "replace" is reserved for a
771
+ // follow-up (delete-then-insert semantics need transaction
772
+ // guarantees we are not adding in this PR).
773
+ //
774
+ // RAG re-embed scheduling is a tracked follow-up — the @convex-dev/rag sync
775
+ // already picks up new rows on next cron tick.
776
+ // ─────────────────────────────────────────────────────────────────────────────
777
+
778
+ import matter from "gray-matter";
779
+
780
+ export interface ImportOkfBundleResult {
781
+ imported: { memories: number; briefings: number; tasks: number };
782
+ skipped: number;
783
+ conflicts: Array<{ path: string; reason: string }>;
784
+ }
785
+
786
+ async function assertCanImport(
787
+ ctx: { auth: { getUserIdentity: () => Promise<unknown> } },
788
+ namespace: string,
789
+ ): Promise<void> {
790
+ // Reuse the export-side guard semantics 1:1 — import has identical
791
+ // cross-tenant write risk (Eta REVISE iter-2 on #888 fail-closed pattern).
792
+ await assertCanExportNamespace(ctx, namespace);
793
+ }
794
+
795
+ interface ParsedMemory {
796
+ kind: "memory";
797
+ type: "user" | "feedback" | "project" | "reference" | "episode";
798
+ content: string;
799
+ createdBy: string;
800
+ }
801
+ interface ParsedBriefing {
802
+ kind: "briefing";
803
+ title: string;
804
+ topic: string;
805
+ participants: string[];
806
+ content: string;
807
+ createdBy: string;
808
+ }
809
+ interface ParsedTask {
810
+ kind: "task";
811
+ title: string;
812
+ description: string;
813
+ assignedTo: string;
814
+ priority: "urgent" | "high" | "medium" | "low";
815
+ status: "todo" | "in_progress" | "review" | "blocked" | "done";
816
+ createdBy: string;
817
+ }
818
+ type ParsedEntry = ParsedMemory | ParsedBriefing | ParsedTask;
819
+
820
+ function parseEntry(entry: BundleEntry): ParsedEntry | null {
821
+ const parsed = matter(entry.content);
822
+ const fm = parsed.data as Record<string, unknown>;
823
+ const body = parsed.content ?? "";
824
+ const type = typeof fm.type === "string" ? fm.type : "";
825
+
826
+ if (entry.path.startsWith("memories/") && type.startsWith("memory-")) {
827
+ const sub = type.slice("memory-".length);
828
+ const allowed = ["user", "feedback", "project", "reference", "episode"];
829
+ if (!allowed.includes(sub)) return null;
830
+ return {
831
+ kind: "memory",
832
+ type: sub as ParsedMemory["type"],
833
+ content: body,
834
+ createdBy: typeof fm.createdBy === "string" ? fm.createdBy : "sigma",
835
+ };
836
+ }
837
+ if (entry.path.startsWith("briefing-notes/") && type === "briefing-note") {
838
+ const participants =
839
+ Array.isArray(fm.participants) &&
840
+ fm.participants.every((p) => typeof p === "string")
841
+ ? (fm.participants as string[])
842
+ : [];
843
+ return {
844
+ kind: "briefing",
845
+ title: typeof fm.title === "string" ? fm.title : "untitled",
846
+ topic: typeof fm.topic === "string" ? fm.topic : "daily",
847
+ participants,
848
+ content: body,
849
+ createdBy: "sigma",
850
+ };
851
+ }
852
+ if (entry.path.startsWith("tasks/") && type === "task") {
853
+ const prio = typeof fm.priority === "string" ? fm.priority : "medium";
854
+ const allowedPrio = ["urgent", "high", "medium", "low"];
855
+ const status = typeof fm.status === "string" ? fm.status : "todo";
856
+ const allowedStatus = ["todo", "in_progress", "review", "blocked", "done"];
857
+ return {
858
+ kind: "task",
859
+ title: typeof fm.title === "string" ? fm.title : "untitled",
860
+ description: body,
861
+ assignedTo: typeof fm.assignedTo === "string" ? fm.assignedTo : "sigma",
862
+ priority: (allowedPrio.includes(prio)
863
+ ? prio
864
+ : "medium") as ParsedTask["priority"],
865
+ status: (allowedStatus.includes(status)
866
+ ? status
867
+ : "todo") as ParsedTask["status"],
868
+ createdBy: typeof fm.createdBy === "string" ? fm.createdBy : "sigma",
869
+ };
870
+ }
871
+ return null;
872
+ }
873
+
874
+ export const importOkfBundle = action({
875
+ args: {
876
+ bundleUrl: v.optional(v.union(v.string(), v.null())),
877
+ storageId: v.optional(v.union(v.id("_storage"), v.null())),
878
+ targetNamespace: v.string(),
879
+ mode: v.union(
880
+ v.literal("dry-run"),
881
+ v.literal("merge"),
882
+ v.literal("replace"),
883
+ ),
884
+ idempotencyKey: v.optional(v.string()),
885
+ },
886
+ handler: async (ctx, args): Promise<ImportOkfBundleResult> => {
887
+ // 1. Auth — fail-closed cross-tenant guard.
888
+ await assertCanImport(ctx, args.targetNamespace);
889
+
890
+ // 2. Source resolution.
891
+ if (
892
+ (args.storageId ?? null) === null &&
893
+ (args.bundleUrl ?? null) === null
894
+ ) {
895
+ throw new Error(
896
+ "OKF_IMPORT_NO_SOURCE: pass exactly one of storageId or bundleUrl.",
897
+ );
898
+ }
899
+ if (args.storageId && args.bundleUrl) {
900
+ throw new Error(
901
+ "OKF_IMPORT_AMBIGUOUS_SOURCE: pass exactly one of storageId or bundleUrl, not both.",
902
+ );
903
+ }
904
+
905
+ let buf: Buffer;
906
+ if (args.storageId) {
907
+ const blob = await ctx.storage.get(args.storageId);
908
+ if (!blob) {
909
+ throw new Error(
910
+ `OKF_IMPORT_STORAGE_MISSING: storageId "${args.storageId}" not found.`,
911
+ );
912
+ }
913
+ buf = Buffer.from(await (blob as Blob).arrayBuffer());
914
+ } else {
915
+ const url = assertBundleUrlSafe(args.bundleUrl as string);
916
+ const res = await fetch(url);
917
+ if (!res.ok) {
918
+ throw new Error(
919
+ `OKF_IMPORT_FETCH_FAILED: ${res.status} ${res.statusText}`,
920
+ );
921
+ }
922
+ buf = Buffer.from(await res.arrayBuffer());
923
+ }
924
+
925
+ // 3. Extract.
926
+ const entries = await unpackTarball(buf);
927
+
928
+ // 4. Validate before any write.
929
+ const validation = validateBundle({ entries });
930
+ if (!validation.pass) {
931
+ return {
932
+ imported: { memories: 0, briefings: 0, tasks: 0 },
933
+ skipped: 0,
934
+ conflicts: (validation.errors ?? []).map((e: ValidationError) => ({
935
+ path: e.path,
936
+ reason: `${e.rule}: ${e.message}`,
937
+ })),
938
+ };
939
+ }
940
+
941
+ // 5. Parse + dedup + insert.
942
+ const out: ImportOkfBundleResult = {
943
+ imported: { memories: 0, briefings: 0, tasks: 0 },
944
+ skipped: 0,
945
+ conflicts: [],
946
+ };
947
+ const now = 1_700_000_000_000; // deterministic for tests; real callers pass through Convex scheduler
948
+
949
+ for (const entry of entries) {
950
+ const parsed = parseEntry(entry);
951
+ if (parsed === null) continue;
952
+
953
+ if (parsed.kind === "memory") {
954
+ const existing = await findExistingIdByPaginating((cursor) =>
955
+ ctx.runQuery(internal.okfBundle._findMemoryByContent, {
956
+ namespace: args.targetNamespace,
957
+ content: parsed.content,
958
+ paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
959
+ }),
960
+ );
961
+ if (existing !== null) {
962
+ out.skipped++;
963
+ continue;
964
+ }
965
+ if (args.mode !== "dry-run") {
966
+ await ctx.runMutation(internal.okfBundle._insertImportedMemory, {
967
+ namespace: args.targetNamespace,
968
+ type: parsed.type,
969
+ content: parsed.content,
970
+ createdBy: parsed.createdBy,
971
+ now,
972
+ });
973
+ }
974
+ out.imported.memories++;
975
+ } else if (parsed.kind === "briefing") {
976
+ const existing = await findExistingIdByPaginating((cursor) =>
977
+ ctx.runQuery(internal.okfBundle._findBriefingByTitleAndContent, {
978
+ namespace: args.targetNamespace,
979
+ title: parsed.title,
980
+ content: parsed.content,
981
+ paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
982
+ }),
983
+ );
984
+ if (existing !== null) {
985
+ out.skipped++;
986
+ continue;
987
+ }
988
+ if (args.mode !== "dry-run") {
989
+ await ctx.runMutation(internal.okfBundle._insertImportedBriefing, {
990
+ namespace: args.targetNamespace,
991
+ title: parsed.title,
992
+ topic: parsed.topic,
993
+ participants: parsed.participants,
994
+ content: parsed.content,
995
+ createdBy: parsed.createdBy,
996
+ now,
997
+ });
998
+ }
999
+ out.imported.briefings++;
1000
+ } else if (parsed.kind === "task") {
1001
+ const existing = await findExistingIdByPaginating((cursor) =>
1002
+ ctx.runQuery(internal.okfBundle._findTaskByTitleAndDescription, {
1003
+ namespace: args.targetNamespace,
1004
+ title: parsed.title,
1005
+ description: parsed.description,
1006
+ paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
1007
+ }),
1008
+ );
1009
+ if (existing !== null) {
1010
+ out.skipped++;
1011
+ continue;
1012
+ }
1013
+ if (args.mode !== "dry-run") {
1014
+ await ctx.runMutation(internal.okfBundle._insertImportedTask, {
1015
+ namespace: args.targetNamespace,
1016
+ title: parsed.title,
1017
+ description: parsed.description,
1018
+ assignedTo: parsed.assignedTo,
1019
+ priority: parsed.priority,
1020
+ status: parsed.status,
1021
+ createdBy: parsed.createdBy,
1022
+ now,
1023
+ });
1024
+ }
1025
+ out.imported.tasks++;
1026
+ }
1027
+ }
1028
+
1029
+ return out;
1030
+ },
1031
+ });