Procházet zdrojové kódy

feat(client): add ownership lifecycle primitives

imccyu před 1 týdnem
rodič
revize
c52ef9fada

+ 2 - 0
packages/typert/protocol/src/index.ts

@@ -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_$.-]+$/
 

+ 39 - 0
packages/typert/protocol/src/owned-value.ts

@@ -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
packages/typert/protocol/src/types.ts

@@ -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. */

+ 27 - 0
packages/typert/protocol/tests/owned-value.spec.ts

@@ -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 - 0
packages/util/values/src/index.ts

@@ -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()
+  }
+}

+ 57 - 0
packages/util/values/tests/weak-map-with-values.spec.ts

@@ -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
tsconfig.base.client.json

@@ -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"]
   }