fix(scope): detach drained entry generations

This commit is contained in:
Tianyi Cui
2026-07-22 12:06:42 +08:00
parent f90b64ac26
commit 0180cdecd0
9 changed files with 103 additions and 27 deletions

View File

@@ -14,8 +14,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, live lookup/iteration, and an idempotent exact-entry undo from `insert()`.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; `append()` returns an idempotent exact-entry undo.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.

View File

@@ -23,11 +23,12 @@ interface EntryValues<V> {
/**
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
*
* Values are borrowed. Iterators are live native `Map` iterators, and each
* Values are borrowed. Iterators are live within one nonempty table
* generation; draining the table detaches them from later insertions. Each
* successful insertion returns an idempotent undo for that exact entry.
*/
export class NamedEntries<V> implements EntryValues<V> {
private readonly data = new Map<string, V>()
private data = new Map<string, V>()
constructor(
private readonly duplicateError: (name: string) => Error,
@@ -40,13 +41,15 @@ export class NamedEntries<V> implements EntryValues<V> {
* @returns an idempotent undo that removes only this insertion.
*/
insert(name: string, value: V): () => void {
if (this.data.has(name)) throw this.duplicateError(name)
this.data.set(name, value)
const data = this.data
if (data.has(name)) throw this.duplicateError(name)
data.set(name, value)
let active = true
return () => {
if (!active) return
active = false
this.data.delete(name)
data.delete(name)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
@@ -104,11 +107,12 @@ export class NamedEntries<V> implements EntryValues<V> {
/**
* Insertion-ordered anonymous entries with independent registration identity.
*
* Equal values remain separate registrations. Values are borrowed and the
* returned iterator retains native live `Map` semantics.
* Equal values remain separate registrations. Values are borrowed, and
* iterators are live within one nonempty table generation; draining the table
* detaches them from later appends.
*/
export class AnonymousEntries<V> implements EntryValues<V> {
private readonly data = new Map<symbol, V>()
private data = new Map<symbol, V>()
/**
* Append one independently owned value.
@@ -116,13 +120,15 @@ export class AnonymousEntries<V> implements EntryValues<V> {
* @returns an idempotent undo for this exact append.
*/
append(value: V): () => void {
const data = this.data
const key = Symbol()
this.data.set(key, value)
data.set(key, value)
let active = true
return () => {
if (!active) return
active = false
this.data.delete(key)
data.delete(key)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}

View File

@@ -59,6 +59,19 @@ describe('NamedEntries', () => {
undoB()
expect([...entries.entries()]).toEqual([['a', 3]])
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
const undo = entries.insert('first', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.insert('replacement', 2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('AnonymousEntries', () => {
@@ -78,6 +91,19 @@ describe('AnonymousEntries', () => {
undoSecond()
expect(entries.isEmpty()).toBe(true)
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new AnonymousEntries<number>()
const undo = entries.append(1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.append(2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('ScopedLayers', () => {