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';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
|
|
18
|
-
|
|
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
|
+
"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
|
+
});
|
package/src/server/d1/index.ts
CHANGED
|
@@ -25,7 +25,13 @@ export {
|
|
|
25
25
|
type LocalBackupOpts,
|
|
26
26
|
type TimeTravelOpts,
|
|
27
27
|
} from './backup';
|
|
28
|
-
|
|
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';
|