@feasibleone/blong-gogo 1.26.0 → 1.28.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.
- package/CHANGELOG.md +32 -0
- package/bin/blong-watch.ts +6 -0
- package/bin/blong.ts +52 -3
- package/package.json +9 -6
- package/src/ApiGateway.test.ts +190 -0
- package/src/ApiGateway.ts +167 -0
- package/src/BrowserLog.ts +5 -1
- package/src/Gateway.ts +17 -1
- package/src/Log.ts +33 -12
- package/src/Registry.ts +87 -7
- package/src/Remote.ts +12 -0
- package/src/Watch.ts +55 -26
- package/src/adapter/browser/http.ts +4 -2
- package/src/adapter/schema/knex/binary.ts +8 -5
- package/src/adapter/schema/knex/database.test.ts +87 -0
- package/src/adapter/schema/knex/database.ts +67 -0
- package/src/adapter/schema/knex/json.test.ts +243 -0
- package/src/adapter/schema/knex/json.ts +213 -0
- package/src/adapter/schema/knex/schemaMysql.ts +0 -26
- package/src/adapter/schema/knex/schemaTable.ts +4 -138
- package/src/adapter/schema/knex/types.ts +46 -2
- package/src/adapter/schema/knex/utils.ts +3 -1
- package/src/adapter/server/knex-deadlock.test.ts +114 -0
- package/src/adapter/server/knex.ts +425 -102
- package/src/adapter/server/redis.ts +236 -0
- package/src/adapter/server.ts +2 -0
- package/src/chain.ts +12 -1
- package/src/cliHelp.test.ts +31 -0
- package/src/cliHelp.ts +45 -0
- package/src/codec/adapter/mle/ready.ts +22 -12
- package/src/favicon.ico +0 -0
- package/src/graceful-shutdown.spawn.test.ts +68 -0
- package/src/graceful-shutdown.test.ts +109 -0
- package/src/jose.ts +7 -293
- package/src/jwt.ts +2 -1
- package/src/kopi.ts +77 -12
- package/src/lib.ts +40 -4
- package/src/load.ts +108 -65
- package/src/log.progress.test.ts +35 -0
- package/src/mle.ts +4 -4
- package/src/pino-cacache.mjs +7 -0
- package/src/pino-cacache.ts +13 -2
- package/src/pino-pretty.mjs +8 -0
- package/src/public.ts +9 -0
- package/src/registry.validation.override.test.ts +203 -0
- package/src/runServer.ts +114 -2
- package/src/static.ts +35 -0
- package/src/template-files.ts +52 -0
- package/template/browser/orchestrator/subject/init.ts +15 -0
- package/template/browser/test/test/test$Object.flow.ts +100 -0
- package/template/browser-test.ts +87 -0
- package/template/browser.ts +29 -0
- package/template/error/error.ts +11 -0
- package/template/eslint.config.mjs +2 -0
- package/template/index.browser.ts +36 -0
- package/template/index.html.ts +7 -0
- package/template/index.test.ts +32 -0
- package/template/index.ts +52 -0
- package/template/meta/db/db.ts +21 -0
- package/template/meta/dbTest/$subject$ObjectMerge.yaml +8 -0
- package/template/meta/dbTest/accessAuthorizationMerge.yaml +21 -0
- package/template/meta/model/$subject$ObjectModel.ts +134 -0
- package/template/meta/type/schema.ts +55 -0
- package/template/orchestrator/subject/init.ts +16 -0
- package/template/playwright.config.ts +7 -0
- package/template/server/test/test/test$Object.ts +83 -0
- package/template/server.ts +12 -0
- package/template/test/$subject.play.ts +64 -0
- package/template/tsconfig.json +28 -0
- package/template/vite.config.ts +5 -0
package/src/Log.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {withProgress} from '@feasibleone/blong-lib';
|
|
1
2
|
import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
2
|
-
import {pino, type
|
|
3
|
+
import {pino, type Logger, type LoggerOptions} from 'pino';
|
|
3
4
|
import {monotonicFactory} from 'ulidx';
|
|
4
5
|
import type {CacacheTransportOptions} from './pino-cacache.js';
|
|
5
6
|
|
|
@@ -28,14 +29,31 @@ const ignoreArgPatterns = [
|
|
|
28
29
|
// execArgv to [], stripping the TypeScript loader. We work around this by
|
|
29
30
|
// explicitly forwarding process.execArgv so the TypeScript loader is available
|
|
30
31
|
// inside the transport worker thread.
|
|
32
|
+
//
|
|
33
|
+
// However, when the process is started under `tap` (e.g. `blong-dev test`),
|
|
34
|
+
// process.execArgv contains tap's own `--import` hooks (@tapjs/typescript →
|
|
35
|
+
// pirates/ts-node). Those hooks intercept `.ts` loads in the worker and bypass
|
|
36
|
+
// Node's native type stripping, which fails on `import type` constructs
|
|
37
|
+
// (SyntaxError: Unexpected identifier 'PinoPretty' in pino-pretty.ts). So we
|
|
38
|
+
// drop tap's hooks from the worker execArgv.
|
|
39
|
+
//
|
|
40
|
+
// Dropping the hooks alone is not enough: pino's multi-target transport loads
|
|
41
|
+
// `.ts`/`.cts` targets with `realRequire()` (CJS require, relying on ts-node),
|
|
42
|
+
// which fails regardless of execArgv. We therefore point both transports at
|
|
43
|
+
// tiny `.mjs` shims (pino-pretty.mjs / pino-cacache.mjs) that re-export the
|
|
44
|
+
// `.ts` modules. Pino then treats them as non-TypeScript targets and loads
|
|
45
|
+
// them with `realImport()` (dynamic ESM import), where Node's native type
|
|
46
|
+
// stripping handles the re-exported `.ts` — in both tap and normal dev runs.
|
|
31
47
|
const WORKER_OPTS = {
|
|
32
48
|
execArgv: process.execArgv.filter(
|
|
33
|
-
arg =>
|
|
49
|
+
arg =>
|
|
50
|
+
!ignoreArgPatterns.some(pattern => arg.startsWith(pattern)) &&
|
|
51
|
+
!(arg.startsWith('--import=') && arg.includes('@tapjs')),
|
|
34
52
|
),
|
|
35
53
|
};
|
|
36
54
|
|
|
37
55
|
const PRETTY_TRANSPORT = {
|
|
38
|
-
target: './pino-pretty.
|
|
56
|
+
target: './pino-pretty.mjs',
|
|
39
57
|
worker: WORKER_OPTS,
|
|
40
58
|
options: {
|
|
41
59
|
singleLine: true,
|
|
@@ -77,7 +95,7 @@ export default class Log extends Internal implements ILog {
|
|
|
77
95
|
targets: [
|
|
78
96
|
PRETTY_TRANSPORT,
|
|
79
97
|
{
|
|
80
|
-
target: './pino-cacache.
|
|
98
|
+
target: './pino-cacache.mjs',
|
|
81
99
|
worker: WORKER_OPTS,
|
|
82
100
|
options: cacacheOptions,
|
|
83
101
|
},
|
|
@@ -99,13 +117,13 @@ export default class Log extends Internal implements ILog {
|
|
|
99
117
|
bindings: object,
|
|
100
118
|
): ReturnType<ILog['logger']> {
|
|
101
119
|
const child = this.#logger.child(bindings, {level});
|
|
102
|
-
const result:
|
|
103
|
-
trace:
|
|
104
|
-
debug:
|
|
105
|
-
info:
|
|
106
|
-
warn:
|
|
107
|
-
error:
|
|
108
|
-
fatal:
|
|
120
|
+
const result: ILogger = {
|
|
121
|
+
trace: undefined,
|
|
122
|
+
debug: undefined,
|
|
123
|
+
info: undefined,
|
|
124
|
+
warn: undefined,
|
|
125
|
+
error: undefined,
|
|
126
|
+
fatal: undefined,
|
|
109
127
|
};
|
|
110
128
|
switch (level) {
|
|
111
129
|
case 'trace':
|
|
@@ -121,6 +139,9 @@ export default class Log extends Internal implements ILog {
|
|
|
121
139
|
case 'fatal':
|
|
122
140
|
result.fatal = child.fatal.bind(child);
|
|
123
141
|
}
|
|
124
|
-
return
|
|
142
|
+
return {
|
|
143
|
+
...result,
|
|
144
|
+
progress: (label, promise, options) => withProgress(result, label, promise, options),
|
|
145
|
+
};
|
|
125
146
|
}
|
|
126
147
|
}
|
package/src/Registry.ts
CHANGED
|
@@ -55,6 +55,10 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
55
55
|
public ports: Map<string, IAdapterRegistry> = new Map();
|
|
56
56
|
public methods: Map<string, Handlers> = new Map();
|
|
57
57
|
public objectSchema: IObjectSchema = {};
|
|
58
|
+
#stopped = false;
|
|
59
|
+
#logger:
|
|
60
|
+
| {info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void}
|
|
61
|
+
| undefined = undefined;
|
|
58
62
|
#reload: PQueue = new PQueue({concurrency: 1});
|
|
59
63
|
#ports: Map<string, Adapter> = new Map();
|
|
60
64
|
#error: IErrorFactory;
|
|
@@ -217,6 +221,7 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
217
221
|
callback = port as MatchMethodsCallback;
|
|
218
222
|
port = undefined;
|
|
219
223
|
}
|
|
224
|
+
const log = this.#log.logger('info', {name: 'registry'});
|
|
220
225
|
for (const [name, value] of this.methods.entries()) {
|
|
221
226
|
if (
|
|
222
227
|
([] as (string | RegExp)[])
|
|
@@ -228,7 +233,20 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
228
233
|
) {
|
|
229
234
|
if (mode === 'merge') {
|
|
230
235
|
for (const item of value) {
|
|
236
|
+
const started = Date.now();
|
|
231
237
|
const {local, literals} = await this._createHandlers([item], port);
|
|
238
|
+
// Visibility: a handler factory that hangs (e.g. a
|
|
239
|
+
// `subject.validation` resolving a model handler whose
|
|
240
|
+
// derived name doesn't match a registered file) would
|
|
241
|
+
// otherwise stall startup silently. Warn after a slow
|
|
242
|
+
// threshold so the stuck method is identified.
|
|
243
|
+
const elapsed = Date.now() - started;
|
|
244
|
+
if (elapsed > 3_000) {
|
|
245
|
+
log.warn?.(
|
|
246
|
+
`registry handler factory for '${name}' took ${elapsed}ms — ` +
|
|
247
|
+
'a hanging dependency (e.g. an unresolvable remote method) is likely',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
232
250
|
callback!(name, local, literals);
|
|
233
251
|
}
|
|
234
252
|
} else {
|
|
@@ -255,9 +273,17 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
255
273
|
? validation
|
|
256
274
|
: {};
|
|
257
275
|
if (typeof validation === 'function') name = validation.name;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
276
|
+
// A later-registered validation REPLACES an earlier one for the
|
|
277
|
+
// same methodParts key instead of merging. `subject.validation`
|
|
278
|
+
// (blong-server, `srv`) registers first and auto-generates the
|
|
279
|
+
// CRUD validations for every `public: true` model; an explicit
|
|
280
|
+
// realm gateway file (`gateway/<subject>/<method>.ts`) registers
|
|
281
|
+
// later and must reliably WIN for the operation it overrides.
|
|
282
|
+
// Merging TypeBox schemas with ut-function.merge also corrupts
|
|
283
|
+
// symbol-keyed Kind markers on nested schemas (e.g. a `details`
|
|
284
|
+
// array) and lets auto-required fields leak into the override,
|
|
285
|
+
// so replace instead. Unrelated keys still merge normally.
|
|
286
|
+
this.#validations[methodParts(name)] = schema as GatewaySchema;
|
|
261
287
|
});
|
|
262
288
|
});
|
|
263
289
|
return this.#validations;
|
|
@@ -374,10 +400,49 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
374
400
|
}
|
|
375
401
|
|
|
376
402
|
public async start(configOverride: object): Promise<IRegistry> {
|
|
403
|
+
this.#stopped = false;
|
|
404
|
+
|
|
377
405
|
await this._collectObjectSchema();
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
406
|
+
this.#logger = this.#log.logger('info', {name: 'registry'});
|
|
407
|
+
let created = 0;
|
|
408
|
+
const portIds = Array.from(this.ports.keys());
|
|
409
|
+
await blongLib.withProgress(
|
|
410
|
+
this.#logger,
|
|
411
|
+
'create ports',
|
|
412
|
+
(async () => {
|
|
413
|
+
for (const id of portIds) {
|
|
414
|
+
await this.createPort(id);
|
|
415
|
+
created += 1;
|
|
416
|
+
}
|
|
417
|
+
})(),
|
|
418
|
+
{getProgress: () => ({done: created, total: portIds.length})},
|
|
419
|
+
);
|
|
420
|
+
let started = 0;
|
|
421
|
+
const startPorts = Array.from(this.#ports.values());
|
|
422
|
+
await blongLib.withProgress(
|
|
423
|
+
this.#logger,
|
|
424
|
+
'start ports',
|
|
425
|
+
(async () => {
|
|
426
|
+
for (const port of startPorts) {
|
|
427
|
+
await port.start(configOverride);
|
|
428
|
+
started += 1;
|
|
429
|
+
}
|
|
430
|
+
})(),
|
|
431
|
+
{getProgress: () => ({done: started, total: startPorts.length})},
|
|
432
|
+
);
|
|
433
|
+
let ready = 0;
|
|
434
|
+
const readyPorts = Array.from(this.#ports.values());
|
|
435
|
+
await blongLib.withProgress(
|
|
436
|
+
this.#logger,
|
|
437
|
+
'ready ports',
|
|
438
|
+
(async () => {
|
|
439
|
+
for (const port of readyPorts) {
|
|
440
|
+
await port.ready();
|
|
441
|
+
ready += 1;
|
|
442
|
+
}
|
|
443
|
+
})(),
|
|
444
|
+
{getProgress: () => ({done: ready, total: readyPorts.length})},
|
|
445
|
+
);
|
|
381
446
|
this.#gateway?.route(await this._validations(), {name: '', version: ''});
|
|
382
447
|
await this.#resolution?.start();
|
|
383
448
|
await this.#rpcServer?.start();
|
|
@@ -405,12 +470,27 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
405
470
|
}
|
|
406
471
|
|
|
407
472
|
public async stop(): Promise<IRegistry> {
|
|
473
|
+
if (this.#stopped) return this;
|
|
408
474
|
await this.#watch?.stop();
|
|
409
475
|
await this.#gateway?.stop();
|
|
410
476
|
await this.#remote?.stop();
|
|
411
477
|
await this.#rpcServer?.stop();
|
|
412
478
|
await this.#resolution?.stop();
|
|
413
|
-
|
|
479
|
+
let stopped = 0;
|
|
480
|
+
const ports = Array.from(this.#ports.values());
|
|
481
|
+
await blongLib.withProgress(
|
|
482
|
+
this.#logger,
|
|
483
|
+
'stop ports',
|
|
484
|
+
(async () => {
|
|
485
|
+
for (const port of ports) {
|
|
486
|
+
await port.stop();
|
|
487
|
+
stopped += 1;
|
|
488
|
+
}
|
|
489
|
+
})(),
|
|
490
|
+
{getProgress: () => ({done: stopped, total: ports.length})},
|
|
491
|
+
);
|
|
492
|
+
this.#stopped = true;
|
|
493
|
+
this.#logger = undefined;
|
|
414
494
|
return this;
|
|
415
495
|
}
|
|
416
496
|
|
package/src/Remote.ts
CHANGED
|
@@ -204,6 +204,18 @@ export default class Remote extends Internal implements IRemote {
|
|
|
204
204
|
if (methodName && !this.gateway?.($applyMeta, methodName)) {
|
|
205
205
|
if (this.#config.canSkipSocket) fn = this._findMethod(methodName, methodType);
|
|
206
206
|
if (fn) unpack = true;
|
|
207
|
+
else if (methodName && this.#config.canSkipSocket) {
|
|
208
|
+
// Visibility: under canSkipSocket every call is expected
|
|
209
|
+
// to resolve locally. A missing method (e.g. a derived
|
|
210
|
+
// handler name that doesn't match a registered file —
|
|
211
|
+
// `$subject$objectModel` vs `$subject$ObjectModel`) would
|
|
212
|
+
// otherwise fail or hang silently. Name it so the typo /
|
|
213
|
+
// case / placeholder mismatch is obvious.
|
|
214
|
+
this.log?.warn?.({
|
|
215
|
+
$meta: {mtid: 'event', method: 'remote.methodNotFound'},
|
|
216
|
+
message: `remote method not found locally: '${methodName}'`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
207
219
|
}
|
|
208
220
|
if (!fn) {
|
|
209
221
|
fn = this.sender(methodType, typeName);
|
package/src/Watch.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {withProgress} from '@feasibleone/blong-lib';
|
|
1
2
|
import {
|
|
2
3
|
handler,
|
|
3
4
|
Internal,
|
|
@@ -47,6 +48,7 @@ const isLayerActivation = (filename: string): boolean =>
|
|
|
47
48
|
/^layer\.(server|browser)\.[mc]?[tj]sx?$/i.test(filename);
|
|
48
49
|
const isConfig = (filename: string): boolean => /^config\.[mc]?[tj]sx?$/i.test(filename);
|
|
49
50
|
const isPlay = (filename: string): boolean => /\.play\.[mc]?[tj]sx?$/i.test(filename);
|
|
51
|
+
const isTest = (filename: string): boolean => /\.test\.[mc]?[tj]sx?$/i.test(filename);
|
|
50
52
|
|
|
51
53
|
const prefixRE: RegExp = /(?:\d+-)?(.*)/;
|
|
52
54
|
|
|
@@ -195,7 +197,8 @@ export default class Watch extends Internal implements IWatch {
|
|
|
195
197
|
},
|
|
196
198
|
[[] as string[], [] as string[]],
|
|
197
199
|
);
|
|
198
|
-
const
|
|
200
|
+
const codeGen = '@sinclair/typebox-codegen';
|
|
201
|
+
const {Formatter, TypeScriptToTypeBox} = await import(/* @vite-ignore */ codeGen);
|
|
199
202
|
if (schema.length)
|
|
200
203
|
this.#platform.writeFileSync(
|
|
201
204
|
this.#platform.join(dir, '~.schema.ts'),
|
|
@@ -288,7 +291,8 @@ export default class Watch extends Internal implements IWatch {
|
|
|
288
291
|
isCode(entry.name) &&
|
|
289
292
|
!isLayerActivation(entry.name) &&
|
|
290
293
|
!isConfig(entry.name) &&
|
|
291
|
-
!isPlay(entry.name)
|
|
294
|
+
!isPlay(entry.name) &&
|
|
295
|
+
!isTest(entry.name),
|
|
292
296
|
);
|
|
293
297
|
await this.#apiSchema?.generateDir(dir, handlerFiles as Dirent[]);
|
|
294
298
|
for (const handlerEntry of handlerFiles) {
|
|
@@ -312,7 +316,11 @@ export default class Watch extends Internal implements IWatch {
|
|
|
312
316
|
).default
|
|
313
317
|
: ((await directory![filename]()) as {default: unknown}).default;
|
|
314
318
|
if (!item) {
|
|
315
|
-
this.log?.error?.(
|
|
319
|
+
this.log?.error?.(
|
|
320
|
+
'Error loading ' +
|
|
321
|
+
filename +
|
|
322
|
+
' ; probably a generic source code was put in a handler group folder',
|
|
323
|
+
);
|
|
316
324
|
continue;
|
|
317
325
|
}
|
|
318
326
|
const expectedName = this.#platform.basename(
|
|
@@ -396,7 +404,8 @@ export default class Watch extends Internal implements IWatch {
|
|
|
396
404
|
if (
|
|
397
405
|
isCode(filename) &&
|
|
398
406
|
!isLayerActivation(this.#platform.basename(filename)) &&
|
|
399
|
-
!isPlay(this.#platform.basename(filename))
|
|
407
|
+
!isPlay(this.#platform.basename(filename)) &&
|
|
408
|
+
!isTest(this.#platform.basename(filename))
|
|
400
409
|
) {
|
|
401
410
|
const item =
|
|
402
411
|
typeof isFile === 'function'
|
|
@@ -478,27 +487,36 @@ export default class Watch extends Internal implements IWatch {
|
|
|
478
487
|
const affected = affectedNamespaces(diff, registry.ports.keys());
|
|
479
488
|
const next = this.#configRuntime.snapshot;
|
|
480
489
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
490
|
+
let reloaded = 0;
|
|
491
|
+
await withProgress(
|
|
492
|
+
this.log,
|
|
493
|
+
'reload config',
|
|
494
|
+
(async () => {
|
|
495
|
+
for (const portId of affected) {
|
|
496
|
+
const portInstance = await registry.getPort(portId);
|
|
497
|
+
if (portInstance) {
|
|
498
|
+
if (typeof portInstance['configChanged'] === 'function') {
|
|
499
|
+
// Adapter supports the configChanged hook — zero-downtime update
|
|
500
|
+
try {
|
|
501
|
+
await portInstance['configChanged'](diff, next, prev);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
this.log?.error?.(error);
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
// Fallback: stop and restart the port with the current configOverride
|
|
507
|
+
await portInstance.stop();
|
|
508
|
+
const fresh = await registry.createPort(portId);
|
|
509
|
+
if (fresh) {
|
|
510
|
+
await fresh.start(configOverride);
|
|
511
|
+
await fresh.ready();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
reloaded += 1;
|
|
499
516
|
}
|
|
500
|
-
}
|
|
501
|
-
|
|
517
|
+
})(),
|
|
518
|
+
{getProgress: () => ({done: reloaded, total: affected.size})},
|
|
519
|
+
);
|
|
502
520
|
|
|
503
521
|
this.#emit.dispatchEvent(new Event('test'));
|
|
504
522
|
}
|
|
@@ -671,8 +689,19 @@ export default class Watch extends Internal implements IWatch {
|
|
|
671
689
|
return result;
|
|
672
690
|
}),
|
|
673
691
|
);
|
|
674
|
-
|
|
675
|
-
|
|
692
|
+
let executed = 0;
|
|
693
|
+
await withProgress(
|
|
694
|
+
this.log,
|
|
695
|
+
'run test groups',
|
|
696
|
+
(async () => {
|
|
697
|
+
await Promise.all(
|
|
698
|
+
steps.map(async step => {
|
|
699
|
+
await chain(step as Parameters<typeof chain>[0]);
|
|
700
|
+
executed += 1;
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
})(),
|
|
704
|
+
{getProgress: () => ({done: executed, total: steps.length})},
|
|
676
705
|
);
|
|
677
706
|
} catch (error) {
|
|
678
707
|
this.log?.error?.(error);
|
|
@@ -23,6 +23,8 @@ const errorMap: IErrorMap = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
let _errors: Errors<typeof errorMap>;
|
|
26
|
+
const undici = 'undici';
|
|
27
|
+
const nodeFs = 'node:fs';
|
|
26
28
|
|
|
27
29
|
export default adapter<IConfig>(({utError, manifest}) => {
|
|
28
30
|
_errors ||= utError.register(errorMap);
|
|
@@ -51,8 +53,8 @@ export default adapter<IConfig>(({utError, manifest}) => {
|
|
|
51
53
|
// Dynamic imports — only run on the server; @vite-ignore prevents
|
|
52
54
|
// Vite from trying to bundle these Node.js-only modules for the browser.
|
|
53
55
|
const [{Agent}, {readFileSync}] = await Promise.all([
|
|
54
|
-
import(/* @vite-ignore */
|
|
55
|
-
import(/* @vite-ignore */
|
|
56
|
+
import(/* @vite-ignore */ undici) as Promise<typeof import('undici')>,
|
|
57
|
+
import(/* @vite-ignore */ nodeFs) as Promise<typeof import('node:fs')>,
|
|
56
58
|
]);
|
|
57
59
|
const {tls} = this.config;
|
|
58
60
|
const agent = new Agent({
|
|
@@ -26,7 +26,12 @@ for (let i = 0; i < CROCKFORD.length; i++) CROCKFORD_REV[CROCKFORD[i]] = i;
|
|
|
26
26
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Discover binary(16) columns from INFORMATION_SCHEMA.
|
|
29
|
+
* Discover binary(16) / varbinary(16) columns from INFORMATION_SCHEMA.
|
|
30
|
+
*
|
|
31
|
+
* `type.uuid()` columns are created as fixed `binary(16)` while `type.uid*()`
|
|
32
|
+
* columns (and most FK columns) are created as `varbinary(16)` by knex's
|
|
33
|
+
* `table.binary()`. Both are 16-byte UUID columns and need the same
|
|
34
|
+
* string ↔ Buffer conversion, so BOTH `binary` and `varbinary` are matched.
|
|
30
35
|
*
|
|
31
36
|
* Call this once during adapter `ready()`, after the schema has been synced,
|
|
32
37
|
* and store the result on `this.config.context.binaryColumns`.
|
|
@@ -34,10 +39,8 @@ for (let i = 0; i < CROCKFORD.length; i++) CROCKFORD_REV[CROCKFORD[i]] = i;
|
|
|
34
39
|
export async function discoverBinaryColumns(knex: Knex): Promise<Map<string, Set<string>>> {
|
|
35
40
|
const rows: Array<{TABLE_NAME: string; COLUMN_NAME: string}> = await knex
|
|
36
41
|
.from('INFORMATION_SCHEMA.COLUMNS')
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
CHARACTER_MAXIMUM_LENGTH: 16,
|
|
40
|
-
})
|
|
42
|
+
.whereIn('DATA_TYPE', ['binary', 'varbinary'])
|
|
43
|
+
.andWhere('CHARACTER_MAXIMUM_LENGTH', 16)
|
|
41
44
|
.select('TABLE_NAME', 'COLUMN_NAME');
|
|
42
45
|
const map = new Map<string, Set<string>>();
|
|
43
46
|
for (const row of rows) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `ensureDatabase` helper (adapter/schema/knex/database.ts).
|
|
3
|
+
*
|
|
4
|
+
* `ensureDatabase` accepts an injectable `connect` factory, so these tests use
|
|
5
|
+
* a fake admin connection and never touch a real MySQL server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {test} from 'tap';
|
|
9
|
+
|
|
10
|
+
import {ensureDatabase, type IAdminConnection} from './database.ts';
|
|
11
|
+
|
|
12
|
+
interface IFakeState {
|
|
13
|
+
calls: string[];
|
|
14
|
+
rows?: Array<{SCHEMA_NAME: string}>;
|
|
15
|
+
createError?: Error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fakeConnect(state: IFakeState): () => Promise<IAdminConnection> {
|
|
19
|
+
return async () => {
|
|
20
|
+
const admin: IAdminConnection = {
|
|
21
|
+
async query<T>(sql: string): Promise<[T, unknown]> {
|
|
22
|
+
state.calls.push(sql);
|
|
23
|
+
if (sql.startsWith('SELECT SCHEMA_NAME')) {
|
|
24
|
+
return [(state.rows ?? []) as T, []];
|
|
25
|
+
}
|
|
26
|
+
if (state.createError) throw state.createError;
|
|
27
|
+
return [undefined as T, []];
|
|
28
|
+
},
|
|
29
|
+
async end() {
|
|
30
|
+
state.calls.push('end');
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return admin;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('ensureDatabase — returns early when no database is configured', async t => {
|
|
38
|
+
const state: IFakeState = {calls: []};
|
|
39
|
+
const result = await ensureDatabase({host: 'localhost'}, fakeConnect(state));
|
|
40
|
+
t.same(result, {created: false, database: ''}, 'no-op result');
|
|
41
|
+
t.equal(state.calls.length, 0, 'no connection made');
|
|
42
|
+
t.end();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('ensureDatabase — creates the database when missing', async t => {
|
|
46
|
+
const state: IFakeState = {calls: [], rows: []};
|
|
47
|
+
const result = await ensureDatabase({database: 'blong-kalin'}, fakeConnect(state));
|
|
48
|
+
t.equal(result.created, true, 'database was created');
|
|
49
|
+
t.equal(result.database, 'blong-kalin', 'reports the database name');
|
|
50
|
+
t.ok(
|
|
51
|
+
state.calls.some(c => c.startsWith('CREATE DATABASE')),
|
|
52
|
+
'CREATE DATABASE issued',
|
|
53
|
+
);
|
|
54
|
+
t.equal(state.calls[state.calls.length - 1], 'end', 'admin connection closed');
|
|
55
|
+
t.end();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('ensureDatabase — skips creation when the database exists', async t => {
|
|
59
|
+
const state: IFakeState = {calls: [], rows: [{SCHEMA_NAME: 'blong-kalin'}]};
|
|
60
|
+
const result = await ensureDatabase({database: 'blong-kalin'}, fakeConnect(state));
|
|
61
|
+
t.equal(result.created, false, 'database already exists');
|
|
62
|
+
t.ok(
|
|
63
|
+
!state.calls.some(c => c.startsWith('CREATE DATABASE')),
|
|
64
|
+
'no CREATE DATABASE issued',
|
|
65
|
+
);
|
|
66
|
+
t.equal(state.calls[state.calls.length - 1], 'end', 'admin connection closed');
|
|
67
|
+
t.end();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ensureDatabase — rejects when CREATE DATABASE fails but still closes the connection', async t => {
|
|
71
|
+
const state: IFakeState = {
|
|
72
|
+
calls: [],
|
|
73
|
+
rows: [],
|
|
74
|
+
createError: new Error('access denied'),
|
|
75
|
+
};
|
|
76
|
+
await t.rejects(ensureDatabase({database: 'blong-kalin'}, fakeConnect(state)), /access denied/);
|
|
77
|
+
t.equal(state.calls[state.calls.length - 1], 'end', 'admin connection closed on failure');
|
|
78
|
+
t.end();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('ensureDatabase — escapes backticks in the database name', async t => {
|
|
82
|
+
const state: IFakeState = {calls: [], rows: []};
|
|
83
|
+
await ensureDatabase({database: 'weird`db'}, fakeConnect(state));
|
|
84
|
+
const createSql = state.calls.find(c => c.startsWith('CREATE DATABASE'));
|
|
85
|
+
t.ok(createSql?.includes('`weird``db`'), 'backtick doubled in identifier');
|
|
86
|
+
t.end();
|
|
87
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ensureDatabase` — create the MySQL database if it does not exist.
|
|
3
|
+
*
|
|
4
|
+
* Used by the knex adapter (`adapter/server/knex.ts`) before building the real
|
|
5
|
+
* pool when `knex.createDatabase` is enabled (default on in the `dev` intent via
|
|
6
|
+
* the shared `srv.db` adapter). Connects WITHOUT a database, checks
|
|
7
|
+
* `information_schema.schemata`, and issues `CREATE DATABASE` when the schema is
|
|
8
|
+
* missing. On failure the caller decides whether to warn-and-continue (schema
|
|
9
|
+
* sync will surface the real error) or fail.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {createConnection} from 'mysql2/promise';
|
|
13
|
+
|
|
14
|
+
export interface IEnsureDatabaseOptions {
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
user?: string;
|
|
18
|
+
password?: string;
|
|
19
|
+
database?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IEnsureDatabaseResult {
|
|
23
|
+
/** Whether the database had to be created (false when it already existed). */
|
|
24
|
+
created: boolean;
|
|
25
|
+
/** The database name that was checked / created. */
|
|
26
|
+
database: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The minimal admin-connection surface used by `ensureDatabase`. */
|
|
30
|
+
export interface IAdminConnection {
|
|
31
|
+
query<T>(sql: string, params?: unknown[]): Promise<[T, unknown]>;
|
|
32
|
+
end(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type IConnect = (config: Record<string, unknown>) => Promise<IAdminConnection>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Try to create `database` if it does not exist. Resolves `{created, database}`
|
|
39
|
+
* on success; rejects if the server cannot be reached or `CREATE DATABASE`
|
|
40
|
+
* fails. `connect` is injectable for tests (defaults to `mysql2`).
|
|
41
|
+
*/
|
|
42
|
+
export async function ensureDatabase(
|
|
43
|
+
options: IEnsureDatabaseOptions,
|
|
44
|
+
connect: IConnect = createConnection as unknown as IConnect,
|
|
45
|
+
): Promise<IEnsureDatabaseResult> {
|
|
46
|
+
const database = options.database;
|
|
47
|
+
if (!database) return {created: false, database: ''};
|
|
48
|
+
const admin = await connect({
|
|
49
|
+
host: options.host ?? 'localhost',
|
|
50
|
+
port: options.port ?? 3306,
|
|
51
|
+
user: options.user,
|
|
52
|
+
password: options.password,
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const [rows] = await admin.query<Array<{SCHEMA_NAME: string}>>(
|
|
56
|
+
'SELECT SCHEMA_NAME FROM information_schema.schemata WHERE SCHEMA_NAME = ?',
|
|
57
|
+
[database],
|
|
58
|
+
);
|
|
59
|
+
if (rows.length > 0) return {created: false, database};
|
|
60
|
+
await admin.query(
|
|
61
|
+
`CREATE DATABASE \`${database.replaceAll('`', '``')}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
|
|
62
|
+
);
|
|
63
|
+
return {created: true, database};
|
|
64
|
+
} finally {
|
|
65
|
+
await admin.end();
|
|
66
|
+
}
|
|
67
|
+
}
|