@declaro/core 2.0.0-beta.99 → 2.1.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/dist/browser/index.js +21 -27
- package/dist/browser/index.js.map +37 -27
- package/dist/browser/scope/index.js +1 -2
- package/dist/browser/scope/index.js.map +1 -1
- package/dist/bun/index.js +19011 -0
- package/dist/bun/index.js.map +132 -0
- package/dist/bun/scope/index.js +4 -0
- package/dist/bun/scope/index.js.map +9 -0
- package/dist/node/index.cjs +2581 -874
- package/dist/node/index.cjs.map +36 -27
- package/dist/node/index.js +2572 -868
- package/dist/node/index.js.map +36 -27
- package/dist/node/scope/index.cjs +31 -10
- package/dist/node/scope/index.cjs.map +1 -1
- package/dist/node/scope/index.js +1 -27
- package/dist/node/scope/index.js.map +1 -1
- package/dist/ts/context/async-context.d.ts +54 -0
- package/dist/ts/context/async-context.d.ts.map +1 -0
- package/dist/ts/context/async-context.test.d.ts +2 -0
- package/dist/ts/context/async-context.test.d.ts.map +1 -0
- package/dist/ts/context/context.circular-deps.test.d.ts +2 -0
- package/dist/ts/context/context.circular-deps.test.d.ts.map +1 -0
- package/dist/ts/context/context.d.ts +297 -38
- package/dist/ts/context/context.d.ts.map +1 -1
- package/dist/ts/http/request-context.d.ts.map +1 -1
- package/dist/ts/index.d.ts +2 -0
- package/dist/ts/index.d.ts.map +1 -1
- package/dist/ts/schema/json-schema.d.ts +9 -1
- package/dist/ts/schema/json-schema.d.ts.map +1 -1
- package/dist/ts/schema/model.d.ts +6 -1
- package/dist/ts/schema/model.d.ts.map +1 -1
- package/dist/ts/schema/test/mock-model.d.ts +2 -2
- package/dist/ts/schema/test/mock-model.d.ts.map +1 -1
- package/dist/ts/shared/utils/schema-utils.d.ts +3 -0
- package/dist/ts/shared/utils/schema-utils.d.ts.map +1 -0
- package/dist/ts/shared/utils/schema-utils.test.d.ts +2 -0
- package/dist/ts/shared/utils/schema-utils.test.d.ts.map +1 -0
- package/dist/ts/shims/async-local-storage.d.ts +36 -0
- package/dist/ts/shims/async-local-storage.d.ts.map +1 -0
- package/dist/ts/shims/async-local-storage.test.d.ts +2 -0
- package/dist/ts/shims/async-local-storage.test.d.ts.map +1 -0
- package/package.json +17 -9
- package/src/context/async-context.test.ts +348 -0
- package/src/context/async-context.ts +129 -0
- package/src/context/context.circular-deps.test.ts +1047 -0
- package/src/context/context.test.ts +150 -0
- package/src/context/context.ts +493 -55
- package/src/http/request-context.ts +1 -3
- package/src/index.ts +2 -0
- package/src/schema/json-schema.ts +14 -1
- package/src/schema/model-schema.test.ts +155 -1
- package/src/schema/model.ts +34 -3
- package/src/schema/test/mock-model.ts +6 -2
- package/src/shared/utils/schema-utils.test.ts +33 -0
- package/src/shared/utils/schema-utils.ts +17 -0
- package/src/shims/async-local-storage.test.ts +258 -0
- package/src/shims/async-local-storage.ts +82 -0
- package/dist/ts/schema/entity-schema.test.d.ts +0 -1
- package/dist/ts/schema/entity-schema.test.d.ts.map +0 -1
- package/src/schema/entity-schema.test.ts +0 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
2
|
-
import { Context, type ContextMiddleware, type
|
|
3
|
-
|
|
4
|
-
import type { RequestScope, AppScope } from '#scope'
|
|
2
|
+
import { Context, type ContextMiddleware, type DeclaroScope } from '../context/context'
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Get the request middleware for the current context.
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export * from './application/use-declaro'
|
|
|
26
26
|
export * from './context/context'
|
|
27
27
|
export * from './context/context-consumer'
|
|
28
28
|
export * from './context/validators'
|
|
29
|
+
export * from './context/async-context'
|
|
29
30
|
export * from './dataflow'
|
|
30
31
|
export * from './events'
|
|
31
32
|
export * from './validation'
|
|
@@ -46,3 +47,4 @@ export * from './schema/schema-mixin'
|
|
|
46
47
|
export * from './schema/test/mock-model'
|
|
47
48
|
|
|
48
49
|
export * from './shared/utils/action-descriptor'
|
|
50
|
+
export * from './shared/utils/schema-utils'
|
|
@@ -1,3 +1,16 @@
|
|
|
1
1
|
import { type JSONSchema7 } from 'json-schema'
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export type JSONSchemaDefinition = JSONSchema | boolean
|
|
4
|
+
|
|
5
|
+
export interface JSONMeta {
|
|
6
|
+
hidden?: boolean
|
|
7
|
+
private?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface JSONSchema extends JSONSchema7, JSONMeta {
|
|
11
|
+
properties?:
|
|
12
|
+
| {
|
|
13
|
+
[key: string]: JSONSchemaDefinition
|
|
14
|
+
}
|
|
15
|
+
| undefined
|
|
16
|
+
}
|
|
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { z } from 'zod/v4'
|
|
3
3
|
import { ModelSchema, type MergeMixins } from './model-schema'
|
|
4
4
|
import { MockModel } from './test/mock-model'
|
|
5
|
-
import type { InferModelOutput } from './model'
|
|
5
|
+
import type { InferModelInput, InferModelOutput } from './model'
|
|
6
6
|
import type { ShallowMerge, UniqueKeys } from '../typescript'
|
|
7
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
7
8
|
|
|
8
9
|
describe('ModelSchema', () => {
|
|
9
10
|
it('should create a ModelSchema instance', () => {
|
|
@@ -125,4 +126,157 @@ describe('ModelSchema', () => {
|
|
|
125
126
|
|
|
126
127
|
expect(schema.getEntityMetadata().primaryKey).toBe('id')
|
|
127
128
|
})
|
|
129
|
+
|
|
130
|
+
it('should be able to manually strip private and hidden keys from input', () => {
|
|
131
|
+
const testModel = new MockModel(
|
|
132
|
+
'TestModel',
|
|
133
|
+
z.object({
|
|
134
|
+
id: z.string(),
|
|
135
|
+
name: z.string(),
|
|
136
|
+
secret: z.string().optional().meta({ private: true }),
|
|
137
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const payload: InferModelInput<typeof testModel> = {
|
|
142
|
+
id: '1',
|
|
143
|
+
name: 'Test',
|
|
144
|
+
secret: 'top-secret',
|
|
145
|
+
internalNote: 'for-internal-use-only',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stripped = testModel.stripExcludedFields(payload)
|
|
149
|
+
|
|
150
|
+
expect(stripped).toEqual({
|
|
151
|
+
id: '1',
|
|
152
|
+
name: 'Test',
|
|
153
|
+
internalNote: 'for-internal-use-only',
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should strip private fields from validation input', async () => {
|
|
158
|
+
const testModel = new MockModel(
|
|
159
|
+
'TestModel',
|
|
160
|
+
z.object({
|
|
161
|
+
id: z.string(),
|
|
162
|
+
name: z.string(),
|
|
163
|
+
secret: z.string().optional().meta({ private: true }),
|
|
164
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const payload: InferModelInput<typeof testModel> = {
|
|
169
|
+
id: '1',
|
|
170
|
+
name: 'Test',
|
|
171
|
+
secret: 'top-secret',
|
|
172
|
+
internalNote: 'for-internal-use-only',
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const validation = await testModel.validate(payload, { strict: false })
|
|
176
|
+
|
|
177
|
+
expect(validation.issues).toBeUndefined()
|
|
178
|
+
|
|
179
|
+
const output = (validation as StandardSchemaV1.SuccessResult<InferModelOutput<typeof testModel>>).value
|
|
180
|
+
|
|
181
|
+
expect(output.secret).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should not strip hidden fields from validation input', async () => {
|
|
185
|
+
const testModel = new MockModel(
|
|
186
|
+
'TestModel',
|
|
187
|
+
z.object({
|
|
188
|
+
id: z.string(),
|
|
189
|
+
name: z.string(),
|
|
190
|
+
secret: z.string().optional().meta({ private: true }),
|
|
191
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const payload: InferModelInput<typeof testModel> = {
|
|
196
|
+
id: '1',
|
|
197
|
+
name: 'Test',
|
|
198
|
+
secret: 'top-secret',
|
|
199
|
+
internalNote: 'for-internal-use-only',
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const validation = await testModel.validate(payload, { strict: false })
|
|
203
|
+
|
|
204
|
+
expect(validation.issues).toBeUndefined()
|
|
205
|
+
|
|
206
|
+
const output = (validation as StandardSchemaV1.SuccessResult<InferModelOutput<typeof testModel>>).value
|
|
207
|
+
|
|
208
|
+
expect(output.internalNote).toBe('for-internal-use-only')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should strip private fields from model schema by default', () => {
|
|
212
|
+
const testModel = new MockModel(
|
|
213
|
+
'TestModel',
|
|
214
|
+
z.object({
|
|
215
|
+
id: z.string(),
|
|
216
|
+
name: z.string(),
|
|
217
|
+
secret: z.string().optional().meta({ private: true }),
|
|
218
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const jsonSchema = testModel.toJSONSchema()
|
|
223
|
+
|
|
224
|
+
expect(Object.keys(jsonSchema.properties!)).toEqual(['id', 'name', 'internalNote'])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should not strip hidden fields from model schema', () => {
|
|
228
|
+
const testModel = new MockModel(
|
|
229
|
+
'TestModel',
|
|
230
|
+
z.object({
|
|
231
|
+
id: z.string(),
|
|
232
|
+
name: z.string(),
|
|
233
|
+
secret: z.string().optional().meta({ private: true }),
|
|
234
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
235
|
+
}),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const jsonSchema = testModel.toJSONSchema({ includePrivateFields: false })
|
|
239
|
+
|
|
240
|
+
expect(Object.keys(jsonSchema.properties!)).toEqual(['id', 'name', 'internalNote'])
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should not strip private fields from model schema when specified', () => {
|
|
244
|
+
const testModel = new MockModel(
|
|
245
|
+
'TestModel',
|
|
246
|
+
z.object({
|
|
247
|
+
id: z.string(),
|
|
248
|
+
name: z.string(),
|
|
249
|
+
secret: z.string().optional().meta({ private: true }),
|
|
250
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const jsonSchema = testModel.toJSONSchema({ includePrivateFields: true })
|
|
255
|
+
|
|
256
|
+
expect(Object.keys(jsonSchema.properties!)).toEqual(['id', 'name', 'secret', 'internalNote'])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('should include hidden fields in model schema when specified', () => {
|
|
260
|
+
const testModel = new MockModel(
|
|
261
|
+
'TestModel',
|
|
262
|
+
z.object({
|
|
263
|
+
id: z.string(),
|
|
264
|
+
name: z.string(),
|
|
265
|
+
secret: z.string().optional().meta({ private: true }),
|
|
266
|
+
internalNote: z.string().optional().meta({ hidden: true }),
|
|
267
|
+
}),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const jsonSchema = testModel.toJSONSchema()
|
|
271
|
+
|
|
272
|
+
expect(Object.keys(jsonSchema.properties!)).toEqual(['id', 'name', 'internalNote'])
|
|
273
|
+
|
|
274
|
+
const internalNoteSchema = jsonSchema.properties?.['internalNote']
|
|
275
|
+
|
|
276
|
+
if (typeof internalNoteSchema === 'object') {
|
|
277
|
+
expect(internalNoteSchema.hidden).toBe(true)
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error('internalNote schema is not an object')
|
|
280
|
+
}
|
|
281
|
+
})
|
|
128
282
|
})
|
package/src/schema/model.ts
CHANGED
|
@@ -7,6 +7,16 @@ export interface ModelValidationOptions {
|
|
|
7
7
|
strict?: boolean
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface ModelSchemaOptions {
|
|
11
|
+
includePrivateFields?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getDefaultModelSchemaOptions(): ModelSchemaOptions {
|
|
15
|
+
return {
|
|
16
|
+
includePrivateFields: false,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export abstract class Model<TName extends Readonly<string>, TSchema extends StandardSchemaV1>
|
|
11
21
|
implements StandardSchemaV1<StandardSchemaV1.InferInput<TSchema>, StandardSchemaV1.InferOutput<TSchema>>
|
|
12
22
|
{
|
|
@@ -27,15 +37,36 @@ export abstract class Model<TName extends Readonly<string>, TSchema extends Stan
|
|
|
27
37
|
this.schema = schema
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
stripExcludedFields(value: StandardSchemaV1.InferInput<TSchema>): StandardSchemaV1.InferInput<TSchema> {
|
|
41
|
+
const meta = this.toJSONSchema({
|
|
42
|
+
includePrivateFields: true,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const excludedKeys = Object.keys(meta.properties ?? {}).filter((key) => {
|
|
46
|
+
const property = meta.properties?.[key]
|
|
47
|
+
return !!property && typeof property === 'object' && property.private === true
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (value && excludedKeys.length > 0) {
|
|
51
|
+
excludedKeys.forEach((key) => {
|
|
52
|
+
delete value[key]
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
async validate(
|
|
31
60
|
value: StandardSchemaV1.InferInput<TSchema>,
|
|
32
61
|
options?: ModelValidationOptions,
|
|
33
62
|
): Promise<StandardSchemaV1.Result<StandardSchemaV1.InferOutput<TSchema>>> {
|
|
63
|
+
const meta = this.toJSONSchema()
|
|
64
|
+
|
|
65
|
+
value = this.stripExcludedFields(value)
|
|
66
|
+
|
|
34
67
|
const result = await this.schema['~standard'].validate(value)
|
|
35
68
|
|
|
36
69
|
if (result.issues) {
|
|
37
|
-
const meta = this.toJSONSchema()
|
|
38
|
-
|
|
39
70
|
const issues = result.issues.map((issue) => {
|
|
40
71
|
let schema: JSONSchema | undefined = meta
|
|
41
72
|
let field: string | undefined = undefined
|
|
@@ -82,7 +113,7 @@ export abstract class Model<TName extends Readonly<string>, TSchema extends Stan
|
|
|
82
113
|
return getLabels(this.name)
|
|
83
114
|
}
|
|
84
115
|
|
|
85
|
-
abstract toJSONSchema(): JSONSchema
|
|
116
|
+
abstract toJSONSchema(options?: ModelSchemaOptions): JSONSchema
|
|
86
117
|
|
|
87
118
|
// Implementing StandardSchemaV1 interface
|
|
88
119
|
get version(): number {
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { z } from 'zod/v4'
|
|
2
2
|
import type { $ZodType } from 'zod/v4/core'
|
|
3
3
|
import type { JSONSchema } from '../json-schema'
|
|
4
|
-
import { Model } from '../model'
|
|
4
|
+
import { Model, type ModelSchemaOptions } from '../model'
|
|
5
|
+
import { stripPrivateFieldsFromSchema } from '../../shared/utils/schema-utils'
|
|
5
6
|
|
|
6
7
|
export class MockModel<TName extends Readonly<string>, TSchema extends $ZodType<any>> extends Model<TName, TSchema> {
|
|
7
8
|
constructor(name: TName, schema: TSchema) {
|
|
8
9
|
super(name, schema)
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
toJSONSchema(): JSONSchema {
|
|
12
|
+
toJSONSchema(options?: ModelSchemaOptions): JSONSchema {
|
|
12
13
|
const jsonSchema = z.toJSONSchema(this.schema)
|
|
14
|
+
if (options?.includePrivateFields !== true) {
|
|
15
|
+
stripPrivateFieldsFromSchema(jsonSchema as JSONSchema)
|
|
16
|
+
}
|
|
13
17
|
return jsonSchema as JSONSchema
|
|
14
18
|
}
|
|
15
19
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { JSONSchema } from '../../schema/json-schema'
|
|
3
|
+
import { stripPrivateFieldsFromSchema } from './schema-utils'
|
|
4
|
+
|
|
5
|
+
describe('Schema Utils', () => {
|
|
6
|
+
it('should strip private fields from schema', () => {
|
|
7
|
+
const schema: JSONSchema = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
id: { type: 'string' },
|
|
11
|
+
name: { type: 'string' },
|
|
12
|
+
secret: { type: 'string', private: true },
|
|
13
|
+
internalNote: { type: 'string', hidden: true },
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const stripped = stripPrivateFieldsFromSchema(schema)
|
|
18
|
+
|
|
19
|
+
// The return value should have the private field removed
|
|
20
|
+
expect(stripped.properties).toBeDefined()
|
|
21
|
+
expect(stripped.properties?.['id']).toBeDefined()
|
|
22
|
+
expect(stripped.properties?.['name']).toBeDefined()
|
|
23
|
+
expect(stripped.properties?.['secret']).toBeUndefined()
|
|
24
|
+
expect(stripped.properties?.['internalNote']).toBeDefined()
|
|
25
|
+
|
|
26
|
+
// The original schema should also have the private field removed
|
|
27
|
+
expect(schema.properties).toBeDefined()
|
|
28
|
+
expect(schema.properties?.['id']).toBeDefined()
|
|
29
|
+
expect(schema.properties?.['name']).toBeDefined()
|
|
30
|
+
expect(schema.properties?.['secret']).toBeUndefined()
|
|
31
|
+
expect(schema.properties?.['internalNote']).toBeDefined()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JSONSchema } from '../../schema/json-schema'
|
|
2
|
+
|
|
3
|
+
export function stripPrivateFieldsFromSchema(schema: JSONSchema): JSONSchema {
|
|
4
|
+
if (typeof schema?.properties === 'object') {
|
|
5
|
+
for (const key of Object.keys(schema.properties)) {
|
|
6
|
+
const property = schema.properties[key]
|
|
7
|
+
if (typeof property === 'object') {
|
|
8
|
+
if (property.private === true) {
|
|
9
|
+
delete schema.properties[key]
|
|
10
|
+
} else {
|
|
11
|
+
stripPrivateFieldsFromSchema(property)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return schema
|
|
17
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { AsyncLocalStorage } from './async-local-storage'
|
|
3
|
+
|
|
4
|
+
describe('AsyncLocalStorage shim', () => {
|
|
5
|
+
describe('run()', () => {
|
|
6
|
+
it('returns the fn result synchronously', () => {
|
|
7
|
+
const als = new AsyncLocalStorage<number>()
|
|
8
|
+
const result = als.run(1, () => 42)
|
|
9
|
+
expect(result).toBe(42)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns a Promise when fn is async', async () => {
|
|
13
|
+
const als = new AsyncLocalStorage<string>()
|
|
14
|
+
const result = await als.run('ctx', async () => 'hello')
|
|
15
|
+
expect(result).toBe('hello')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('forwards extra args to fn', () => {
|
|
19
|
+
const als = new AsyncLocalStorage<string>()
|
|
20
|
+
const result = als.run('ctx', (a: number, b: number) => a + b, 3, 4)
|
|
21
|
+
expect(result).toBe(7)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('getStore() returns the active store inside run()', () => {
|
|
25
|
+
const als = new AsyncLocalStorage<string>()
|
|
26
|
+
als.run('hello', () => {
|
|
27
|
+
expect(als.getStore()).toBe('hello')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('getStore() returns undefined outside of run()', () => {
|
|
32
|
+
const als = new AsyncLocalStorage<string>()
|
|
33
|
+
expect(als.getStore()).toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('store is not visible after run() completes', () => {
|
|
37
|
+
const als = new AsyncLocalStorage<string>()
|
|
38
|
+
als.run('hello', () => {})
|
|
39
|
+
expect(als.getStore()).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('nested run() calls use the innermost store', () => {
|
|
43
|
+
const als = new AsyncLocalStorage<string>()
|
|
44
|
+
|
|
45
|
+
als.run('outer', () => {
|
|
46
|
+
expect(als.getStore()).toBe('outer')
|
|
47
|
+
|
|
48
|
+
als.run('inner', () => {
|
|
49
|
+
expect(als.getStore()).toBe('inner')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(als.getStore()).toBe('outer')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('restores prior store after nested run() completes', () => {
|
|
57
|
+
const als = new AsyncLocalStorage<string>()
|
|
58
|
+
als.run('outer', () => {
|
|
59
|
+
als.run('inner', () => {})
|
|
60
|
+
expect(als.getStore()).toBe('outer')
|
|
61
|
+
})
|
|
62
|
+
expect(als.getStore()).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('full lifecycle: undefined → outer → inner → outer → undefined', () => {
|
|
66
|
+
const als = new AsyncLocalStorage<string>()
|
|
67
|
+
const snapshots: (string | undefined)[] = []
|
|
68
|
+
|
|
69
|
+
snapshots.push(als.getStore())
|
|
70
|
+
als.run('outer', () => {
|
|
71
|
+
snapshots.push(als.getStore())
|
|
72
|
+
als.run('inner', () => {
|
|
73
|
+
snapshots.push(als.getStore())
|
|
74
|
+
})
|
|
75
|
+
snapshots.push(als.getStore())
|
|
76
|
+
})
|
|
77
|
+
snapshots.push(als.getStore())
|
|
78
|
+
|
|
79
|
+
expect(snapshots).toEqual([undefined, 'outer', 'inner', 'outer', undefined])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('restores store even when fn throws', () => {
|
|
83
|
+
const als = new AsyncLocalStorage<string>()
|
|
84
|
+
expect(() =>
|
|
85
|
+
als.run('ctx', () => {
|
|
86
|
+
throw new Error('boom')
|
|
87
|
+
}),
|
|
88
|
+
).toThrow('boom')
|
|
89
|
+
expect(als.getStore()).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('multiple ALS instances are isolated from each other', () => {
|
|
93
|
+
const als1 = new AsyncLocalStorage<string>()
|
|
94
|
+
const als2 = new AsyncLocalStorage<number>()
|
|
95
|
+
|
|
96
|
+
als1.run('hello', () => {
|
|
97
|
+
als2.run(42, () => {
|
|
98
|
+
expect(als1.getStore()).toBe('hello')
|
|
99
|
+
expect(als2.getStore()).toBe(42)
|
|
100
|
+
})
|
|
101
|
+
expect(als1.getStore()).toBe('hello')
|
|
102
|
+
expect(als2.getStore()).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('async usage', () => {
|
|
108
|
+
it('store is accessible at the start of an async fn (before any await)', async () => {
|
|
109
|
+
const als = new AsyncLocalStorage<string>()
|
|
110
|
+
let captured: string | undefined
|
|
111
|
+
|
|
112
|
+
await als.run('ctx', async () => {
|
|
113
|
+
// No await yet — still in the synchronous call stack of run()
|
|
114
|
+
captured = als.getStore()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(captured).toBe('ctx')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('resolves the returned Promise correctly', async () => {
|
|
121
|
+
const als = new AsyncLocalStorage<string>()
|
|
122
|
+
const result = await als.run('ctx', async () => 'resolved')
|
|
123
|
+
expect(result).toBe('resolved')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('propagates rejections correctly', async () => {
|
|
127
|
+
const als = new AsyncLocalStorage<string>()
|
|
128
|
+
await expect(
|
|
129
|
+
als.run('ctx', async () => {
|
|
130
|
+
throw new Error('async boom')
|
|
131
|
+
}),
|
|
132
|
+
).rejects.toThrow('async boom')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('restores store after an async fn resolves', async () => {
|
|
136
|
+
const als = new AsyncLocalStorage<string>()
|
|
137
|
+
await als.run('ctx', async () => {})
|
|
138
|
+
expect(als.getStore()).toBeUndefined()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// The shim uses synchronous save/restore. The finally block in run()
|
|
142
|
+
// fires as soon as the async fn returns its Promise — before any
|
|
143
|
+
// awaited microtasks resume. Context is therefore not visible after an
|
|
144
|
+
// await boundary. Native AsyncLocalStorage (Node/Bun) does not have
|
|
145
|
+
// this limitation.
|
|
146
|
+
it('does NOT propagate store across await boundaries (synchronous shim limitation)', async () => {
|
|
147
|
+
const als = new AsyncLocalStorage<string>()
|
|
148
|
+
let captured: string | undefined = 'sentinel'
|
|
149
|
+
|
|
150
|
+
await als.run('ctx', async () => {
|
|
151
|
+
await Promise.resolve()
|
|
152
|
+
captured = als.getStore()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(captured).toBeUndefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('does NOT isolate concurrent async tasks (synchronous shim limitation)', async () => {
|
|
159
|
+
const als = new AsyncLocalStorage<string>()
|
|
160
|
+
const results: (string | undefined)[] = []
|
|
161
|
+
|
|
162
|
+
await Promise.all([
|
|
163
|
+
als.run('task-a', async () => {
|
|
164
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
165
|
+
results.push(als.getStore())
|
|
166
|
+
}),
|
|
167
|
+
als.run('task-b', async () => {
|
|
168
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
169
|
+
results.push(als.getStore())
|
|
170
|
+
}),
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
expect(results).toEqual([undefined, undefined])
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('exit()', () => {
|
|
178
|
+
it('clears the store for the duration of fn', () => {
|
|
179
|
+
const als = new AsyncLocalStorage<string>()
|
|
180
|
+
als.run('ctx', () => {
|
|
181
|
+
als.exit(() => {
|
|
182
|
+
expect(als.getStore()).toBeUndefined()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('restores the store after exit() completes', () => {
|
|
188
|
+
const als = new AsyncLocalStorage<string>()
|
|
189
|
+
als.run('ctx', () => {
|
|
190
|
+
als.exit(() => {})
|
|
191
|
+
expect(als.getStore()).toBe('ctx')
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('restores store even when fn throws', () => {
|
|
196
|
+
const als = new AsyncLocalStorage<string>()
|
|
197
|
+
als.run('ctx', () => {
|
|
198
|
+
expect(() =>
|
|
199
|
+
als.exit(() => {
|
|
200
|
+
throw new Error('boom')
|
|
201
|
+
}),
|
|
202
|
+
).toThrow('boom')
|
|
203
|
+
expect(als.getStore()).toBe('ctx')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('enterWith()', () => {
|
|
209
|
+
it('sets the store imperatively', () => {
|
|
210
|
+
const als = new AsyncLocalStorage<string>()
|
|
211
|
+
als.enterWith('hello')
|
|
212
|
+
expect(als.getStore()).toBe('hello')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('can be overwritten by another enterWith()', () => {
|
|
216
|
+
const als = new AsyncLocalStorage<string>()
|
|
217
|
+
als.enterWith('first')
|
|
218
|
+
als.enterWith('second')
|
|
219
|
+
expect(als.getStore()).toBe('second')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('disable()', () => {
|
|
224
|
+
it('clears the store', () => {
|
|
225
|
+
const als = new AsyncLocalStorage<string>()
|
|
226
|
+
als.enterWith('hello')
|
|
227
|
+
als.disable()
|
|
228
|
+
expect(als.getStore()).toBeUndefined()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('has no effect when store is already empty', () => {
|
|
232
|
+
const als = new AsyncLocalStorage<string>()
|
|
233
|
+
als.disable()
|
|
234
|
+
expect(als.getStore()).toBeUndefined()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('AsyncLocalStorage.bind()', () => {
|
|
239
|
+
it('returns the function unchanged', () => {
|
|
240
|
+
const fn = () => 42
|
|
241
|
+
expect(AsyncLocalStorage.bind(fn)).toBe(fn)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('AsyncLocalStorage.snapshot()', () => {
|
|
246
|
+
it('returns a function that calls fn with provided args', () => {
|
|
247
|
+
const run = AsyncLocalStorage.snapshot()
|
|
248
|
+
const result = run((a: number, b: number) => a + b, 3, 4)
|
|
249
|
+
expect(result).toBe(7)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('each call to snapshot() returns an independent executor', () => {
|
|
253
|
+
const run1 = AsyncLocalStorage.snapshot()
|
|
254
|
+
const run2 = AsyncLocalStorage.snapshot()
|
|
255
|
+
expect(run1).not.toBe(run2)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser polyfill for Node's AsyncLocalStorage.
|
|
3
|
+
*
|
|
4
|
+
* ## How it works
|
|
5
|
+
*
|
|
6
|
+
* Each AsyncLocalStorage instance has a unique Symbol as its "column" ID.
|
|
7
|
+
* The currently active state is a single `Map<symbol, unknown>` called a
|
|
8
|
+
* _frame_, where each column holds the value for one ALS instance.
|
|
9
|
+
*
|
|
10
|
+
* `run()` creates a new frame (a shallow copy of the parent frame with this
|
|
11
|
+
* instance's value set), makes it active for the duration of `fn`, then
|
|
12
|
+
* restores the previous frame in a `finally` block.
|
|
13
|
+
*
|
|
14
|
+
* Multiple ALS instances are fully isolated from each other within the same
|
|
15
|
+
* frame: `als1.run()` only writes to its own column and does not affect any
|
|
16
|
+
* other instance's column.
|
|
17
|
+
*
|
|
18
|
+
* ## Browser limitation
|
|
19
|
+
*
|
|
20
|
+
* Because browsers have no native async-context hook, the frame is propagated
|
|
21
|
+
* only within the synchronous call stack of `fn`. Any code that runs after an
|
|
22
|
+
* `await` boundary cannot see the frame that was active when the `await` was
|
|
23
|
+
* encountered. For true async propagation in the browser, use Zone.js or the
|
|
24
|
+
* forthcoming `AsyncContext` TC39 API.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// A frame maps ALS instance IDs → their stored values.
|
|
28
|
+
// All concurrently active ALS instances share one frame object.
|
|
29
|
+
type Frame = Map<symbol, unknown>
|
|
30
|
+
|
|
31
|
+
let _activeFrame: Frame | undefined = undefined
|
|
32
|
+
|
|
33
|
+
export class AsyncLocalStorage<T = any> {
|
|
34
|
+
// Unique identity for this ALS instance within a frame.
|
|
35
|
+
readonly #id: symbol = Symbol('AsyncLocalStorage')
|
|
36
|
+
|
|
37
|
+
getStore(): T | undefined {
|
|
38
|
+
return _activeFrame?.get(this.#id) as T | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
run<R>(store: T, fn: (...args: any[]) => R, ...args: any[]): R {
|
|
42
|
+
const prev = _activeFrame
|
|
43
|
+
_activeFrame = new Map(prev)
|
|
44
|
+
_activeFrame.set(this.#id, store)
|
|
45
|
+
try {
|
|
46
|
+
return fn(...args)
|
|
47
|
+
} finally {
|
|
48
|
+
_activeFrame = prev
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
exit<R>(fn: (...args: any[]) => R, ...args: any[]): R {
|
|
53
|
+
const prev = _activeFrame
|
|
54
|
+
_activeFrame = new Map(prev)
|
|
55
|
+
_activeFrame.delete(this.#id)
|
|
56
|
+
try {
|
|
57
|
+
return fn(...args)
|
|
58
|
+
} finally {
|
|
59
|
+
_activeFrame = prev
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
enterWith(store: T): void {
|
|
64
|
+
const frame = new Map(_activeFrame)
|
|
65
|
+
frame.set(this.#id, store)
|
|
66
|
+
_activeFrame = frame
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
disable(): void {
|
|
70
|
+
const frame = new Map(_activeFrame)
|
|
71
|
+
frame.delete(this.#id)
|
|
72
|
+
_activeFrame = frame
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static bind<F extends (...args: any[]) => any>(fn: F): F {
|
|
76
|
+
return fn
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static snapshot(): <R>(fn: (...args: any[]) => R, ...args: any[]) => R {
|
|
80
|
+
return (fn, ...args) => fn(...args)
|
|
81
|
+
}
|
|
82
|
+
}
|