cursedbelt-server 4.3.0 โ†’ 4.4.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.
@@ -15,7 +15,6 @@
15
15
  * ```
16
16
  */
17
17
  export { backupFor, type BackupPoint, checkpointWal, createLocalBackup, createTimeTravelBackup, type DatabaseBackup, type LocalBackupOpts, type TimeTravelOpts, } from './backup';
18
- export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
19
18
  export { type InvocationD1, perInvocation } from './invocation';
20
19
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
21
20
  export { createLocalD1, refuseInteractiveTransaction } from './local';
@@ -15,7 +15,13 @@
15
15
  * ```
16
16
  */
17
17
  export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
18
- export { createD1Kysely, D1LikeDialect } from './kysely';
18
+ // ๐Ÿ”ด `./kysely` is deliberately NOT re-exported here โ€” import it from
19
+ // `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
20
+ // `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
21
+ // made `import 'cursedbelt-server/d1'` throw `Cannot find package 'kysely'` for every app
22
+ // that does not use the query builder โ€” which per `../db/kysely.ts`'s own header is most
23
+ // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
24
+ // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
19
25
  export { perInvocation } from './invocation';
20
26
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
21
27
  export { createLocalD1, refuseInteractiveTransaction } from './local';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -78,6 +78,12 @@
78
78
  "source": "./src/server/d1/index.ts",
79
79
  "import": "./dist/server/d1/index.js"
80
80
  },
