@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/blong-watch.ts +6 -0
  3. package/bin/blong.ts +52 -3
  4. package/package.json +9 -6
  5. package/src/ApiGateway.test.ts +190 -0
  6. package/src/ApiGateway.ts +167 -0
  7. package/src/BrowserLog.ts +5 -1
  8. package/src/Gateway.ts +17 -1
  9. package/src/Log.ts +33 -12
  10. package/src/Registry.ts +87 -7
  11. package/src/Remote.ts +12 -0
  12. package/src/Watch.ts +55 -26
  13. package/src/adapter/browser/http.ts +4 -2
  14. package/src/adapter/schema/knex/binary.ts +8 -5
  15. package/src/adapter/schema/knex/database.test.ts +87 -0
  16. package/src/adapter/schema/knex/database.ts +67 -0
  17. package/src/adapter/schema/knex/json.test.ts +243 -0
  18. package/src/adapter/schema/knex/json.ts +213 -0
  19. package/src/adapter/schema/knex/schemaMysql.ts +0 -26
  20. package/src/adapter/schema/knex/schemaTable.ts +4 -138
  21. package/src/adapter/schema/knex/types.ts +46 -2
  22. package/src/adapter/schema/knex/utils.ts +3 -1
  23. package/src/adapter/server/knex-deadlock.test.ts +114 -0
  24. package/src/adapter/server/knex.ts +425 -102
  25. package/src/adapter/server/redis.ts +236 -0
  26. package/src/adapter/server.ts +2 -0
  27. package/src/chain.ts +12 -1
  28. package/src/cliHelp.test.ts +31 -0
  29. package/src/cliHelp.ts +45 -0
  30. package/src/codec/adapter/mle/ready.ts +22 -12
  31. package/src/favicon.ico +0 -0
  32. package/src/graceful-shutdown.spawn.test.ts +68 -0
  33. package/src/graceful-shutdown.test.ts +109 -0
  34. package/src/jose.ts +7 -293
  35. package/src/jwt.ts +2 -1
  36. package/src/kopi.ts +77 -12
  37. package/src/lib.ts +40 -4
  38. package/src/load.ts +108 -65
  39. package/src/log.progress.test.ts +35 -0
  40. package/src/mle.ts +4 -4
  41. package/src/pino-cacache.mjs +7 -0
  42. package/src/pino-cacache.ts +13 -2
  43. package/src/pino-pretty.mjs +8 -0
  44. package/src/public.ts +9 -0
  45. package/src/registry.validation.override.test.ts +203 -0
  46. package/src/runServer.ts +114 -2
  47. package/src/static.ts +35 -0
  48. package/src/template-files.ts +52 -0
  49. package/template/browser/orchestrator/subject/init.ts +15 -0
  50. package/template/browser/test/test/test$Object.flow.ts +100 -0
  51. package/template/browser-test.ts +87 -0
  52. package/template/browser.ts +29 -0
  53. package/template/error/error.ts +11 -0
  54. package/template/eslint.config.mjs +2 -0
  55. package/template/index.browser.ts +36 -0
  56. package/template/index.html.ts +7 -0
  57. package/template/index.test.ts +32 -0
  58. package/template/index.ts +52 -0
  59. package/template/meta/db/db.ts +21 -0
  60. package/template/meta/dbTest/$subject$ObjectMerge.yaml +8 -0
  61. package/template/meta/dbTest/accessAuthorizationMerge.yaml +21 -0
  62. package/template/meta/model/$subject$ObjectModel.ts +134 -0
  63. package/template/meta/type/schema.ts +55 -0
  64. package/template/orchestrator/subject/init.ts +16 -0
  65. package/template/playwright.config.ts +7 -0
  66. package/template/server/test/test/test$Object.ts +83 -0
  67. package/template/server.ts +12 -0
  68. package/template/test/$subject.play.ts +64 -0
  69. package/template/tsconfig.json +28 -0
  70. 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 LogFn, type Logger, type LoggerOptions} from 'pino';
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 => !ignoreArgPatterns.some(pattern => arg.startsWith(pattern)),
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.ts',
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.ts',
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: Record<keyof ILogger, LogFn | null> = {
103
- trace: null,
104
- debug: null,
105
- info: null,
106
- warn: null,
107
- error: null,
108
- fatal: null,
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 result as unknown as ILogger;
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
- const prev = this.#validations[methodParts(name)];
259
- if (prev) merge(prev, schema);
260
- else this.#validations[methodParts(name)] = schema as GatewaySchema;
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
- for (const id of Array.from(this.ports.keys())) await this.createPort(id);
379
- for (const port of this.#ports.values()) await port.start(configOverride);
380
- for (const port of this.#ports.values()) await port.ready();
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
- for (const port of this.#ports.values()) await port.stop();
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 {Formatter, TypeScriptToTypeBox} = await import('@sinclair/typebox-codegen');
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?.('Error loading ' + filename);
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
- for (const portId of affected) {
482
- const portInstance = await registry.getPort(portId);
483
- if (!portInstance) continue;
484
-
485
- if (typeof portInstance['configChanged'] === 'function') {
486
- // Adapter supports the configChanged hook — zero-downtime update
487
- try {
488
- await portInstance['configChanged'](diff, next, prev);
489
- } catch (error) {
490
- this.log?.error?.(error);
491
- }
492
- } else {
493
- // Fallback: stop and restart the port with the current configOverride
494
- await portInstance.stop();
495
- const fresh = await registry.createPort(portId);
496
- if (fresh) {
497
- await fresh.start(configOverride);
498
- await fresh.ready();
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
- await Promise.all(
675
- steps.map(step => chain(step as Parameters<typeof chain>[0])),
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 */ 'undici') as Promise<typeof import('undici')>,
55
- import(/* @vite-ignore */ 'node:fs') as Promise<typeof import('node:fs')>,
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
- .where({
38
- DATA_TYPE: 'binary',
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
+ }