cursedbelt-server 4.40.3 → 4.40.4

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.
@@ -86,6 +86,27 @@ export type Wrangler = (args: string[]) => {
86
86
  * line until 4.22.0.
87
87
  */
88
88
  export declare const wranglerArgv: (bun?: string) => string[];
89
+ /**
90
+ * Run `argv` to completion with its stdout written to a FILE, never a pipe, and return what it
91
+ * wrote.
92
+ *
93
+ * 🔴 A Bun child that ends with `process.exit()` drops whatever of its stdout the pipe had not
94
+ * taken yet, at a 64 KiB boundary — and wrangler is a Bun child whenever `node` is not on PATH
95
+ * (`bun x` then runs it under Bun), which is every launchd job and the station's recovery
96
+ * backup. Measured 2026-10-01: desk's `rows` pages came back cut at exactly 655,360 and 786,432
97
+ * bytes (`JSON Parse error: Unterminated string`) on every run under a launchd PATH and never in
98
+ * a shell with nvm's node; roms' `battery_saves` was halved to 3-row pages and flix's `films`
99
+ * refused 50 times in its log. A page that was cut halved and retried until it fit in the pipe,
100
+ * so backups stayed green but did 80 wrangler calls where 31 do. A file has no buffer to drop.
101
+ */
102
+ export declare function spawnToFile(argv: string[], options?: {
103
+ cwd?: string;
104
+ env?: Record<string, string | undefined>;
105
+ }): {
106
+ code: number;
107
+ stdout: string;
108
+ stderr: string;
109
+ };
89
110
  /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
90
111
  export declare function spawnWrangler(options: {
91
112
  cwd: string;
@@ -44,7 +44,9 @@
44
44
  * below still refuses a load short of what was read.
45
45
  */
46
46
  import { Database } from 'bun:sqlite';
47
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
47
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
48
+ import { tmpdir } from 'node:os';
49
+ import { join } from 'node:path';
48
50
  /** Which side a backup must read. Pure, so every branch is testable without a network. */
49
51
  export function chooseBackupSource(input) {
50
52
  if (input.fromFileFlag) {
@@ -112,17 +114,34 @@ export const PAGE_ROWS = 500;
112
114
  * line until 4.22.0.
113
115
  */
114
116
  export const wranglerArgv = (bun = process.execPath) => [bun, 'x', 'wrangler'];
117
+ /**
118
+ * Run `argv` to completion with its stdout written to a FILE, never a pipe, and return what it
119
+ * wrote.
120
+ *
121
+ * 🔴 A Bun child that ends with `process.exit()` drops whatever of its stdout the pipe had not
122
+ * taken yet, at a 64 KiB boundary — and wrangler is a Bun child whenever `node` is not on PATH
123
+ * (`bun x` then runs it under Bun), which is every launchd job and the station's recovery
124
+ * backup. Measured 2026-10-01: desk's `rows` pages came back cut at exactly 655,360 and 786,432
125
+ * bytes (`JSON Parse error: Unterminated string`) on every run under a launchd PATH and never in
126
+ * a shell with nvm's node; roms' `battery_saves` was halved to 3-row pages and flix's `films`
127
+ * refused 50 times in its log. A page that was cut halved and retried until it fit in the pipe,
128
+ * so backups stayed green but did 80 wrangler calls where 31 do. A file has no buffer to drop.
129
+ */
130
+ export function spawnToFile(argv, options = {}) {
131
+ const dir = mkdtempSync(join(tmpdir(), 'cursedbelt-spawn-'));
132
+ const out = join(dir, 'stdout');
133
+ try {
134
+ const r = Bun.spawnSync(argv, { cwd: options.cwd, env: options.env, stdout: Bun.file(out), stderr: 'pipe' });
135
+ const stdout = existsSync(out) ? readFileSync(out, 'utf8') : '';
136
+ return { code: r.exitCode ?? 1, stdout, stderr: r.stderr.toString() };
137
+ }
138
+ finally {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ }
115
142
  /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
116
143
  export function spawnWrangler(options) {
117
- return (args) => {
118
- const r = Bun.spawnSync([...wranglerArgv(), ...args], {
119
- cwd: options.cwd,
120
- env: { ...process.env, ...options.env },
121
- stdout: 'pipe',
122
- stderr: 'pipe',
123
- });
124
- return { code: r.exitCode ?? 1, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
125
- };
144
+ return (args) => spawnToFile([...wranglerArgv(), ...args], { cwd: options.cwd, env: { ...process.env, ...options.env } });
126
145
  }
127
146
  /**
128
147
  * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
@@ -174,7 +193,14 @@ function execute(wrangler, database, sql) {
174
193
  const r = wrangler(['d1', 'execute', database, '--remote', '--env=', '--json', '--command', sql]);
175
194
  if (r.code !== 0)
176
195
  throw new Error(r.stderr.trim() || r.stdout.trim() || `wrangler exited ${r.code}`);
177
- const parsed = JSON.parse(r.stdout.slice(r.stdout.indexOf('[')));
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(r.stdout.slice(r.stdout.indexOf('[')));
199
+ }
200
+ catch (error) {
201
+ // The byte count is the diagnosis: a multiple of 65,536 is a cut pipe (see `spawnToFile`).
202
+ throw new Error(`${error.message} (${r.stdout.length} bytes of stdout)`);
203
+ }
178
204
  return parsed.map((statement) => statement.results ?? []);
179
205
  }
180
206
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.40.3",
3
+ "version": "4.40.4",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -353,7 +353,7 @@
353
353
  "dependencies": {
354
354
  "argon2id": "1.0.1",
355
355
  "cursedbelt-core": "^2.1.1",
356
- "cursedops": ">=0.8.1 <0.13.0",
356
+ "cursedops": ">=0.8.1 <0.14.0",
357
357
  "cwip": "^4.6.0",
358
358
  "jose": "^6.2.3"
359
359
  },
@@ -12,7 +12,7 @@ import {
12
12
  type Wrangler,
13
13
  wranglerArgv,
14
14
  } from './localBackup.js';
15
- import { PAGE_ROWS, pageSql, tablePlan } from './pullD1.js';
15
+ import { PAGE_ROWS, pageSql, spawnToFile, tablePlan } from './pullD1.js';
16
16
 
17
17
  const dirs: string[] = [];
18
18
  const scratch = (): string => {
@@ -58,7 +58,7 @@ describe('🔴 chooseBackupSource — the one whose failure looks like a green b
58
58
 
59
59
  describe('servedRuntime', () => {
60
60
  const serve = (body: unknown, status = 200) =>
61
- Bun.serve({ port: 0, fetch: () => Response.json(body, { status }) });
61
+ Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => Response.json(body, { status }) });
62
62
 
63
63
  test('reads `runtime` off /healthz', async () => {
64
64
  const server = serve({ runtime: 'worker' });
@@ -361,6 +361,27 @@ END;`;
361
361
  expect(wranglerArgv('/x/bun')).toEqual(['/x/bun', 'x', 'wrangler']);
362
362
  });
363
363
 
364
+ test('🔴 spawnToFile keeps ALL of a Bun child\'s stdout — a pipe loses the tail at a 64 KiB boundary', async () => {
365
+ // wrangler under `bun x` with no node on PATH (every launchd job) is exactly this child.
366
+ const dir = scratch();
367
+ const child = join(dir, 'big.js');
368
+ writeFileSync(child, "process.stdout.write('[' + JSON.stringify('x'.repeat(2_000_000)) + ']'); process.exit(0);");
369
+ const proc = Bun.spawn([process.execPath, child], { stdout: 'pipe' });
370
+ const piped = (await new Response(proc.stdout).text()).length;
371
+ await proc.exited;
372
+ // The failure this exists for: if Bun ever stops cutting the pipe, this line says so.
373
+ expect(piped).toBeLessThan(2_000_004);
374
+ const r = spawnToFile([process.execPath, child]);
375
+ expect(r.code).toBe(0);
376
+ expect(r.stdout.length).toBe(2_000_004);
377
+ expect(JSON.parse(r.stdout)).toHaveLength(1);
378
+ });
379
+
380
+ test('a cut page names its byte count, so a 64 KiB multiple is visible in the log', async () => {
381
+ const cut: Wrangler = () => ({ code: 0, stdout: '[{"results":[{"name":"ite', stderr: '' });
382
+ await expect(pull(scratch(), cut)).rejects.toThrow(/\(25 bytes of stdout\)/);
383
+ });
384
+
364
385
  test('🔴 a pull with no rows at all THROWS — never an empty library dated today', async () => {
365
386
  await expect(pull(scratch(), d1(live()).run)).rejects.toThrow(/no rows/);
366
387
  });
@@ -45,7 +45,9 @@
45
45
  */
46
46
 
47
47
  import { Database } from 'bun:sqlite';
48
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
48
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
49
+ import { tmpdir } from 'node:os';
50
+ import { join } from 'node:path';
49
51
 
50
52
  export type BackupSourceChoice = { kind: 'file' | 'd1' | 'refuse'; why: string };
51
53
 
@@ -132,17 +134,37 @@ export type Wrangler = (args: string[]) => { code: number; stdout: string; stder
132
134
  */
133
135
  export const wranglerArgv = (bun: string = process.execPath): string[] => [bun, 'x', 'wrangler'];
134
136
 
137
+ /**
138
+ * Run `argv` to completion with its stdout written to a FILE, never a pipe, and return what it
139
+ * wrote.
140
+ *
141
+ * 🔴 A Bun child that ends with `process.exit()` drops whatever of its stdout the pipe had not
142
+ * taken yet, at a 64 KiB boundary — and wrangler is a Bun child whenever `node` is not on PATH
143
+ * (`bun x` then runs it under Bun), which is every launchd job and the station's recovery
144
+ * backup. Measured 2026-10-01: desk's `rows` pages came back cut at exactly 655,360 and 786,432
145
+ * bytes (`JSON Parse error: Unterminated string`) on every run under a launchd PATH and never in
146
+ * a shell with nvm's node; roms' `battery_saves` was halved to 3-row pages and flix's `films`
147
+ * refused 50 times in its log. A page that was cut halved and retried until it fit in the pipe,
148
+ * so backups stayed green but did 80 wrangler calls where 31 do. A file has no buffer to drop.
149
+ */
150
+ export function spawnToFile(
151
+ argv: string[],
152
+ options: { cwd?: string; env?: Record<string, string | undefined> } = {},
153
+ ): { code: number; stdout: string; stderr: string } {
154
+ const dir = mkdtempSync(join(tmpdir(), 'cursedbelt-spawn-'));
155
+ const out = join(dir, 'stdout');
156
+ try {
157
+ const r = Bun.spawnSync(argv, { cwd: options.cwd, env: options.env, stdout: Bun.file(out), stderr: 'pipe' });
158
+ const stdout = existsSync(out) ? readFileSync(out, 'utf8') : '';
159
+ return { code: r.exitCode ?? 1, stdout, stderr: r.stderr.toString() };
160
+ } finally {
161
+ rmSync(dir, { recursive: true, force: true });
162
+ }
163
+ }
164
+
135
165
  /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
136
166
  export function spawnWrangler(options: { cwd: string; env?: Record<string, string | undefined> }): Wrangler {
137
- return (args) => {
138
- const r = Bun.spawnSync([...wranglerArgv(), ...args], {
139
- cwd: options.cwd,
140
- env: { ...process.env, ...options.env },
141
- stdout: 'pipe',
142
- stderr: 'pipe',
143
- });
144
- return { code: r.exitCode ?? 1, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
145
- };
167
+ return (args) => spawnToFile([...wranglerArgv(), ...args], { cwd: options.cwd, env: { ...process.env, ...options.env } });
146
168
  }
147
169
 
148
170
  /**
@@ -216,7 +238,13 @@ const sqlText = (text: string): string => `'${text.replaceAll("'", "''")}'`;
216
238
  function execute(wrangler: Wrangler, database: string, sql: string): Array<Array<Record<string, unknown>>> {
217
239
  const r = wrangler(['d1', 'execute', database, '--remote', '--env=', '--json', '--command', sql]);
218
240
  if (r.code !== 0) throw new Error(r.stderr.trim() || r.stdout.trim() || `wrangler exited ${r.code}`);
219
- const parsed = JSON.parse(r.stdout.slice(r.stdout.indexOf('['))) as Array<{ results?: Array<Record<string, unknown>> }>;
241
+ let parsed: Array<{ results?: Array<Record<string, unknown>> }>;
242
+ try {
243
+ parsed = JSON.parse(r.stdout.slice(r.stdout.indexOf('[')));
244
+ } catch (error) {
245
+ // The byte count is the diagnosis: a multiple of 65,536 is a cut pipe (see `spawnToFile`).
246
+ throw new Error(`${(error as Error).message} (${r.stdout.length} bytes of stdout)`);
247
+ }
220
248
  return parsed.map((statement) => statement.results ?? []);
221
249
  }
222
250
 
@@ -131,9 +131,9 @@ describe('diskStore + serveRange', () => {
131
131
  await store.putStream('fs/tenant1/live.bin', body, 'application/octet-stream');
132
132
  return c.json({ ok: true });
133
133
  });
134
- const server = Bun.serve({ fetch: app.fetch, port: 0 });
134
+ const server = Bun.serve({ hostname: "127.0.0.1", fetch: app.fetch, port: 0 });
135
135
  try {
136
- const res = await fetch(`http://localhost:${server.port}/u`, {
136
+ const res = await fetch(`http://127.0.0.1:${server.port}/u`, {
137
137
  method: 'PUT',
138
138
  body: 'live request bytes',
139
139
  });