@feasibleone/blong-gogo 1.37.0 → 1.38.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 +13 -0
- package/package.json +2 -2
- package/src/ConfigRuntime.test.ts +79 -1
- package/src/ConfigRuntime.ts +61 -0
- package/src/Registry.ts +2 -2
- package/src/Watch.test.ts +150 -1
- package/src/Watch.ts +79 -22
- package/src/adapter/schema/knex/types.ts +4 -3
- package/src/adapter/server/acl.ts +4 -2
- package/src/adapter/server/knex.ts +482 -360
- package/src/adapter/server/pathRefresh.test.ts +208 -0
- package/src/adapter/server/pathRefresh.ts +132 -0
- package/src/kopi.test.ts +95 -0
- package/src/kopi.ts +24 -8
- package/src/layerProxy.ts +25 -5
- package/src/loadServer.ts +13 -1
- package/src/template-files.ts +5 -0
- package/template/.storybook/main.ts +14 -0
- package/template/.storybook/preview.tsx +19 -0
- package/template/browser.ts +22 -4
- package/template/meta/db/db.ts +10 -1
- package/template/meta/fixture/$subjectFixture.ts +37 -0
- package/template/src/stories/$Object.stories.tsx +17 -0
- package/template/src/stories/$Subject.stories.tsx +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.38.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.37.0...blong-gogo-v1.38.0) (2026-09-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* improve storybook scaffold ([cc66ab4](https://github.com/feasibleone/blong/commit/cc66ab480eb9af2def562873cac12d06591c0de4))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* failed tests ([108caf5](https://github.com/feasibleone/blong/commit/108caf5f3e92151f8b929115e0a8f6b2acdd3d23))
|
|
14
|
+
* gaps ([19beb7d](https://github.com/feasibleone/blong/commit/19beb7d191e853c8182409256c97af7a1e413d57))
|
|
15
|
+
|
|
3
16
|
## [1.37.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.36.0...blong-gogo-v1.37.0) (2026-09-26)
|
|
4
17
|
|
|
5
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"tap": "^21.6.3",
|
|
93
93
|
"typescript": "^6.0.3",
|
|
94
94
|
"vite": "^8.2.1",
|
|
95
|
-
"@feasibleone/blong-dev": "1.7.
|
|
95
|
+
"@feasibleone/blong-dev": "1.7.1"
|
|
96
96
|
},
|
|
97
97
|
"scripts": {
|
|
98
98
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import {test} from 'tap';
|
|
19
19
|
|
|
20
|
-
import {createConfigProxy, deepDiff} from './ConfigRuntime.ts';
|
|
20
|
+
import ConfigRuntime, {createConfigProxy, deepDiff} from './ConfigRuntime.ts';
|
|
21
21
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// deepDiff
|
|
@@ -325,3 +325,81 @@ test('factory phase guard — exitConfigFactoryPhase returns empty array in thro
|
|
|
325
325
|
const errors = exitConfig();
|
|
326
326
|
t.equal(errors.length, 0, 'no errors collected in throw mode (they were thrown)');
|
|
327
327
|
});
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// liveLayerConfig — the config a handler closure receives
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The merge input is a getter over a `let`, so a "reload" in these tests replaces
|
|
335
|
+
* the object the next merge sees — the way `ConfigRuntime.#rawSnapshot` is
|
|
336
|
+
* replaced while the version moves with it.
|
|
337
|
+
*/
|
|
338
|
+
|
|
339
|
+
test('liveLayerConfig — a handler sees a reloaded value', async t => {
|
|
340
|
+
let source: Record<string, unknown> = {timeout: 5, db: {host: 'a', port: 1}};
|
|
341
|
+
let version = 0;
|
|
342
|
+
const config = ConfigRuntime.liveLayerConfig(
|
|
343
|
+
() => source,
|
|
344
|
+
() => version,
|
|
345
|
+
) as {
|
|
346
|
+
timeout: number;
|
|
347
|
+
db: {host: string};
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
t.equal(config.timeout, 5, 'the value at assembly time');
|
|
351
|
+
|
|
352
|
+
source = {timeout: 9, db: {host: 'b', port: 2}};
|
|
353
|
+
version += 1;
|
|
354
|
+
|
|
355
|
+
t.equal(config.timeout, 9, 'a leaf read after the reload is fresh');
|
|
356
|
+
t.end();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('liveLayerConfig — an intermediate node destructured at startup stays live', async t => {
|
|
360
|
+
let source: Record<string, unknown> = {db: {host: 'a'}};
|
|
361
|
+
let version = 0;
|
|
362
|
+
const config = ConfigRuntime.liveLayerConfig(
|
|
363
|
+
() => source,
|
|
364
|
+
() => version,
|
|
365
|
+
) as {
|
|
366
|
+
db: {host: string};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// The pattern the config rationale calls safe: the node is taken once, its
|
|
370
|
+
// leaves are read at call time.
|
|
371
|
+
const {db} = config;
|
|
372
|
+
t.equal(db.host, 'a', 'the leaf before the reload');
|
|
373
|
+
t.equal(config.db, db, 'and the node reference is stable');
|
|
374
|
+
|
|
375
|
+
source = {db: {host: 'b'}};
|
|
376
|
+
version += 1;
|
|
377
|
+
|
|
378
|
+
t.equal(db.host, 'b', 'the same node reads the reloaded leaf');
|
|
379
|
+
t.end();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('liveLayerConfig — an unchanged version does not re-merge', async t => {
|
|
383
|
+
let merges = 0;
|
|
384
|
+
let version = 3;
|
|
385
|
+
const config = ConfigRuntime.liveLayerConfig(
|
|
386
|
+
() => {
|
|
387
|
+
merges += 1;
|
|
388
|
+
return {timeout: 5};
|
|
389
|
+
},
|
|
390
|
+
() => version,
|
|
391
|
+
) as {timeout: number};
|
|
392
|
+
|
|
393
|
+
t.equal(merges, 1, 'assembled once');
|
|
394
|
+
void config.timeout;
|
|
395
|
+
void config.timeout;
|
|
396
|
+
t.equal(merges, 1, 'reads at the same version do not merge again');
|
|
397
|
+
|
|
398
|
+
version += 1;
|
|
399
|
+
void config.timeout;
|
|
400
|
+
t.equal(merges, 2, 'a new version merges once');
|
|
401
|
+
|
|
402
|
+
void config.timeout;
|
|
403
|
+
t.equal(merges, 2, 'and then caches again');
|
|
404
|
+
t.end();
|
|
405
|
+
});
|
package/src/ConfigRuntime.ts
CHANGED
|
@@ -43,6 +43,17 @@ import merge from 'ut-function.merge';
|
|
|
43
43
|
*/
|
|
44
44
|
export function createConfigProxy<T extends object>(
|
|
45
45
|
store: T,
|
|
46
|
+
options: {
|
|
47
|
+
/**
|
|
48
|
+
* Called before every read, at every level of the proxy.
|
|
49
|
+
*
|
|
50
|
+
* A caller whose backing store has to be re-read from somewhere charges
|
|
51
|
+
* the check here, so a node destructured once at startup (a handler's
|
|
52
|
+
* `config.db`, say) refreshes on its own reads rather than only when the
|
|
53
|
+
* root is touched.
|
|
54
|
+
*/
|
|
55
|
+
beforeRead?: () => void;
|
|
56
|
+
} = {},
|
|
46
57
|
): {
|
|
47
58
|
proxy: T;
|
|
48
59
|
update: (next: T) => void;
|
|
@@ -131,6 +142,7 @@ export function createConfigProxy<T extends object>(
|
|
|
131
142
|
|
|
132
143
|
const pathProxy = new Proxy({} as T, {
|
|
133
144
|
get(_target, prop) {
|
|
145
|
+
options.beforeRead?.();
|
|
134
146
|
const container = getNode(path);
|
|
135
147
|
const val = container == null ? undefined : Reflect.get(container as object, prop);
|
|
136
148
|
if (
|
|
@@ -166,16 +178,19 @@ export function createConfigProxy<T extends object>(
|
|
|
166
178
|
return Reflect.set(container as object, prop, value);
|
|
167
179
|
},
|
|
168
180
|
has(_target, prop) {
|
|
181
|
+
options.beforeRead?.();
|
|
169
182
|
const container = getNode(path);
|
|
170
183
|
if (container == null) return false;
|
|
171
184
|
return Reflect.has(container as object, prop);
|
|
172
185
|
},
|
|
173
186
|
ownKeys() {
|
|
187
|
+
options.beforeRead?.();
|
|
174
188
|
const container = getNode(path);
|
|
175
189
|
if (container == null) return [];
|
|
176
190
|
return Reflect.ownKeys(container as object);
|
|
177
191
|
},
|
|
178
192
|
getOwnPropertyDescriptor(_target, prop) {
|
|
193
|
+
options.beforeRead?.();
|
|
179
194
|
const container = getNode(path);
|
|
180
195
|
if (container == null) return undefined;
|
|
181
196
|
return Object.getOwnPropertyDescriptor(container as object, prop);
|
|
@@ -265,6 +280,7 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
265
280
|
#rawSnapshot: object = {};
|
|
266
281
|
#proxy: object;
|
|
267
282
|
#updateProxy: (next: object) => void;
|
|
283
|
+
#version = 0;
|
|
268
284
|
|
|
269
285
|
readonly enterConfig: (mode?: FactoryPhaseMode) => void;
|
|
270
286
|
readonly exitConfig: () => Error[];
|
|
@@ -292,6 +308,11 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
292
308
|
return this.#rawSnapshot;
|
|
293
309
|
}
|
|
294
310
|
|
|
311
|
+
/** Bumped whenever the effective config is replaced; see `IConfigRuntime`. */
|
|
312
|
+
public get version(): number {
|
|
313
|
+
return this.#version;
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
private mergeConfigs(blongConfig: object): object {
|
|
296
317
|
const loaded = loadBlong(blongConfig);
|
|
297
318
|
return merge({}, this.#baseConfig, ...this.#configs, loaded);
|
|
@@ -305,6 +326,7 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
305
326
|
public async load(params: object = {}): Promise<object> {
|
|
306
327
|
const loaded = this.mergeConfigs({config: {suite: this.#suite}, ...params});
|
|
307
328
|
this.#rawSnapshot = loaded;
|
|
329
|
+
this.#version += 1;
|
|
308
330
|
this.#updateProxy(loaded);
|
|
309
331
|
return this.#proxy;
|
|
310
332
|
}
|
|
@@ -324,6 +346,7 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
324
346
|
// Only do work when something actually changed
|
|
325
347
|
if (diff.size > 0) {
|
|
326
348
|
this.#rawSnapshot = next;
|
|
349
|
+
this.#version += 1;
|
|
327
350
|
this.#updateProxy(next);
|
|
328
351
|
for (const subscriber of this.#subscribers) {
|
|
329
352
|
try {
|
|
@@ -374,6 +397,44 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
374
397
|
return merge({}, moduleConfigSlice, portNamespaceConfig) as Record<string, unknown>;
|
|
375
398
|
}
|
|
376
399
|
|
|
400
|
+
/**
|
|
401
|
+
* A live view of one component's config slice, for a handler closure.
|
|
402
|
+
*
|
|
403
|
+
* `mergeLayerConfig` copies, so a handler that reads `config.timeout` inside
|
|
404
|
+
* its function keeps whatever the value was when the layer was assembled,
|
|
405
|
+
* however often the config is reloaded (T-159). This hands the closure a
|
|
406
|
+
* proxy over the merged slice instead: `mergeOnce` is called again when
|
|
407
|
+
* `version()` reports a new runtime version, and the readers see the new
|
|
408
|
+
* values — the handler's own `config` argument, and any intermediate node it
|
|
409
|
+
* destructured at startup, because the nodes are the path proxies of a
|
|
410
|
+
* `createConfigProxy` instance whose backing is replaced rather than a copy
|
|
411
|
+
* of it.
|
|
412
|
+
*
|
|
413
|
+
* The merge runs at most once per runtime version, so a hot path that reads a
|
|
414
|
+
* config leaf pays a property lookup and an integer compare, not a merge.
|
|
415
|
+
*/
|
|
416
|
+
static liveLayerConfig(
|
|
417
|
+
mergeOnce: () => Record<string, unknown>,
|
|
418
|
+
version: () => number,
|
|
419
|
+
): Record<string, unknown> {
|
|
420
|
+
let seen = version();
|
|
421
|
+
const refresh = (): void => {
|
|
422
|
+
const current = version();
|
|
423
|
+
if (current === seen) return;
|
|
424
|
+
seen = current;
|
|
425
|
+
live.update(mergeOnce());
|
|
426
|
+
};
|
|
427
|
+
// The hook is passed to the proxy instance itself, so a node the closure
|
|
428
|
+
// destructured at startup refreshes on its own reads — reading through
|
|
429
|
+
// the root would leave it stale.
|
|
430
|
+
//
|
|
431
|
+
// This instance's factory-phase guard is never armed: layer assembly arms
|
|
432
|
+
// the *runtime's* proxy, and a handler has always been free to destructure
|
|
433
|
+
// a leaf from its `config` argument at factory time.
|
|
434
|
+
const live = createConfigProxy(mergeOnce(), {beforeRead: refresh});
|
|
435
|
+
return live.proxy as Record<string, unknown>;
|
|
436
|
+
}
|
|
437
|
+
|
|
377
438
|
/**
|
|
378
439
|
* Merge activation-scoped config for an adapter.
|
|
379
440
|
*
|
package/src/Registry.ts
CHANGED
|
@@ -396,8 +396,8 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
396
396
|
// carries only config/handler/errors/schema/gateway/apiSchema. And
|
|
397
397
|
// the lib proxy returns an attached function **raw**, so `this`
|
|
398
398
|
// inside it is this object — not the port, which is only bound in the
|
|
399
|
-
// proxy's not-yet-attached fallback path. Without these keys
|
|
400
|
-
// library function therefore cannot reach the platform or registry at
|
|
399
|
+
// proxy's not-yet-attached fallback path. Without these keys an
|
|
400
|
+
// injected library function therefore cannot reach the platform or registry at
|
|
401
401
|
// all, which is what a library needs to be reusable outside a
|
|
402
402
|
// request.
|
|
403
403
|
platform: this.#platform,
|
package/src/Watch.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import {test} from 'tap';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type {ConfigDiff} from '@feasibleone/blong/types';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
affectedNamespaces,
|
|
7
|
+
applyConfigReload,
|
|
8
|
+
type IReloadablePort,
|
|
9
|
+
type IReloadRegistry,
|
|
10
|
+
} from './Watch.ts';
|
|
4
11
|
|
|
5
12
|
// ---------------------------------------------------------------------------
|
|
6
13
|
// affectedNamespaces
|
|
@@ -49,3 +56,145 @@ test('affectedNamespaces — partial name prefix does not match', async t => {
|
|
|
49
56
|
const affected = affectedNamespaces(diff, ports);
|
|
50
57
|
t.equal(affected.size, 0, 'partial-name prefix should not match');
|
|
51
58
|
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// applyConfigReload — what a reload does to the ports it affects
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
interface FakePort extends IReloadablePort {
|
|
65
|
+
/** Every lifecycle call, in order — the assertion surface. */
|
|
66
|
+
calls: string[];
|
|
67
|
+
/** The arguments `configChanged` was handed, once per call. */
|
|
68
|
+
changed: Array<{diff: ConfigDiff; next: unknown; prev: unknown}>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fakePort(id: string, options: {configChanged?: boolean; throws?: boolean} = {}): FakePort {
|
|
72
|
+
const calls: string[] = [];
|
|
73
|
+
const changed: FakePort['changed'] = [];
|
|
74
|
+
const port: FakePort = {
|
|
75
|
+
calls,
|
|
76
|
+
changed,
|
|
77
|
+
async stop() {
|
|
78
|
+
calls.push('stop');
|
|
79
|
+
},
|
|
80
|
+
async start() {
|
|
81
|
+
calls.push('start');
|
|
82
|
+
},
|
|
83
|
+
async ready() {
|
|
84
|
+
calls.push('ready');
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
if (options.configChanged !== false) {
|
|
88
|
+
port.configChanged = async (diff, next, prev) => {
|
|
89
|
+
calls.push('configChanged');
|
|
90
|
+
changed.push({diff, next, prev});
|
|
91
|
+
if (options.throws) throw new Error(`${id}: reload failed`);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return port;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fakeRegistry(ports: Record<string, FakePort>): IReloadRegistry {
|
|
98
|
+
return {
|
|
99
|
+
ports: {keys: () => Object.keys(ports)},
|
|
100
|
+
async getPort(id) {
|
|
101
|
+
return ports[id];
|
|
102
|
+
},
|
|
103
|
+
async createPort(id) {
|
|
104
|
+
return ports[id];
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const reloadDiff: ConfigDiff = new Map([['payment.adapter.db.host', {prev: 'a', next: 'b'}]]);
|
|
110
|
+
|
|
111
|
+
test('applyConfigReload — the affected port is asked to reconfigure itself', async t => {
|
|
112
|
+
const port = fakePort('payment.adapter.db');
|
|
113
|
+
const registry = fakeRegistry({'payment.adapter.db': port});
|
|
114
|
+
const next = {payment: {adapter: {db: {host: 'b'}}}};
|
|
115
|
+
|
|
116
|
+
const reloaded = await applyConfigReload(
|
|
117
|
+
registry,
|
|
118
|
+
affectedNamespaces(reloadDiff, registry.ports.keys()),
|
|
119
|
+
reloadDiff,
|
|
120
|
+
next,
|
|
121
|
+
{payment: {adapter: {db: {host: 'a'}}}},
|
|
122
|
+
{},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
t.equal(reloaded, 1, 'one port reached');
|
|
126
|
+
t.equal(port.calls.join(','), 'configChanged', 'and reconfigured, not restarted');
|
|
127
|
+
t.equal(port.changed.length, 1, 'configChanged called once');
|
|
128
|
+
t.equal(port.changed[0]?.diff, reloadDiff, 'handed the diff');
|
|
129
|
+
t.equal(port.changed[0]?.next, next, 'and the new effective config');
|
|
130
|
+
t.end();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('applyConfigReload — an unrelated key leaves a port running', async t => {
|
|
134
|
+
const affectedPort = fakePort('payment.adapter.db');
|
|
135
|
+
const untouched = fakePort('audit.adapter.kafka');
|
|
136
|
+
const registry = fakeRegistry({
|
|
137
|
+
'payment.adapter.db': affectedPort,
|
|
138
|
+
'audit.adapter.kafka': untouched,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const reloaded = await applyConfigReload(
|
|
142
|
+
registry,
|
|
143
|
+
affectedNamespaces(reloadDiff, registry.ports.keys()),
|
|
144
|
+
reloadDiff,
|
|
145
|
+
{payment: {adapter: {db: {host: 'b'}}}},
|
|
146
|
+
{},
|
|
147
|
+
{},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
t.equal(reloaded, 1, 'only the affected port was reached');
|
|
151
|
+
t.equal(untouched.calls.length, 0, 'the unrelated port was not stopped or started');
|
|
152
|
+
t.end();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('applyConfigReload — a port without configChanged is stopped and started again', async t => {
|
|
156
|
+
const port = fakePort('payment.adapter.db', {configChanged: false});
|
|
157
|
+
const registry = fakeRegistry({'payment.adapter.db': port});
|
|
158
|
+
const override = {marker: true};
|
|
159
|
+
|
|
160
|
+
await applyConfigReload(
|
|
161
|
+
registry,
|
|
162
|
+
affectedNamespaces(reloadDiff, registry.ports.keys()),
|
|
163
|
+
reloadDiff,
|
|
164
|
+
{},
|
|
165
|
+
{},
|
|
166
|
+
override,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
t.equal(port.calls.join(','), 'stop,start,ready', 'the fallback restarts the port in order');
|
|
170
|
+
t.end();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('applyConfigReload — a failing port does not stop the ones behind it', async t => {
|
|
174
|
+
const failing = fakePort('payment.adapter.db', {throws: true});
|
|
175
|
+
const healthy = fakePort('user.adapter.db');
|
|
176
|
+
const registry = fakeRegistry({
|
|
177
|
+
'payment.adapter.db': failing,
|
|
178
|
+
'user.adapter.db': healthy,
|
|
179
|
+
});
|
|
180
|
+
const diff: ConfigDiff = new Map([
|
|
181
|
+
['payment.adapter.db.host', {prev: 'a', next: 'b'}],
|
|
182
|
+
['user.adapter.db.host', {prev: 'a', next: 'b'}],
|
|
183
|
+
]);
|
|
184
|
+
const errors: unknown[] = [];
|
|
185
|
+
|
|
186
|
+
const reloaded = await applyConfigReload(
|
|
187
|
+
registry,
|
|
188
|
+
affectedNamespaces(diff, registry.ports.keys()),
|
|
189
|
+
diff,
|
|
190
|
+
{},
|
|
191
|
+
{},
|
|
192
|
+
{},
|
|
193
|
+
error => errors.push(error),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
t.equal(reloaded, 2, 'both ports were reached');
|
|
197
|
+
t.equal(healthy.calls.join(','), 'configChanged', 'the second port still reloaded');
|
|
198
|
+
t.equal(errors.length, 1, 'the failure was reported');
|
|
199
|
+
t.end();
|
|
200
|
+
});
|
package/src/Watch.ts
CHANGED
|
@@ -116,6 +116,73 @@ export function affectedNamespaces(diff: ConfigDiff, portNames: Iterable<string>
|
|
|
116
116
|
return affected;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The port surface a config reload needs — `IRegistry`, narrowed so the reload
|
|
121
|
+
* loop can be driven with fakes (see `Watch.test.ts`).
|
|
122
|
+
*/
|
|
123
|
+
export interface IReloadablePort {
|
|
124
|
+
/** Zero-downtime path: the port reconfigures itself. */
|
|
125
|
+
configChanged?(diff: ConfigDiff, next: unknown, prev: unknown): Promise<void> | void;
|
|
126
|
+
stop(): Promise<void> | void;
|
|
127
|
+
start(configOverride: object): Promise<void> | void;
|
|
128
|
+
ready(): Promise<void> | void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface IReloadRegistry {
|
|
132
|
+
ports: {keys: () => Iterable<string>};
|
|
133
|
+
getPort(id: string): Promise<IReloadablePort | undefined>;
|
|
134
|
+
createPort(id: string): Promise<IReloadablePort | undefined>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Apply a config reload to the ports the diff affects.
|
|
139
|
+
*
|
|
140
|
+
* A port that implements `configChanged` is asked to reconfigure itself — the
|
|
141
|
+
* zero-downtime path, which is what keeps an in-flight request or an expensive
|
|
142
|
+
* connection alive. A port that does not is stopped and started again with the
|
|
143
|
+
* current override. A port the diff does not name is not touched at all, which is
|
|
144
|
+
* the property that makes a log-level change safe: it must not cost a database
|
|
145
|
+
* pool.
|
|
146
|
+
*
|
|
147
|
+
* Returns the number of ports the loop reached, and reports each one through
|
|
148
|
+
* `onReloaded` so the caller's progress view can follow it.
|
|
149
|
+
*/
|
|
150
|
+
export async function applyConfigReload(
|
|
151
|
+
registry: IReloadRegistry,
|
|
152
|
+
affected: Iterable<string>,
|
|
153
|
+
diff: ConfigDiff,
|
|
154
|
+
next: object,
|
|
155
|
+
prev: object,
|
|
156
|
+
configOverride: object,
|
|
157
|
+
onPortError: (error: unknown) => void = () => undefined,
|
|
158
|
+
onReloaded?: (done: number, total: number) => void,
|
|
159
|
+
): Promise<number> {
|
|
160
|
+
const total = [...affected].length;
|
|
161
|
+
let reloaded = 0;
|
|
162
|
+
for (const portId of affected) {
|
|
163
|
+
const portInstance = await registry.getPort(portId);
|
|
164
|
+
if (portInstance) {
|
|
165
|
+
if (typeof portInstance.configChanged === 'function') {
|
|
166
|
+
try {
|
|
167
|
+
await portInstance.configChanged(diff, next, prev);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
onPortError(error);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
await portInstance.stop();
|
|
173
|
+
const fresh = await registry.createPort(portId);
|
|
174
|
+
if (fresh) {
|
|
175
|
+
await fresh.start(configOverride);
|
|
176
|
+
await fresh.ready();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
reloaded += 1;
|
|
181
|
+
onReloaded?.(reloaded, total);
|
|
182
|
+
}
|
|
183
|
+
return reloaded;
|
|
184
|
+
}
|
|
185
|
+
|
|
119
186
|
export default class Watch extends Internal implements IWatch {
|
|
120
187
|
#config: IConfig = {
|
|
121
188
|
enabled: false,
|
|
@@ -566,28 +633,18 @@ export default class Watch extends Internal implements IWatch {
|
|
|
566
633
|
this.log,
|
|
567
634
|
'reload config',
|
|
568
635
|
(async () => {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
await portInstance.stop();
|
|
582
|
-
const fresh = await registry.createPort(portId);
|
|
583
|
-
if (fresh) {
|
|
584
|
-
await fresh.start(configOverride);
|
|
585
|
-
await fresh.ready();
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
reloaded += 1;
|
|
590
|
-
}
|
|
636
|
+
await applyConfigReload(
|
|
637
|
+
registry as unknown as IReloadRegistry,
|
|
638
|
+
affected,
|
|
639
|
+
diff,
|
|
640
|
+
next,
|
|
641
|
+
prev,
|
|
642
|
+
configOverride,
|
|
643
|
+
error => this.log?.error?.(error),
|
|
644
|
+
done => {
|
|
645
|
+
reloaded = done;
|
|
646
|
+
},
|
|
647
|
+
);
|
|
591
648
|
})(),
|
|
592
649
|
{getProgress: () => ({done: reloaded, total: affected.size})},
|
|
593
650
|
);
|
|
@@ -96,9 +96,10 @@ export interface IResourceTableSpec {
|
|
|
96
96
|
* Record-level (ACL) guard for a table.
|
|
97
97
|
*
|
|
98
98
|
* The ACL lives in `access_acl` rows — `(principalId, actionId, targetId,
|
|
99
|
-
* targetKind, effect)` — where `principalId` is a user, role
|
|
100
|
-
*
|
|
101
|
-
* `
|
|
99
|
+
* targetKind, effect)` — where `principalId` is a user, role or unit (never a
|
|
100
|
+
* capability — the filter resolves the caller, their effective roles and their
|
|
101
|
+
* units), `targetId` is the guarded record (or a scope node) and `effect` is
|
|
102
|
+
* `allow` / `deny` (a deny beats a grant for a record inside a scope).
|
|
102
103
|
*/
|
|
103
104
|
export interface IAclTableSpec {
|
|
104
105
|
/**
|
|
@@ -13,10 +13,12 @@ import type {IAclAdapterConfig} from '../schema/knex/types.ts';
|
|
|
13
13
|
* access_acl(aclId, principalId, actionId, targetId, targetKind, effect, isActive)
|
|
14
14
|
* ```
|
|
15
15
|
*
|
|
16
|
-
* - `principalId` — a user, role
|
|
16
|
+
* - `principalId` — a user, role or unit resource id (never a capability: the
|
|
17
|
+
* filter resolves the caller, their effective roles and their units);
|
|
17
18
|
* - `actionId` — the `access_action` resource of the guarded method;
|
|
18
19
|
* - `targetId` — the guarded record, or a **scope** node (`targetKind`);
|
|
19
|
-
* - `effect` — `allow` / `deny
|
|
20
|
+
* - `effect` — `allow` / `deny`; a deny beats a grant for a record inside
|
|
21
|
+
* a scope, while a `record`-targeted deny on a record in no scope is ignored.
|
|
20
22
|
*
|
|
21
23
|
* Two things grant access, and both narrow RBAC (a method the caller's action
|
|
22
24
|
* list does not contain is refused by the gateway before this ever runs):
|