feat(client): add ownership lifecycle primitives

This commit is contained in:
imccyu committed 2026-09-17 05:27:15 +08:00
1 parent 98ebcccf9e
commit c52ef9fada
7 files changed
+197 -4

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ import { Service, type Context } from '@deepseek-ai/cordis'
import type { TypertContextMap } from './types.ts'
export { RemoteError, remoteErrorOf } from './remote-error.ts'
export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owned-value.ts'
export type { TypertOwnedValue } from './owned-value.ts'
const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
@@ -0,0 +1,39 @@
/** Generic invocation-owned values returned by synchronous Client Context resolvers. */
/** Shared identity across independently bundled Context providers and Gateway. */
export const TYPERT_OWNED_VALUE = Symbol.for('dsh.typert.owned-value')
/** A borrowed payload paired with the invocation owner's idempotent cleanup. */
export interface TypertOwnedValue<Value> extends Disposable {
readonly [TYPERT_OWNED_VALUE]: true
readonly value: Value
}
/**
* Transfer cleanup ownership without adding another resource reference count.
* @param value - resolved payload passed to the invocation.
* @param release - non-throwing synchronous release, called at most once.
* @returns an owned payload disposed after invocation and reply settlement.
*/
export function typertOwnedValue<Value>(value: Value, release: () => void): TypertOwnedValue<Value> {
let active = true
return {
[TYPERT_OWNED_VALUE]: true,
value,
[Symbol.dispose]() {
if (!active) return
active = false
release()
},
}
}
/**
* Identify invocation-owned values using the shared marker.
* @param value - borrowed or owned resolver result.
* @returns whether the result carries invocation cleanup.
*/
export function isTypertOwnedValue(value: unknown): value is TypertOwnedValue<unknown> {
return typeof value === 'object' && value !== null
&& TYPERT_OWNED_VALUE in value && value[TYPERT_OWNED_VALUE] === true
}
+4 -3
View File
@@ -5,6 +5,7 @@
*/
import type { Context, Events } from '@deepseek-ai/cordis'
import type { TypertOwnedValue } from './owned-value.ts'
declare const LOOKUP_HOST: unique symbol
declare const LOOKUP_WIRE: unique symbol
@@ -393,11 +394,11 @@ export interface TypertClientContextAdapter<Wire = unknown> {
*/
identity(ctx: Context): Wire | undefined
/**
* Resolve a wire identity from the Client's currently materialized Contexts.
* Resolve a validated identity synchronously for one Client invocation.
* @param id - validated wire identity.
* @returns the Client Context, or `undefined` when unavailable.
* @returns a borrowed or invocation-owned Client Context, or undefined when unavailable.
*/
resolve(id: Wire): Context | undefined
resolve(id: Wire): Context | TypertOwnedValue<Context> | undefined
}
/** Notification emitted after a Typert runtime registry changes. */
@@ -0,0 +1,27 @@
/** Invocation ownership remains generic, idempotent, and shared across bundles. */
import { describe, expect, it, vi } from 'vitest'
import { isTypertOwnedValue, TYPERT_OWNED_VALUE, typertOwnedValue } from '../src/owned-value.ts'
describe('TypertOwnedValue', () => {
it('preserves its value and releases once across explicit and scoped disposal', () => {
const value = { context: 'owned' }
const release = vi.fn()
{
using owned = typertOwnedValue(value, release)
expect(owned.value).toBe(value)
expect(isTypertOwnedValue(owned)).toBe(true)
owned[Symbol.dispose]()
owned[Symbol.dispose]()
}
expect(release).toHaveBeenCalledOnce()
})
it('recognizes a separately produced marker without accepting false markers or borrowed values', () => {
expect(TYPERT_OWNED_VALUE).toBe(Symbol.for('dsh.typert.owned-value'))
const siblingBundle = { [Symbol.for('dsh.typert.owned-value')]: true, value: {}, [Symbol.dispose]() {} }
expect(isTypertOwnedValue(siblingBundle)).toBe(true)
for (const value of [undefined, null, 1, {}, { [Symbol.for('dsh.typert.owned-value')]: false }]) {
expect(isTypertOwnedValue(value)).toBe(false)
}
})
})
+67
View File
@@ -237,3 +237,70 @@ export function deepFreeze<T>(value: T): T {
}
return value
}
/**
* Weak-key lookup with a strongly retained iterable set of associated values.
*
* Each value must belong to only one key. The container performs no automatic
* cleanup; owners delete associations or clear the container at lifecycle end.
*/
export class WeakMapWithValues<Key extends object, Value> {
private keys = new WeakMap<Key, Value>()
private readonly valueSet = new Set<Value>()
/** Live strongly retained values in insertion order. */
readonly values: ReadonlySet<Value> = this.valueSet
/**
* Read the value associated with a key.
* @param key - weakly held lookup key.
* @returns the associated value, or absence.
*/
get(key: Key): Value | undefined {
return this.keys.get(key)
}
/**
* Test whether a key has an association.
* @param key - weakly held lookup key.
* @returns whether the key is present.
*/
has(key: Key): boolean {
return this.keys.has(key)
}
/**
* Associate one key with one caller-unique value.
* @param key - weakly held lookup key.
* @param value - strongly retained value that belongs to no other key.
* @returns this container.
*/
set(key: Key, value: Value): this {
if (this.keys.has(key)) {
const previous = this.keys.get(key) as Value
if (previous === value) return this
this.valueSet.delete(previous)
}
this.keys.set(key, value)
this.valueSet.add(value)
return this
}
/**
* Remove one association and its strongly retained value.
* @param key - weakly held lookup key.
* @returns whether an association was removed.
*/
delete(key: Key): boolean {
if (!this.keys.has(key)) return false
const value = this.keys.get(key) as Value
const deleted = this.keys.delete(key)
this.valueSet.delete(value)
return deleted
}
/** Remove every association and strongly retained value. */
clear(): void {
this.keys = new WeakMap()
this.valueSet.clear()
}
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { WeakMapWithValues } from '../src/index.ts'
describe('WeakMapWithValues', () => {
it('looks up weak keys while iterating strongly retained values in insertion order', () => {
const firstKey = {}
const secondKey = {}
const firstValue = { name: 'first' }
const secondValue = { name: 'second' }
const values = new WeakMapWithValues<object, { name: string }>()
expect(values.get(firstKey)).toBeUndefined()
expect(values.has(firstKey)).toBe(false)
expect(values.set(firstKey, firstValue)).toBe(values)
values.set(secondKey, secondValue)
expect(values.get(firstKey)).toBe(firstValue)
expect(values.has(secondKey)).toBe(true)
expect([...values.values]).toEqual([firstValue, secondValue])
})
it('keeps an identical association and replaces its prior retained value', () => {
const key = {}
const firstValue = { name: 'first' }
const replacement = { name: 'replacement' }
const values = new WeakMapWithValues<object, { name: string }>()
values.set(key, firstValue)
values.set(key, firstValue)
expect([...values.values]).toEqual([firstValue])
values.set(key, replacement)
expect(values.get(key)).toBe(replacement)
expect([...values.values]).toEqual([replacement])
})
it('deletes one association and clears every lookup and retained value', () => {
const firstKey = {}
const secondKey = {}
const values = new WeakMapWithValues<object, string>()
values.set(firstKey, 'first').set(secondKey, 'second')
expect(values.delete({})).toBe(false)
expect(values.delete(firstKey)).toBe(true)
expect(values.has(firstKey)).toBe(false)
expect([...values.values]).toEqual(['second'])
values.clear()
expect(values.get(secondKey)).toBeUndefined()
expect(values.has(secondKey)).toBe(false)
expect([...values.values]).toEqual([])
values.set(firstKey, 'again')
expect(values.get(firstKey)).toBe('again')
expect([...values.values]).toEqual(['again'])
})
})
+1 -1
View File
@@ -5,7 +5,7 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"lib": ["ES2024", "DOM", "DOM.Iterable", "ESNext.Disposable"],
"typeRoots": ["./scripts/types", "./node_modules/@types"],
"types": ["client-build-environment"]
}