@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
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Unit tests for the knex JSON-column interception (json.ts).
3
+ *
4
+ * Verifies that:
5
+ * - object/array values for `*JSON` columns are serialized to strings on write
6
+ * - `*JSON` string values are parsed back to objects on read (lenient)
7
+ * - `wrapJsonBuilder` intercepts insert/update inputs and query results
8
+ */
9
+
10
+ import {test} from 'tap';
11
+
12
+ import {
13
+ isDeadlock,
14
+ parseJsonResult,
15
+ parseJsonRow,
16
+ stringifyJsonValues,
17
+ wrapJsonBuilder,
18
+ wrapKnex,
19
+ } from './json.ts';
20
+
21
+ test('stringifyJsonValues serializes object/array values for *JSON columns only', t => {
22
+ const out = stringifyJsonValues({
23
+ credentialParamsJSON: {function: 'hash', iterations: 100000},
24
+ flowStepsJSON: ['password', 'totp'],
25
+ credentialSalt: 'salt',
26
+ credentialType: 'password',
27
+ });
28
+ t.equal(out.credentialParamsJSON, '{"function":"hash","iterations":100000}');
29
+ t.equal(out.flowStepsJSON, '["password","totp"]');
30
+ t.equal(out.credentialSalt, 'salt');
31
+ t.equal(out.credentialType, 'password');
32
+ t.end();
33
+ });
34
+
35
+ test('stringifyJsonValues leaves already-string *JSON values untouched', t => {
36
+ const out = stringifyJsonValues({credentialParamsJSON: '{"a":1}', userId: 'u1'});
37
+ t.equal(out.credentialParamsJSON, '{"a":1}');
38
+ t.equal(out.userId, 'u1');
39
+ t.end();
40
+ });
41
+
42
+ test('parseJsonRow parses *JSON strings, keeps invalid JSON and non-JSON columns', t => {
43
+ const row = parseJsonRow({
44
+ credentialParamsJSON: '{"function":"hash"}',
45
+ configJSON: 'not-json',
46
+ credentialSalt: 'salt',
47
+ });
48
+ t.same(row.credentialParamsJSON, {function: 'hash'});
49
+ t.equal(row.configJSON, 'not-json');
50
+ t.equal(row.credentialSalt, 'salt');
51
+ t.end();
52
+ });
53
+
54
+ test('parseJsonResult handles single row, row arrays and [rows, meta] DML shapes', t => {
55
+ // single row
56
+ t.same(parseJsonResult({credentialParamsJSON: '{"a":1}'}), {
57
+ credentialParamsJSON: {a: 1},
58
+ });
59
+ // array of rows
60
+ t.same(
61
+ parseJsonResult([{credentialParamsJSON: '{"a":1}'}, {credentialParamsJSON: '{"b":2}'}]),
62
+ [{credentialParamsJSON: {a: 1}}, {credentialParamsJSON: {b: 2}}],
63
+ );
64
+ // [rows, meta] from MySQL DML — meta object is left untouched
65
+ const dml = parseJsonResult([{fieldCount: 0, insertId: 1}, undefined]) as Array<unknown>;
66
+ t.equal((dml[0] as Record<string, unknown>).insertId, 1);
67
+ // scalars pass through
68
+ t.equal(parseJsonResult(3), 3);
69
+ t.end();
70
+ });
71
+
72
+ test('wrapJsonBuilder serializes JSON columns on insert/update and parses on then', async t => {
73
+ const captured: Array<Record<string, unknown>> = [];
74
+ const builder = {
75
+ _result: undefined as unknown,
76
+ insert(value: Record<string, unknown>) {
77
+ captured.push(value);
78
+ return this;
79
+ },
80
+ update(value: Record<string, unknown>) {
81
+ captured.push(value);
82
+ return this;
83
+ },
84
+ then(onFulfilled: (value: unknown) => unknown) {
85
+ return Promise.resolve(this._result).then(onFulfilled);
86
+ },
87
+ };
88
+ const wrapped = wrapJsonBuilder(builder);
89
+
90
+ // insert — object JSON column is serialized, other columns unchanged
91
+ const insertResult = wrapped.insert({credentialParamsJSON: {function: 'hash'}, userId: 1});
92
+ t.equal(insertResult, builder, 'insert returns the same builder for chaining');
93
+ t.equal(captured[0].credentialParamsJSON, '{"function":"hash"}');
94
+ t.equal(captured[0].userId, 1);
95
+
96
+ // update — same serialization
97
+ wrapped.update({credentialParamsJSON: {function: 'totp', digits: 6}});
98
+ t.equal(captured[1].credentialParamsJSON, '{"function":"totp","digits":6}');
99
+
100
+ // then — result rows have *JSON strings parsed back to objects
101
+ (builder as unknown as {_result: unknown})._result = {
102
+ credentialParamsJSON: '{"function":"hash","iterations":100000}',
103
+ credentialSalt: 'salt',
104
+ };
105
+ const row = (await wrapped.then((value: unknown) => value)) as Record<string, unknown>;
106
+ t.same(row.credentialParamsJSON, {function: 'hash', iterations: 100000});
107
+ t.equal(row.credentialSalt, 'salt');
108
+ t.end();
109
+ });
110
+
111
+ test('isDeadlock detects MySQL deadlock errors', t => {
112
+ t.equal(isDeadlock({errno: 1213}), true, 'errno 1213');
113
+ t.equal(isDeadlock({code: 'ER_LOCK_DEADLOCK'}), true, 'ER_LOCK_DEADLOCK code');
114
+ t.equal(
115
+ isDeadlock({errno: 1213, code: 'ER_LOCK_DEADLOCK', sql: 'UPDATE t SET x = 1'}),
116
+ true,
117
+ 'deadlock with sql',
118
+ );
119
+ t.equal(isDeadlock({errno: 1062}), false, 'non-deadlock errno');
120
+ t.equal(isDeadlock(new Error('boom')), false, 'plain error');
121
+ t.equal(isDeadlock(null), false, 'null');
122
+ t.equal(isDeadlock('ER_LOCK_DEADLOCK'), false, 'string');
123
+ t.end();
124
+ });
125
+
126
+ test('wrapJsonBuilder reports deadlocks via onDeadlock and preserves rejection', async t => {
127
+ const deadlock = {
128
+ errno: 1213,
129
+ code: 'ER_LOCK_DEADLOCK',
130
+ sql: 'UPDATE `t` set `x` = 1',
131
+ sqlMessage: 'Deadlock found when trying to get lock',
132
+ };
133
+ const reported: unknown[] = [];
134
+ const builder = wrapJsonBuilder(makeRejectingBuilder(deadlock), {
135
+ onDeadlock: error => reported.push(error),
136
+ });
137
+
138
+ await t.rejects(Promise.resolve(builder.then(null, null)), deadlock, 'query still rejects');
139
+ t.same(reported, [deadlock], 'onDeadlock fired once with the error');
140
+ });
141
+
142
+ test('wrapJsonBuilder passes non-deadlock errors through without reporting', async t => {
143
+ const boom = new Error('boom');
144
+ const reported: unknown[] = [];
145
+ const builder = wrapJsonBuilder(makeRejectingBuilder(boom), {
146
+ onDeadlock: error => reported.push(error),
147
+ });
148
+
149
+ await t.rejects(Promise.resolve(builder.then(null, null)), /boom/, 'error still propagates');
150
+ t.same(reported, [], 'onDeadlock not called');
151
+ });
152
+
153
+ test('wrapKnex raw() reports deadlocks via onDeadlock (stored-procedure / SQL calls)', async t => {
154
+ const deadlock = {
155
+ errno: 1213,
156
+ code: 'ER_LOCK_DEADLOCK',
157
+ sql: 'CALL `sql_deadlock`()',
158
+ sqlMessage: 'Deadlock found when trying to get lock',
159
+ message: 'Deadlock found when trying to get lock',
160
+ };
161
+ const reported: unknown[] = [];
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ const fakeKnex = (() => undefined) as any;
164
+ fakeKnex.raw = () => makeRejectingThenable(deadlock);
165
+ const wrapped = wrapKnex(fakeKnex, {onDeadlock: error => reported.push(error)});
166
+
167
+ const raw = wrapped.raw('CALL `sql_deadlock`()');
168
+ await t.rejects(Promise.resolve(raw.then(null, null)), deadlock, 'raw query still rejects');
169
+ t.same(reported, [deadlock], 'onDeadlock fired for the raw query');
170
+ t.end();
171
+ });
172
+
173
+ test('wrapKnex transaction() wraps the trx so its queries report deadlocks (callback form)', async t => {
174
+ const deadlock = {
175
+ errno: 1213,
176
+ code: 'ER_LOCK_DEADLOCK',
177
+ sql: 'UPDATE `deadlock_demo` set `x` = 1',
178
+ sqlMessage: 'Deadlock found when trying to get lock',
179
+ message: 'Deadlock found when trying to get lock',
180
+ };
181
+ const reported: unknown[] = [];
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ const fakeKnex = (() => undefined) as any;
184
+ fakeKnex.transaction = (cb: (trx: unknown) => unknown) => {
185
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
+ const trx = (() => makeRejectingBuilder(deadlock)) as any;
187
+ return Promise.resolve(cb(trx));
188
+ };
189
+ const wrapped = wrapKnex(fakeKnex, {onDeadlock: error => reported.push(error)});
190
+
191
+ await wrapped.transaction(async (trx: (...args: unknown[]) => unknown) => {
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
193
+ const builder = trx('deadlock_demo') as any;
194
+ await t.rejects(Promise.resolve(builder.then(null, null)), deadlock, 'trx query rejects');
195
+ });
196
+ t.same(reported, [deadlock], 'onDeadlock fired for a transaction query');
197
+ t.end();
198
+ });
199
+
200
+ test('wrapKnex transaction() wraps the resolved trx (promise form)', async t => {
201
+ const deadlock = {errno: 1213, code: 'ER_LOCK_DEADLOCK'};
202
+ const reported: unknown[] = [];
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ const fakeKnex = (() => undefined) as any;
205
+ fakeKnex.transaction = () => {
206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
207
+ const trx = (() => makeRejectingBuilder(deadlock)) as any;
208
+ return Promise.resolve(trx);
209
+ };
210
+ const wrapped = wrapKnex(fakeKnex, {onDeadlock: error => reported.push(error)});
211
+
212
+ const trx = await wrapped.transaction();
213
+ const builder = trx('deadlock_demo');
214
+ await t.rejects(Promise.resolve(builder.then(null, null)), deadlock, 'trx query rejects');
215
+ t.same(reported, [deadlock], 'onDeadlock fired for a promise-form transaction query');
216
+ t.end();
217
+ });
218
+
219
+ /** A minimal knex-like builder whose `then` rejects with `error`. */
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
+ function makeRejectingBuilder(error: unknown): any {
222
+ return {
223
+ insert(..._args: unknown[]) {
224
+ return this;
225
+ },
226
+ update(..._args: unknown[]) {
227
+ return this;
228
+ },
229
+ then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) {
230
+ return Promise.reject(error).then(onFulfilled, onRejected);
231
+ },
232
+ };
233
+ }
234
+
235
+ /** A minimal thenable whose `then` rejects with `error`. */
236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
237
+ function makeRejectingThenable(error: unknown): any {
238
+ return {
239
+ then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) {
240
+ return Promise.reject(error).then(onFulfilled, onRejected);
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * JSON-column interception for the knex adapter.
3
+ *
4
+ * Any column whose name ends with `JSON` is treated as a JSON document stored
5
+ * in a plain string column. The shared `srv.db` queryBuilder is wrapped so
6
+ * this conversion is automatic for every consumer of the adapter:
7
+ *
8
+ * - `insert()` / `update()` — plain object/array values for `*JSON` columns
9
+ * are serialized with `JSON.stringify` before hitting the database.
10
+ * - query results (`select`, `first`, ...) — string values in `*JSON`
11
+ * columns are parsed back with `JSON.parse` (lenient: values that are not
12
+ * valid JSON are left untouched).
13
+ *
14
+ * This lets a realm store structured parameters in a single column without
15
+ * manual (de)serialization — e.g. blong-access stores the credential function
16
+ * and its parameters (`{"function":"hash","algorithm":"pbkdf2",...}`) in
17
+ * `access_credential.credentialParamsJSON`.
18
+ */
19
+
20
+ /** Column names ending in `JSON` are treated as JSON documents. */
21
+ const JSON_COLUMN = /JSON$/;
22
+
23
+ /** Whether a column name is a JSON column (ends in `JSON`). */
24
+ export function isJsonColumn(column: string): boolean {
25
+ return JSON_COLUMN.test(column);
26
+ }
27
+
28
+ /**
29
+ * Detect a MySQL deadlock error (errno 1213 / `ER_LOCK_DEADLOCK`). Used to log
30
+ * deadlock details — including the offending query — in dev mode.
31
+ */
32
+ export function isDeadlock(error: unknown): boolean {
33
+ if (!error || typeof error !== 'object') return false;
34
+ const {code, errno} = error as {code?: unknown; errno?: unknown};
35
+ return code === 'ER_LOCK_DEADLOCK' || errno === 1213;
36
+ }
37
+
38
+ /**
39
+ * Optional hooks applied while wrapping the shared knex instance.
40
+ */
41
+ export interface WrapKnexOptions {
42
+ /** Invoked when a query rejects with a deadlock error, before the caller's rejection handler. */
43
+ onDeadlock?: (error: unknown) => void;
44
+ }
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ /**
51
+ * Serialize object/array values of `*JSON` columns to JSON strings.
52
+ * Returns a new object; scalar/string values are copied unchanged.
53
+ */
54
+ export function stringifyJsonValues(values: Record<string, unknown>): Record<string, unknown> {
55
+ const result: Record<string, unknown> = {};
56
+ for (const [key, value] of Object.entries(values)) {
57
+ result[key] =
58
+ JSON_COLUMN.test(key) && (isRecord(value) || Array.isArray(value))
59
+ ? JSON.stringify(value)
60
+ : value;
61
+ }
62
+ return result;
63
+ }
64
+
65
+ /** Parse `*JSON` string values in a single result row. Mutates and returns the row. */
66
+ export function parseJsonRow(row: Record<string, unknown>): Record<string, unknown> {
67
+ for (const [key, value] of Object.entries(row)) {
68
+ if (JSON_COLUMN.test(key) && typeof value === 'string') {
69
+ try {
70
+ row[key] = JSON.parse(value);
71
+ } catch {
72
+ // Not valid JSON — keep the raw string.
73
+ }
74
+ }
75
+ }
76
+ return row;
77
+ }
78
+
79
+ /**
80
+ * Parse `*JSON` columns in a query result. Handles a single row, an array of
81
+ * rows, and the `[rows, meta]` shape MySQL returns for DML. Rows are mutated
82
+ * in place; the overall result shape is preserved.
83
+ */
84
+ export function parseJsonResult(result: unknown): unknown {
85
+ if (Array.isArray(result)) {
86
+ return result.map(row => (isRecord(row) ? parseJsonRow(row) : row));
87
+ }
88
+ if (isRecord(result)) return parseJsonRow(result);
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Wrap a knex QueryBuilder instance so JSON columns are serialized on write and
94
+ * parsed on read.
95
+ *
96
+ * Only `insert`, `update`, and `then` are intercepted — all other builder
97
+ * behaviour is untouched. Chained calls keep working because knex builder
98
+ * methods return the same instance. The `then` rejection path is also
99
+ * intercepted to detect MySQL deadlocks and report them via `options.onDeadlock`.
100
+ */
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ export function wrapJsonBuilder(builder: any, options: WrapKnexOptions = {}): any {
103
+ const originalInsert = builder.insert.bind(builder);
104
+ builder.insert = (...args: unknown[]) => {
105
+ const [first, ...rest] = args;
106
+ if (isRecord(first)) return originalInsert(stringifyJsonValues(first), ...rest);
107
+ if (Array.isArray(first))
108
+ return originalInsert(
109
+ first.map(row => (isRecord(row) ? stringifyJsonValues(row) : row)),
110
+ ...rest,
111
+ );
112
+ return originalInsert(...args);
113
+ };
114
+
115
+ const originalUpdate = builder.update.bind(builder);
116
+ builder.update = (...args: unknown[]) => {
117
+ const [first, ...rest] = args;
118
+ if (isRecord(first)) return originalUpdate(stringifyJsonValues(first), ...rest);
119
+ return originalUpdate(...args);
120
+ };
121
+
122
+ const originalThen = builder.then.bind(builder);
123
+ builder.then = (
124
+ onFulfilled?: ((value: unknown) => unknown) | null,
125
+ onRejected?: ((reason: unknown) => unknown) | null,
126
+ ) =>
127
+ originalThen(
128
+ (result: unknown) => {
129
+ const processed = parseJsonResult(result);
130
+ return typeof onFulfilled === 'function' ? onFulfilled(processed) : processed;
131
+ },
132
+ (reason: unknown) => {
133
+ if (isDeadlock(reason)) options.onDeadlock?.(reason);
134
+ if (typeof onRejected === 'function') return onRejected(reason);
135
+ throw reason;
136
+ },
137
+ );
138
+
139
+ return builder;
140
+ }
141
+
142
+ /**
143
+ * Intercept the `then` rejection path of a thenable (e.g. a knex `Raw` result)
144
+ * so MySQL deadlocks are reported via `options.onDeadlock`. Mirrors the
145
+ * rejection interception in {@link wrapJsonBuilder} without the JSON column
146
+ * handling, which does not apply to `raw` queries / stored-procedure calls.
147
+ */
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ function attachDeadlockHook(thenable: any, options: WrapKnexOptions): any {
150
+ const originalThen = thenable.then.bind(thenable);
151
+ thenable.then = (
152
+ onFulfilled?: ((value: unknown) => unknown) | null,
153
+ onRejected?: ((reason: unknown) => unknown) | null,
154
+ ) =>
155
+ originalThen(onFulfilled, (reason: unknown) => {
156
+ if (isDeadlock(reason)) options.onDeadlock?.(reason);
157
+ if (typeof onRejected === 'function') return onRejected(reason);
158
+ throw reason;
159
+ });
160
+ return thenable;
161
+ }
162
+
163
+ /**
164
+ * Wrap the shared knex instance so every query path is deadlock-aware (and
165
+ * `knex('table')` builders are JSON-column aware):
166
+ * - `knex('table')` → {@link wrapJsonBuilder} (JSON columns + deadlock hook)
167
+ * - `knex.raw(...)` → deadlock hook (covers stored-procedure `CALL` + SQL)
168
+ * - `knex.transaction()` → the returned `trx` is wrapped like the top-level
169
+ * knex, so queries inside transactions are JSON-aware and deadlock-aware.
170
+ * All other knex surface (`.fn`, `.client`, `.schema`, ...) is left untouched
171
+ * via the default Proxy get behaviour.
172
+ *
173
+ * The parameter is intentionally unconstrained: the installed `knex` types and
174
+ * the `Knex` re-exported by `@feasibleone/blong` are structurally different, so
175
+ * callers cast the result with `as unknown as Knex`.
176
+ */
177
+ export function wrapKnex<T>(knex: T, options: WrapKnexOptions = {}): T {
178
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
+ return new Proxy(knex as any, {
180
+ apply(target, thisArg, args) {
181
+ return wrapJsonBuilder(Reflect.apply(target, thisArg, args), options);
182
+ },
183
+ get(target, prop, receiver) {
184
+ if (typeof prop !== 'string') return Reflect.get(target, prop, receiver);
185
+ if (prop === 'raw') {
186
+ const original = Reflect.get(target, prop, target);
187
+ if (typeof original !== 'function') return original;
188
+ const bound = original.bind(target);
189
+ return (...args: unknown[]) => attachDeadlockHook(bound(...args), options);
190
+ }
191
+ if (prop === 'transaction') {
192
+ const original = Reflect.get(target, prop, target);
193
+ if (typeof original !== 'function') return original;
194
+ const bound = original.bind(target);
195
+ return (...args: unknown[]) => {
196
+ const cbIndex = args.findIndex(arg => typeof arg === 'function');
197
+ if (cbIndex !== -1) {
198
+ const cb = args[cbIndex] as (trx: unknown) => unknown;
199
+ const nextArgs = [...args];
200
+ nextArgs[cbIndex] = (trx: unknown) => cb(wrapKnex(trx, options));
201
+ return bound(...nextArgs);
202
+ }
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ const result = bound(...args) as any;
205
+ return typeof result?.then === 'function'
206
+ ? result.then((trx: unknown) => wrapKnex(trx, options))
207
+ : result;
208
+ };
209
+ }
210
+ return Reflect.get(target, prop, receiver);
211
+ },
212
+ });
213
+ }
@@ -1,6 +1,5 @@
1
1
  import {type Knex} from '@feasibleone/blong/types';
2
2
  import {Type, type TFunction, type TSchema} from 'typebox';
3
- import {attachHandlers} from './schemaTable.ts';
4
3
  import {
5
4
  extractProcedureBody,
6
5
  methodId,
@@ -181,28 +180,3 @@ export async function bindSyntheticHandlers(
181
180
  self[camelName] = callable;
182
181
  }
183
182
  }
184
-
185
- /**
186
- * Bind CRUD synthetic handlers for every table in `tables`, keyed by namespace.
187
- * Called from `ready()` when `config.namespace` is set.
188
- */
189
- export async function bindSyntheticCrud(
190
- self: Record<string, unknown>,
191
- knex: Knex,
192
- namespace: string,
193
- tables: Array<{tableName: string; definition: import('typebox').TObject}>,
194
- ): Promise<void> {
195
- const {schemaCrudBindImpl} = await import('./schemaTable.ts');
196
- for (const {tableName, definition} of tables) {
197
- const objectName = snakeToCamel(tableName);
198
- const {handlers} = await schemaCrudBindImpl(
199
- knex,
200
- namespace,
201
- objectName,
202
- definition,
203
- [],
204
- tableName,
205
- );
206
- attachHandlers(self, handlers);
207
- }
208
- }
@@ -1,7 +1,7 @@
1
1
  import {type Knex} from '@feasibleone/blong/types';
2
- import {Type, type TFunction, type TObject, type TSchema} from 'typebox';
2
+ import {type TObject} from 'typebox';
3
3
  import {type IColumnSchema, type ITableConstraints} from './types.ts';
4
- import {addColumn, capitalize, methodId} from './utils.ts';
4
+ import {addColumn, methodId} from './utils.ts';
5
5
 
6
6
  /**
7
7
  * Create or synchronise a database table from a TypeBox `TObject` schema.
@@ -86,11 +86,13 @@ export async function schemaTableConstraintSyncImpl(
86
86
  const existingConstraints = (
87
87
  await knex('information_schema.TABLE_CONSTRAINTS').select('CONSTRAINT_NAME').where({
88
88
  TABLE_SCHEMA: knex.client.database(),
89
+ TABLE_NAME: tableName,
89
90
  })
90
91
  ).map(c => (c.CONSTRAINT_NAME === 'PRIMARY' ? `${tableName}_pk` : c.CONSTRAINT_NAME));
91
92
  const existingIndexes = (
92
93
  await knex('information_schema.STATISTICS').select('INDEX_NAME').where({
93
94
  TABLE_SCHEMA: knex.client.database(),
95
+ TABLE_NAME: tableName,
94
96
  })
95
97
  ).map(i => i.INDEX_NAME);
96
98
 
@@ -147,142 +149,6 @@ export async function schemaTableConstraintSyncImpl(
147
149
  });
148
150
  }
149
151
 
150
- /**
151
- * Generate CRUD handler functions and their TypeBox schemas for a given table.
152
- *
153
- * The returned `handlers` map can be called directly or stored as synthetic
154
- * handlers on the adapter object. Operations already listed in
155
- * `existingHandlers` are skipped so realm-defined handlers take precedence.
156
- */
157
- export async function schemaCrudBindImpl(
158
- knex: Knex,
159
- subject: string,
160
- objectName: string,
161
- schema: TObject,
162
- existingHandlers: string[] = [],
163
- tableName?: string,
164
- ): Promise<{
165
- handlers: Record<string, (params: Record<string, unknown>) => Promise<unknown>>;
166
- schemas: Record<string, TFunction>;
167
- }> {
168
- const existing = new Set(existingHandlers);
169
- const table = tableName ?? objectName;
170
- const objectId = `${objectName}Id`;
171
- const objectCapitalized = capitalize(objectName);
172
- const handlers: Record<string, (params: Record<string, unknown>) => Promise<unknown>> = {};
173
- const schemas: Record<string, TFunction> = {};
174
- const required = new Set(schema.required ?? []);
175
- const idSchema = (schema.properties[objectId] as TSchema) ?? Type.String();
176
- const entitySchema = Type.Object(
177
- Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, v as TSchema])),
178
- );
179
- const allOptionalProps: Record<string, TSchema> = {};
180
- for (const [name, prop] of Object.entries(schema.properties))
181
- allOptionalProps[name] = Type.Optional(prop as TSchema);
182
- const addProps: Record<string, TSchema> = {};
183
- for (const [name, prop] of Object.entries(schema.properties)) {
184
- if (name === objectId && (prop as IColumnSchema).type === 'integer') continue;
185
- addProps[name] =
186
- required.has(name) && name !== objectId
187
- ? (prop as TSchema)
188
- : Type.Optional(prop as TSchema);
189
- }
190
-
191
- const getName = `${subject}${objectCapitalized}Get`;
192
- if (!existing.has(getName)) {
193
- handlers[getName] = async (params: Record<string, unknown>) => {
194
- const {select = '*', ...where} = params;
195
- return knex(table)
196
- .where(where)
197
- .first(select as string);
198
- };
199
- schemas[getName] = Type.Function(
200
- [Type.Object({[objectId]: idSchema})],
201
- Type.Promise(entitySchema),
202
- );
203
- }
204
-
205
- const findName = `${subject}${objectCapitalized}Find`;
206
- if (!existing.has(findName)) {
207
- handlers[findName] = async (params: Record<string, unknown>) => {
208
- const {select = '*', order, limit, offset, ...where} = params;
209
- let query = knex(table).where(where);
210
- if (order) query = query.orderBy(order as string);
211
- if (limit) query = query.limit(limit as number);
212
- if (offset) query = query.offset(offset as number);
213
- return query.select(select as string);
214
- };
215
- schemas[findName] = Type.Function(
216
- [
217
- Type.Object({
218
- ...allOptionalProps,
219
- select: Type.Optional(Type.String()),
220
- order: Type.Optional(Type.String()),
221
- limit: Type.Optional(Type.Integer()),
222
- offset: Type.Optional(Type.Integer()),
223
- }),
224
- ],
225
- Type.Promise(Type.Array(entitySchema)),
226
- );
227
- }
228
-
229
- const addName = `${subject}${objectCapitalized}Add`;
230
- if (!existing.has(addName)) {
231
- handlers[addName] = async (params: Record<string, unknown>) => ({
232
- [objectId]: (await knex(table).insert(params))?.[0],
233
- });
234
- schemas[addName] = Type.Function(
235
- [Type.Object(addProps)],
236
- Type.Promise(Type.Object({[objectId]: idSchema})),
237
- );
238
- }
239
-
240
- const editName = `${subject}${objectCapitalized}Edit`;
241
- if (!existing.has(editName)) {
242
- handlers[editName] = async (params: Record<string, unknown>) => {
243
- const {key: keyName = objectId, ...columns} = params;
244
- const {[keyName as string]: key, ...update} = columns;
245
- return knex(table)
246
- .where({[keyName as string]: key})
247
- .update(update);
248
- };
249
- schemas[editName] = Type.Function(
250
- [Type.Object({[objectId]: idSchema, ...allOptionalProps})],
251
- Type.Promise(Type.Integer({description: 'Number of affected rows'})),
252
- );
253
- }
254
-
255
- const removeName = `${subject}${objectCapitalized}Remove`;
256
- if (!existing.has(removeName)) {
257
- handlers[removeName] = async (params: Record<string, unknown>) =>
258
- knex(table)
259
- .where({[objectId]: params[objectId]})
260
- .del();
261
- schemas[removeName] = Type.Function(
262
- [Type.Object({[objectId]: idSchema})],
263
- Type.Promise(Type.Integer({description: 'Number of affected rows'})),
264
- );
265
- }
266
-
267
- const mergeName = `${subject}${objectCapitalized}Merge`;
268
- if (!existing.has(mergeName)) {
269
- handlers[mergeName] = async (params: Record<string, unknown>) =>
270
- knex(table).insert(params).onConflict(objectId).merge();
271
- schemas[mergeName] = Type.Function(
272
- [
273
- Type.Object(
274
- Object.fromEntries(
275
- Object.entries(schema.properties).map(([k, v]) => [k, v as TSchema]),
276
- ),
277
- ),
278
- ],
279
- Type.Promise(Type.Unknown()),
280
- );
281
- }
282
-
283
- return {handlers, schemas};
284
- }
285
-
286
152
  /**
287
153
  * Store each handler from `handlersMap` on `self` under two keys so it can be
288
154
  * reached via both framework dispatch and `super` calls: