@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
|
@@ -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 {
|
|
2
|
+
import {type TObject} from 'typebox';
|
|
3
3
|
import {type IColumnSchema, type ITableConstraints} from './types.ts';
|
|
4
|
-
import {addColumn,
|
|
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:
|