Commit 924883d2 authored by nanahira's avatar nanahira

dual object

parent 977ad6f8
// dual-unified.ts
export type Dual<T> = T & PromiseLike<T>;
type ThenKey = 'then' | 'catch' | 'finally';
const isThenKey = (k: PropertyKey): k is ThenKey =>
k === 'then' || k === 'catch' || k === 'finally';
type State = 'undecided' | 'pending' | 'fulfilled' | 'rejected';
/** 仅允许填入 “返回 Promise 的方法名” */
export type AsyncMethodKeys<T> = {
[K in keyof T]-?: T[K] extends (...args: any[]) => Promise<any> ? K : never;
}[keyof T];
export interface DualizeOptions<T> {
/** 这些方法在 undecided/pending 时会返回一个延迟执行函数,等待对象 Promise 完成后再调用 */
asyncMethods?: readonly AsyncMethodKeys<T>[];
}
export function dualizeAny<T>(
sync: () => T, // 同步构造;若抛错则视为 rejected
asyncFn: () => Promise<T>, // 异步构造
options?: DualizeOptions<T>,
): Dual<T> {
let state: State = 'undecided';
let value!: T; // fulfilled 时的值(含来自 sync 或 async)
let reason: any; // rejected 的错误
let p!: Promise<T>; // 缓存 Promise(resolved/rejected/进行中)
const asyncMethodSet = new Set<PropertyKey>(
(options?.asyncMethods ?? []) as readonly PropertyKey[],
);
const startAsync = () => {
if (!p || state === 'undecided') {
state = 'pending';
p = Promise.resolve()
.then(asyncFn)
.then(
(v) => {
value = v;
state = 'fulfilled';
return v;
},
(e) => {
reason = e;
state = 'rejected';
throw e;
},
);
}
return p;
};
const ensureSync = () => {
if (state === 'undecided') {
try {
value = sync();
state = 'fulfilled';
} catch (e) {
reason = e;
state = 'rejected';
}
}
};
/** 在“对象可用”后调用某个异步方法(由 asyncMethods 声明) */
const makeDeferredAsyncMethod =
(prop: PropertyKey) =>
(...args: any[]) =>
startAsync().then((obj) => {
const fn = (obj as any)[prop];
return fn.apply(obj, args);
});
// 从某个值上取属性(原始值会装箱),并绑定 this
const getFrom = (v: unknown, prop: PropertyKey) => {
if (prop === Symbol.toPrimitive) {
return (hint: 'default' | 'number' | 'string') => {
const x: any = v;
if (hint === 'number') return Number(x);
if (hint === 'string') return String(x);
if (typeof x === 'string') return x;
const n = Number(x);
return Number.isNaN(n) ? String(x) : n;
};
}
if (prop === 'valueOf') return () => v as any;
if (prop === 'toString') return () => String(v);
const boxed: any =
v !== null && (typeof v === 'object' || typeof v === 'function')
? v
: Object(v as any);
const out = boxed[prop];
return typeof out === 'function' ? out.bind(boxed) : out;
};
const proxy = new Proxy(Object.create(null) as any, {
get(_t, prop) {
// then/catch/finally:走 Promise 通道
if (isThenKey(prop)) {
if (state === 'undecided') {
startAsync();
} else if (state === 'fulfilled') {
// 若已 fulfilled(来自 sync 或 async),补一个已完成的 Promise
p ||= Promise.resolve(value);
} else if (state === 'rejected') {
p ||= Promise.reject(reason);
} else {
// pending:已有 p
startAsync();
}
const anyP: any = p;
const m = anyP[prop];
return typeof m === 'function' ? m.bind(anyP) : m;
}
// 声明为异步方法的键:在 undecided/pending 时返回“延迟函数”
if (asyncMethodSet.has(prop)) {
if (state === 'undecided' || state === 'pending') {
startAsync();
return makeDeferredAsyncMethod(prop);
}
if (state === 'fulfilled') {
return getFrom(value, prop); // 同步可直接取到方法(其本身返回 Promise)
}
if (state === 'rejected') {
// 访问即抛;也可以选择返回 () => Promise.reject(reason)
throw reason;
}
}
// 其它属性访问:遵循状态机
switch (state) {
case 'undecided': {
ensureSync();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (state === 'fulfilled') return getFrom(value, prop);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (state === 'rejected') throw reason;
// 理论上不会到这里
throw new TypeError('Invalid state transition');
}
case 'pending': {
// 非 asyncMethods 的属性在 pending 时不可同步读取
throw new TypeError('Value is not ready yet. Please await it first.');
}
case 'fulfilled': {
return getFrom(value, prop);
}
case 'rejected': {
throw reason;
}
}
},
has(_t, key) {
if (state === 'undecided') {
ensureSync();
}
if (state === 'fulfilled') return key in Object(value as any);
return false; // pending/rejected:保守处理
},
ownKeys() {
if (state === 'undecided') ensureSync();
if (state === 'fulfilled') return Reflect.ownKeys(Object(value as any));
return [];
},
getOwnPropertyDescriptor(_t, key) {
if (state === 'undecided') ensureSync();
if (state === 'fulfilled')
return Object.getOwnPropertyDescriptor(Object(value as any), key);
return undefined;
},
});
return proxy as Dual<T>;
}
...@@ -47,8 +47,17 @@ class Node { ...@@ -47,8 +47,17 @@ class Node {
} }
} }
export const WF_NODE = Symbol('@@workflow/node');
function isWorkflowChain(x: any): x is Chain<any> {
return !!x && typeof x === 'function' && x[WF_NODE] instanceof Node;
}
// ========== 对外 API:workflow ========== // ========== 对外 API:workflow ==========
export function workflow<T>(source: T | Promise<T>): Chain<T> { export function workflow<T>(source: T | Promise<T>): Chain<T> {
if (isWorkflowChain(source)) {
return source as unknown as Chain<T>;
}
const root = new Node(source, null, null); const root = new Node(source, null, null);
return makeProxy<T>(root) as any; return makeProxy<T>(root) as any;
} }
...@@ -59,6 +68,7 @@ function makeProxy<T>(node: Node): Chain<T> { ...@@ -59,6 +68,7 @@ function makeProxy<T>(node: Node): Chain<T> {
const rootHandler: ProxyHandler<any> = { const rootHandler: ProxyHandler<any> = {
get(_t, prop) { get(_t, prop) {
if (prop === WF_NODE) return node;
// 结束信号:所有 then/catch/finally 复用同一个 Promise // 结束信号:所有 then/catch/finally 复用同一个 Promise
if (prop === 'then') if (prop === 'then')
return (res: any, rej?: any) => runOnce().then(res, rej); return (res: any, rej?: any) => runOnce().then(res, rej);
...@@ -94,6 +104,7 @@ function makeProxy<T>(node: Node): Chain<T> { ...@@ -94,6 +104,7 @@ function makeProxy<T>(node: Node): Chain<T> {
}, },
// 把 “.bar” 记录为 Get;继续深入时在这个 Get 的结果上再处理 // 把 “.bar” 记录为 Get;继续深入时在这个 Get 的结果上再处理
get(_t, next) { get(_t, next) {
if (next === WF_NODE) return node;
if (next === 'then') if (next === 'then')
return (r: any, j?: any) => return (r: any, j?: any) =>
node node
......
import { workflow } from '../src/workflow';
import { dualizeAny } from '../src/dual-object';
describe('dualizeAny + workflow', () => {
test('workflow over dualizeAny works correctly', async () => {
const obj = dualizeAny(
() => {
return {
id: 'local',
name: () => 'cached',
ping: async () => 1,
};
},
async () => {
await new Promise((r) => setTimeout(r, 30));
return {
id: 'remote',
name: () => 'fresh',
ping: async () => 42,
};
},
{ asyncMethods: ['ping'] as const },
);
const wf = workflow(obj);
// Should be async property access
const name1 = await wf.name();
expect(name1).toBe('fresh');
// Now call async method
const pingResult = await wf.ping();
expect(pingResult).toBe(42);
// Finally, await the whole object
const finalObj = await wf;
expect(finalObj.id).toBe('remote');
});
});
// tests/dual-object.spec.ts
import { dualizeAny, AsyncMethodKeys } from '../src/dual-object';
type Client = {
id: string;
name(): string; // 同步方法
ping(): Promise<number>; // 异步方法
};
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
describe('dualizeAny basic state machine', () => {
test('sync-first property access => fulfilled (from sync), asyncFn not called', async () => {
let syncCalled = 0;
let asyncCalled = 0;
const obj = dualizeAny<Client>(
() => {
syncCalled++;
return {
id: 'local',
name: () => 'cached',
ping: async () => 1,
};
},
async () => {
asyncCalled++;
await delay(30);
return {
id: 'remote',
name: () => 'fresh',
ping: async () => 42,
};
},
{ asyncMethods: ['ping'] satisfies readonly AsyncMethodKeys<Client>[] },
);
// undecided 下先读同步属性
expect(obj.id).toBe('local');
expect(obj.name()).toBe('cached');
expect(syncCalled).toBe(1);
// 现在 await 只会 Promise.resolve(已有值),不会触发 asyncFn
const v = await obj;
expect(v.id).toBe('local');
expect(asyncCalled).toBe(0);
});
test('await-first => pending→fulfilled (from async), sync not called', async () => {
let syncCalled = 0;
let asyncCalled = 0;
const obj = dualizeAny<Client>(
() => {
syncCalled++;
return {
id: 'local',
name: () => 'cached',
ping: async () => 1,
};
},
async () => {
asyncCalled++;
await delay(10);
return {
id: 'remote',
name: () => 'fresh',
ping: async () => 42,
};
},
{ asyncMethods: ['ping'] as const },
);
// 先 await(或 obj.then),应走 asyncFn
const v = await obj;
expect(v.id).toBe('remote');
expect(syncCalled).toBe(0);
expect(asyncCalled).toBe(1);
// fulfilled 后再取属性为同步
expect(obj.name()).toBe('fresh');
});
test('pending: accessing non-async property throws TypeError', async () => {
const obj = dualizeAny<Client>(
() => ({
id: 'local',
name: () => 'cached',
ping: async () => 1,
}),
async () => {
await delay(50);
return {
id: 'remote',
name: () => 'fresh',
ping: async () => 42,
};
},
{ asyncMethods: ['ping'] as const },
);
// 触发 pending(但不等待完成)
// 通过 .then 或 await 的方式均可;这里用 .then 触发
const p = (obj as unknown as Promise<Client>).then(() => {
/* noop */
});
// pending 状态下访问非 asyncMethods 的键应抛错
expect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(obj as Client).id;
}).toThrow(new TypeError('Value is not ready yet. Please await it first.'));
await p; // 清理 pending
});
test('asyncMethods in undecided: returns deferred function that waits object promise', async () => {
const obj = dualizeAny<Client>(
() => ({
id: 'local',
name: () => 'cached',
ping: async () => 1,
}),
async () => {
await delay(20);
return {
id: 'remote',
name: () => 'fresh',
ping: async () => 42,
};
},
{ asyncMethods: ['ping'] as const },
);
// undecided 下先访问 async 方法:应进入 pending,并返回一个 Promise<number> 的函数结果
const r = await obj.ping();
expect(r).toBe(42);
// 之后对象应已 fulfilled(来自 async)
expect(obj.name()).toBe('fresh');
});
test('asyncMethods in pending: returns deferred function (no throw)', async () => {
const obj = dualizeAny<Client>(
() => ({
id: 'local',
name: () => 'cached',
ping: async () => 1,
}),
async () => {
await delay(30);
return {
id: 'remote',
name: () => 'fresh',
ping: async () => 42,
};
},
{ asyncMethods: ['ping'] as const },
);
// 触发 pending
const start = (obj as unknown as Promise<Client>).then(() => {});
// pending 下访问 async 方法不抛错,而是返回延迟函数的结果
const r = await obj.ping();
expect(r).toBe(42);
await start;
});
test('rejected from sync(): sync() throws => state rejected; any access throws; await rejects', async () => {
const err = new Error('boom-sync');
const obj = dualizeAny<Client>(
() => {
throw err;
},
async () => {
await delay(10);
return { id: 'remote', name: () => 'fresh', ping: async () => 42 };
},
{ asyncMethods: ['ping'] as const },
);
// undecided 下访问普通属性会触发 ensureSync → 抛错
expect(() => (obj as Client).name()).toThrow(err);
// then/await 也应得到同样的 rejection
await expect(obj).rejects.toThrow(err);
});
test('rejected from async(): await-first triggers async then rejects; further access throws same', async () => {
const err = new Error('boom-async');
const obj = dualizeAny<Client>(
() => ({
id: 'local',
name: () => 'cached',
ping: async () => 1,
}),
async () => {
await delay(10);
throw err;
},
{ asyncMethods: ['ping'] as const },
);
await expect(obj).rejects.toThrow(err);
// 之后访问任何键也应抛相同错误
expect(() => (obj as Client).name()).toThrow(err);
expect(() => (obj as Client).id).toThrow(err);
});
});
describe('primitives & coercion', () => {
test('number primitive: arithmetic and toString/valueOf work in fulfilled (sync)', () => {
const x = dualizeAny<number>(
() => 5,
async () => 99,
);
// 首次发生隐式转换,走 sync → fulfilled
expect(x + 1).toBe(6);
expect(String(x)).toBe('5');
// 原型方法(通过装箱):
expect((x as any).toFixed(1)).toBe('5.0');
});
test('string primitive: template literal, slice', () => {
const s = dualizeAny<string>(
() => 'hello',
async () => 'world',
);
expect(`${s} world`).toBe('hello world');
expect((s as any).slice(0, 3)).toBe('hel');
});
});
describe('then/catch/finally semantics', () => {
test('then/catch/finally chaining (fulfilled)', async () => {
const obj = dualizeAny<{ a: number }>(
() => ({ a: 1 }),
async () => ({ a: 2 }),
);
// 首次 await 触发 async
const seen: number[] = [];
await (obj as unknown as Promise<{ a: number }>)
.then((v) => {
seen.push(v.a);
})
.finally(() => {
seen.push(9);
});
expect(seen).toEqual([2, 9]);
});
test('rejected path via catch (trigger sync first)', async () => {
const err = new Error('oops');
const obj = dualizeAny<{ a: number }>(
() => {
throw err;
},
async () => ({ a: 2 }),
);
// 触发同步路径 → 立刻进入 rejected
expect(() => (obj as any).a).toThrow(err);
// thenable 现在也应当 rejected
await expect(obj as unknown as Promise<{ a: number }>).rejects.toBe(err);
});
describe('reflect traps: has/ownKeys/getOwnPropertyDescriptor on fulfilled', () => {
test('has / ownKeys reflect fulfilled value', () => {
const obj = dualizeAny<{ a: number; b: number }>(
() => ({ a: 1, b: 2 }),
async () => ({ a: 10, b: 20 }),
);
// 首次同步访问使其 fulfilled(from sync)
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(obj as any).a;
expect('a' in (obj as any)).toBe(true);
expect(Object.keys(obj as any).sort()).toEqual(['a', 'b']);
const desc = Object.getOwnPropertyDescriptor(Object(obj as any), 'a');
expect(desc?.enumerable).toBe(true);
});
});
});
...@@ -434,3 +434,120 @@ describe('workflow – Promise combinators interop', () => { ...@@ -434,3 +434,120 @@ describe('workflow – Promise combinators interop', () => {
expect(c.counts.get).toBe(1); expect(c.counts.get).toBe(1);
}); });
}); });
describe('workflow – nested workflow(workflow(x)) semantics', () => {
class Svc {
count = 0;
async inc(by = 1, ms = 2) {
await sleep(ms);
this.count += by;
return this;
}
val() {
return this.count;
}
}
it('is idempotent: wrapping an existing chain returns the same chain (no new steps)', async () => {
const s = new Svc();
const c1 = workflow(s).inc(2); // 构建一条链
const c2 = workflow(c1); // 套娃:应当恒等
expect(c2).toBe(c1); // 引用相等(若你选择“同 Node 新代理”,可改为 not.toBe 但 Node 相等)
// 两次 then 也只执行一遍链
const [a, b] = await Promise.all([c1.then((x) => x), c2.then((x) => x)]);
expect(a).toBe(b);
expect(s.count).toBe(2);
});
it('does not double-execute when awaited via inner and outer chains', async () => {
const s = new Svc();
const inner = workflow(s).inc(3);
const outer = workflow(inner); // 恒等
await inner;
await outer; // 不应重复执行
expect(s.count).toBe(3);
});
it('branches share the same instance; results are {6,8} and final count is 8', async () => {
const s = new Svc();
// 前缀(未执行前只是定义步骤)
const base = workflow(s).inc(5, 1); // -> count: 5
const wrapped = workflow(base); // 恒等返回
// 分支一:先 +1(较快完成)
const b1 = wrapped.inc(1, 5).val(); // 5 -> 6
// 分支二:后 +2(稍慢完成)
const b2 = wrapped.inc(2, 10).val(); // 6 -> 8 (若它先完成,则 5->7,再被另一条改成 8)
const [v1, v2] = await Promise.all([b1, b2]);
// 两个返回值是 6 和 8(顺序不保证)
expect(new Set([v1, v2])).toEqual(new Set([6, 8]));
// 最终状态必然为 8
expect(s.count).toBe(8);
});
it('interop with Promise combinators remains correct when nested', async () => {
const s = new Svc();
const chain = workflow(s).inc(1); // 前缀
const nested = workflow(chain); // 恒等
const [all, race, any] = await Promise.all([
Promise.all([nested.val(), nested.inc(1).val()]), // [1, 2]
Promise.race([nested.val(), nested.inc(1).val()]), // 先 settle 的可能是 val()(1)
Promise.any([nested.val(), nested.inc(1).val()]), // 第一个 fulfill
]);
expect(all).toEqual([1, 2]);
expect([race === 1, race === 2].some(Boolean)).toBe(true);
expect([any === 1, any === 2].some(Boolean)).toBe(true);
});
});
describe('workflow – wrapping a method chain property', () => {
class A {
value = 0;
async foo() {
await sleep(2);
this.value += 10;
return this;
}
async bar() {
await sleep(2);
this.value += 5;
return this;
}
}
it('workflow(workflow(a).foo)() should behave like workflow(a).foo()', async () => {
const a1 = new A();
const a2 = new A();
// baseline:直接调用 workflow(a).foo()
await workflow(a1).foo();
const baselineValue = a1.value;
// wrapped:先拿出 workflow(a).foo 再 workflow 一次
const wrapped = workflow(workflow(a2).foo);
await wrapped(); // 调用它
const wrappedValue = a2.value;
// 两者行为应该一致
expect(wrappedValue).toBe(baselineValue);
expect(wrappedValue).toBe(10);
});
it('should also support chaining after such wrapped call', async () => {
const a = new A();
// workflow(workflow(a).foo)() 之后还能继续链
const result = await workflow(workflow(a).foo)().bar();
expect(result.value).toBe(15); // foo +10, bar +5
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment