cabloy 5.1.221 → 5.1.222
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/.cabloy-version +1 -1
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +15 -15
- package/repo-agent-governance/policies/repository.md +1 -0
- package/repo-agent-governance/skills/cabloy-backend-scaffold/SKILL.md +5 -1
- package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +7 -5
- package/repo-agent-governance/skills/cabloy-frontend-scaffold/SKILL.md +5 -1
- package/repo-agent-governance/skills/cabloy-frontend-scaffold/references/follow-up-checklist.md +6 -4
- package/repo-docs/backend/unit-testing.md +5 -1
- package/repo-docs/frontend/scripts.md +8 -0
- package/vona/packages-cli/cabloy-cli/package.json +1 -1
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-utils/process-helper/package.json +1 -1
- package/vona/packages-utils/process-helper/src/index.test.ts +62 -0
- package/vona/packages-utils/process-helper/src/index.ts +9 -4
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/completedReadiness.test.ts +558 -0
- package/vona/packages-vona/vona-core/src/lib/core/completedReadiness.ts +389 -0
- package/vona/packages-vona/vona-core/src/lib/core/context.ts +3 -0
- package/vona/packages-vona/vona-core/src/lib/core/index.ts +11 -0
- package/vona/packages-vona/vona-core/src/lib/core/meta.ts +5 -0
- package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +10 -0
- package/vona/packages-vona/vona-core/src/lib/framework/start.ts +8 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +124 -4
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/types/executor.ts +7 -0
- package/vona/src/suite-vendor/a-vona/modules/a-executor/test/readyExisting.test.ts +423 -0
- package/vona/src/suite-vendor/a-vona/modules/a-executor/test/readyExistingCommit.test.ts +174 -0
- package/vona/src/suite-vendor/a-vona/modules/a-instance/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-instance/src/bean/bean.instance.ts +3 -1
- package/vona/src/suite-vendor/a-vona/modules/a-instance/src/lib/existingReadyAdmission.ts +125 -0
- package/vona/src/suite-vendor/a-vona/modules/a-instance/src/service/instance.ts +72 -25
- package/vona/src/suite-vendor/a-vona/modules/a-instance/test/existingReadyAdmission.test.ts +949 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +61 -59
- package/vona/src/suite-vendor/a-vona/modules/a-mail/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-mail/src/service/mail.ts +5 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +61 -2
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +0 -1
- package/zova/packages-cli/cli/package.json +4 -4
- package/zova/packages-cli/cli-set-front/package.json +2 -2
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/pnpm-lock.yaml +7 -78
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
import type { FunctionAsync, VonaContext } from 'vona';
|
|
1
|
+
import type { ExistingReadyCapability, FunctionAsync, VonaContext } from 'vona';
|
|
2
|
+
import type { ServiceDb } from 'vona-module-a-orm';
|
|
2
3
|
import type { IApiPathRecordMethodMap } from 'vona-module-a-web';
|
|
3
4
|
|
|
4
5
|
import { isNil } from '@cabloy/utils';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
BeanBase,
|
|
8
|
+
captureRequestBoundary,
|
|
9
|
+
isExistingReadyContext,
|
|
10
|
+
pinExistingReadyContext,
|
|
11
|
+
resolveExistingReadyCapability,
|
|
12
|
+
} from 'vona';
|
|
6
13
|
import { Bean } from 'vona-module-a-bean';
|
|
7
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ServiceDatabaseAsyncLocalStorage,
|
|
16
|
+
ServiceTransactionAsyncLocalStorage,
|
|
17
|
+
} from 'vona-module-a-orm';
|
|
8
18
|
|
|
9
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
INewCtxLifecycle,
|
|
21
|
+
INewCtxOptions,
|
|
22
|
+
INewCtxReadyExistingOptions,
|
|
23
|
+
IPerformActionOptions,
|
|
24
|
+
} from '../types/executor.ts';
|
|
10
25
|
|
|
11
26
|
import { __delegateProperties } from '../lib/utils.ts';
|
|
12
27
|
|
|
@@ -16,12 +31,14 @@ export class BeanExecutor extends BeanBase {
|
|
|
16
31
|
METHOD extends keyof IApiPathRecordMethodMap,
|
|
17
32
|
PATH extends keyof IApiPathRecordMethodMap[METHOD],
|
|
18
33
|
>(method: METHOD, path: PATH, options?: IPerformActionOptions): Promise<any> {
|
|
34
|
+
this._assertOrdinaryContext();
|
|
19
35
|
// url
|
|
20
36
|
const url = this.app.util.combineApiPath(path as any, '', true, true);
|
|
21
37
|
return await this.scope.service.executor.performActionInner(method, url, options);
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
runInBackground(fn: FunctionAsync<void>) {
|
|
41
|
+
this._assertOrdinaryContext();
|
|
25
42
|
return this.bean.database.switchDbIsolate(() => {
|
|
26
43
|
return this.newCtx(fn);
|
|
27
44
|
});
|
|
@@ -32,6 +49,7 @@ export class BeanExecutor extends BeanBase {
|
|
|
32
49
|
options?: INewCtxOptions,
|
|
33
50
|
lifecycle?: INewCtxLifecycle,
|
|
34
51
|
): Promise<RESULT> {
|
|
52
|
+
this._assertOrdinaryContext();
|
|
35
53
|
options = Object.assign({}, options);
|
|
36
54
|
// instanceName !== undefined means isolate
|
|
37
55
|
options.instanceName =
|
|
@@ -48,6 +66,7 @@ export class BeanExecutor extends BeanBase {
|
|
|
48
66
|
options?: INewCtxOptions,
|
|
49
67
|
lifecycle?: INewCtxLifecycle,
|
|
50
68
|
): Promise<RESULT> {
|
|
69
|
+
this._assertOrdinaryContext();
|
|
51
70
|
options = Object.assign({}, options);
|
|
52
71
|
options.dbInfo = isNil(options.dbInfo) ? { level: 1 } : options.dbInfo;
|
|
53
72
|
options.instanceName =
|
|
@@ -61,6 +80,7 @@ export class BeanExecutor extends BeanBase {
|
|
|
61
80
|
options?: INewCtxOptions,
|
|
62
81
|
lifecycle?: INewCtxLifecycle,
|
|
63
82
|
): Promise<RESULT> {
|
|
83
|
+
this._assertOrdinaryContext();
|
|
64
84
|
return this._newCtxInnerStep1(
|
|
65
85
|
() => {
|
|
66
86
|
if (isNil(options?.dbInfo)) return this._newCtxInnerStep2(fn, options);
|
|
@@ -73,6 +93,106 @@ export class BeanExecutor extends BeanBase {
|
|
|
73
93
|
);
|
|
74
94
|
}
|
|
75
95
|
|
|
96
|
+
private _assertOrdinaryContext() {
|
|
97
|
+
if (isExistingReadyContext(this.ctx)) throw new Error('Existing-ready rebinding unavailable');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async newCtxReadyExisting<RESULT>(
|
|
101
|
+
capability: ExistingReadyCapability,
|
|
102
|
+
fn: FunctionAsync<RESULT>,
|
|
103
|
+
options?: INewCtxReadyExistingOptions,
|
|
104
|
+
lifecycle?: INewCtxLifecycle,
|
|
105
|
+
): Promise<RESULT> {
|
|
106
|
+
this._assertOrdinaryContext();
|
|
107
|
+
const capture = captureRequestBoundary();
|
|
108
|
+
const owner = this.app.meta.completedReadiness;
|
|
109
|
+
const resolved = owner && resolveExistingReadyCapability(owner, capability, this.app);
|
|
110
|
+
if (!capture || capture.snapshot().portFault || !resolved)
|
|
111
|
+
throw new Error('Existing-ready unavailable');
|
|
112
|
+
const data = (value: object | undefined, allowed: readonly string[]) => {
|
|
113
|
+
const result: Record<string, any> = Object.create(null);
|
|
114
|
+
if (value === undefined) return result;
|
|
115
|
+
if (!value || typeof value !== 'object')
|
|
116
|
+
throw new Error('Existing-ready options unavailable');
|
|
117
|
+
const proto = Object.getPrototypeOf(value);
|
|
118
|
+
if (proto !== null && proto !== Object.prototype)
|
|
119
|
+
throw new Error('Existing-ready options unavailable');
|
|
120
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
121
|
+
if (typeof key !== 'string' || !allowed.includes(key))
|
|
122
|
+
throw new Error('Existing-ready options unavailable');
|
|
123
|
+
const desc = Object.getOwnPropertyDescriptor(value, key)!;
|
|
124
|
+
if (!('value' in desc)) throw new Error('Existing-ready options unavailable');
|
|
125
|
+
result[key] = desc.value;
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
};
|
|
129
|
+
const selected = data(options, ['req', 'res', 'locale', 'tz']);
|
|
130
|
+
const hooks = data(lifecycle, ['initializeContext', 'onScopeOutcome']);
|
|
131
|
+
if (
|
|
132
|
+
(selected.locale !== undefined && typeof selected.locale !== 'string') ||
|
|
133
|
+
(selected.tz !== undefined && typeof selected.tz !== 'string') ||
|
|
134
|
+
(hooks.initializeContext !== undefined && typeof hooks.initializeContext !== 'function') ||
|
|
135
|
+
(hooks.onScopeOutcome !== undefined && typeof hooks.onScopeOutcome !== 'function')
|
|
136
|
+
) {
|
|
137
|
+
throw new Error('Existing-ready options unavailable');
|
|
138
|
+
}
|
|
139
|
+
if (typeof fn !== 'function') throw new Error('Existing-ready unavailable');
|
|
140
|
+
let context: VonaContext;
|
|
141
|
+
try {
|
|
142
|
+
context = this.app.createAnonymousContext(selected.req, selected.res);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
const observed: unknown = hooks.onScopeOutcome?.('not_created');
|
|
146
|
+
if (observed && (typeof observed === 'object' || typeof observed === 'function')) {
|
|
147
|
+
const then = (observed as PromiseLike<unknown>).then;
|
|
148
|
+
if (typeof then === 'function') {
|
|
149
|
+
capture.reportFailure('observer_failed');
|
|
150
|
+
void new Promise<unknown>((resolve, reject) =>
|
|
151
|
+
then.call(observed, resolve, reject),
|
|
152
|
+
).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
capture.reportFailure('observer_failed');
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
const execute = async () => {
|
|
161
|
+
if (!pinExistingReadyContext(owner, capability, this.app, context))
|
|
162
|
+
throw new Error('Existing-ready unavailable');
|
|
163
|
+
if (selected.locale !== undefined) context.locale = selected.locale;
|
|
164
|
+
if (selected.tz !== undefined) context.tz = selected.tz;
|
|
165
|
+
const initialized: unknown = hooks.initializeContext?.(context);
|
|
166
|
+
if (initialized && (typeof initialized === 'object' || typeof initialized === 'function')) {
|
|
167
|
+
const then = (initialized as PromiseLike<unknown>).then;
|
|
168
|
+
if (typeof then === 'function') {
|
|
169
|
+
await new Promise<unknown>((resolve, reject) =>
|
|
170
|
+
then.call(initialized, resolve, reject),
|
|
171
|
+
).catch(() => undefined);
|
|
172
|
+
throw new Error('Context initializer must be synchronous');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
!resolveExistingReadyCapability(owner, capability, this.app) ||
|
|
177
|
+
capture.snapshot().portFault
|
|
178
|
+
) {
|
|
179
|
+
throw new Error('Existing-ready unavailable');
|
|
180
|
+
}
|
|
181
|
+
const database = this.bean._getBean(ServiceDatabaseAsyncLocalStorage);
|
|
182
|
+
const transaction = this.bean._getBean(ServiceTransactionAsyncLocalStorage);
|
|
183
|
+
return database.dbStorage.run(resolved.handle as ServiceDb, () =>
|
|
184
|
+
transaction.run(async () => {
|
|
185
|
+
const result = await fn();
|
|
186
|
+
await context.commitsDone();
|
|
187
|
+
return result;
|
|
188
|
+
}, true),
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
return hooks.onScopeOutcome
|
|
192
|
+
? this.app.ctxStorage.runObserved(context, execute, hooks.onScopeOutcome)
|
|
193
|
+
: this.app.ctxStorage.run(context, execute);
|
|
194
|
+
}
|
|
195
|
+
|
|
76
196
|
private async _newCtxInnerStep1<RESULT>(
|
|
77
197
|
fn: FunctionAsync<RESULT>,
|
|
78
198
|
options: INewCtxOptions = {},
|
|
@@ -22,6 +22,13 @@ export interface INewCtxOptions extends INewCtxBaseOptions {
|
|
|
22
22
|
dbInfo?: Partial<IDbInfo>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface INewCtxReadyExistingOptions {
|
|
26
|
+
req?: any;
|
|
27
|
+
res?: any;
|
|
28
|
+
locale?: keyof ILocaleRecord;
|
|
29
|
+
tz?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
export interface INewCtxLifecycle {
|
|
26
33
|
initializeContext?: (ctx: VonaContext) => void;
|
|
27
34
|
onScopeOutcome?: VonaScopeOutcomeObserver;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
beginCompletedInstance,
|
|
5
|
+
captureCompletedInstance,
|
|
6
|
+
createCompletedReadiness,
|
|
7
|
+
createRequestBoundary,
|
|
8
|
+
finishCompletedInstance,
|
|
9
|
+
pinExistingReadyContext,
|
|
10
|
+
runWithRequestBoundary,
|
|
11
|
+
} from 'vona';
|
|
12
|
+
import { app } from 'vona-mock';
|
|
13
|
+
import {
|
|
14
|
+
ServiceDatabaseAsyncLocalStorage,
|
|
15
|
+
ServiceTransactionAsyncLocalStorage,
|
|
16
|
+
} from 'vona-module-a-orm';
|
|
17
|
+
|
|
18
|
+
import { BeanExecutor } from '../src/bean/bean.executor.ts';
|
|
19
|
+
|
|
20
|
+
// Fixture-only internal access; use the canonical module shared by the public 'vona' imports.
|
|
21
|
+
const completedReadinessUrl = new URL(
|
|
22
|
+
'../../../../../../packages-vona/vona-core/src/lib/core/completedReadiness.ts',
|
|
23
|
+
import.meta.url,
|
|
24
|
+
);
|
|
25
|
+
const {
|
|
26
|
+
beginCompletedApplication,
|
|
27
|
+
finishCompletedApplication,
|
|
28
|
+
getCompletedPublication,
|
|
29
|
+
issueExistingReadyCapability,
|
|
30
|
+
matchesCompletedRow,
|
|
31
|
+
provisionCompletedInstanceForTest,
|
|
32
|
+
} = await import(completedReadinessUrl.href);
|
|
33
|
+
const existingReadyAdmissionUrl = new URL(
|
|
34
|
+
'../../a-instance/src/lib/existingReadyAdmission.ts',
|
|
35
|
+
import.meta.url,
|
|
36
|
+
);
|
|
37
|
+
const { createExistingReadyAdmissionForTest } = await import(existingReadyAdmissionUrl.href);
|
|
38
|
+
const admissionInternals = {
|
|
39
|
+
getCompletedPublication,
|
|
40
|
+
issueExistingReadyCapability,
|
|
41
|
+
matchesCompletedRow,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function fixture() {
|
|
45
|
+
const local = { meta: { completedReadiness: createCompletedReadiness({}) } };
|
|
46
|
+
const owner = createCompletedReadiness(local);
|
|
47
|
+
local.meta.completedReadiness = owner;
|
|
48
|
+
const registry = {},
|
|
49
|
+
route = {},
|
|
50
|
+
handle = {};
|
|
51
|
+
const row = Object.freeze({ id: 1, name: '', disabled: false, isolate: false, config: null });
|
|
52
|
+
let time = 0;
|
|
53
|
+
provisionCompletedInstanceForTest(owner, '', { registry, route, handle, isolate: false });
|
|
54
|
+
const started = beginCompletedApplication(owner);
|
|
55
|
+
const attempt = beginCompletedInstance(owner, '');
|
|
56
|
+
finishCompletedInstance(
|
|
57
|
+
owner,
|
|
58
|
+
'',
|
|
59
|
+
attempt,
|
|
60
|
+
captureCompletedInstance(owner, '', attempt, row, { marker: 'pinned' }),
|
|
61
|
+
);
|
|
62
|
+
finishCompletedApplication(owner, started, true);
|
|
63
|
+
const provider = createExistingReadyAdmissionForTest({
|
|
64
|
+
...admissionInternals,
|
|
65
|
+
owner,
|
|
66
|
+
registry,
|
|
67
|
+
route,
|
|
68
|
+
read: () => ({ outcome: 'found', row }),
|
|
69
|
+
now: () => time,
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
local,
|
|
73
|
+
owner,
|
|
74
|
+
handle,
|
|
75
|
+
provider,
|
|
76
|
+
expire: () => {
|
|
77
|
+
time = 1_000;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function localExecutor(local: object, current: object | undefined, create: () => never) {
|
|
83
|
+
const executor = Object.create(BeanExecutor.prototype) as BeanExecutor;
|
|
84
|
+
Object.defineProperties(executor, {
|
|
85
|
+
app: { value: { ...local, createAnonymousContext: create } },
|
|
86
|
+
ctx: { value: current },
|
|
87
|
+
});
|
|
88
|
+
return executor;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('ready-existing executor', { concurrency: false, timeout: 120_000 }, () => {
|
|
92
|
+
it(
|
|
93
|
+
'refuses forged, foreign, expired and conflicting options before context construction',
|
|
94
|
+
{ timeout: 15_000 },
|
|
95
|
+
async () => {
|
|
96
|
+
const f = fixture();
|
|
97
|
+
let created = 0,
|
|
98
|
+
getters = 0;
|
|
99
|
+
// Use the exact app identity: the public entry must not adopt a foreign capability.
|
|
100
|
+
const executor = Object.create(BeanExecutor.prototype) as BeanExecutor;
|
|
101
|
+
Object.assign(f.local, {
|
|
102
|
+
createAnonymousContext() {
|
|
103
|
+
created++;
|
|
104
|
+
throw new Error('unexpected context');
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
Object.defineProperties(executor, { app: { value: f.local }, ctx: { value: undefined } });
|
|
108
|
+
await runWithRequestBoundary(createRequestBoundary(), async () => {
|
|
109
|
+
const result = await f.provider.admit('');
|
|
110
|
+
assert.equal(result.outcome, 'ready');
|
|
111
|
+
if (result.outcome !== 'ready') return;
|
|
112
|
+
await assert.rejects(executor.newCtxReadyExisting({} as never, async () => undefined));
|
|
113
|
+
await assert.rejects(
|
|
114
|
+
executor.newCtxReadyExisting({ ...result.capability } as never, async () => undefined),
|
|
115
|
+
);
|
|
116
|
+
for (const options of [
|
|
117
|
+
{ instanceName: '' },
|
|
118
|
+
{ dbInfo: {} },
|
|
119
|
+
{ innerAccess: true },
|
|
120
|
+
{ transaction: true },
|
|
121
|
+
{ extraData: {} },
|
|
122
|
+
{ authToken: 'synthetic' },
|
|
123
|
+
]) {
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
executor.newCtxReadyExisting(
|
|
126
|
+
result.capability,
|
|
127
|
+
async () => undefined,
|
|
128
|
+
options as never,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const options = Object.defineProperty({}, 'locale', {
|
|
133
|
+
get() {
|
|
134
|
+
getters++;
|
|
135
|
+
return 'en-us';
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
executor.newCtxReadyExisting(result.capability, async () => undefined, options),
|
|
140
|
+
);
|
|
141
|
+
const foreign = localExecutor(
|
|
142
|
+
{ meta: { completedReadiness: createCompletedReadiness({}) } },
|
|
143
|
+
undefined,
|
|
144
|
+
() => {
|
|
145
|
+
created++;
|
|
146
|
+
throw new Error('unexpected context');
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
await assert.rejects(foreign.newCtxReadyExisting(result.capability, async () => undefined));
|
|
150
|
+
f.expire();
|
|
151
|
+
await assert.rejects(
|
|
152
|
+
executor.newCtxReadyExisting(result.capability, async () => undefined),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
assert.equal(created, 0);
|
|
156
|
+
assert.equal(getters, 0);
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
it(
|
|
161
|
+
'pins before initializer and drains commit tails in the bound scope before disposal',
|
|
162
|
+
{ timeout: 15_000 },
|
|
163
|
+
async () => {
|
|
164
|
+
// The managed runner owns its listener; executor scopes must leave it unchanged.
|
|
165
|
+
const server = app.server;
|
|
166
|
+
const registry = {},
|
|
167
|
+
route = {},
|
|
168
|
+
handle = {};
|
|
169
|
+
const row = { id: 1, name: '', disabled: false, isolate: false, config: null };
|
|
170
|
+
await app.bean.executor.mockCtx(async () => {
|
|
171
|
+
const database = app.bean._getBean(ServiceDatabaseAsyncLocalStorage);
|
|
172
|
+
const transaction = app.bean._getBean(ServiceTransactionAsyncLocalStorage);
|
|
173
|
+
const outer = transaction.transactionState;
|
|
174
|
+
const executor = Object.create(BeanExecutor.prototype) as BeanExecutor;
|
|
175
|
+
// A local app facade owns this test-only owner; actual context creation/ALS/disposal
|
|
176
|
+
// remain the runner-owned application's facilities. No singleton property is patched.
|
|
177
|
+
const facade = Object.create(app);
|
|
178
|
+
Object.defineProperty(facade, 'meta', { value: { ...app.meta } });
|
|
179
|
+
Object.defineProperties(executor, {
|
|
180
|
+
app: { value: facade },
|
|
181
|
+
ctx: { get: () => app.ctx },
|
|
182
|
+
bean: { get: () => app.bean },
|
|
183
|
+
});
|
|
184
|
+
// Capability app ownership is explicit; fixture publication uses the facade, not app.
|
|
185
|
+
const localOwner = createCompletedReadiness(facade);
|
|
186
|
+
facade.meta.completedReadiness = localOwner;
|
|
187
|
+
provisionCompletedInstanceForTest(localOwner, '', {
|
|
188
|
+
registry,
|
|
189
|
+
route,
|
|
190
|
+
handle,
|
|
191
|
+
isolate: false,
|
|
192
|
+
});
|
|
193
|
+
const appAttempt = beginCompletedApplication(localOwner),
|
|
194
|
+
localAttempt = beginCompletedInstance(localOwner, '');
|
|
195
|
+
finishCompletedInstance(
|
|
196
|
+
localOwner,
|
|
197
|
+
'',
|
|
198
|
+
localAttempt,
|
|
199
|
+
captureCompletedInstance(localOwner, '', localAttempt, row, { marker: 'pinned' }),
|
|
200
|
+
);
|
|
201
|
+
finishCompletedApplication(localOwner, appAttempt, true);
|
|
202
|
+
const localProvider = createExistingReadyAdmissionForTest({
|
|
203
|
+
...admissionInternals,
|
|
204
|
+
owner: localOwner,
|
|
205
|
+
registry,
|
|
206
|
+
route,
|
|
207
|
+
read: () => ({ outcome: 'found', row }),
|
|
208
|
+
now: () => 0,
|
|
209
|
+
});
|
|
210
|
+
await runWithRequestBoundary(createRequestBoundary(), async () => {
|
|
211
|
+
const result = await localProvider.admit('');
|
|
212
|
+
assert.equal(result.outcome, 'ready');
|
|
213
|
+
if (result.outcome !== 'ready') return;
|
|
214
|
+
const events: string[] = [];
|
|
215
|
+
const returned = await executor.newCtxReadyExisting(
|
|
216
|
+
result.capability,
|
|
217
|
+
async () => {
|
|
218
|
+
events.push('callback');
|
|
219
|
+
const context = app.ctx;
|
|
220
|
+
const state = transaction.transactionState;
|
|
221
|
+
assert.equal((context.config as any).marker, 'pinned');
|
|
222
|
+
assert.equal(database.dbStorage.getStore(), handle);
|
|
223
|
+
assert.notEqual(state, outer);
|
|
224
|
+
assert.throws(() => {
|
|
225
|
+
context.instanceName = 'replacement' as never;
|
|
226
|
+
});
|
|
227
|
+
assert.throws(() => {
|
|
228
|
+
context.innerAccess = true;
|
|
229
|
+
});
|
|
230
|
+
context.commit(async () => {
|
|
231
|
+
events.push('commit');
|
|
232
|
+
assert.equal(app.ctx, context);
|
|
233
|
+
assert.equal(database.dbStorage.getStore(), handle);
|
|
234
|
+
assert.equal(transaction.transactionState, state);
|
|
235
|
+
await Promise.resolve();
|
|
236
|
+
context.commit(() => events.push('nested_commit'));
|
|
237
|
+
events.push('commit_done');
|
|
238
|
+
});
|
|
239
|
+
return 'result';
|
|
240
|
+
},
|
|
241
|
+
undefined,
|
|
242
|
+
{
|
|
243
|
+
initializeContext(ctx) {
|
|
244
|
+
events.push('initializer');
|
|
245
|
+
assert.equal((ctx.config as any).marker, 'pinned');
|
|
246
|
+
assert.equal(ctx.instanceName, '');
|
|
247
|
+
assert.equal(ctx.innerAccess, false);
|
|
248
|
+
assert.equal(transaction.transactionState, outer);
|
|
249
|
+
},
|
|
250
|
+
onScopeOutcome(outcome) {
|
|
251
|
+
events.push(outcome);
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
assert.equal(returned, 'result');
|
|
256
|
+
assert.deepEqual(events, [
|
|
257
|
+
'initializer',
|
|
258
|
+
'callback',
|
|
259
|
+
'commit',
|
|
260
|
+
'commit_done',
|
|
261
|
+
'nested_commit',
|
|
262
|
+
'disposed',
|
|
263
|
+
]);
|
|
264
|
+
assert.equal(transaction.transactionState, outer);
|
|
265
|
+
|
|
266
|
+
for (const phase of ['callback', 'commit'] as const) {
|
|
267
|
+
events.length = 0;
|
|
268
|
+
const failure = new Error(`controlled_${phase}_failure`);
|
|
269
|
+
await assert.rejects(
|
|
270
|
+
executor.newCtxReadyExisting(
|
|
271
|
+
result.capability,
|
|
272
|
+
async () => {
|
|
273
|
+
events.push('callback');
|
|
274
|
+
app.ctx.commit(() => {
|
|
275
|
+
events.push('commit');
|
|
276
|
+
throw failure;
|
|
277
|
+
});
|
|
278
|
+
if (phase === 'callback') throw failure;
|
|
279
|
+
},
|
|
280
|
+
undefined,
|
|
281
|
+
{ onScopeOutcome: outcome => events.push(outcome) },
|
|
282
|
+
),
|
|
283
|
+
error => error === failure,
|
|
284
|
+
);
|
|
285
|
+
assert.deepEqual(
|
|
286
|
+
events,
|
|
287
|
+
phase === 'callback' ? ['callback', 'disposed'] : ['callback', 'commit', 'disposed'],
|
|
288
|
+
);
|
|
289
|
+
assert.equal(transaction.transactionState, outer);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
assert.ok(app.server === server, 'executor scopes must preserve the runner-owned listener');
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
it(
|
|
298
|
+
'blocks every ordinary nested entry before database or getter work',
|
|
299
|
+
{ timeout: 15_000 },
|
|
300
|
+
async () => {
|
|
301
|
+
const f = fixture();
|
|
302
|
+
await runWithRequestBoundary(createRequestBoundary(), async () => {
|
|
303
|
+
const result = await f.provider.admit('');
|
|
304
|
+
assert.equal(result.outcome, 'ready');
|
|
305
|
+
if (result.outcome !== 'ready') return;
|
|
306
|
+
const context = {};
|
|
307
|
+
assert.equal(pinExistingReadyContext(f.owner, result.capability, f.local, context), true);
|
|
308
|
+
let setups = 0;
|
|
309
|
+
const executor = localExecutor(f.local, context, () => {
|
|
310
|
+
setups++;
|
|
311
|
+
throw new Error('unexpected setup');
|
|
312
|
+
});
|
|
313
|
+
Object.defineProperty(executor, 'bean', {
|
|
314
|
+
get() {
|
|
315
|
+
setups++;
|
|
316
|
+
throw new Error('unexpected DB');
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
await assert.rejects(executor.newCtx(async () => undefined));
|
|
320
|
+
await assert.rejects(executor.newCtxIsolate(async () => undefined));
|
|
321
|
+
await assert.rejects(executor.mockCtx(async () => undefined));
|
|
322
|
+
await assert.rejects(executor.performAction('get' as never, '/' as never));
|
|
323
|
+
assert.throws(() => executor.runInBackground(async () => undefined));
|
|
324
|
+
assert.equal(setups, 0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Controlled failure port: real owned ALS/disposer logic, inert local app facade.
|
|
328
|
+
// The retained counter is custody proof, not a test-owned live application leak.
|
|
329
|
+
const failed = fixture();
|
|
330
|
+
let current: any;
|
|
331
|
+
let retained = 0,
|
|
332
|
+
callbacks = 0,
|
|
333
|
+
setups = 0;
|
|
334
|
+
const failure = new Error('controlled_dispose_failure');
|
|
335
|
+
const context = {
|
|
336
|
+
bean: {
|
|
337
|
+
async dispose() {
|
|
338
|
+
throw failure;
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
Object.assign(failed.local.meta, {
|
|
343
|
+
ctxCounter: {
|
|
344
|
+
increment() {
|
|
345
|
+
retained++;
|
|
346
|
+
},
|
|
347
|
+
decrement() {
|
|
348
|
+
retained--;
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
const local: any = failed.local;
|
|
353
|
+
const Storage = app.ctxStorage.constructor as new (
|
|
354
|
+
application: typeof app,
|
|
355
|
+
) => typeof app.ctxStorage;
|
|
356
|
+
const storage = new Storage(local);
|
|
357
|
+
Object.defineProperty(local, 'currentContext', { get: () => storage.getStore() });
|
|
358
|
+
Object.assign(local, { ctxStorage: storage, createAnonymousContext: () => context });
|
|
359
|
+
const executor = Object.create(BeanExecutor.prototype) as BeanExecutor;
|
|
360
|
+
Object.defineProperties(executor, {
|
|
361
|
+
app: { value: local },
|
|
362
|
+
ctx: { get: () => current },
|
|
363
|
+
bean: {
|
|
364
|
+
get() {
|
|
365
|
+
setups++;
|
|
366
|
+
throw new Error('unexpected setup after initializer failure');
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
const events: string[] = [];
|
|
371
|
+
await runWithRequestBoundary(createRequestBoundary(), async () => {
|
|
372
|
+
const admitted = await failed.provider.admit('');
|
|
373
|
+
assert.equal(admitted.outcome, 'ready');
|
|
374
|
+
if (admitted.outcome !== 'ready') return;
|
|
375
|
+
await assert.rejects(
|
|
376
|
+
executor.newCtxReadyExisting(
|
|
377
|
+
admitted.capability,
|
|
378
|
+
async () => {
|
|
379
|
+
callbacks++;
|
|
380
|
+
},
|
|
381
|
+
undefined,
|
|
382
|
+
{
|
|
383
|
+
initializeContext(ctx) {
|
|
384
|
+
current = ctx;
|
|
385
|
+
assert.equal((ctx.config as any).marker, 'pinned');
|
|
386
|
+
assert.equal(ctx.innerAccess, false);
|
|
387
|
+
throw new Error('controlled_initializer_failure');
|
|
388
|
+
},
|
|
389
|
+
onScopeOutcome(outcome) {
|
|
390
|
+
events.push(outcome);
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
),
|
|
394
|
+
error => error === failure,
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
assert.deepEqual(events, ['dispose_failed']);
|
|
398
|
+
assert.equal(retained, 1);
|
|
399
|
+
assert.equal(callbacks, 0);
|
|
400
|
+
assert.equal(setups, 0);
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
it(
|
|
405
|
+
'retains ordinary mock context behavior and actual healthy disposal',
|
|
406
|
+
{ timeout: 15_000 },
|
|
407
|
+
async () => {
|
|
408
|
+
const outcomes: string[] = [];
|
|
409
|
+
let called = false;
|
|
410
|
+
await app.bean.executor.mockCtx(
|
|
411
|
+
async () => {
|
|
412
|
+
called = true;
|
|
413
|
+
assert.ok(app.ctx.instance);
|
|
414
|
+
assert.notEqual(app.ctx.innerAccess, false);
|
|
415
|
+
},
|
|
416
|
+
undefined,
|
|
417
|
+
{ onScopeOutcome: outcome => outcomes.push(outcome) },
|
|
418
|
+
);
|
|
419
|
+
assert.equal(called, true);
|
|
420
|
+
assert.deepEqual(outcomes, ['disposed']);
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
});
|