@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.
Files changed (60) hide show
  1. package/dist/browser/index.js +21 -27
  2. package/dist/browser/index.js.map +37 -27
  3. package/dist/browser/scope/index.js +1 -2
  4. package/dist/browser/scope/index.js.map +1 -1
  5. package/dist/bun/index.js +19011 -0
  6. package/dist/bun/index.js.map +132 -0
  7. package/dist/bun/scope/index.js +4 -0
  8. package/dist/bun/scope/index.js.map +9 -0
  9. package/dist/node/index.cjs +2581 -874
  10. package/dist/node/index.cjs.map +36 -27
  11. package/dist/node/index.js +2572 -868
  12. package/dist/node/index.js.map +36 -27
  13. package/dist/node/scope/index.cjs +31 -10
  14. package/dist/node/scope/index.cjs.map +1 -1
  15. package/dist/node/scope/index.js +1 -27
  16. package/dist/node/scope/index.js.map +1 -1
  17. package/dist/ts/context/async-context.d.ts +54 -0
  18. package/dist/ts/context/async-context.d.ts.map +1 -0
  19. package/dist/ts/context/async-context.test.d.ts +2 -0
  20. package/dist/ts/context/async-context.test.d.ts.map +1 -0
  21. package/dist/ts/context/context.circular-deps.test.d.ts +2 -0
  22. package/dist/ts/context/context.circular-deps.test.d.ts.map +1 -0
  23. package/dist/ts/context/context.d.ts +297 -38
  24. package/dist/ts/context/context.d.ts.map +1 -1
  25. package/dist/ts/http/request-context.d.ts.map +1 -1
  26. package/dist/ts/index.d.ts +2 -0
  27. package/dist/ts/index.d.ts.map +1 -1
  28. package/dist/ts/schema/json-schema.d.ts +9 -1
  29. package/dist/ts/schema/json-schema.d.ts.map +1 -1
  30. package/dist/ts/schema/model.d.ts +6 -1
  31. package/dist/ts/schema/model.d.ts.map +1 -1
  32. package/dist/ts/schema/test/mock-model.d.ts +2 -2
  33. package/dist/ts/schema/test/mock-model.d.ts.map +1 -1
  34. package/dist/ts/shared/utils/schema-utils.d.ts +3 -0
  35. package/dist/ts/shared/utils/schema-utils.d.ts.map +1 -0
  36. package/dist/ts/shared/utils/schema-utils.test.d.ts +2 -0
  37. package/dist/ts/shared/utils/schema-utils.test.d.ts.map +1 -0
  38. package/dist/ts/shims/async-local-storage.d.ts +36 -0
  39. package/dist/ts/shims/async-local-storage.d.ts.map +1 -0
  40. package/dist/ts/shims/async-local-storage.test.d.ts +2 -0
  41. package/dist/ts/shims/async-local-storage.test.d.ts.map +1 -0
  42. package/package.json +17 -9
  43. package/src/context/async-context.test.ts +348 -0
  44. package/src/context/async-context.ts +129 -0
  45. package/src/context/context.circular-deps.test.ts +1047 -0
  46. package/src/context/context.test.ts +150 -0
  47. package/src/context/context.ts +493 -55
  48. package/src/http/request-context.ts +1 -3
  49. package/src/index.ts +2 -0
  50. package/src/schema/json-schema.ts +14 -1
  51. package/src/schema/model-schema.test.ts +155 -1
  52. package/src/schema/model.ts +34 -3
  53. package/src/schema/test/mock-model.ts +6 -2
  54. package/src/shared/utils/schema-utils.test.ts +33 -0
  55. package/src/shared/utils/schema-utils.ts +17 -0
  56. package/src/shims/async-local-storage.test.ts +258 -0
  57. package/src/shims/async-local-storage.ts +82 -0
  58. package/dist/ts/schema/entity-schema.test.d.ts +0 -1
  59. package/dist/ts/schema/entity-schema.test.d.ts.map +0 -1
  60. 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 DeclaroRequestScope, type DeclaroScope } from '../context/context'
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 interface JSONSchema extends JSONSchema7 {}
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
  })
@@ -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
+ }