@fungi.computer/bonsai 0.0.0-stage → 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 (133) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +848 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,388 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { expect, test } from "vitest";
3
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
4
+ import { commitFiles } from "@fungi.computer/agar/git-commit";
5
+ import {
6
+ countingGit,
7
+ ReadCopyBudgetExceeded,
8
+ } from "@fungi.computer/agar/read-copy";
9
+ import { realGit } from "@fungi.computer/agar/test-kit/git";
10
+ import { gitStore, readCopyFrame } from "../backend/git.js";
11
+ import { BonsaiState, ensureBonsaiTables } from "../backend/state.js";
12
+ import { appBackend } from "../backend/methods.js";
13
+ import { observePush } from "../backend/service.js";
14
+ import { getRepoDetail } from "../backend/repository-copy.js";
15
+ import {
16
+ listFilesCopy,
17
+ listTreeCopy,
18
+ readFileCopy,
19
+ } from "../backend/files-copy.js";
20
+ import {
21
+ readGraph,
22
+ readCommits,
23
+ readCommitDetail,
24
+ } from "../backend/graph-commit-copies.js";
25
+ import { commitDetail, graph, type JsonValue } from "../shared/contract.js";
26
+ import { copyRebuildPage, fileList } from "@fungi.computer/agar/roles/files";
27
+
28
+ const actor = {
29
+ actor: { kind: "member" as const, memberId: "owner" },
30
+ computerId: null,
31
+ };
32
+ const author = { name: "Morel", email: "morel@test.invalid" };
33
+ const text = (value: string) => new TextEncoder().encode(value);
34
+
35
+ async function fixture() {
36
+ const provider = realGit();
37
+ const database = new DatabaseSync(":memory:");
38
+ const owner = fromNodeSqlite(database);
39
+ ensureBonsaiTables(owner.sql);
40
+ const state = new BonsaiState(owner.sql, owner.transactionSync);
41
+ await provider.git.create("pond");
42
+ state.registerRepo({
43
+ name: "pond",
44
+ description: null,
45
+ parent: null,
46
+ owner: null,
47
+ });
48
+ const written = await commitFiles(provider.git, {
49
+ repository: "pond",
50
+ branch: "main",
51
+ expectedHead: null,
52
+ changes: [{ path: "README.md", content: text("Pond\n") }],
53
+ message: "Plant pond",
54
+ author,
55
+ });
56
+ if (written.kind !== "committed") throw new Error("Fixture commit failed");
57
+ const lifetime = new AbortController();
58
+ const counted = countingGit(provider.git, {
59
+ limit: 10000,
60
+ signal: lifetime.signal,
61
+ });
62
+ const runtime = {
63
+ state,
64
+ store: gitStore(counted.git),
65
+ events: { emit: () => Promise.resolve() },
66
+ };
67
+ await getRepoDetail(runtime, "pond");
68
+ await readCopyFrame(runtime.store, async (store) => {
69
+ const bounded = { ...runtime, store };
70
+ await listFilesCopy(bounded, { repo: "pond", ref: "main" });
71
+ await listTreeCopy(bounded, { repo: "pond", ref: "main", path: "" });
72
+ await readFileCopy(bounded, {
73
+ repo: "pond",
74
+ ref: "main",
75
+ path: "README.md",
76
+ });
77
+ await readGraph(bounded, "pond");
78
+ await readCommits(bounded, { repo: "pond", ref: "main" });
79
+ await readCommitDetail(bounded, { repo: "pond", hash: written.commit });
80
+ });
81
+ const reopen = () => ({
82
+ ...runtime,
83
+ state: new BonsaiState(owner.sql, owner.transactionSync),
84
+ });
85
+ const call = (method: string, input: JsonValue, bonsai = runtime) =>
86
+ appBackend.call(method, input, { bonsai }, actor);
87
+ return {
88
+ provider,
89
+ counted,
90
+ runtime,
91
+ hash: written.commit,
92
+ reopen,
93
+ call,
94
+ close() {
95
+ lifetime.abort();
96
+ database.close();
97
+ provider.close();
98
+ },
99
+ };
100
+ }
101
+
102
+ const mutableReads = [
103
+ ["files.list", { repo: "pond", ref: "main" }],
104
+ ["files.tree", { repo: "pond", ref: "main", path: "" }],
105
+ ["files.read", { repo: "pond", ref: "main", path: "README.md" }],
106
+ ["graph.get", { repo: "pond" }],
107
+ ["commits.list", { repo: "pond", ref: "main" }],
108
+ ] satisfies [string, JsonValue][];
109
+
110
+ test("public Files and Graph/Commits methods use ready copies after SQLite reconstruction with zero Git", async () => {
111
+ const f = await fixture();
112
+ try {
113
+ const runtime = f.reopen();
114
+ const before = f.counted.total();
115
+ for (const [method, input] of mutableReads) {
116
+ expect(await f.call(method, input, runtime)).not.toMatchObject({
117
+ kind: "rebuilding",
118
+ });
119
+ }
120
+ expect(
121
+ await f.call("commits.get", { repo: "pond", hash: f.hash }, runtime),
122
+ ).toMatchObject({ commit: { hash: f.hash } });
123
+ expect(f.counted.total() - before).toBe(0);
124
+ } finally {
125
+ f.close();
126
+ }
127
+ });
128
+
129
+ test("a push exhausting before metadata blocks every later mutable view after reopen and foreground pages recover it", async () => {
130
+ const f = await fixture();
131
+ try {
132
+ const written = await commitFiles(f.provider.git, {
133
+ repository: "pond",
134
+ branch: "main",
135
+ expectedHead: f.hash,
136
+ changes: [
137
+ { path: "README.md", content: text("New pond\n") },
138
+ ...Array.from({ length: 60 }, (_, i) => ({
139
+ path: `dir${String(i)}/fish.txt`,
140
+ content: text(`Fish ${String(i)}\n`),
141
+ })),
142
+ ],
143
+ message: "Grow pond",
144
+ author,
145
+ });
146
+ if (written.kind !== "committed")
147
+ throw new Error("Large fixture commit failed");
148
+ const before = f.counted.total();
149
+ await expect(
150
+ observePush(f.runtime, {
151
+ repository: "pond",
152
+ writer: { kind: "actor", actor: actor.actor },
153
+ eventId: "grow-pond",
154
+ updates: [
155
+ { ref: "refs/heads/main", before: f.hash, after: written.commit },
156
+ ],
157
+ }),
158
+ ).rejects.toBeInstanceOf(ReadCopyBudgetExceeded);
159
+ expect(f.counted.total() - before).toBeLessThanOrEqual(40);
160
+ const runtime = f.reopen();
161
+ // Retain the OLD ready metadata to prove the pending gate, not just a missing row.
162
+ expect(runtime.state.repositoryCopies.read("pond")).toMatchObject({
163
+ kind: "ready",
164
+ head: f.hash,
165
+ });
166
+ const blockedAt = f.counted.total();
167
+ for (const [method, input] of mutableReads)
168
+ expect(await f.call(method, input, runtime)).toEqual({
169
+ kind: "rebuilding",
170
+ });
171
+ expect(f.counted.total() - blockedAt).toBe(0);
172
+ expect(
173
+ await f.call("commits.get", { repo: "pond", hash: f.hash }, runtime),
174
+ ).toMatchObject({ commit: { hash: f.hash } });
175
+ let cursor: string | null = null;
176
+ let done = false;
177
+ let pages = 0;
178
+ while (!done && pages < 30) {
179
+ const start = f.counted.total();
180
+ const page = copyRebuildPage.parse(
181
+ await f.call("rebuildCopies", { cursor }, runtime),
182
+ );
183
+ expect(f.counted.total() - start).toBeLessThanOrEqual(40);
184
+ cursor = page.cursor;
185
+ done = page.done;
186
+ pages++;
187
+ }
188
+ expect(done).toBe(true);
189
+ expect(runtime.state.repositoryRefStatus("pond").pending).toBe(false);
190
+ const readyAt = f.counted.total();
191
+ expect(
192
+ fileList.parse(
193
+ await f.call("files.list", { repo: "pond", ref: "main" }, runtime),
194
+ ).commit.hash,
195
+ ).toBe(written.commit);
196
+ expect(
197
+ await f.call(
198
+ "files.read",
199
+ { repo: "pond", ref: "main", path: "README.md" },
200
+ runtime,
201
+ ),
202
+ ).toMatchObject({ text: "New pond\n" });
203
+ expect(
204
+ await f.call(
205
+ "files.tree",
206
+ { repo: "pond", ref: "main", path: "" },
207
+ runtime,
208
+ ),
209
+ ).toMatchObject({ commit: { hash: written.commit } });
210
+ expect(
211
+ graph.parse(await f.call("graph.get", { repo: "pond" }, runtime)).lanes[0]
212
+ ?.head,
213
+ ).toBe(written.commit);
214
+ expect(
215
+ await f.call("commits.list", { repo: "pond", ref: "main" }, runtime),
216
+ ).toMatchObject({
217
+ commits: [
218
+ { commit: { hash: written.commit } },
219
+ { commit: { hash: f.hash } },
220
+ ],
221
+ });
222
+ expect(
223
+ await f.call(
224
+ "commits.get",
225
+ { repo: "pond", hash: written.commit },
226
+ runtime,
227
+ ),
228
+ ).toMatchObject({ commit: { hash: written.commit } });
229
+ expect(f.counted.total() - readyAt).toBe(0);
230
+ } finally {
231
+ f.close();
232
+ }
233
+ });
234
+
235
+ test("a cold public Commit detail exhausting its frame returns rebuilding without a partial result", async () => {
236
+ const f = await fixture();
237
+ try {
238
+ const written = await commitFiles(f.provider.git, {
239
+ repository: "pond",
240
+ branch: "main",
241
+ expectedHead: f.hash,
242
+ changes: Array.from({ length: 60 }, (_, i) => ({
243
+ path: `fish-${String(i)}.txt`,
244
+ content: text(`Fish ${String(i)}\n`),
245
+ })),
246
+ message: "More fish",
247
+ author,
248
+ });
249
+ if (written.kind !== "committed")
250
+ throw new Error("Large fixture commit failed");
251
+ const before = f.counted.total();
252
+ expect(
253
+ await f.call("commits.get", { repo: "pond", hash: written.commit }),
254
+ ).toEqual({ kind: "rebuilding" });
255
+ expect(f.counted.total() - before).toBeLessThanOrEqual(40);
256
+ } finally {
257
+ f.close();
258
+ }
259
+ });
260
+
261
+ /** A fresh repository whose first push is a whole App tree: too large for one
262
+ * frame to observe, and too large for one commit diff row to hold every text. */
263
+ async function wholeTreeRepository() {
264
+ const provider = realGit();
265
+ const database = new DatabaseSync(":memory:");
266
+ const owner = fromNodeSqlite(database);
267
+ ensureBonsaiTables(owner.sql);
268
+ const state = new BonsaiState(owner.sql, owner.transactionSync);
269
+ await provider.git.create("app");
270
+ state.registerRepo({
271
+ name: "app",
272
+ description: null,
273
+ parent: null,
274
+ owner: null,
275
+ });
276
+ const page = "x".repeat(13_000);
277
+ const written = await commitFiles(provider.git, {
278
+ repository: "app",
279
+ branch: "main",
280
+ expectedHead: null,
281
+ changes: Array.from({ length: 64 }, (_, i) => ({
282
+ path: `pkg${String(i % 16)}/src/file${String(i)}.ts`,
283
+ content: text(`// ${String(i)}\n${page}\n`),
284
+ })),
285
+ message: "Whole App",
286
+ author,
287
+ });
288
+ if (written.kind !== "committed") throw new Error("Whole tree commit failed");
289
+ // A provider that answers after a delay, as a remote one does: every read
290
+ // a frame launches is still in flight when its budget runs out.
291
+ const later = <T>(value: () => Promise<T>) =>
292
+ new Promise<T>((resolve, reject) => {
293
+ setTimeout(() => {
294
+ value().then(resolve, reject);
295
+ }, 2);
296
+ });
297
+ const remote: typeof provider.git = {
298
+ ...provider.git,
299
+ readTree: (...args) => later(() => provider.git.readTree(...args)),
300
+ readBlob: (...args) => later(() => provider.git.readBlob(...args)),
301
+ readCommit: (...args) => later(() => provider.git.readCommit(...args)),
302
+ };
303
+ const runtime = {
304
+ state,
305
+ store: gitStore(remote),
306
+ events: { emit: () => Promise.resolve() },
307
+ };
308
+ const call = (method: string, input: JsonValue) =>
309
+ appBackend.call(method, input, { bonsai: runtime }, actor);
310
+ return {
311
+ provider,
312
+ runtime,
313
+ head: written.commit,
314
+ call,
315
+ close() {
316
+ database.close();
317
+ provider.close();
318
+ },
319
+ };
320
+ }
321
+
322
+ const zero = "0".repeat(40);
323
+
324
+ test("a fresh repository's whole-tree first push recovers through foreground pages", async () => {
325
+ // Each page reads Git, which the host grants only to a method declaring it.
326
+ expect(appBackend.methods.rebuildCopies?.policy.requires).toContain(
327
+ "git.repository",
328
+ );
329
+ const f = await wholeTreeRepository();
330
+ try {
331
+ await expect(
332
+ observePush(f.runtime, {
333
+ repository: "app",
334
+ writer: { kind: "actor", actor: actor.actor },
335
+ eventId: "whole-tree",
336
+ updates: [{ ref: "refs/heads/main", before: zero, after: f.head }],
337
+ }),
338
+ ).rejects.toBeInstanceOf(ReadCopyBudgetExceeded);
339
+ let cursor: string | null = null;
340
+ let done = false;
341
+ for (let pages = 0; !done && pages < 60; pages++) {
342
+ const page = copyRebuildPage.parse(
343
+ await f.call("rebuildCopies", { cursor }),
344
+ );
345
+ cursor = page.cursor;
346
+ done = page.done;
347
+ }
348
+ expect(done).toBe(true);
349
+ // The root commit's diff fits its copy row: texts that would not fit are
350
+ // listed as too large to show, with their line counts.
351
+ const detail = commitDetail.parse(
352
+ await f.call("commits.get", { repo: "app", hash: f.head }),
353
+ );
354
+ expect(detail.diff.files.some((file) => file.truncated)).toBe(true);
355
+ expect(detail.diff.files.some((file) => file.newText !== null)).toBe(true);
356
+ } finally {
357
+ f.close();
358
+ }
359
+ });
360
+
361
+ test("a new branch's push diffs against the default head within one frame", async () => {
362
+ const f = await wholeTreeRepository();
363
+ try {
364
+ await getRepoDetail(f.runtime, "app");
365
+ const edited = await commitFiles(f.provider.git, {
366
+ repository: "app",
367
+ branch: "edit",
368
+ expectedHead: null,
369
+ startFrom: "main",
370
+ changes: [{ path: "pkg0/src/file0.ts", content: text("// edited\n") }],
371
+ message: "Edit one file",
372
+ author,
373
+ });
374
+ if (edited.kind !== "committed") throw new Error("Edit commit failed");
375
+ await observePush(f.runtime, {
376
+ repository: "app",
377
+ writer: { kind: "actor", actor: actor.actor },
378
+ eventId: "new-branch",
379
+ updates: [{ ref: "refs/heads/edit", before: zero, after: edited.commit }],
380
+ });
381
+ const detail = await getRepoDetail(f.runtime, "app");
382
+ expect(detail.refs).toContainEqual(
383
+ expect.objectContaining({ name: "refs/heads/edit", hash: edited.commit }),
384
+ );
385
+ } finally {
386
+ f.close();
387
+ }
388
+ });
@@ -0,0 +1,214 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { rmSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { expect, it } from "vitest";
5
+ import { commitFiles } from "@fungi.computer/agar/git-commit";
6
+ import { countingGit } from "@fungi.computer/agar/read-copy";
7
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
8
+ import { realGit } from "@fungi.computer/agar/test-kit/git";
9
+ import { observePush } from "../backend/service.js";
10
+ import { commitDetail, jsonValue, type JsonValue } from "../shared/contract.js";
11
+ import { commitPage } from "@fungi.computer/agar/roles/files";
12
+ import { appBackend } from "../backend/methods.js";
13
+ import { gitStore } from "../backend/git.js";
14
+ import { getRepoDetail } from "../backend/repository-copy.js";
15
+ import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
16
+ import { invalidateFilesCopies } from "../backend/files-copy.js";
17
+ import { invalidateGraphCommitCopies } from "../backend/graph-commit-copies.js";
18
+
19
+ const caller = {
20
+ actor: { kind: "member" as const, memberId: "Levi" },
21
+ computerId: null,
22
+ role: "owner" as const,
23
+ };
24
+ const author = { name: "Morel", email: "morel@test.invalid" };
25
+
26
+ async function fixture() {
27
+ const provider = realGit();
28
+ const database = new DatabaseSync(":memory:");
29
+ const owner = fromNodeSqlite(database);
30
+ ensureBonsaiTables(owner.sql);
31
+ const controller = new AbortController();
32
+ const binding = {
33
+ ...provider.git,
34
+ delete: async (name: string) => {
35
+ if ((await provider.git.info(name)) === null) return false;
36
+ rmSync(fileURLToPath(await provider.git.cloneUrl(name)), {
37
+ recursive: true,
38
+ });
39
+ return true;
40
+ },
41
+ };
42
+ const counted = countingGit(binding, {
43
+ limit: 10_000,
44
+ signal: controller.signal,
45
+ });
46
+ const bonsai = {
47
+ state: new BonsaiState(owner.sql, owner.transactionSync),
48
+ store: gitStore(counted.git),
49
+ events: { emit: () => Promise.resolve() },
50
+ };
51
+ const registration = {
52
+ name: "pond",
53
+ description: null,
54
+ parent: null,
55
+ owner: null,
56
+ };
57
+ await provider.git.create("pond");
58
+ bonsai.state.registerRepo(registration);
59
+ const result = await commitFiles(provider.git, {
60
+ repository: "pond",
61
+ branch: "main",
62
+ expectedHead: null,
63
+ author,
64
+ message: "Add fish",
65
+ changes: [
66
+ { path: "notes.txt", content: new TextEncoder().encode("Fish\n") },
67
+ ],
68
+ });
69
+ if (result.kind !== "committed") throw new Error("Fixture did not commit");
70
+ await getRepoDetail(bonsai, "pond");
71
+ const reopen = () => ({
72
+ ...bonsai,
73
+ state: new BonsaiState(owner.sql, owner.transactionSync),
74
+ });
75
+ return {
76
+ bonsai,
77
+ provider,
78
+ counted,
79
+ hash: result.commit,
80
+ reopen,
81
+ async recreate() {
82
+ invalidateFilesCopies(bonsai, "pond");
83
+ invalidateGraphCommitCopies(bonsai, "pond");
84
+ bonsai.state.removeRepo("pond");
85
+ await counted.git.delete("pond");
86
+ bonsai.state.completeRepoDeletion("pond");
87
+ await provider.git.create("pond");
88
+ bonsai.state.registerRepo(registration);
89
+ const runtime = reopen();
90
+ await getRepoDetail(runtime, "pond");
91
+ return runtime;
92
+ },
93
+ close() {
94
+ controller.abort();
95
+ database.close();
96
+ provider.close();
97
+ },
98
+ };
99
+ }
100
+
101
+ for (const method of [
102
+ "files.list",
103
+ "files.tree",
104
+ "files.read",
105
+ "commits.get",
106
+ "commits.list",
107
+ ] as const) {
108
+ it(`${method} does not serve an old saved OID after deletion and same-name recreation across SQLite reopen`, async () => {
109
+ const f = await fixture();
110
+ try {
111
+ const input =
112
+ method === "commits.get"
113
+ ? { repo: "pond", hash: f.hash }
114
+ : method === "files.list" || method === "commits.list"
115
+ ? { repo: "pond", ref: f.hash }
116
+ : {
117
+ repo: "pond",
118
+ ref: f.hash,
119
+ path: method === "files.read" ? "notes.txt" : "",
120
+ };
121
+ const first = await appBackend.call(
122
+ method,
123
+ input,
124
+ { bonsai: f.bonsai },
125
+ caller,
126
+ );
127
+ const before = f.counted.total();
128
+ expect(
129
+ await appBackend.call(method, input, { bonsai: f.reopen() }, caller),
130
+ ).toEqual(first);
131
+ expect(f.counted.total() - before).toBe(0);
132
+ const recreated = await f.recreate();
133
+ if (method === "commits.list")
134
+ await expect(
135
+ appBackend.call(method, input, { bonsai: recreated }, caller),
136
+ ).resolves.toEqual({ ref: f.hash, commits: [] });
137
+ else
138
+ await expect(
139
+ appBackend.call(method, input, { bonsai: recreated }, caller),
140
+ ).rejects.toMatchObject({ code: "not_found" });
141
+ } finally {
142
+ f.close();
143
+ }
144
+ });
145
+ }
146
+
147
+ it("keeps saved immutable views warm with zero Git after a normal push and SQLite reopen", async () => {
148
+ const f = await fixture();
149
+ try {
150
+ const reads = [
151
+ ["files.list", { repo: "pond", ref: f.hash }],
152
+ ["files.tree", { repo: "pond", ref: f.hash, path: "" }],
153
+ ["files.read", { repo: "pond", ref: f.hash, path: "notes.txt" }],
154
+ ["commits.get", { repo: "pond", hash: f.hash }],
155
+ ["commits.list", { repo: "pond", ref: f.hash }],
156
+ ] satisfies [string, JsonValue][];
157
+ const content = (method: string, value: JsonValue) => {
158
+ if (method === "commits.get") {
159
+ const detail = commitDetail.parse(value);
160
+ return { commit: detail.commit, diff: detail.diff };
161
+ }
162
+ if (method === "commits.list")
163
+ return commitPage.parse(value).commits.map((item) => item.commit);
164
+ return value;
165
+ };
166
+ const saved = [];
167
+ for (const [method, input] of reads)
168
+ saved.push(
169
+ content(
170
+ method,
171
+ jsonValue.parse(
172
+ await appBackend.call(method, input, { bonsai: f.bonsai }, caller),
173
+ ),
174
+ ),
175
+ );
176
+ const result = await commitFiles(f.provider.git, {
177
+ repository: "pond",
178
+ branch: "main",
179
+ expectedHead: f.hash,
180
+ author,
181
+ message: "Add more fish",
182
+ changes: [
183
+ { path: "notes.txt", content: new TextEncoder().encode("More fish\n") },
184
+ ],
185
+ });
186
+ if (result.kind !== "committed")
187
+ throw new Error("Push fixture did not commit");
188
+ await observePush(f.bonsai, {
189
+ repository: "pond",
190
+ writer: { kind: "actor", actor: caller.actor },
191
+ eventId: "lifetime-push",
192
+ updates: [
193
+ { ref: "refs/heads/main", before: f.hash, after: result.commit },
194
+ ],
195
+ });
196
+ const reopened = f.reopen();
197
+ expect(reopened.state.repositoryGeneration("pond")).toBe(1);
198
+ const before = f.counted.total();
199
+ const restored = [];
200
+ for (const [method, input] of reads)
201
+ restored.push(
202
+ content(
203
+ method,
204
+ jsonValue.parse(
205
+ await appBackend.call(method, input, { bonsai: reopened }, caller),
206
+ ),
207
+ ),
208
+ );
209
+ expect(restored).toEqual(saved);
210
+ expect(f.counted.total() - before).toBe(0);
211
+ } finally {
212
+ f.close();
213
+ }
214
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "bonsai-read-copy-rpc-proof",
3
+ "compatibility_date": "2026-09-04",
4
+ "compatibility_flags": ["nodejs_compat"],
5
+ "services": [
6
+ {
7
+ "binding": "GIT",
8
+ "service": "bonsai-read-copy-rpc-proof",
9
+ "entrypoint": "GitRpc",
10
+ },
11
+ ],
12
+ }
@@ -0,0 +1,47 @@
1
+ /** countingGit must accept native RPC methods without dispatching Function.bind remotely. */
2
+ import assert from "node:assert/strict";
3
+ import { URL } from "node:url";
4
+ import { log } from "node:console";
5
+ import { unstable_dev } from "wrangler";
6
+ import * as z from "zod";
7
+ const repository = z.strictObject({
8
+ name: z.string(),
9
+ defaultBranch: z.string(),
10
+ lastPushAt: z.string().nullable(),
11
+ source: z.string().nullable(),
12
+ });
13
+ const result = z.strictObject({
14
+ direct: repository,
15
+ wrapped: repository,
16
+ total: z.literal(1),
17
+ counts: z.strictObject({ info: z.literal(1) }),
18
+ exhausted: z.literal(true),
19
+ aborted: z.literal(true),
20
+ });
21
+ const worker = await unstable_dev(
22
+ new URL("./read-copy-rpc.worker.ts", import.meta.url).pathname,
23
+ {
24
+ config: new URL("./read-copy-rpc.config.jsonc", import.meta.url).pathname,
25
+ local: true,
26
+ ip: "127.0.0.1",
27
+ port: 0,
28
+ logLevel: "none",
29
+ experimental: {
30
+ disableExperimentalWarning: true,
31
+ disableDevRegistry: true,
32
+ },
33
+ },
34
+ );
35
+ try {
36
+ const response = await worker.fetch();
37
+ assert.equal(
38
+ response.status,
39
+ 200,
40
+ "countingGit must wrap the real native GIT RPC binding",
41
+ );
42
+ const value = result.parse(await response.json());
43
+ assert.deepEqual(value.direct, value.wrapped);
44
+ log("PASS: native GIT RPC counting preserves dispatch, budget and abort");
45
+ } finally {
46
+ await worker.stop();
47
+ }