81
+ "./d1/kysely": {
82
+ "types": "./dist/server/d1/kysely.d.ts",
83
+ "bun": "./src/server/d1/kysely.ts",
84
+ "source": "./src/server/d1/kysely.ts",
85
+ "import": "./dist/server/d1/kysely.js"
86
+ },
81
87
  "./d1/testing": {
82
88
  "types": "./dist/server/d1/fakeD1.d.ts",
83
89
  "bun": "./src/server/d1/fakeD1.ts",
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pkg from '../package.json';
5
+
6
+ /**
7
+ * A public subpath must not STATICALLY drag an OPTIONAL peer, because a static import is
8
+ * paid at import time by every consumer โ€” including the ones that will never call it.
9
+ *
10
+ * ## ๐Ÿ”ด This is the same defect for the THIRD time, and the first two are why the rule is
11
+ * a check rather than a sentence
12
+ *
13
+ * `cursedbelt/src/barrelsReachNoOptionalPeer.spec.ts` carries the full history; the short
14
+ * version is that `apps/collections` once imported a TYPE from `cursedbelt/server/storage`
15
+ * and paid for it with `error: Cannot find package 'otplib'` and four dead route suites,
16
+ * because the barrel reached `streamToken.ts` โ†’ the `../auth` index โ†’ `totp.ts` โ†’ an
17
+ * optional peer. The app installed a one-time-password library to satisfy a claim shape.
18
+ *
19
+ * When the server tier split into this package (task 148), the RUNTIME-FIXTURE half came
20
+ * with it as `leafSubpathsImportNothing.spec.ts` โ€” but that spec proves what a declared
21
+ * ZERO-RUNTIME LEAF pulls in, and a barrel is neither. So the barrel half stayed behind in
22
+ * `cursedbelt`, pointed at `./react`, and **this package shipped with no barrel check at
23
+ * all.**
24
+ *
25
+ * ๐Ÿ”ด It cost exactly what the first two cost, measured 2026-09-18 against the PUBLISHED
26
+ * `cursedbelt-server@4.3.0` tarball in a clean directory:
27
+ *
28
+ * $ bun add cursedbelt-server && bun -e 'import "cursedbelt-server/d1"'
29
+ * error: Cannot find package 'kysely' from โ€ฆ/src/server/d1/kysely.ts
30
+ *
31
+ * `./d1`'s index re-exported `./kysely`, which statically imports real VALUES from
32
+ * `kysely` (`Kysely`, `SqliteAdapter`, `SqliteQueryCompiler` โ€” not types, so they cannot be
33
+ * erased). `kysely` is an optional peer. So the D1 seam โ€” the thing the whole fleet is
34
+ * meant to port ONTO โ€” could not be imported by any app that had not already installed a
35
+ * query builder, which by `../db/kysely.ts`'s own header is most of them: *"Business /
36
+ * `data JSON` tables stay on raw `db.query()` โ€” do NOT add them here"*. The seam was
37
+ * unusable by precisely the apps it was built for.
38
+ *
39
+ * The fix was to give `createD1Kysely` its own subpath (`./d1/kysely`) and take it out of
40
+ * the barrel. This spec is what stops the fourth occurrence.
41
+ *
42
+ * ## What it measures
43
+ *
44
+ * Every subpath in the `exports` map is bundled with bare imports left external, and the
45
+ * remaining static specifiers are read out of the emitted JS. A DYNAMIC `await
46
+ * import('sharp')` inside the function that needs it matches neither branch of
47
+ * {@link STATIC_SPECIFIER} and must not โ€” that is the lazy shape this file exists to
48
+ * permit, not forbid.
49
+ *
50
+ * ## Verified failing before it was trusted (2026-09-18)
51
+ *
52
+ * ยท re-adding `export โ€ฆ from './kysely'` to `src/server/d1/index.ts`
53
+ * โ†’ red: "./d1 statically drags optional peer(s): kysely"
54
+ * ยท adding `import 'plainjob';` to `src/server/d1/local.ts` โ€” two hops out of the
55
+ * barrel, which grepping the index file cannot see
56
+ * โ†’ red identically. That is the branch that matters.
57
+ * ยท removing `'./jobs'` from {@link MAY_DRAG} โ†’ red, proving the allowlist is load-bearing
58
+ * rather than decorative.
59
+ */
60
+
61
+ const REPO = fileURLToPath(new URL('..', import.meta.url));
62
+
63
+ /**
64
+ * The subpaths whose whole PURPOSE is the optional peer they pull, mapped to what they are
65
+ * allowed to pull and why. An entry here is a promise that the peer is the point of the
66
+ * subpath โ€” not a place to park a new drag.
67
+ *
68
+ * ๐Ÿ”ด Measured 2026-09-18: these three are the ONLY subpaths in the map that reach an
69
+ * optional peer. Everything else is clean, which is what makes this list an allowlist
70
+ * rather than a baseline โ€” it can only shrink.
71
+ */
72
+ const MAY_DRAG: Record<string, readonly string[]> = {
73
+ // The whole-package barrel. It exists to re-export everything, so it necessarily reaches
74
+ // what the specific subpaths reach. An app importing `cursedbelt-server` whole is asking
75
+ // for that; an app importing `cursedbelt-server/d1` is not, and that is the distinction
76
+ // this spec protects.
77
+ '.': ['kysely', 'kysely-bun-sqlite', 'otplib', 'plainjob'],
78
+ // `createD1Kysely` IS the kysely adapter. Split out of `./d1` on 2026-09-18 precisely so
79
+ // the seam itself stops paying for it โ€” see the header.
80
+ './d1/kysely': ['kysely'],
81
+ // The job queue is plainjob. Nothing else here is.
82
+ './jobs': ['plainjob'],
83
+ };
84
+
85
+ const OPTIONAL_PEERS = new Set(
86
+ Object.entries(
87
+ (pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> }).peerDependenciesMeta ?? {},
88
+ )
89
+ .filter(([, meta]) => meta?.optional === true)
90
+ .map(([name]) => name),
91
+ );
92
+
93
+ /**
94
+ * `import x from "pkg"` ยท `export โ€ฆ from "pkg"` ยท the side-effect-only `import "pkg";`.
95
+ *
96
+ * Anchored on the `from` clause rather than on a line starting with `import`, so a
97
+ * multi-line named import still counts โ€” bun emits `import {\n โ€ฆ \n} from "pkg";` and a
98
+ * line-anchored pattern silently misses it. A dynamic `import("pkg")` matches neither
99
+ * branch, which is deliberate.
100
+ */
101
+ const STATIC_SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
102
+
103
+ /** `@scope/name/deep` โ†’ `@scope/name`, `pkg/deep` โ†’ `pkg`. */
104
+ const packageOf = (specifier: string): string | undefined => {
105
+ const parts = specifier.split('/');
106
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
107
+ };
108
+
109
+ /**
110
+ * Bundle one entry and return the bare packages it still imports.
111
+ *
112
+ * ๐Ÿ”ด A bundle that did not happen must never read as a subpath that pulls nothing, so a
113
+ * non-zero exit or an empty bundle THROWS rather than returning an empty set. That is the
114
+ * one way a check like this dies quietly.
115
+ */
116
+ const staticExternalsOf = (entry: string, subpath: string): Set<string> => {
117
+ const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-server-barrel-scan/${subpath.replace(/[^a-z0-9]+/gi, '-')}`;
118
+ const build = Bun.spawnSync(['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir], {
119
+ cwd: REPO,
120
+ stdout: 'pipe',
121
+ stderr: 'pipe',
122
+ });
123
+ const emitted = [...new Bun.Glob('**/*.js').scanSync({ cwd: outdir, onlyFiles: true })];
124
+ const bundle = emitted.map((f) => readFileSync(`${outdir}/${f}`, 'utf8')).join('\n');
125
+ if (build.exitCode !== 0 || bundle.trim() === '') {
126
+ throw new Error(
127
+ `could not bundle ${subpath} (${entry}, exit ${build.exitCode}) โ€” a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
128
+ );
129
+ }
130
+ const found = new Set<string>();
131
+ for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
132
+ const specifier = match[1] ?? match[2];
133
+ if (specifier === undefined || specifier.startsWith('.') || specifier.startsWith('bun:')) continue;
134
+ const name = packageOf(specifier);
135
+ if (name !== undefined) found.add(name);
136
+ }
137
+ return found;
138
+ };
139
+
140
+ /** Every subpath with a `source` entry โ€” the ones whose real graph can be measured. */
141
+ const SUBPATHS = Object.entries(pkg.exports as Record<string, { source?: string }>)
142
+ .filter(([, e]) => typeof e?.source === 'string' && /\.tsx?$/.test(e.source))
143
+ .map(([subpath, e]) => ({ subpath, entry: (e as { source: string }).source }));
144
+
145
+ describe('no public subpath statically drags an optional peer', () => {
146
+ it('measures a non-trivial number of subpaths, so a broken exports map cannot pass vacuously', () => {
147
+ expect(SUBPATHS.length).toBeGreaterThan(20);
148
+ expect(OPTIONAL_PEERS.size).toBeGreaterThan(0);
149
+ });
150
+
151
+ for (const { subpath, entry } of SUBPATHS) {
152
+ const allowed = MAY_DRAG[subpath] ?? [];
153
+ it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, () => {
154
+ const dragged = [...staticExternalsOf(entry, subpath)].filter((n) => OPTIONAL_PEERS.has(n)).sort();
155
+ const unexpected = dragged.filter((n) => !allowed.includes(n));
156
+ expect(
157
+ unexpected,
158
+ `${subpath} statically drags optional peer(s): ${unexpected.join(', ')}\n` +
159
+ ` Every app importing '${pkg.name}${subpath.slice(1)}' must now install them or crash on import.\n` +
160
+ ` Give the adapter its own subpath and take it out of this barrel โ€” see ./d1/kysely, which is\n` +
161
+ ` exactly this fix applied on 2026-09-18 after the published 4.3.0 could not be imported at all.`,
162
+ ).toEqual([]);
163
+
164
+ // The allowlist may only shrink: an entry that no longer drags what it promised is
165
+ // a stale exception, and a stale exception is how a list like this stops meaning
166
+ // anything.
167
+ const stale = allowed.filter((n) => !dragged.includes(n));
168
+ expect(stale, `${subpath} is allowed to drag ${stale.join(', ')} but no longer does โ€” remove the exception`).toEqual(
169
+ [],
170
+ );
171
+ });
172
+ }
173
+ });
@@ -25,7 +25,13 @@ export {
25
25
  type LocalBackupOpts,
26
26
  type TimeTravelOpts,
27
27
  } from './backup';
28
- export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
28
+ // ๐Ÿ”ด `./kysely` is deliberately NOT re-exported here โ€” import it from
29
+ // `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
30
+ // `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
31
+ // made `import 'cursedbelt-server/d1'` throw `Cannot find package 'kysely'` for every app
32
+ // that does not use the query builder โ€” which per `../db/kysely.ts`'s own header is most
33
+ // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
34
+ // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
29
35
  export { type InvocationD1, perInvocation } from './invocation';
30
36
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
31
37
  export { createLocalD1, refuseInteractiveTransaction } from './local';