From f90237d7fc75bfc6e459b1ee79c96274c0eb935c Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:53:17 +0200 Subject: [PATCH 01/12] feat(evaluator): support this in script functions A function that is not an arrow gets strict-mode this: the object of a method call, undefined for a plain call, and the receiver call, apply and bind pass, never boxed. An arrow sees the this of the function it is written in. At the top level this is undefined, since there is no global object. Host functions and intrinsics still receive no this. Constructors and prototypes stay unsupported. --- packages/maple-parity/README.md | 5 +- .../maple-parity/src/cases/functions.spec.ts | 56 ++++++++ packages/maple-parity/src/divergences.spec.ts | 34 ++--- packages/maple/docs/builtins.md | 4 +- packages/maple/docs/divergences.md | 6 +- packages/maple/docs/extending.md | 6 +- packages/maple/docs/language.md | 21 +-- packages/maple/docs/security.md | 4 + packages/maple/src/escapes.spec.ts | 132 +++++++++++++++++- packages/maple/src/evaluator/evaluator.ts | 36 ++++- .../maple/src/evaluator/functions.spec.ts | 121 +++++++++++++++- packages/maple/src/evaluator/scope.spec.ts | 37 +++++ packages/maple/src/evaluator/scope.ts | 23 +++ 13 files changed, 442 insertions(+), 43 deletions(-) diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index a69af50..76e3607 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -53,6 +53,7 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `with`, implicit globals, `arguments.callee` | Every script runs in strict mode, where these are errors. js-interpreter runs sloppy code and has no `callee`. | | A method called without its receiver: `const p = [].push; p(1)`, `(0, a.push)(1)`, `arr.map("".trim)` | As in strict mode, a plain call has no receiver, so it is a `TypeError`. js-interpreter passes the global object instead. | +| `this` at the top level and in a plain call: `typeof this` is `'undefined'` | As in strict mode, there is no global object to stand in. js-interpreter in sloppy mode passes its global object. | | `window`, `self`, `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` | Host APIs, not part of JavaScript. A run is synchronous and ends with its result, so nothing can be scheduled either. | | `String(Math)`, `"" + JSON`: Maple gives `[object Math]`, js-interpreter `[object Object]` | A namespace converts with its own tag, as in JavaScript; js-interpreter tags every object as a plain one. | | `Object.isExtensible(5)`: Maple gives `false`, js-interpreter `true` | A primitive is never extensible since ES2015; js-interpreter follows ES5. | @@ -73,14 +74,14 @@ On purpose: these are what the sandbox leaves out. | Divergence | Why | | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -| `this`, `new` on a script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes or receivers. An object's behaviour comes from its type. | +| `new` on a script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes. An object's behaviour comes from its type. | | `new` on a bound function: `new (Error.bind(null, 'x'))()` | Only the listed built-in constructors can be constructed, and a bound function is not one of them. | | A built-in method called on a receiver of another type: `''.trim.call({})` | A method's behaviour comes from its receiver's type, so it refuses another type with a `TypeError`. | | `Object.create`, `Object.getPrototypeOf`, `isPrototypeOf` | The same object model: there is no prototype chain to create or inspect. | | Getters and setters | They would run script code on a plain property read or write, host conversion included. | | `new String`, `new Number`, `new Boolean`, `Object(primitive)`, `instanceof String` / `Number` / `Boolean` | There are no primitive wrapper objects. They are a well-known trap and would add a value kind. | | `eval`, the `Function` constructor and its members (`Function.toString`, `Function.valueOf`, `Function.prototype`) | Creating code from a string is the escape the sandbox exists to prevent, so the global is not reachable at all. | -| The global `this`, the global `constructor` | There is no global object: globals are read-only bindings, and the host decides what exists. | +| The global `constructor` | There is no global object: globals are read-only bindings, and the host decides what exists. | ### Not supported yet diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 67b02d1..8aa6340 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -455,6 +455,62 @@ describe('method calls', () => { }); }); +describe('syntax:ThisExpression', () => { + it('is the object a function is called on', () => { + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return o.f();')).toBe(1); + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return o["f"]();')).toBe(1); + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return (o.f)();')).toBe(1); + }); + + it('is undefined for a plain call', () => { + expect(parity('function f() { return typeof this; } return f();')).toBe('undefined'); + expect(parity('var o = { f: function () { return typeof this; } }; return (0, o.f)();')).toBe( + 'undefined' + ); + expect( + parity('var o = { f: function () { return typeof this; } }; var g = o.f; return g();') + ).toBe('undefined'); + expect(parity('return typeof this;')).toBe('undefined'); + }); + + it('is what call, apply and bind pass, unboxed', () => { + expect( + parity( + 'function f() { return this; } return [f.call(5), f.apply("s", []), f.bind(true)(), f.call(null)];' + ) + ).toEqual([5, 's', true, null]); + expect(parity('function f() { return typeof this; } return f.call(5);')).toBe('number'); + }); + + it('keeps the bound receiver', () => { + expect(parity('function f() { return this; } return f.bind(1).call(2);')).toBe(1); + expect(parity('function f() { return this; } var o = { g: f.bind(1) }; return o.g();')).toBe(1); + }); + + it('is undefined again in a nested function', () => { + expect( + parity( + 'var o = { f: function () { function g() { return typeof this; } return g(); } }; return o.f();' + ) + ).toBe('undefined'); + }); + + it('lets a method return its object for chaining', () => { + expect( + parity( + 'var o = { n: 0, add: function (k) { this.n += k; return this; } }; return o.add(1).add(2).n;' + ) + ).toBe(3); + }); + + it('is read and written like any object', () => { + expect( + parity('var o = { f: function () { this.x = 1; return this.x; } }; return [o.f(), o.x];') + ).toEqual([1, 1]); + expect(allThrow('function f() { return this.x; } return f();')).toBe('TypeError'); + }); +}); + describe('assign:function property', () => { it('adds a property to a script function', () => { expect( diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index 8b06e56..ba992d0 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -248,19 +248,27 @@ describe('Array.prototype.sort', () => { }); }); -// Maple departs from JavaScript on purpose. - -// The object model: Maple has no script-visible prototypes or receivers. An object's behaviour -// comes from its type, so `this`, constructors and prototype chains are left out, not sandboxed. - describe('syntax:ThisExpression', () => { - it('rejects this in a function', () => { - expect(rejected('function f() { return this; } return f();')).toBe( - MapleErrorCode.DISALLOWED_SYNTAX - ); + // As in strict mode, there is no global object to stand in; sloppy js-interpreter passes its own + it('has no global this', () => { + expect(followsJavaScript('return typeof this;', { sloppy: true })).toEqual({ + maple: 'undefined', + jsInterpreter: 'object' + }); + }); + + it('is undefined in a plain call', () => { + expect( + followsJavaScript('function f() { return typeof this; } return f();', { sloppy: true }) + ).toEqual({ maple: 'undefined', jsInterpreter: 'object' }); }); }); +// Maple departs from JavaScript on purpose. + +// The object model: Maple has no script-visible prototypes. An object's behaviour comes from its +// type, so constructors and prototype chains are left out, not sandboxed. + describe('syntax:NewExpression', () => { it('rejects new on a script function', () => { expect(rejected('function Point(x) { this.x = x; } return new Point(1).x;')).toBe( @@ -417,14 +425,6 @@ describe('Function.prototype.length', () => { // There is no global object: globals are read-only bindings, and the host decides what exists. -describe('this', () => { - it('has no global this', () => { - expect(rejected('return typeof this;', { sloppy: true })).toBe( - MapleErrorCode.DISALLOWED_SYNTAX - ); - }); -}); - describe('constructor', () => { // js-interpreter's global object inherits from Object.prototype, so `constructor` is a global it('has no global named constructor', () => { diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 95f8688..45ef232 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -79,8 +79,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). Since there is no `this`, a reviver receives the holder as a third argument, -`(key, value, holder)`. +as `{}`). A reviver receives the holder as a third argument, `(key, value, holder)`, with `this` +undefined. A replacer, too, has `this` undefined rather than the holder. ## RegExp diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 18eb2d1..6f4fda4 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -5,7 +5,7 @@ one that does; the linked section has the detail. ## Syntax and names -- `this`, `class`, `super`, `async` / `await`, generators, getters and setters, tagged templates, +- `class`, `super`, `async` / `await`, generators, getters and setters, tagged templates, `import` / `export`, `import.meta` and `new.target` throw `DISALLOWED_SYNTAX` ([Language](language.md#not-supported)). - An `async` or generator function throws when it is created, not when it is called. @@ -49,8 +49,8 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives the holder as a third argument instead of `this` - ([JSON](builtins.md#json)). +- A JSON reviver receives the holder as a third argument instead of `this`, and a JSON replacer + has `undefined` as `this` instead of the holder ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/extending.md b/packages/maple/docs/extending.md index f86bbd0..6e9ba62 100644 --- a/packages/maple/docs/extending.md +++ b/packages/maple/docs/extending.md @@ -5,7 +5,9 @@ A host function is any function found in the globals or in `data` that is not marked as an intrinsic. A script can call it, store it and pass it around, but never reach the native function. It has no properties, not even `length` or `name`: those are the host's business. Its arguments and -its return value are copied (see [Boundary](boundary.md#host-function-calls)). +its return value are copied (see [Boundary](boundary.md#host-function-calls)). It never receives a +`this`: called as a method, as in `obj.fn()`, or through `call`, `apply` or `bind`, it runs with +`this` undefined. A host call costs what any call costs; the work and the time inside the host function are not measured (see [Limits](limits.md)). @@ -55,6 +57,8 @@ interface Sandbox { program is one more call level and appears as `` in stack traces. Like any run, it neither sees nor leaves properties the calling run added to shared built-ins. Data that is not an object throws a plain `TypeError` to the intrinsic. +- An intrinsic receives no `this`, as a host function does, and `sandbox.call` calls a script + function with `this` undefined. - An exception the intrinsic throws is treated like a host function's. - The handle is usable only while the intrinsic runs. Once it has returned, every method throws a plain `Error`, so a handle or closure an intrinsic keeps cannot run script code outside the call. diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index 3b25e18..468fe32 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -32,9 +32,9 @@ Expressions: ## Not supported -Reaching any of these throws `DISALLOWED_SYNTAX`: `this`, `class`, `super`, `async` / `await`, -generators and `yield`, getters and setters, tagged templates, `import` / `export`, `import.meta`, -`new.target`, and `arguments` at the script level, which is no function. +Reaching any of these throws `DISALLOWED_SYNTAX`: `class`, `super`, `async` / `await`, generators +and `yield`, getters and setters, tagged templates, `import` / `export`, `import.meta`, `new.target`, +and `arguments` at the script level, which is no function. An `async` or generator function throws when the closure is created, not when it is called. For a function declaration that is hoisting time, so the script fails before its first statement runs. @@ -72,11 +72,16 @@ Script functions and built-in functions have JavaScript's read-only `length` and inferred from bindings included. Host functions, and functions bound from them, have no properties at all. Every function answers to `toString`, which gives `[function]`, `valueOf`, `call`, `apply` and `bind` ([Functions](builtins.md#functions)). The receiver these pass on reaches a built-in -method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`. A script -function never sees it, since there is no `this`, which is how JavaScript behaves for a strict -function that does not use `this`. - -Inside a function that is not an arrow, `arguments` is the strict-mode arguments object. +method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script +function as its `this`. + +Inside a function that is not an arrow, `this` and `arguments` are those of a strict-mode function. +`this` is the object of a method call, so `obj.f()` and `obj[key]()` see `obj`, and `undefined` for +a plain call such as `f()` or `(0, obj.f)()`. `call`, `apply` and `bind` pass their receiver as it is, +never boxed, so `f.call(5)` sees `5`, and a bound function keeps its receiver whatever it is called +on. An arrow function has no `this` of its own and sees the one of the function it is written in. +At the top level of the script `this` is `undefined`, since there is no global object. A script +function still cannot be constructed ([Constructors](#constructors)). Every function, and every namespace such as `Math`, takes the properties a script adds, as in `fn.cache = {}`. Built-ins, host functions and namespaces are shared by every run, so what a run diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 5862765..beb3248 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -41,6 +41,10 @@ it can terminate. ignored by `+`, templates, comparison and the built-ins (see [Coercion](language.md#coercion)). The host never holds a script function: callbacks of built-ins run through the interpreter, and set algebra and `Iterator.from` refuse objects whose methods the script wrote. +- **No new reach through `this`.** A receiver is only ever a value the script already holds: the + object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, + or `undefined`. There is no global object, built-ins never pass an internal value as `this`, and + host functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 811a5ee..b850846 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -34,6 +34,16 @@ function assertHostUntouched(): void { expect(Array.prototype).not.toHaveProperty('polluted'); } +/** Whether `call` invoked `cb` at all, and whether every invocation had `this` undefined. */ +function receiversIn(call: string): unknown { + return run(` + const seen = []; + const cb = function (x) { seen.push(this); return x; }; + ${call}; + return [seen.length > 0, seen.every((receiver) => receiver === undefined)]; + `); +} + describe('escape attempts', () => { afterEach(assertHostUntouched); @@ -79,7 +89,7 @@ describe('escape attempts', () => { expect(errorCode('Function("return this")();')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('eval("1");')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('globalThis;')).toBe('DISALLOWED_SYNTAX'); - expect(errorCode('return this;')).toBe('DISALLOWED_SYNTAX'); + expect(run('return this;')).toBeUndefined(); expect(errorCode('return arguments;')).toBe('DISALLOWED_SYNTAX'); expect(errorName('function f() { return arguments.callee; } f();')).toBe('TypeError'); expect(errorCode('Reflect.get({}, "a");')).toBe('DISALLOWED_SYNTAX'); @@ -317,6 +327,126 @@ describe('escape attempts', () => { }); }); + describe('this', () => { + it('is never a global object', () => { + expect(run('return this;')).toBeUndefined(); + expect(run('return (function () { return this; })();')).toBeUndefined(); + expect(run('return (() => this)();')).toBeUndefined(); + expect(run('function f() { return this; } return [f(), [1].map(f)[0]];')).toEqual([ + undefined, + undefined + ]); + }); + + it('guards its prototype keys like any other object', () => { + const method = (body: string): string | undefined => + errorName(`const o = { f() { return ${body}; } }; o.f();`); + + expect(method('this.constructor')).toBe('TypeError'); + expect(method('this.__proto__')).toBe('TypeError'); + expect(method('this["prototype"]')).toBe('TypeError'); + expect(method('this["__proto__"]')).toBe('TypeError'); + expect( + errorName('const f = () => 1; f.g = function () { return this.constructor; }; f.g();') + ).toBe('TypeError'); + expect(errorName('const o = { f() { this.__proto__ = { polluted: true }; } }; o.f();')).toBe( + 'TypeError' + ); + }); + + it('writes to a shared built-in for the current run only', () => { + const shared = new Maple(); + + expect( + shared.run('Math.f = function () { this.x = 1; return this; }; return Math.f() === Math;') + .value + ).toBe(true); + expect(shared.run('return [typeof Math.f, typeof Math.x];').value).toEqual([ + 'undefined', + 'undefined' + ]); + }); + + it('cannot change frozen input, and changes only the copy of unfrozen input', () => { + const data = { obj: { n: 1 } }; + const code = 'const o = { f() { this.x = 1; return this.x; } }; return o.f.call(obj);'; + + expect(errorName(code, data)).toBe('TypeError'); + expect( + runUnfrozen('obj.f = function () { this.x = 1; return this.x; }; return obj.f();', data) + ).toBe(1); + expect(runUnfrozen(code, data)).toBe(1); + expect(data).toEqual({ obj: { n: 1 } }); + }); + + it('is never passed to a host function or an intrinsic', () => { + const receivers: Array = []; + const probes = new Maple({ + globals: { + probe(this: unknown): void { + receivers.push(this); + }, + intrinsicProbe: intrinsic(function (this: unknown): void { + receivers.push(this); + }) + } + }); + + probes.run( + 'const o = { probe, intrinsicProbe }; o.probe(); o.intrinsicProbe(); probe.call(o); intrinsicProbe.apply(o, []); probe.bind(o)(); obj.probe();', + { + obj: { + probe(this: unknown): void { + receivers.push(this); + } + } + } + ); + + expect(receivers).toEqual(Array.from({ length: 6 }, () => undefined)); + }); + + it('is undefined for every built-in callback that is given no thisArg', () => { + const cases: ReadonlyArray = [ + '[2, 1, 3].sort(cb)', + '[2, 1, 3].toSorted(cb)', + '[1, 2].reduce(cb)', + '[1, 2].reduce(cb, 0)', + '[1, 2].reduceRight(cb)', + '[1].values().map(cb).toArray()', + '[1].values().filter(cb).toArray()', + '[1].values().flatMap((x) => [x]).map(cb).toArray()', + '[1].values().flatMap(function (x) { seen.push(this); return [x]; }).toArray()', + '[1].values().forEach(cb)', + '[1].values().some(cb)', + '[1].values().every(cb)', + '[1].values().find(cb)', + '[1, 2].values().reduce(cb)', + '"ab".replace("a", cb)', + '"ab".replace(/a/g, cb)', + '"aa".replaceAll("a", cb)', + 'Object.groupBy([1], cb)', + 'Map.groupBy([1], cb)', + 'JSON.stringify({ a: 1 }, function (key, value) { seen.push(this); return value; })', + 'Array.from([1], cb)', + 'Array.from({ length: 1 }, cb)', + '[1].map(cb)', + 'new Map([[1, 1]]).forEach(cb)', + 'new Set([1]).forEach(cb)' + ]; + for (const call of cases) { + expect([call, receiversIn(call)]).toEqual([call, [true, true]]); + } + }); + + it('returns to the host as a plain copy, without its functions', () => { + expect(run('return { n: 1, f() { return this; } }.f();')).toStrictEqual({ + n: 1, + f: undefined + }); + }); + }); + describe('functions never cross the boundary', () => { it('neither out through results nor into host functions', () => { expect(run('return () => 1;')).toBeUndefined(); diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index 5697fa4..a2d812c 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -134,7 +134,6 @@ const HOST_FAILURE_MESSAGE = 'A host function failed'; const NODE_NAMES: Partial> = { ClassDeclaration: 'class', ClassExpression: 'class', - ThisExpression: 'this', Super: 'super', AwaitExpression: 'await', YieldExpression: 'yield', @@ -203,9 +202,13 @@ export class Evaluator { return this.#budget.stats(); } - /** Evaluates the program as a function body below `globals` and returns what the script returned. */ + /** + * Evaluates the program as a function body below `globals` and returns what the script returned. + * Like the body of a strict function called plainly, it has `undefined` as `this`. + */ public run(program: Program, globals: Scope): unknown { const scope = new Scope(globals); + scope.bindReceiver(undefined); const completion = this.evaluateFunctionBody(program.body, scope); return completion.type === 'return' ? completion.value : undefined; } @@ -684,6 +687,8 @@ export class Evaluator { return node.value; case 'Identifier': return this.readIdentifier(node, scope); + case 'ThisExpression': + return scope.receiver(); case 'TemplateLiteral': return this.evaluateTemplate(node, scope); case 'ObjectExpression': @@ -1039,6 +1044,15 @@ export class Evaluator { receiver = access.object; target = access.value; } + } else if (callee.type === 'ChainExpression' && callee.expression.type === 'MemberExpression') { + // `(a?.b)()` keeps `a` as the receiver; a short circuit ends at the parenthesis + this.tick(callee); + this.tick(callee.expression); + const access = this.accessMember(callee.expression, scope); + if (access !== SHORT_CIRCUIT) { + receiver = access.object; + target = access.value; + } } else { target = this.evaluateExpression(callee, scope); } @@ -1138,7 +1152,7 @@ export class Evaluator { ): unknown { this.tick(node); if (callee instanceof Closure) { - return this.callClosure(callee, args, node); + return this.callClosure(receiver, callee, args, node); } this.enterCall(node); try { @@ -1157,7 +1171,7 @@ export class Evaluator { description: string ): unknown { if (callee instanceof Closure) { - return this.callClosure(callee, args, node); + return this.callClosure(receiver, callee, args, node); } if (callee instanceof NativeFunction) { return callee.invoke(receiver, args, this.callContext(node)); @@ -1305,14 +1319,24 @@ export class Evaluator { } } - /** `node` is where the call happened, which is what a depth error points at. */ - private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + /** + * `node` is where the call happened, which is what a depth error points at. An arrow ignores + * `receiver` and sees the `this` of the scope it closed over. + */ + private callClosure( + receiver: unknown, + closure: Closure, + args: ReadonlyArray, + node: Node + ): unknown { this.enterCall(node); const { node: fn } = closure; this.#frames.push({ name: closure.name || '', calledFrom: node }); try { const scope = new Scope(this.scopeForNamedFunction(closure)); if (fn.type !== 'ArrowFunctionExpression') { + // Strict mode: the receiver as it is, never boxed or replaced by a global object + scope.bindReceiver(receiver); // Built on the first read only; copying the arguments costs one operation per argument scope.defineLazy('arguments', () => { this.charge(args.length, node); diff --git a/packages/maple/src/evaluator/functions.spec.ts b/packages/maple/src/evaluator/functions.spec.ts index e1a8b19..2254cbb 100644 --- a/packages/maple/src/evaluator/functions.spec.ts +++ b/packages/maple/src/evaluator/functions.spec.ts @@ -400,6 +400,122 @@ describe('functions', () => { }); }); + describe('this', () => { + it('is the object of a member call', () => { + expect(run('const o = { n: 1, f() { return this.n; } }; return o.f();')).toBe(1); + expect(run('const o = { n: 2, f: function () { return this.n; } }; return o["f"]();')).toBe( + 2 + ); + expect(run('const o = { f() { return this; } }; return o?.f() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return o.f?.() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return (o.f)() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return (o?.f)() === o;')).toBe(true); + }); + + it('is undefined for a plain call', () => { + expect(run('function f() { return this; } return f();')).toBeUndefined(); + expect(run('const o = { f() { return this; } }; return (0, o.f)();')).toBeUndefined(); + expect(run('const o = { f() { return this; } }; const g = o.f; return g();')).toBeUndefined(); + expect(run('return (function () { return typeof this; })();')).toBe('undefined'); + }); + + it('is undefined at the top level and in arrows defined there', () => { + expect(run('return this;')).toBeUndefined(); + expect(run('return typeof this;')).toBe('undefined'); + expect(run('const f = () => this; return f();')).toBeUndefined(); + expect(run('const o = { f: () => this }; return o.f();')).toBeUndefined(); + }); + + it('is what call, apply and bind pass, never boxed', () => { + expect( + run( + 'function f() { return this; } return [f.call(5), f.apply("s", []), f.bind(true)(), f.call(null), f.call()];' + ) + ).toEqual([5, 's', true, null, undefined]); + expect(run('function f() { return typeof this; } return f.call(5);')).toBe('number'); + }); + + it('keeps the bound receiver over a later one', () => { + expect(run('function f() { return this; } return f.bind(1).call(2);')).toBe(1); + expect(run('function f() { return this; } return { g: f.bind(1) }.g();')).toBe(1); + }); + + it('is lexical in an arrow, which ignores any receiver', () => { + expect(run('const o = { f() { return (() => this)(); } }; return o.f() === o;')).toBe(true); + expect( + run('const o = { f() { const g = () => this; return g.call(1); } }; return o.f() === o;') + ).toBe(true); + expect(run('const o = { f() { return [1].map(() => this)[0]; } }; return o.f() === o;')).toBe( + true + ); + }); + + it('is a function of its own in a nested function', () => { + expect( + run('const o = { f() { function g() { return this; } return g(); } }; return o.f();') + ).toBeUndefined(); + expect( + run( + 'const o = { f() { function g() { return () => this; } return g()(); } }; return o.f();' + ) + ).toBeUndefined(); + }); + + it('chains methods that return it', () => { + expect(run('return { n: 0, add(k) { this.n += k; return this; } }.add(1).add(2).n;')).toBe(3); + }); + + it('is the receiver in default parameter values', () => { + expect(run('const o = { n: 1, f(a = this.n) { return a; } }; return o.f();')).toBe(1); + expect(run('const o = { f(a = () => this) { return a(); } }; return o.f() === o;')).toBe( + true + ); + }); + + it('sits next to arguments and a named function expression calling itself', () => { + expect( + run('const o = { f() { return [this === o, arguments.length]; } }; return o.f(1, 2);') + ).toEqual([true, 2]); + expect( + run( + 'const o = { n: 3, f: function g(k) { return k === 0 ? this.n : g.call(this, k - 1); } }; return o.f(2);' + ) + ).toBe(3); + expect( + run('const o = { f: function g(k) { return k ? g(0) : this; } }; return o.f(1);') + ).toBeUndefined(); + }); + + it('stays with each loop iteration', () => { + expect( + run(` + const o = { + f() { + const fs = []; + for (let i = 0; i < 2; i++) { fs.push(() => [this === o, i]); } + for (const k of [2]) { fs.push(() => [this === o, k]); } + return fs.map((g) => g()); + } + }; + return o.f(); + `) + ).toEqual([ + [true, 0], + [true, 1], + [true, 2] + ]); + }); + + it('fails like any undefined value when read from', () => { + expect(errorMessage('function f() { return this.x; } return f();')).toBe( + "Cannot read properties of undefined (reading 'x')" + ); + expect(errorMessage('function f() { this.x = 1; } f();')).toBe( + "Cannot set properties of undefined (setting 'x')" + ); + }); + }); + describe('toString and valueOf', () => { it('convert any function to the placeholder and give it back itself', () => { expect( @@ -430,9 +546,8 @@ describe('functions', () => { expect(runAndCatch(maple, 'function* g() {}').message).toBe('generators are not supported'); }); - it('throws for this and arguments only when reached', () => { - expect(maple.run('const f = () => this; return 1;').value).toBe(1); - expect(runAndCatch(maple, 'return (() => this)();').message).toBe("'this' is not supported"); + it('throws for a script-level arguments only when reached', () => { + expect(maple.run('const f = () => arguments; return 1;').value).toBe(1); expect(runAndCatch(maple, 'return arguments;').message).toBe("'arguments' is not supported"); }); diff --git a/packages/maple/src/evaluator/scope.spec.ts b/packages/maple/src/evaluator/scope.spec.ts index ffbc8be..46b90ed 100644 --- a/packages/maple/src/evaluator/scope.spec.ts +++ b/packages/maple/src/evaluator/scope.spec.ts @@ -96,4 +96,41 @@ describe('Scope', () => { expect(create).toHaveBeenCalledTimes(1); }); }); + + describe('receiver', () => { + it('comes from the nearest scope that binds one', () => { + const outer = new Scope(); + const middle = new Scope(outer); + const inner = new Scope(middle); + outer.bindReceiver(1); + + expect(inner.receiver()).toBe(1); + + middle.bindReceiver(undefined); + + expect(inner.receiver()).toBeUndefined(); + expect(outer.receiver()).toBe(1); + }); + + it('is not a binding named this', () => { + const scope = new Scope(); + scope.bindReceiver(1); + + expect(scope.lookup('this')).toBeUndefined(); + expect(scope.hasOwn('this')).toBe(false); + }); + + it('is carried along by a copy', () => { + const outer = new Scope(); + outer.bindReceiver(1); + const scope = new Scope(outer); + scope.bindReceiver(2); + + expect(scope.copy().receiver()).toBe(2); + }); + + it('fails when no scope binds one', () => { + expect(() => new Scope().receiver()).toThrow('No scope binds a receiver'); + }); + }); }); diff --git a/packages/maple/src/evaluator/scope.ts b/packages/maple/src/evaluator/scope.ts index 586d7ca..e618215 100644 --- a/packages/maple/src/evaluator/scope.ts +++ b/packages/maple/src/evaluator/scope.ts @@ -7,11 +7,17 @@ export interface Binding { value: unknown; } +/** Marks a scope that has no receiver of its own, so `this` comes from a parent. */ +const NO_RECEIVER: unique symbol = Symbol('no-receiver'); + /** One lexical environment; a `Map` so that names like `__proto__` are ordinary keys. */ export class Scope { readonly #bindings = new Map(); readonly #parent: Scope | undefined; + /** What `this` is in this scope; only the scope of a script or a non-arrow call has one. */ + #receiver: unknown = NO_RECEIVER; + constructor(parent?: Scope) { this.#parent = parent; } @@ -65,12 +71,29 @@ export class Scope { return this.#bindings.get(name) ?? this.#parent?.lookup(name); } + /** Makes `value` what `this` is here and in every scope below that has no receiver of its own. */ + public bindReceiver(value: unknown): void { + this.#receiver = value; + } + + /** The receiver of the nearest scope that has one; kept apart from the bindings, as a keyword. */ + public receiver(): unknown { + if (this.#receiver !== NO_RECEIVER) { + return this.#receiver; + } + if (!this.#parent) { + throw new Error('No scope binds a receiver'); + } + return this.#parent.receiver(); + } + /** * A sibling scope with copies of the own bindings, which gives a loop fresh per-iteration * bindings. */ public copy(): Scope { const scope = new Scope(this.#parent); + scope.#receiver = this.#receiver; for (const [name, binding] of this.#bindings) { scope.#bindings.set(name, { ...binding }); } From 80e5ccbe96c2951be38225741ff4581346d4eaa7 Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:53:43 +0200 Subject: [PATCH 02/12] feat(builtins): pass thisArg to array, Map and Set callbacks every, filter, find, findIndex, findLast, findLastIndex, flatMap, forEach, map, some, Array.from and the forEach of Map and Set call their callback with the thisArg as this. Every other built-in callback keeps undefined. --- .../maple-parity/src/cases/functions.spec.ts | 18 +++ packages/maple/docs/builtins.md | 8 ++ .../maple/src/builtins/array/array.spec.ts | 133 ++++++++++++++++++ .../maple/src/builtins/array/methods/every.ts | 4 +- .../src/builtins/array/methods/filter.ts | 4 +- .../src/builtins/array/methods/find-index.ts | 4 +- .../builtins/array/methods/find-last-index.ts | 4 +- .../src/builtins/array/methods/find-last.ts | 4 +- .../maple/src/builtins/array/methods/find.ts | 3 +- .../src/builtins/array/methods/flat-map.ts | 4 +- .../src/builtins/array/methods/for-each.ts | 4 +- .../maple/src/builtins/array/methods/map.ts | 4 +- .../maple/src/builtins/array/methods/some.ts | 4 +- .../maple/src/builtins/array/statics/from.ts | 6 +- .../builtins/array/utils/array-callback.ts | 9 +- packages/maple/src/builtins/map/map.spec.ts | 19 +++ .../src/builtins/map/methods/for-each.ts | 4 +- .../src/builtins/set/methods/for-each.ts | 4 +- packages/maple/src/builtins/set/set.spec.ts | 17 +++ 19 files changed, 227 insertions(+), 30 deletions(-) diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 8aa6340..f91740f 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -511,6 +511,24 @@ describe('syntax:ThisExpression', () => { }); }); +describe('thisArg of array callbacks', () => { + it('is this in the callback', () => { + expect( + parity( + 'var t = { k: 10 }; var seen = []; var f = function (x) { seen.push(this.k + x); return true; }; [1].every(f, t); [1].filter(f, t); [1].forEach(f, t); [1].map(f, t); [1].some(f, t); return seen;' + ) + ).toEqual([11, 11, 11, 11, 11]); + }); + + it('passes a primitive unboxed', () => { + expect(parity('return [1].map(function () { return typeof this; }, 5);')).toEqual(['number']); + }); + + it('leaves this undefined when omitted', () => { + expect(parity('return [1].map(function () { return typeof this; });')).toEqual(['undefined']); + }); +}); + describe('assign:function property', () => { it('adds a property to a script function', () => { expect( diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 45ef232..ab63049 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -41,6 +41,11 @@ Instance: `length`, `at`, `concat`, `copyWithin`, `entries`, `every`, `fill`, `f Static: `Array.isArray`, `Array.from`, `Array.of`. +`every`, `filter`, `find`, `findIndex`, `findLast`, `findLastIndex`, `flatMap`, `forEach`, `map`, +`some` and `Array.from` take a `thisArg` after the callback, which the callback sees as `this`. +The comparator of `sort` and `toSorted` and the reducer of `reduce` and `reduceRight` take none +and have `this` undefined, as in JavaScript. + ## Number and Boolean Number instance: `toFixed`, `toPrecision`, `toExponential`, `toLocaleString`, `toString`, `valueOf`. @@ -123,6 +128,9 @@ Set: `add`, `has`, `delete`, `clear`, `forEach`, `size`, `keys`, `values`, `entr `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`. +`forEach` of a Map and of a Set takes a `thisArg` after the callback, which the callback sees as +`this`. + The set algebra takes a Set or a Map as the other operand. JavaScript's other set-like objects are refused with a `TypeError`, so the host never calls a script `has` or `keys`. diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index a6bc66b..d202ef3 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -206,6 +206,127 @@ describe('array methods', () => { }); }); + describe('thisArg', () => { + it('every passes it to its callback as this', () => { + expect(run('return [1, 2].every(function (x) { return x < this.max; }, { max: 3 });')).toBe( + true + ); + expect(run('return [1].every(function () { return this === undefined; });')).toBe(true); + expect( + run('const o = { f() { return [1].every(() => this === o, 5); } }; return o.f();') + ).toBe(true); + }); + + it('filter passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].filter(function (x) { return x > this.min; }, { min: 1 });') + ).toEqual([2, 3]); + expect(run('return [1].filter(function () { return this === undefined; });')).toEqual([1]); + expect( + run('const o = { f() { return [1].filter(() => this === o, 5); } }; return o.f();') + ).toEqual([1]); + }); + + it('find passes it to its callback as this', () => { + expect(run('return [1, 2, 3].find(function (x) { return x > this.min; }, { min: 1 });')).toBe( + 2 + ); + expect(run('return [1].find(function () { return this === undefined; });')).toBe(1); + expect( + run('const o = { f() { return [1].find(() => this === o, 5); } }; return o.f();') + ).toBe(1); + }); + + it('findIndex passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findIndex(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(1); + expect(run('return [1].findIndex(function () { return this === undefined; });')).toBe(0); + expect( + run('const o = { f() { return [1].findIndex(() => this === o, 5); } }; return o.f();') + ).toBe(0); + }); + + it('findLast passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findLast(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(3); + expect(run('return [1].findLast(function () { return this === undefined; });')).toBe(1); + expect( + run('const o = { f() { return [1].findLast(() => this === o, 5); } }; return o.f();') + ).toBe(1); + }); + + it('findLastIndex passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findLastIndex(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(2); + expect(run('return [1].findLastIndex(function () { return this === undefined; });')).toBe(0); + expect( + run('const o = { f() { return [1].findLastIndex(() => this === o, 5); } }; return o.f();') + ).toBe(0); + }); + + it('flatMap passes it to its callback as this', () => { + expect( + run('return [1, 2].flatMap(function (x) { return [x, x * this.factor]; }, { factor: 10 });') + ).toEqual([1, 10, 2, 20]); + expect(run('return [1].flatMap(function () { return [this]; });')).toEqual([undefined]); + expect( + run('const o = { f() { return [1].flatMap(() => [this === o], 5); } }; return o.f();') + ).toEqual([true]); + }); + + it('forEach passes it to its callback as this', () => { + expect( + run( + 'let sum = 0; [1, 2].forEach(function (x) { sum += x * this.factor; }, { factor: 10 }); return sum;' + ) + ).toBe(30); + expect( + run('let seen = 1; [1].forEach(function () { seen = this; }); return seen;') + ).toBeUndefined(); + expect( + run( + 'const o = { f() { let seen; [1].forEach(() => { seen = this; }, 5); return seen === o; } }; return o.f();' + ) + ).toBe(true); + }); + + it('map passes it to its callback as this', () => { + expect( + run('return [1, 2].map(function (x) { return x + this.offset; }, { offset: 10 });') + ).toEqual([11, 12]); + expect(run('return [1].map(function () { return this; });')).toEqual([undefined]); + expect( + run('const o = { f() { return [1].map(() => this === o, 5); } }; return o.f();') + ).toEqual([true]); + }); + + it('some passes it to its callback as this', () => { + expect( + run('return [1, 2].some(function (x) { return x === this.wanted; }, { wanted: 2 });') + ).toBe(true); + expect(run('return [1].some(function () { return this === undefined; });')).toBe(true); + expect( + run('const o = { f() { return [1].some(() => this === o, 5); } }; return o.f();') + ).toBe(true); + }); + + it('is passed as it is, never boxed', () => { + expect(run('return [1].map(function () { return typeof this; }, "s");')).toEqual(['string']); + expect(run('return [1].map(function () { return this; }, 5);')).toEqual([5]); + }); + + it('leaves the length of each method at 1', () => { + expect( + run( + 'return [[].every.length, [].filter.length, [].find.length, [].findIndex.length, [].findLast.length, [].findLastIndex.length, [].flatMap.length, [].forEach.length, [].map.length, [].some.length];' + ) + ).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); + }); + }); + describe('flat and flatMap', () => { it('charge one operation per element visited, nested arrays included', () => { expect( @@ -403,6 +524,18 @@ describe('Array statics', () => { expect(run('return Array.foo;')).toBeUndefined(); }); + it('from calls the mapper with the thisArg as this', () => { + expect(run('return Array.from([1], function (x) { return [this, x]; }, 5);')).toEqual([[5, 1]]); + expect(run('return Array.from({ length: 1 }, function () { return this; }, "t");')).toEqual([ + 't' + ]); + expect(run('return Array.from([1], function () { return this; });')).toEqual([undefined]); + expect( + run('const o = { f() { return Array.from([1], () => this, 5)[0] === o; } }; return o.f();') + ).toBe(true); + expect(run('return Array.from.length;')).toBe(1); + }); + it('from reads an array-like length as ToLength does', () => { expect(run('return Array.from({ length: 1.5, 0: "a", 1: "b" });')).toEqual(['a']); expect(run('return Array.from({ length: -1, 0: "a" });')).toEqual([]); diff --git a/packages/maple/src/builtins/array/methods/every.ts b/packages/maple/src/builtins/array/methods/every.ts index 2f3d3b0..7b3dff2 100644 --- a/packages/maple/src/builtins/array/methods/every.ts +++ b/packages/maple/src/builtins/array/methods/every.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_EVERY: Method> = method( Array.prototype.every.length, - (a, [fn], context) => - findEntryBy(a, presentEntries(a), arrayCallback(fn, context), false) === undefined + (a, [fn, thisArg], context) => + findEntryBy(a, presentEntries(a), arrayCallback(fn, context, thisArg), false) === undefined ); diff --git a/packages/maple/src/builtins/array/methods/filter.ts b/packages/maple/src/builtins/array/methods/filter.ts index 6232100..d6d684a 100644 --- a/packages/maple/src/builtins/array/methods/filter.ts +++ b/packages/maple/src/builtins/array/methods/filter.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_FILTER: Method> = method( Array.prototype.filter.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); const result: Array = []; for (const [index, element] of presentEntries(a)) { if (call(element, index, a)) { diff --git a/packages/maple/src/builtins/array/methods/find-index.ts b/packages/maple/src/builtins/array/methods/find-index.ts index a278897..8400959 100644 --- a/packages/maple/src/builtins/array/methods/find-index.ts +++ b/packages/maple/src/builtins/array/methods/find-index.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_INDEX: Method> = method( Array.prototype.findIndex.length, - (a, [fn], context) => - findEntryBy(a, indexedEntries(a), arrayCallback(fn, context), true)?.[0] ?? -1 + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntries(a), arrayCallback(fn, context, thisArg), true)?.[0] ?? -1 ); diff --git a/packages/maple/src/builtins/array/methods/find-last-index.ts b/packages/maple/src/builtins/array/methods/find-last-index.ts index 9a89907..f5fd346 100644 --- a/packages/maple/src/builtins/array/methods/find-last-index.ts +++ b/packages/maple/src/builtins/array/methods/find-last-index.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_LAST_INDEX: Method> = method( Array.prototype.findLastIndex.length, - (a, [fn], context) => - findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context), true)?.[0] ?? -1 + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context, thisArg), true)?.[0] ?? -1 ); diff --git a/packages/maple/src/builtins/array/methods/find-last.ts b/packages/maple/src/builtins/array/methods/find-last.ts index b0f0798..d24c32d 100644 --- a/packages/maple/src/builtins/array/methods/find-last.ts +++ b/packages/maple/src/builtins/array/methods/find-last.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_LAST: Method> = method( Array.prototype.findLast.length, - (a, [fn], context) => - findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context), true)?.[1] + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context, thisArg), true)?.[1] ); diff --git a/packages/maple/src/builtins/array/methods/find.ts b/packages/maple/src/builtins/array/methods/find.ts index b062a21..a491fed 100644 --- a/packages/maple/src/builtins/array/methods/find.ts +++ b/packages/maple/src/builtins/array/methods/find.ts @@ -5,5 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND: Method> = method( Array.prototype.find.length, - (a, [fn], context) => findEntryBy(a, indexedEntries(a), arrayCallback(fn, context), true)?.[1] + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntries(a), arrayCallback(fn, context, thisArg), true)?.[1] ); diff --git a/packages/maple/src/builtins/array/methods/flat-map.ts b/packages/maple/src/builtins/array/methods/flat-map.ts index 036cf17..f7e2635 100644 --- a/packages/maple/src/builtins/array/methods/flat-map.ts +++ b/packages/maple/src/builtins/array/methods/flat-map.ts @@ -5,8 +5,8 @@ import { flattenInto } from '../utils/flatten-into.ts'; export const ARRAY_METHOD_FLAT_MAP: Method> = method( Array.prototype.flatMap.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); const result: Array = []; for (const [index, element] of presentEntries(a)) { const mapped = call(element, index, a); diff --git a/packages/maple/src/builtins/array/methods/for-each.ts b/packages/maple/src/builtins/array/methods/for-each.ts index c552071..bd62bce 100644 --- a/packages/maple/src/builtins/array/methods/for-each.ts +++ b/packages/maple/src/builtins/array/methods/for-each.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_FOR_EACH: Method> = method( Array.prototype.forEach.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); for (const [index, element] of presentEntries(a)) { call(element, index, a); } diff --git a/packages/maple/src/builtins/array/methods/map.ts b/packages/maple/src/builtins/array/methods/map.ts index cd8d35d..d26fe1a 100644 --- a/packages/maple/src/builtins/array/methods/map.ts +++ b/packages/maple/src/builtins/array/methods/map.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_MAP: Method> = method( Array.prototype.map.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); // The result keeps the holes of the source, which the callback never sees const result: Array = []; result.length = a.length; diff --git a/packages/maple/src/builtins/array/methods/some.ts b/packages/maple/src/builtins/array/methods/some.ts index 322c189..4b4e6e9 100644 --- a/packages/maple/src/builtins/array/methods/some.ts +++ b/packages/maple/src/builtins/array/methods/some.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_SOME: Method> = method( Array.prototype.some.length, - (a, [fn], context) => - findEntryBy(a, presentEntries(a), arrayCallback(fn, context), true) !== undefined + (a, [fn, thisArg], context) => + findEntryBy(a, presentEntries(a), arrayCallback(fn, context, thisArg), true) !== undefined ); diff --git a/packages/maple/src/builtins/array/statics/from.ts b/packages/maple/src/builtins/array/statics/from.ts index 951950f..f6eda2c 100644 --- a/packages/maple/src/builtins/array/statics/from.ts +++ b/packages/maple/src/builtins/array/statics/from.ts @@ -6,16 +6,14 @@ import { getMember } from '../../method-dispatch.ts'; export const ARRAY_STATIC_FROM: NativeFunction = new NativeFunction( 'from', Array.from.length, - (_, [source, mapper], context) => { + (_, [source, mapper, thisArg], context) => { const { fail } = context; if (mapper !== undefined && !isCallable(mapper)) { return fail('TypeError', `${describeValue(mapper)} is not a function`); } const items: Array = []; const add = (item: unknown): void => { - items.push( - mapper === undefined ? item : context.call(undefined, mapper, [item, items.length]) - ); + items.push(mapper === undefined ? item : context.call(thisArg, mapper, [item, items.length])); }; const iterator = iterate(source); if (iterator) { diff --git a/packages/maple/src/builtins/array/utils/array-callback.ts b/packages/maple/src/builtins/array/utils/array-callback.ts index ea880c3..c98e840 100644 --- a/packages/maple/src/builtins/array/utils/array-callback.ts +++ b/packages/maple/src/builtins/array/utils/array-callback.ts @@ -3,10 +3,13 @@ import { isCallable, type CallContext } from '../../../values/native.ts'; export type ArrayCallback = (...args: Array) => unknown; -/** The script callback as a host function; a non-callable argument fails like the host would. */ -export function arrayCallback(fn: unknown, context: CallContext): ArrayCallback { +/** + * The script callback as a host function; a non-callable argument fails like the host would. It + * runs with `thisArg` as `this`, which is `undefined` for the methods that take none. + */ +export function arrayCallback(fn: unknown, context: CallContext, thisArg?: unknown): ArrayCallback { if (!isCallable(fn)) { context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(undefined, fn, args); + return (...args) => context.call(thisArg, fn, args); } diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 1af68e9..657d957 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -56,6 +56,25 @@ describe('Map', () => { expect(run(`${setup} const [[k]] = m; return k;`)).toBe('b'); }); + it('forEach calls its callback with the thisArg as this', () => { + expect( + run( + 'const seen = []; new Map([[1, 2]]).forEach(function () { seen.push(this); }, 5); return seen;' + ) + ).toEqual([5]); + expect( + run( + 'const seen = []; new Map([[1, 2]]).forEach(function () { seen.push(this); }); return seen;' + ) + ).toEqual([undefined]); + expect( + run( + 'const o = { f() { let s; new Map([[1, 2]]).forEach(() => { s = this; }, 5); return s === o; } }; return o.f();' + ) + ).toBe(true); + expect(run('return new Map().forEach.length;')).toBe(1); + }); + it('has no own properties besides size but takes the ones a script adds', () => { expect(run('return ["size" in new Map(), "get" in new Map()];')).toEqual([true, true]); expect(run('return new Map().foo;')).toBeUndefined(); diff --git a/packages/maple/src/builtins/map/methods/for-each.ts b/packages/maple/src/builtins/map/methods/for-each.ts index 5e17c66..83b2326 100644 --- a/packages/maple/src/builtins/map/methods/for-each.ts +++ b/packages/maple/src/builtins/map/methods/for-each.ts @@ -4,13 +4,13 @@ import type { MapWrapper } from '../../../values/wrappers/map-wrapper.ts'; export const MAP_METHOD_FOR_EACH: Method = method( Map.prototype.forEach.length, - (m, [fn], context) => { + (m, [fn, thisArg], context) => { if (!isCallable(fn)) { return context.fail('TypeError', `${describeValue(fn)} is not a function`); } for (const [key, value] of m.map) { - context.call(undefined, fn, [value, key, m]); + context.call(thisArg, fn, [value, key, m]); } return undefined; diff --git a/packages/maple/src/builtins/set/methods/for-each.ts b/packages/maple/src/builtins/set/methods/for-each.ts index 8328349..5aa3a3e 100644 --- a/packages/maple/src/builtins/set/methods/for-each.ts +++ b/packages/maple/src/builtins/set/methods/for-each.ts @@ -4,13 +4,13 @@ import type { SetWrapper } from '../../../values/wrappers/set-wrapper.ts'; export const SET_METHOD_FOR_EACH: Method = method( Set.prototype.forEach.length, - (s, [fn], context) => { + (s, [fn, thisArg], context) => { if (!isCallable(fn)) { return context.fail('TypeError', `${describeValue(fn)} is not a function`); } for (const value of s.set) { - context.call(undefined, fn, [value, value, s]); + context.call(thisArg, fn, [value, value, s]); } return undefined; diff --git a/packages/maple/src/builtins/set/set.spec.ts b/packages/maple/src/builtins/set/set.spec.ts index 906c8ec..60310e4 100644 --- a/packages/maple/src/builtins/set/set.spec.ts +++ b/packages/maple/src/builtins/set/set.spec.ts @@ -31,6 +31,23 @@ describe('Set', () => { ).toBe('[object Object] is not iterable'); }); + it('forEach calls its callback with the thisArg as this', () => { + expect( + run( + 'const seen = []; new Set([1]).forEach(function () { seen.push(this); }, 5); return seen;' + ) + ).toEqual([5]); + expect( + run('const seen = []; new Set([1]).forEach(function () { seen.push(this); }); return seen;') + ).toEqual([undefined]); + expect( + run( + 'const o = { f() { let s; new Set([1]).forEach(() => { s = this; }, 5); return s === o; } }; return o.f();' + ) + ).toBe(true); + expect(run('return new Set().forEach.length;')).toBe(1); + }); + it('is read-only from the input and crosses the boundary as a native Set', () => { const data = { tags: new Set(['a']) }; From 7ed2e5f4492a81e4f262e130a499a1abeb0ef3c9 Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:54:12 +0200 Subject: [PATCH 03/12] feat(json): give a JSON reviver its holder as this A reviver no longer receives the holder as a third argument; it is this instead, as in JavaScript. Use this where a reviver read the third argument. --- packages/maple-parity/README.md | 1 + packages/maple-parity/src/divergences.spec.ts | 11 +++++++++++ packages/maple/docs/builtins.md | 4 ++-- packages/maple/docs/divergences.md | 4 ++-- packages/maple/docs/security.md | 5 +++-- packages/maple/src/builtins/json/json.spec.ts | 17 +++++++++++++++-- .../maple/src/builtins/json/statics/parse.ts | 4 ++-- 7 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 76e3607..6b93003 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -67,6 +67,7 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | An error named by an array that holds it: Maple gives `m: m` | `join` cuts the cycle at the array, as in JavaScript; js-interpreter gives `...: m`. | | `f.bind(null, 1)`: Maple names it `bound f` with the rest of `f`'s length, js-interpreter `fBound`, 0 | A bound function's `name` and `length` are ES2015; js-interpreter follows ES5. | | `f.apply(null, 1)`, `f.apply(null, 'ab')` | A primitive is no list of arguments, so it is a `TypeError`. js-interpreter ignores a number and spreads a string. | +| `JSON.parse(text, reviver)`: Maple applies the reviver, with the holder as `this` | As in JavaScript; js-interpreter ignores the reviver and returns the parsed value as it is. | ### Maple departs from JavaScript diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index ba992d0..851d4c0 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -264,6 +264,17 @@ describe('syntax:ThisExpression', () => { }); }); +describe('JSON.parse', () => { + // js-interpreter ignores the reviver; Maple calls it with the holder as this + it('applies a reviver with the holder as this', () => { + expect( + followsJavaScript( + 'return JSON.parse(\'{"a":1}\', function (k, v) { return k === "a" ? Object.keys(this) : v; });' + ) + ).toEqual({ maple: { a: ['a'] }, jsInterpreter: { a: 1 } }); + }); +}); + // Maple departs from JavaScript on purpose. // The object model: Maple has no script-visible prototypes. An object's behaviour comes from its diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index ab63049..c1d034f 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -84,8 +84,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). A reviver receives the holder as a third argument, `(key, value, holder)`, with `this` -undefined. A replacer, too, has `this` undefined rather than the holder. +as `{}`). A reviver receives the holder as `this`, as in JavaScript, but not the third `context` +argument with the `source` text. A replacer has `this` undefined rather than the holder. ## RegExp diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 6f4fda4..677c555 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -49,8 +49,8 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives the holder as a third argument instead of `this`, and a JSON replacer - has `undefined` as `this` instead of the holder ([JSON](builtins.md#json)). +- A JSON reviver receives no third `context` argument, and a JSON replacer has `undefined` as + `this` instead of the holder ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index beb3248..3c79e96 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -43,8 +43,9 @@ it can terminate. set algebra and `Iterator.from` refuse objects whose methods the script wrote. - **No new reach through `this`.** A receiver is only ever a value the script already holds: the object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, - or `undefined`. There is no global object, built-ins never pass an internal value as `this`, and - host functions and intrinsics receive none. + the holder a JSON reviver visits (part of the script's own value, or a fresh object around it), or + `undefined`. There is no global object, built-ins never pass an internal value as `this`, and host + functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index a40840f..2daf15f 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -36,7 +36,7 @@ describe('JSON.parse', () => { expect(run('try { JSON.parse(); } catch (e) { return e.name; }')).toBe('SyntaxError'); }); - it('applies a reviver bottom-up, with the holder as third argument', () => { + it('applies a reviver bottom-up, with the holder as this', () => { const text = '{"a":{"b":1},"c":[2,3]}'; expect( @@ -60,9 +60,14 @@ describe('JSON.parse', () => { expect(run('return JSON.parse("{}", (k, v) => (k === "" ? 42 : v));')).toBe(42); expect( run( - 'return JSON.parse(\'{"a":1}\', (k, v, holder) => (k === "a" ? Object.keys(holder) : v));' + 'return JSON.parse(\'{"a":1}\', function (k, v) { return k === "a" ? Object.keys(this) : v; });' ) ).toEqual({ a: ['a'] }); + expect( + run('return JSON.parse("[1]", function (k, v) { return k === "0" ? this.length : v; });') + ).toEqual([1]); + expect(run('return JSON.parse("1", function () { return Object.keys(this); });')).toEqual(['']); + expect(run('return JSON.parse(\'{"a":1}\', (k, v, holder) => holder);')).toBeUndefined(); expect( run( 'return JSON.parse(\'{"when":"1970-01-01T00:00:00.000Z"}\', (k, v) => (k === "when" ? new Date(v) : v)).when.getTime();' @@ -72,6 +77,14 @@ describe('JSON.parse', () => { }); it('keeps a reviver inside the sandbox rules', () => { + expect( + runAndCatch(maple, 'JSON.parse("{}", function () { return this.constructor; });').sandboxError + ?.name + ).toBe('TypeError'); + expect( + runAndCatch(maple, 'JSON.parse("{}", function () { return this.__proto__; });').sandboxError + ?.name + ).toBe('TypeError'); expect(run('return Object.keys(JSON.parse(\'{"__proto__": 1}\', (k, v) => v));')).toEqual([ '__proto__' ]); diff --git a/packages/maple/src/builtins/json/statics/parse.ts b/packages/maple/src/builtins/json/statics/parse.ts index b164aa5..236e386 100644 --- a/packages/maple/src/builtins/json/statics/parse.ts +++ b/packages/maple/src/builtins/json/statics/parse.ts @@ -26,7 +26,7 @@ export const JSON_STATIC_PARSE: NativeFunction = new NativeFunction( /** * The reviver visits children before their holder's key and the root last under the key `''`, - * as the spec requires. The holder comes as a third argument in place of `this`. + * as the spec requires, with the holder as `this`. */ function revive( holder: Record | Array, @@ -61,5 +61,5 @@ function revive( } } } - return context.call(undefined, reviver, [key, value, holder]); + return context.call(holder, reviver, [key, value]); } From d1c1e202fea439b180f02630594efb91237a7a51 Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:54:32 +0200 Subject: [PATCH 04/12] feat(json): give a JSON replacer its holder as this A function replacer is called with the object holding the key as this, and with a fresh object around the value at the top level, as in JavaScript. --- packages/maple/docs/builtins.md | 4 +- packages/maple/docs/divergences.md | 3 +- packages/maple/docs/security.md | 6 +-- packages/maple/src/builtins/json/json.spec.ts | 40 +++++++++++++++++++ .../src/builtins/json/statics/stringify.ts | 33 ++++++++++----- packages/maple/src/escapes.spec.ts | 1 - 6 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index c1d034f..a45d456 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -84,8 +84,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). A reviver receives the holder as `this`, as in JavaScript, but not the third `context` -argument with the `source` text. A replacer has `this` undefined rather than the holder. +as `{}`). A reviver and a function replacer receive the holder as `this`, as in JavaScript; a +reviver does not receive the third `context` argument with the `source` text. ## RegExp diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 677c555..a3c5a8e 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -49,8 +49,7 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives no third `context` argument, and a JSON replacer has `undefined` as - `this` instead of the holder ([JSON](builtins.md#json)). +- A JSON reviver receives no third `context` argument ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 3c79e96..67175cc 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -43,9 +43,9 @@ it can terminate. set algebra and `Iterator.from` refuse objects whose methods the script wrote. - **No new reach through `this`.** A receiver is only ever a value the script already holds: the object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, - the holder a JSON reviver visits (part of the script's own value, or a fresh object around it), or - `undefined`. There is no global object, built-ins never pass an internal value as `this`, and host - functions and intrinsics receive none. + the holder a JSON reviver or replacer visits (part of the script's own value, or a fresh object + around it), or `undefined`. There is no global object, built-ins never pass an internal value as + `this`, and host functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index 2daf15f..638b409 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -182,6 +182,46 @@ describe('JSON.stringify', () => { ); }); + it('calls a function replacer with the holder as this', () => { + expect( + run( + 'return JSON.stringify({ a: 1 }, function (k, v) { return k === "" ? Object.keys(this) : v; });' + ) + ).toBe('[""]'); + expect( + run( + 'const o = { a: 1 }; return JSON.stringify(o, function (k, v) { return k === "a" ? this === o : v; });' + ) + ).toBe('{"a":true}'); + expect( + run('return JSON.stringify([1, 2], function (k, v) { return k === "" ? v : this.length; });') + ).toBe('[2,2]'); + expect( + run( + 'return JSON.stringify({ a: 1 }, function (k, v) { return k === "" ? { b: 2 } : this.b; });' + ) + ).toBe('{"b":2}'); + expect( + run( + 'return JSON.stringify({ when: new Date(0) }, function (k, v) { return this[k] instanceof Date ? this[k].getTime() : v; });' + ) + ).toBe('{"when":0}'); + expect( + run('const o = { f() { return JSON.stringify([1], () => this === o); } }; return o.f();') + ).toBe('true'); + }); + + it('keeps a replacer inside the sandbox rules', () => { + expect( + runAndCatch(maple, 'JSON.stringify({}, function () { return this.constructor; });') + .sandboxError?.name + ).toBe('TypeError'); + expect( + runAndCatch(maple, 'JSON.stringify({}, function () { return this.__proto__; });').sandboxError + ?.name + ).toBe('TypeError'); + }); + it("writes an array replacer's keys in its order, own properties only", () => { expect(run('return JSON.stringify({ a: 1, b: 2 }, ["b", "a", "b"]);')).toBe('{"b":2,"a":1}'); expect(run('return JSON.stringify({ a: 1 }, ["a", "missing", "toString"]);')).toBe('{"a":1}'); diff --git a/packages/maple/src/builtins/json/statics/stringify.ts b/packages/maple/src/builtins/json/statics/stringify.ts index 3250f8f..b57d56d 100644 --- a/packages/maple/src/builtins/json/statics/stringify.ts +++ b/packages/maple/src/builtins/json/statics/stringify.ts @@ -1,5 +1,6 @@ import { isPlainObject } from '../../../support/is-plain-object.ts'; import { MAX_NESTING } from '../../../support/nesting-limit.ts'; +import { defineOwn } from '../../../support/own-property.ts'; import { getOwnPropertiesOf } from '../../../values/members.ts'; import { type CallContext, isCallable, NativeFunction } from '../../../values/native.ts'; import { SandboxObject } from '../../../values/sandbox-object.ts'; @@ -16,7 +17,7 @@ interface PropertyView { interface Serialization { readonly gap: string; readonly allowed: ReadonlySet | undefined; - readonly replace: ((key: string, current: unknown) => unknown) | undefined; + readonly replace: ((holder: object, key: string, current: unknown) => unknown) | undefined; readonly ancestors: Array; readonly context: CallContext; } @@ -28,14 +29,18 @@ interface Serialization { export const JSON_STATIC_STRINGIFY: NativeFunction = new NativeFunction( 'stringify', JSON.stringify.length, - (_, [value, replacer, space], context) => - serialize('', value, '', { + (_, [value, replacer, space], context) => { + // The value is serialized as the property '' of a fresh object, which a replacer sees as this + const root: Record = {}; + defineOwn(root, '', value); + return serialize(root, '', value, '', { gap: gapOf(space), allowed: allowedKeys(replacer), replace: replacerOf(replacer, context), ancestors: [], context - }) + }); + } ); /** @@ -69,17 +74,19 @@ function allowedKeys(replacer: unknown): ReadonlySet | undefined { return keys; } -/** A function replacer, which runs as a script callback. */ +/** A function replacer, which runs as a script callback with the holder as `this`. */ function replacerOf( replacer: unknown, context: CallContext -): ((key: string, current: unknown) => unknown) | undefined { +): ((holder: object, key: string, current: unknown) => unknown) | undefined { return isCallable(replacer) - ? (key, current) => context.call(undefined, replacer, [key, current]) + ? (holder, key, current) => context.call(holder, replacer, [key, current]) : undefined; } +/** `holder` is the object or array whose property `key` is, as the replacer sees it. */ function serialize( + holder: object, key: string, raw: unknown, indent: string, @@ -94,7 +101,7 @@ function serialize( ? null : raw.date.toISOString() : raw; - const current = replace ? replace(key, json) : json; + const current = replace ? replace(holder, key, json) : json; if (current === null) { return 'null'; @@ -143,7 +150,7 @@ function serialize( const parts: Array = []; const length = current.length; for (let index = 0; index < length; index++) { - parts.push(serialize(String(index), current[index], inner, serialization) ?? 'null'); + parts.push(serialize(current, String(index), current[index], inner, serialization) ?? 'null'); } result = wrap('[', ']', parts, gap, indent, inner); } else { @@ -176,7 +183,13 @@ function serialize( ? [...allowed].filter((propertyKey) => properties.has(propertyKey)) : properties.keys(); for (const propertyKey of keys) { - const serialized = serialize(propertyKey, properties.get(propertyKey), inner, serialization); + const serialized = serialize( + current, + propertyKey, + properties.get(propertyKey), + inner, + serialization + ); if (serialized !== undefined) { parts.push(`${JSON.stringify(propertyKey)}:${gap ? ' ' : ''}${serialized}`); } diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index b850846..ed37e1e 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -427,7 +427,6 @@ describe('escape attempts', () => { '"aa".replaceAll("a", cb)', 'Object.groupBy([1], cb)', 'Map.groupBy([1], cb)', - 'JSON.stringify({ a: 1 }, function (key, value) { seen.push(this); return value; })', 'Array.from([1], cb)', 'Array.from({ length: 1 }, cb)', '[1].map(cb)', From 8cc9c43fdca1bac36ef109b03942a279dccb2dde Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 22:01:13 +0200 Subject: [PATCH 05/12] docs(contributing): update conventional commit instructions --- AGENTS.md | 5 +++-- CONTRIBUTING.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8301947..f3122ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,9 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence - Use kebab-case for file names. - Use `#private` for state fields only, and spell out `public` / `protected` / `private` on all other members. -- Use [Conventional Commits](https://www.conventionalcommits.org) with a scope: the package, such as `maple`, or `*` for - the whole repository. The version and changelog come from them, so never edit `version` in +- Use [Conventional Commits](https://www.conventionalcommits.org) with a scope naming the area the commit changes, such + as `evaluator`, `json`, `playground` or `parity`, or `*` for the whole repository. Older commits name the package + (`maple`); new ones name the area. The version and changelog come from them, so never edit `version` in `packages/maple/package.json` or `CHANGELOG.md` by hand. - Don't add attribution to commits or pull requests: no `Co-Authored-By:` or other trailers, no "Generated with" lines and no session links. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a9a2cd..2c24606 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,8 @@ It runs `test` on Node 22, 24 and 26: the minimum from the library's `engines` f ## Commits & releases Commits must follow [Conventional Commits](https://www.conventionalcommits.org), which commitlint enforces in the `commit-msg` hook. -Every commit needs a scope: the package it changes, such as `maple`, `playground` or `parity`, or `*` when it spans the repository. +Every commit needs a scope: the area it changes, such as `evaluator`, `boundary`, `builtins` or `json` in the library, `playground` or `parity`, or `*` when it spans the repository. +Older commits name the library's package, `maple`, instead; new ones name the area, so the changelog says what changed. Pull requests target `increment`, the default branch, and are merged with a merge commit or rebased, never squashed, so their commits reach `master` as they are. A release is a merge of `increment` into `master`, with a merge commit as well. From 54e170e0e0f25adcd2a500febbed961b8e9a1129 Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 19:11:40 +0200 Subject: [PATCH 06/12] chore(playground): rename measure-memory to measure-run and add timing measure-run parses the context once, before any run, so both engines receive a plain object as a host's run call does. A second loop without forced collections reports median, min and max time, and a closing line says how many times faster or slower Maple is than js-interpreter. A synthetic contact record (46 flat fields, address, 450 activities, 300 custom fields) with three small scripts replaces context.json and demo-script.js as the default fixture. runJsInterpreter takes parsed data; the Benchmark worker parses inside its timed runs, as before. --- AGENTS.md | 2 +- apps/maple-playground/README.md | 40 +- apps/maple-playground/package.json | 2 +- .../scripts/contact-context.json | 4410 +++++++++++++++++ .../scripts/contact-flat-key.js | 1 + .../scripts/contact-large-key.js | 1 + .../scripts/contact-nested-key.js | 1 + apps/maple-playground/scripts/context.json | 4 - apps/maple-playground/scripts/demo-script.js | 1 - .../{measure-memory.ts => measure-run.ts} | 106 +- .../src/app/data-access/benchmark.worker.ts | 2 +- .../app/data-access/js-interpreter-engine.ts | 9 +- 12 files changed, 4524 insertions(+), 55 deletions(-) create mode 100644 apps/maple-playground/scripts/contact-context.json create mode 100644 apps/maple-playground/scripts/contact-flat-key.js create mode 100644 apps/maple-playground/scripts/contact-large-key.js create mode 100644 apps/maple-playground/scripts/contact-nested-key.js delete mode 100644 apps/maple-playground/scripts/context.json delete mode 100644 apps/maple-playground/scripts/demo-script.js rename apps/maple-playground/scripts/{measure-memory.ts => measure-run.ts} (57%) diff --git a/AGENTS.md b/AGENTS.md index f3122ad..7192eb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence boundary as JSON text (`maple-worker-protocol.ts`, `benchmark-protocol.ts`). - `@netwin/js-interpreter` is a dev dependency used only to compare against; `js-interpreter-engine.ts` is the one place in the app that calls it. -- `scripts/` holds Node-only tools such as `measure-memory.ts`. Node ignores the `paths` mapping, so they import +- `scripts/` holds Node-only tools such as `measure-run.ts`. Node ignores the `paths` mapping, so they import `packages/maple/src/index.ts` by relative path. - Use standalone components and signals, styled with Tailwind. Don't add `.component` / `.service` suffixes to file names. diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index b772fb6..b3a2d62 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -35,30 +35,42 @@ context and converting the result. The benchmark has its own worker and a 60 s timeout. js-interpreter has no operation limit, so for an endless loop that timeout is the only stop. -### Memory: `measure-memory` +### Time and memory: `measure-run` -Browsers have no usable per-run memory measurement, so memory is measured in Node, for a script and context from files: +Browsers have no usable per-run memory measurement, so `measure-run` measures time and memory in Node, for a script +and a context from files: ```sh -pnpm --filter @netwin/maple-playground measure-memory [context.json] [--runs 5] +pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] [--time-runs 200] ``` +The context defaults to `scripts/contact-context.json`, a synthetic contact record of 46 flat fields, an `address`, +450 `activities` and 300 `customFields` (about 75 KB as JSON). `contact-flat-key.js`, `contact-nested-key.js` and +`contact-large-key.js` beside it read a flat key, a nested key and the large array. The file is read and parsed once, +before any run, so both engines receive a plain object, as a host's `run` call does. + ``` -┌──────────────────┬────────────┬───────────┬─────────────┬──────┐ -│ engine │ allocated │ retained │ time │ note │ -├──────────────────┼────────────┼───────────┼─────────────┼──────┤ -│ 'Maple' │ '64.84 MB' │ '0.02 MB' │ '78.15 ms' │ '' │ -│ 'js-interpreter' │ '83.86 MB' │ '0.02 MB' │ '825.29 ms' │ '' │ -└──────────────────┴────────────┴───────────┴─────────────┴──────┘ +┌──────────────────┬───────────┬───────────┬────────────┬────────────┬─────────────┬──────┐ +│ engine │ allocated │ retained │ median │ min │ max │ note │ +├──────────────────┼───────────┼───────────┼────────────┼────────────┼─────────────┼──────┤ +│ 'Maple' │ '1.24 MB' │ '0.01 MB' │ '0.785 ms' │ '0.634 ms' │ '1.215 ms' │ '' │ +│ 'js-interpreter' │ '3.99 MB' │ '0.01 MB' │ '3.562 ms' │ '2.667 ms' │ '65.447 ms' │ '' │ +└──────────────────┴───────────┴───────────┴────────────┴────────────┴─────────────┴──────┘ + +Maple is 4.54× faster than js-interpreter (median). ``` -Each figure is the median of the measured runs, after 3 warm-up runs: +Both loops start after 3 warm-up runs: - **allocated** is everything one run creates, short-lived garbage included: the heap growth between a forced garbage - collection before the run and the end of the run. -- **retained** is what is still alive after another forced collection. It should be close to zero; anything more means - the engine keeps something from a run alive. -- **time** is only a rough guide here because of the memory settings below; use the Benchmark tab for speed. + collection before the run and the end of the run. It is the median of `--runs` runs. +- **retained** is what is still alive after another forced collection, again the median of `--runs` runs. It should + be close to zero; anything more means the engine keeps something from a run alive. +- **median**, **min** and **max** time `--time-runs` runs in a second loop, without the forced collections that would + disturb them. Because of the memory settings below they are somewhat optimistic: compare them before and after a + change on one machine, not with production figures. + +Below the table, the medians give the factor by which Maple is faster or slower than js-interpreter. The figure that decides whether a large input runs out of memory is the peak during the run, which lies between retained and allocated. Node cannot measure it directly for synchronous code. diff --git a/apps/maple-playground/package.json b/apps/maple-playground/package.json index d96eee3..4936b31 100644 --- a/apps/maple-playground/package.json +++ b/apps/maple-playground/package.json @@ -9,7 +9,7 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "typecheck": "ngc -p tsconfig.app.json --noEmit && tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.scripts.json --noEmit", - "measure-memory": "node scripts/measure-memory.ts" + "measure-run": "node scripts/measure-run.ts" }, "dependencies": { "@angular/common": "^22.2.2", diff --git a/apps/maple-playground/scripts/contact-context.json b/apps/maple-playground/scripts/contact-context.json new file mode 100644 index 0000000..4e4b15d --- /dev/null +++ b/apps/maple-playground/scripts/contact-context.json @@ -0,0 +1,4410 @@ +{ + "id": "c-000123", + "salutation": "Ms", + "title": "", + "firstName": "Ada", + "lastName": "Example", + "middleName": "", + "company": "Example Corp", + "department": "Purchasing", + "jobTitle": "Buyer", + "email": "ada@example.com", + "email2": "", + "phone": "+49 30 0000000", + "mobile": "+49 170 0000000", + "fax": "", + "website": "https://example.com", + "language": "de", + "timezone": "Europe/Berlin", + "currency": "EUR", + "status": "active", + "source": "web", + "owner": "u-17", + "createdAt": "2024-03-02T09:14:00Z", + "updatedAt": "2026-10-01T16:40:00Z", + "birthday": null, + "notes": "", + "vip": false, + "newsletter": true, + "doNotCall": false, + "doNotEmail": false, + "score": 72, + "revenue": 18250.5, + "openDeals": 2, + "wonDeals": 5, + "lostDeals": 1, + "lastContactAt": "2026-09-28", + "nextFollowUpAt": "2026-10-25", + "industry": "Manufacturing", + "employees": 250, + "rating": "B", + "region": "North", + "segment": "SMB", + "tags": "customer, newsletter", + "referredBy": "", + "twitter": "", + "linkedin": "", + "vatId": "DE000000000", + "address": { + "street": "Examplestraße 1", + "street2": "", + "zip": "10115", + "city": "Berlin", + "state": "BE", + "country": "DE" + }, + "activities": [ + { + "id": "a-0001", + "type": "meeting", + "date": "2025-11-08", + "subject": "support demo contract demo", + "outcome": "invoice follow call", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0002", + "type": "task", + "date": "2026-03-03", + "subject": "demo meeting offer follow", + "outcome": "support support support", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0003", + "type": "task", + "date": "2025-01-28", + "subject": "contract update contract follow", + "outcome": "meeting question call", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0004", + "type": "note", + "date": "2025-06-12", + "subject": "invoice call invoice invoice", + "outcome": "question demo support", + "done": false, + "ownerId": "u-7" + }, + { + "id": "a-0005", + "type": "task", + "date": "2025-09-14", + "subject": "update follow support offer", + "outcome": "call renewal call", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0006", + "type": "email", + "date": "2025-07-15", + "subject": "question demo question contract", + "outcome": "question question follow", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0007", + "type": "task", + "date": "2026-08-09", + "subject": "up contract up renewal", + "outcome": "call call follow", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0008", + "type": "task", + "date": "2025-09-12", + "subject": "meeting meeting question meeting", + "outcome": "call contract renewal", + "done": false, + "ownerId": "u-6" + }, + { + "id": "a-0009", + "type": "note", + "date": "2025-11-14", + "subject": "contract question renewal renewal", + "outcome": "up call offer", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0010", + "type": "task", + "date": "2026-05-10", + "subject": "question follow update question", + "outcome": "question follow follow", + "done": false, + "ownerId": "u-21" + }, + { + "id": "a-0011", + "type": "meeting", + "date": "2026-04-14", + "subject": "question support meeting follow", + "outcome": "renewal call support", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0012", + "type": "meeting", + "date": "2025-02-10", + "subject": "follow invoice renewal update", + "outcome": "call update update", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0013", + "type": "meeting", + "date": "2025-10-04", + "subject": "invoice up up follow", + "outcome": "offer update question", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0014", + "type": "email", + "date": "2026-03-01", + "subject": "support offer call invoice", + "outcome": "contract question up", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0015", + "type": "call", + "date": "2025-01-20", + "subject": "up up renewal up", + "outcome": "contract offer call", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0016", + "type": "call", + "date": "2026-05-06", + "subject": "call support call demo", + "outcome": "up contract offer", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0017", + "type": "meeting", + "date": "2026-02-20", + "subject": "demo demo meeting offer", + "outcome": "question contract support", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0018", + "type": "call", + "date": "2026-06-19", + "subject": "invoice invoice support contract", + "outcome": "support support call", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0019", + "type": "call", + "date": "2025-10-11", + "subject": "demo question support demo", + "outcome": "question call contract", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0020", + "type": "call", + "date": "2025-04-11", + "subject": "call up demo call", + "outcome": "follow demo follow", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0021", + "type": "call", + "date": "2026-05-22", + "subject": "demo meeting meeting demo", + "outcome": "meeting question contract", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0022", + "type": "call", + "date": "2025-01-27", + "subject": "call meeting invoice up", + "outcome": "demo demo meeting", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0023", + "type": "meeting", + "date": "2026-03-10", + "subject": "follow question support renewal", + "outcome": "support follow demo", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0024", + "type": "meeting", + "date": "2026-03-22", + "subject": "offer invoice demo update", + "outcome": "call call invoice", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0025", + "type": "call", + "date": "2026-01-09", + "subject": "up offer invoice renewal", + "outcome": "up renewal renewal", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0026", + "type": "note", + "date": "2026-05-09", + "subject": "call support support demo", + "outcome": "question meeting follow", + "done": false, + "ownerId": "u-19" + }, + { + "id": "a-0027", + "type": "task", + "date": "2025-06-02", + "subject": "question renewal update contract", + "outcome": "question call question", + "done": false, + "ownerId": "u-2" + }, + { + "id": "a-0028", + "type": "call", + "date": "2026-01-15", + "subject": "follow update offer question", + "outcome": "invoice demo invoice", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0029", + "type": "note", + "date": "2025-12-17", + "subject": "question demo demo support", + "outcome": "update follow update", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0030", + "type": "call", + "date": "2026-05-29", + "subject": "meeting offer call offer", + "outcome": "invoice update offer", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0031", + "type": "meeting", + "date": "2025-07-05", + "subject": "call meeting offer demo", + "outcome": "contract support up", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0032", + "type": "email", + "date": "2026-05-22", + "subject": "up offer update offer", + "outcome": "meeting support question", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0033", + "type": "task", + "date": "2025-05-27", + "subject": "demo follow contract invoice", + "outcome": "call contract follow", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0034", + "type": "task", + "date": "2025-02-27", + "subject": "update meeting question question", + "outcome": "meeting meeting renewal", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0035", + "type": "task", + "date": "2025-01-25", + "subject": "invoice offer up demo", + "outcome": "meeting question contract", + "done": false, + "ownerId": "u-20" + }, + { + "id": "a-0036", + "type": "task", + "date": "2026-08-09", + "subject": "renewal support follow call", + "outcome": "offer offer meeting", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0037", + "type": "meeting", + "date": "2026-05-31", + "subject": "support contract support call", + "outcome": "meeting update contract", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0038", + "type": "meeting", + "date": "2025-10-02", + "subject": "renewal call question demo", + "outcome": "meeting demo contract", + "done": false, + "ownerId": "u-4" + }, + { + "id": "a-0039", + "type": "meeting", + "date": "2025-12-10", + "subject": "support offer follow update", + "outcome": "update contract demo", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0040", + "type": "email", + "date": "2026-07-03", + "subject": "update update demo meeting", + "outcome": "up meeting follow", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0041", + "type": "task", + "date": "2025-08-26", + "subject": "call support contract offer", + "outcome": "update support invoice", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0042", + "type": "call", + "date": "2025-04-11", + "subject": "update offer renewal offer", + "outcome": "update update demo", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0043", + "type": "meeting", + "date": "2026-05-25", + "subject": "meeting up renewal call", + "outcome": "renewal renewal follow", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0044", + "type": "email", + "date": "2025-05-23", + "subject": "follow up support call", + "outcome": "up update renewal", + "done": false, + "ownerId": "u-14" + }, + { + "id": "a-0045", + "type": "note", + "date": "2026-03-31", + "subject": "offer support contract contract", + "outcome": "invoice question invoice", + "done": false, + "ownerId": "u-23" + }, + { + "id": "a-0046", + "type": "task", + "date": "2026-05-09", + "subject": "offer up offer follow", + "outcome": "update update up", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0047", + "type": "meeting", + "date": "2025-04-30", + "subject": "demo support contract demo", + "outcome": "call up call", + "done": false, + "ownerId": "u-23" + }, + { + "id": "a-0048", + "type": "email", + "date": "2026-07-12", + "subject": "support invoice meeting support", + "outcome": "call support contract", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0049", + "type": "meeting", + "date": "2025-10-16", + "subject": "question demo question contract", + "outcome": "update contract contract", + "done": false, + "ownerId": "u-26" + }, + { + "id": "a-0050", + "type": "call", + "date": "2026-01-18", + "subject": "meeting update invoice update", + "outcome": "follow meeting update", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0051", + "type": "call", + "date": "2025-05-01", + "subject": "support meeting update question", + "outcome": "up renewal offer", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0052", + "type": "call", + "date": "2025-09-16", + "subject": "up follow contract question", + "outcome": "question support invoice", + "done": false, + "ownerId": "u-4" + }, + { + "id": "a-0053", + "type": "call", + "date": "2026-02-11", + "subject": "contract contract invoice invoice", + "outcome": "invoice question contract", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0054", + "type": "note", + "date": "2025-09-19", + "subject": "meeting offer demo renewal", + "outcome": "offer up renewal", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0055", + "type": "call", + "date": "2025-04-17", + "subject": "demo support renewal update", + "outcome": "call contract invoice", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0056", + "type": "note", + "date": "2025-07-17", + "subject": "invoice support meeting invoice", + "outcome": "demo question demo", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0057", + "type": "call", + "date": "2026-08-17", + "subject": "follow demo renewal contract", + "outcome": "demo call meeting", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0058", + "type": "meeting", + "date": "2026-06-19", + "subject": "renewal offer invoice call", + "outcome": "question contract up", + "done": false, + "ownerId": "u-7" + }, + { + "id": "a-0059", + "type": "task", + "date": "2026-06-29", + "subject": "offer offer demo update", + "outcome": "call call demo", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0060", + "type": "note", + "date": "2025-01-17", + "subject": "call invoice offer follow", + "outcome": "renewal update demo", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0061", + "type": "task", + "date": "2025-10-12", + "subject": "offer demo renewal follow", + "outcome": "update up follow", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0062", + "type": "meeting", + "date": "2025-05-20", + "subject": "support offer invoice support", + "outcome": "update update follow", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0063", + "type": "meeting", + "date": "2025-01-17", + "subject": "demo renewal support contract", + "outcome": "offer question renewal", + "done": false, + "ownerId": "u-14" + }, + { + "id": "a-0064", + "type": "call", + "date": "2025-08-25", + "subject": "up support update meeting", + "outcome": "update renewal support", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0065", + "type": "task", + "date": "2025-04-21", + "subject": "meeting follow contract renewal", + "outcome": "question demo follow", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0066", + "type": "call", + "date": "2025-02-13", + "subject": "follow meeting invoice invoice", + "outcome": "contract offer support", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0067", + "type": "call", + "date": "2025-11-03", + "subject": "meeting question call renewal", + "outcome": "call offer meeting", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0068", + "type": "task", + "date": "2025-11-04", + "subject": "demo offer follow contract", + "outcome": "up meeting meeting", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0069", + "type": "meeting", + "date": "2026-08-21", + "subject": "call call follow support", + "outcome": "contract demo meeting", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0070", + "type": "call", + "date": "2026-03-31", + "subject": "contract follow support call", + "outcome": "demo contract support", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0071", + "type": "note", + "date": "2025-09-01", + "subject": "update update question renewal", + "outcome": "invoice meeting question", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0072", + "type": "email", + "date": "2025-03-06", + "subject": "support offer up up", + "outcome": "invoice support demo", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0073", + "type": "call", + "date": "2025-09-16", + "subject": "contract follow update follow", + "outcome": "up question renewal", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0074", + "type": "task", + "date": "2025-01-16", + "subject": "demo follow meeting update", + "outcome": "contract invoice update", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0075", + "type": "meeting", + "date": "2026-04-03", + "subject": "contract update question update", + "outcome": "update follow contract", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0076", + "type": "task", + "date": "2025-08-12", + "subject": "offer call contract question", + "outcome": "update call renewal", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0077", + "type": "task", + "date": "2026-07-02", + "subject": "contract support invoice question", + "outcome": "up support update", + "done": false, + "ownerId": "u-17" + }, + { + "id": "a-0078", + "type": "task", + "date": "2026-05-13", + "subject": "demo update contract call", + "outcome": "contract invoice follow", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0079", + "type": "note", + "date": "2026-03-04", + "subject": "renewal up offer support", + "outcome": "question up contract", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0080", + "type": "email", + "date": "2025-05-12", + "subject": "offer meeting follow call", + "outcome": "offer invoice support", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0081", + "type": "call", + "date": "2025-03-14", + "subject": "invoice offer renewal up", + "outcome": "offer update renewal", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0082", + "type": "call", + "date": "2025-05-04", + "subject": "follow follow renewal follow", + "outcome": "update follow question", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0083", + "type": "email", + "date": "2025-07-02", + "subject": "offer call question question", + "outcome": "up offer support", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0084", + "type": "meeting", + "date": "2025-01-28", + "subject": "question follow support update", + "outcome": "offer demo follow", + "done": true, + "ownerId": "u-26" + }, + { + "id": "a-0085", + "type": "call", + "date": "2025-11-15", + "subject": "follow renewal contract up", + "outcome": "call contract support", + "done": false, + "ownerId": "u-10" + }, + { + "id": "a-0086", + "type": "call", + "date": "2025-05-13", + "subject": "up up follow contract", + "outcome": "renewal up demo", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0087", + "type": "call", + "date": "2026-08-01", + "subject": "invoice support offer meeting", + "outcome": "contract up support", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0088", + "type": "email", + "date": "2026-03-25", + "subject": "invoice call update contract", + "outcome": "call follow invoice", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0089", + "type": "meeting", + "date": "2025-02-02", + "subject": "offer invoice contract renewal", + "outcome": "question offer question", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0090", + "type": "meeting", + "date": "2025-11-15", + "subject": "offer up invoice update", + "outcome": "question offer invoice", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0091", + "type": "call", + "date": "2026-03-14", + "subject": "up offer invoice call", + "outcome": "update up meeting", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0092", + "type": "note", + "date": "2026-03-06", + "subject": "support call follow follow", + "outcome": "question follow meeting", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0093", + "type": "call", + "date": "2026-03-15", + "subject": "question question demo demo", + "outcome": "question offer renewal", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0094", + "type": "call", + "date": "2025-08-14", + "subject": "up up invoice follow", + "outcome": "support support renewal", + "done": false, + "ownerId": "u-15" + }, + { + "id": "a-0095", + "type": "meeting", + "date": "2026-04-27", + "subject": "update question contract up", + "outcome": "meeting meeting call", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0096", + "type": "note", + "date": "2026-01-17", + "subject": "invoice meeting contract follow", + "outcome": "contract renewal up", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0097", + "type": "note", + "date": "2025-09-17", + "subject": "renewal invoice contract call", + "outcome": "update question renewal", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0098", + "type": "call", + "date": "2026-01-13", + "subject": "contract follow renewal question", + "outcome": "demo call up", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0099", + "type": "call", + "date": "2026-04-19", + "subject": "contract invoice contract renewal", + "outcome": "demo renewal call", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0100", + "type": "email", + "date": "2026-05-01", + "subject": "contract invoice call meeting", + "outcome": "question follow up", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0101", + "type": "meeting", + "date": "2026-01-04", + "subject": "up demo follow up", + "outcome": "invoice invoice call", + "done": false, + "ownerId": "u-14" + }, + { + "id": "a-0102", + "type": "note", + "date": "2025-06-16", + "subject": "contract offer offer invoice", + "outcome": "contract meeting update", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0103", + "type": "note", + "date": "2025-02-12", + "subject": "meeting offer meeting demo", + "outcome": "demo call contract", + "done": false, + "ownerId": "u-30" + }, + { + "id": "a-0104", + "type": "call", + "date": "2025-09-03", + "subject": "contract update question support", + "outcome": "question up call", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0105", + "type": "call", + "date": "2025-03-28", + "subject": "question meeting call question", + "outcome": "offer offer update", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0106", + "type": "meeting", + "date": "2026-02-23", + "subject": "renewal offer update contract", + "outcome": "update support up", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0107", + "type": "email", + "date": "2026-05-09", + "subject": "support demo follow renewal", + "outcome": "up question up", + "done": false, + "ownerId": "u-18" + }, + { + "id": "a-0108", + "type": "task", + "date": "2026-08-13", + "subject": "question support follow contract", + "outcome": "meeting up demo", + "done": false, + "ownerId": "u-1" + }, + { + "id": "a-0109", + "type": "call", + "date": "2025-09-05", + "subject": "renewal invoice meeting demo", + "outcome": "renewal contract follow", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0110", + "type": "note", + "date": "2026-05-28", + "subject": "offer question offer call", + "outcome": "follow offer renewal", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0111", + "type": "email", + "date": "2025-09-30", + "subject": "update support renewal contract", + "outcome": "question up invoice", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0112", + "type": "note", + "date": "2025-04-23", + "subject": "update offer up call", + "outcome": "demo call update", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0113", + "type": "call", + "date": "2025-08-16", + "subject": "follow call question demo", + "outcome": "contract contract question", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0114", + "type": "call", + "date": "2025-08-30", + "subject": "update question meeting offer", + "outcome": "renewal call invoice", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0115", + "type": "note", + "date": "2025-05-17", + "subject": "update update support contract", + "outcome": "contract up follow", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0116", + "type": "note", + "date": "2026-01-18", + "subject": "meeting support renewal contract", + "outcome": "support offer follow", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0117", + "type": "email", + "date": "2025-03-07", + "subject": "call up support update", + "outcome": "offer up meeting", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0118", + "type": "meeting", + "date": "2026-02-04", + "subject": "up renewal question demo", + "outcome": "up question offer", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0119", + "type": "email", + "date": "2025-07-06", + "subject": "demo up follow up", + "outcome": "follow call contract", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0120", + "type": "note", + "date": "2026-05-31", + "subject": "call up up update", + "outcome": "contract up question", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0121", + "type": "note", + "date": "2025-07-11", + "subject": "follow follow renewal demo", + "outcome": "renewal up meeting", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0122", + "type": "task", + "date": "2025-07-22", + "subject": "support update question up", + "outcome": "demo contract question", + "done": false, + "ownerId": "u-20" + }, + { + "id": "a-0123", + "type": "note", + "date": "2026-05-22", + "subject": "question demo up call", + "outcome": "update up invoice", + "done": false, + "ownerId": "u-12" + }, + { + "id": "a-0124", + "type": "call", + "date": "2025-10-23", + "subject": "call call renewal meeting", + "outcome": "offer follow invoice", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0125", + "type": "meeting", + "date": "2026-03-28", + "subject": "question call renewal renewal", + "outcome": "renewal invoice call", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0126", + "type": "email", + "date": "2026-01-12", + "subject": "call invoice support invoice", + "outcome": "support meeting contract", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0127", + "type": "email", + "date": "2026-03-06", + "subject": "up renewal question update", + "outcome": "renewal demo offer", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0128", + "type": "note", + "date": "2025-06-12", + "subject": "invoice question call support", + "outcome": "follow call update", + "done": false, + "ownerId": "u-7" + }, + { + "id": "a-0129", + "type": "meeting", + "date": "2025-12-15", + "subject": "support offer update call", + "outcome": "call contract up", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0130", + "type": "call", + "date": "2026-02-18", + "subject": "contract demo offer support", + "outcome": "contract follow offer", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0131", + "type": "email", + "date": "2025-05-14", + "subject": "invoice demo demo renewal", + "outcome": "up update offer", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0132", + "type": "task", + "date": "2025-01-15", + "subject": "offer renewal support support", + "outcome": "question support offer", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0133", + "type": "task", + "date": "2025-05-23", + "subject": "contract question contract update", + "outcome": "meeting contract question", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0134", + "type": "email", + "date": "2025-05-24", + "subject": "contract follow renewal meeting", + "outcome": "follow meeting meeting", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0135", + "type": "note", + "date": "2025-05-29", + "subject": "contract offer up question", + "outcome": "up update meeting", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0136", + "type": "task", + "date": "2025-01-10", + "subject": "meeting update renewal follow", + "outcome": "question call update", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0137", + "type": "note", + "date": "2026-05-03", + "subject": "support follow call question", + "outcome": "meeting offer contract", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0138", + "type": "note", + "date": "2025-06-28", + "subject": "invoice meeting invoice update", + "outcome": "meeting invoice contract", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0139", + "type": "note", + "date": "2025-07-11", + "subject": "demo demo follow contract", + "outcome": "offer support renewal", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0140", + "type": "call", + "date": "2025-05-05", + "subject": "update offer up demo", + "outcome": "call contract offer", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0141", + "type": "task", + "date": "2026-03-03", + "subject": "renewal contract meeting invoice", + "outcome": "offer invoice contract", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0142", + "type": "note", + "date": "2025-06-10", + "subject": "follow offer call update", + "outcome": "question follow invoice", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0143", + "type": "note", + "date": "2025-12-22", + "subject": "contract support follow meeting", + "outcome": "question invoice follow", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0144", + "type": "task", + "date": "2025-10-16", + "subject": "call meeting call offer", + "outcome": "follow invoice call", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0145", + "type": "email", + "date": "2026-02-03", + "subject": "offer renewal demo support", + "outcome": "demo up follow", + "done": false, + "ownerId": "u-6" + }, + { + "id": "a-0146", + "type": "email", + "date": "2025-10-15", + "subject": "demo up contract offer", + "outcome": "contract call meeting", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0147", + "type": "task", + "date": "2026-03-12", + "subject": "offer offer invoice support", + "outcome": "demo support demo", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0148", + "type": "meeting", + "date": "2026-02-05", + "subject": "offer renewal up support", + "outcome": "renewal call renewal", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0149", + "type": "meeting", + "date": "2026-03-04", + "subject": "offer support demo contract", + "outcome": "follow offer demo", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0150", + "type": "call", + "date": "2025-05-24", + "subject": "call renewal up demo", + "outcome": "call support up", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0151", + "type": "note", + "date": "2025-11-09", + "subject": "question update call renewal", + "outcome": "meeting contract invoice", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0152", + "type": "meeting", + "date": "2025-08-25", + "subject": "up support offer up", + "outcome": "update invoice offer", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0153", + "type": "meeting", + "date": "2025-08-02", + "subject": "follow question follow up", + "outcome": "up meeting call", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0154", + "type": "email", + "date": "2026-05-31", + "subject": "update demo meeting support", + "outcome": "demo question meeting", + "done": false, + "ownerId": "u-27" + }, + { + "id": "a-0155", + "type": "meeting", + "date": "2025-04-19", + "subject": "up offer follow question", + "outcome": "meeting follow update", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0156", + "type": "meeting", + "date": "2025-10-17", + "subject": "call contract question up", + "outcome": "renewal meeting invoice", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0157", + "type": "meeting", + "date": "2026-01-06", + "subject": "follow up invoice up", + "outcome": "renewal question call", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0158", + "type": "meeting", + "date": "2026-08-13", + "subject": "follow follow demo renewal", + "outcome": "update up update", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0159", + "type": "email", + "date": "2026-07-10", + "subject": "update invoice call offer", + "outcome": "support demo meeting", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0160", + "type": "meeting", + "date": "2026-08-03", + "subject": "invoice meeting call call", + "outcome": "call meeting follow", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0161", + "type": "note", + "date": "2025-04-18", + "subject": "invoice up meeting renewal", + "outcome": "demo contract call", + "done": false, + "ownerId": "u-18" + }, + { + "id": "a-0162", + "type": "email", + "date": "2025-01-17", + "subject": "call update support follow", + "outcome": "support demo meeting", + "done": false, + "ownerId": "u-15" + }, + { + "id": "a-0163", + "type": "task", + "date": "2025-05-30", + "subject": "contract support renewal up", + "outcome": "renewal question contract", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0164", + "type": "email", + "date": "2025-09-06", + "subject": "demo invoice offer follow", + "outcome": "support offer follow", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0165", + "type": "note", + "date": "2026-04-23", + "subject": "up question demo renewal", + "outcome": "call renewal renewal", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0166", + "type": "email", + "date": "2026-02-15", + "subject": "support call contract question", + "outcome": "call invoice call", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0167", + "type": "call", + "date": "2025-11-12", + "subject": "meeting follow update demo", + "outcome": "up demo update", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0168", + "type": "task", + "date": "2025-02-22", + "subject": "renewal question call update", + "outcome": "follow call demo", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0169", + "type": "call", + "date": "2025-11-27", + "subject": "question renewal follow up", + "outcome": "offer support call", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0170", + "type": "task", + "date": "2025-04-20", + "subject": "invoice follow support support", + "outcome": "update contract offer", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0171", + "type": "note", + "date": "2025-11-01", + "subject": "offer update renewal call", + "outcome": "contract call support", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0172", + "type": "task", + "date": "2025-02-22", + "subject": "support offer meeting update", + "outcome": "renewal invoice follow", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0173", + "type": "meeting", + "date": "2025-08-10", + "subject": "renewal support meeting invoice", + "outcome": "contract support update", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0174", + "type": "email", + "date": "2025-03-29", + "subject": "call offer invoice demo", + "outcome": "follow offer update", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0175", + "type": "meeting", + "date": "2025-03-25", + "subject": "invoice offer meeting invoice", + "outcome": "renewal call support", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0176", + "type": "email", + "date": "2025-07-17", + "subject": "call support question meeting", + "outcome": "support meeting meeting", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0177", + "type": "note", + "date": "2025-08-29", + "subject": "question follow meeting update", + "outcome": "invoice question invoice", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0178", + "type": "note", + "date": "2026-06-22", + "subject": "update demo demo up", + "outcome": "follow up update", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0179", + "type": "note", + "date": "2025-06-26", + "subject": "follow update demo update", + "outcome": "update update update", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0180", + "type": "task", + "date": "2025-06-12", + "subject": "offer follow renewal question", + "outcome": "demo renewal renewal", + "done": false, + "ownerId": "u-28" + }, + { + "id": "a-0181", + "type": "note", + "date": "2026-04-25", + "subject": "call up question update", + "outcome": "demo demo demo", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0182", + "type": "note", + "date": "2026-08-10", + "subject": "support invoice call support", + "outcome": "update support contract", + "done": false, + "ownerId": "u-17" + }, + { + "id": "a-0183", + "type": "email", + "date": "2025-05-02", + "subject": "offer contract offer up", + "outcome": "update offer contract", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0184", + "type": "note", + "date": "2026-01-30", + "subject": "call support call offer", + "outcome": "call support update", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0185", + "type": "call", + "date": "2025-07-31", + "subject": "update follow call renewal", + "outcome": "question offer renewal", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0186", + "type": "meeting", + "date": "2025-12-07", + "subject": "renewal support meeting up", + "outcome": "invoice invoice question", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0187", + "type": "call", + "date": "2025-05-20", + "subject": "offer renewal up follow", + "outcome": "follow demo demo", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0188", + "type": "call", + "date": "2025-12-19", + "subject": "up offer meeting contract", + "outcome": "offer follow update", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0189", + "type": "task", + "date": "2025-03-08", + "subject": "invoice follow support question", + "outcome": "invoice update follow", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0190", + "type": "call", + "date": "2026-05-05", + "subject": "offer offer question demo", + "outcome": "contract support renewal", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0191", + "type": "task", + "date": "2026-07-21", + "subject": "contract demo demo follow", + "outcome": "call follow meeting", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0192", + "type": "meeting", + "date": "2025-04-30", + "subject": "support update contract demo", + "outcome": "contract demo offer", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0193", + "type": "meeting", + "date": "2026-06-27", + "subject": "update contract question offer", + "outcome": "offer follow offer", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0194", + "type": "email", + "date": "2025-07-11", + "subject": "support update question offer", + "outcome": "meeting renewal follow", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0195", + "type": "task", + "date": "2026-05-20", + "subject": "up question question update", + "outcome": "offer contract support", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0196", + "type": "call", + "date": "2026-01-07", + "subject": "renewal question offer renewal", + "outcome": "demo offer offer", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0197", + "type": "meeting", + "date": "2025-08-07", + "subject": "call follow meeting meeting", + "outcome": "renewal contract up", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0198", + "type": "task", + "date": "2025-04-27", + "subject": "update renewal demo contract", + "outcome": "up invoice question", + "done": false, + "ownerId": "u-27" + }, + { + "id": "a-0199", + "type": "task", + "date": "2025-12-10", + "subject": "update follow contract follow", + "outcome": "update update demo", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0200", + "type": "call", + "date": "2025-08-04", + "subject": "question renewal follow call", + "outcome": "demo contract invoice", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0201", + "type": "task", + "date": "2026-07-03", + "subject": "follow up contract contract", + "outcome": "meeting contract up", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0202", + "type": "note", + "date": "2025-08-23", + "subject": "call renewal update support", + "outcome": "support follow meeting", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0203", + "type": "note", + "date": "2026-04-03", + "subject": "support renewal renewal renewal", + "outcome": "call follow invoice", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0204", + "type": "email", + "date": "2025-10-24", + "subject": "contract question follow call", + "outcome": "follow invoice support", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0205", + "type": "meeting", + "date": "2025-07-18", + "subject": "call up question renewal", + "outcome": "invoice follow up", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0206", + "type": "email", + "date": "2026-01-28", + "subject": "question call support meeting", + "outcome": "question meeting meeting", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0207", + "type": "email", + "date": "2025-12-07", + "subject": "call contract demo invoice", + "outcome": "question support offer", + "done": false, + "ownerId": "u-30" + }, + { + "id": "a-0208", + "type": "call", + "date": "2025-11-20", + "subject": "follow meeting meeting update", + "outcome": "question meeting offer", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0209", + "type": "note", + "date": "2025-04-27", + "subject": "contract update offer question", + "outcome": "invoice invoice invoice", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0210", + "type": "meeting", + "date": "2026-06-29", + "subject": "meeting follow renewal meeting", + "outcome": "offer call follow", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0211", + "type": "note", + "date": "2025-01-15", + "subject": "update follow question renewal", + "outcome": "invoice invoice call", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0212", + "type": "meeting", + "date": "2025-05-08", + "subject": "support renewal invoice up", + "outcome": "invoice update call", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0213", + "type": "task", + "date": "2026-06-30", + "subject": "up offer update renewal", + "outcome": "call update support", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0214", + "type": "meeting", + "date": "2025-12-25", + "subject": "renewal renewal invoice call", + "outcome": "demo follow meeting", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0215", + "type": "task", + "date": "2026-05-30", + "subject": "invoice call demo contract", + "outcome": "follow offer update", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0216", + "type": "call", + "date": "2025-05-24", + "subject": "up meeting contract meeting", + "outcome": "follow support update", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0217", + "type": "call", + "date": "2026-03-12", + "subject": "follow meeting update follow", + "outcome": "contract demo question", + "done": false, + "ownerId": "u-24" + }, + { + "id": "a-0218", + "type": "email", + "date": "2025-05-10", + "subject": "update support follow demo", + "outcome": "update invoice up", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0219", + "type": "meeting", + "date": "2025-05-25", + "subject": "meeting renewal invoice update", + "outcome": "demo demo meeting", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0220", + "type": "task", + "date": "2025-02-09", + "subject": "demo renewal up question", + "outcome": "update support up", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0221", + "type": "call", + "date": "2026-02-01", + "subject": "offer follow offer offer", + "outcome": "call call contract", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0222", + "type": "note", + "date": "2026-01-19", + "subject": "demo follow up offer", + "outcome": "meeting call question", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0223", + "type": "task", + "date": "2025-10-31", + "subject": "demo contract follow update", + "outcome": "contract contract offer", + "done": false, + "ownerId": "u-21" + }, + { + "id": "a-0224", + "type": "email", + "date": "2026-01-29", + "subject": "contract offer question up", + "outcome": "renewal invoice contract", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0225", + "type": "call", + "date": "2025-12-09", + "subject": "update contract offer meeting", + "outcome": "renewal follow meeting", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0226", + "type": "email", + "date": "2026-01-18", + "subject": "update invoice question follow", + "outcome": "call question call", + "done": false, + "ownerId": "u-13" + }, + { + "id": "a-0227", + "type": "meeting", + "date": "2025-04-28", + "subject": "contract update meeting renewal", + "outcome": "contract invoice question", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0228", + "type": "call", + "date": "2026-07-08", + "subject": "invoice update contract meeting", + "outcome": "renewal update meeting", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0229", + "type": "note", + "date": "2025-02-09", + "subject": "renewal demo question offer", + "outcome": "follow up call", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0230", + "type": "meeting", + "date": "2025-02-15", + "subject": "follow offer offer contract", + "outcome": "follow call meeting", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0231", + "type": "call", + "date": "2025-10-17", + "subject": "invoice meeting support invoice", + "outcome": "renewal follow follow", + "done": true, + "ownerId": "u-26" + }, + { + "id": "a-0232", + "type": "meeting", + "date": "2026-05-29", + "subject": "question invoice update question", + "outcome": "question call contract", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0233", + "type": "task", + "date": "2026-07-26", + "subject": "up question meeting demo", + "outcome": "demo demo contract", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0234", + "type": "note", + "date": "2025-07-21", + "subject": "invoice call question call", + "outcome": "renewal update renewal", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0235", + "type": "email", + "date": "2026-01-24", + "subject": "up up follow demo", + "outcome": "renewal follow support", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0236", + "type": "call", + "date": "2025-09-02", + "subject": "meeting offer renewal question", + "outcome": "up call offer", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0237", + "type": "call", + "date": "2025-08-18", + "subject": "meeting question support renewal", + "outcome": "update up follow", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0238", + "type": "email", + "date": "2026-01-04", + "subject": "update renewal up contract", + "outcome": "demo up renewal", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0239", + "type": "email", + "date": "2026-04-04", + "subject": "invoice update meeting question", + "outcome": "update up contract", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0240", + "type": "email", + "date": "2026-05-15", + "subject": "follow follow call question", + "outcome": "follow follow contract", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0241", + "type": "call", + "date": "2026-03-01", + "subject": "demo contract update call", + "outcome": "renewal invoice meeting", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0242", + "type": "task", + "date": "2025-11-28", + "subject": "question question demo call", + "outcome": "offer demo call", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0243", + "type": "call", + "date": "2025-07-24", + "subject": "follow offer renewal up", + "outcome": "follow offer update", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0244", + "type": "call", + "date": "2025-12-27", + "subject": "offer call invoice support", + "outcome": "invoice contract question", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0245", + "type": "note", + "date": "2026-07-25", + "subject": "demo contract question meeting", + "outcome": "support follow up", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0246", + "type": "task", + "date": "2025-08-21", + "subject": "contract demo contract call", + "outcome": "up demo renewal", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0247", + "type": "task", + "date": "2025-01-28", + "subject": "offer offer support follow", + "outcome": "demo update contract", + "done": false, + "ownerId": "u-29" + }, + { + "id": "a-0248", + "type": "email", + "date": "2025-05-24", + "subject": "invoice invoice support update", + "outcome": "call update follow", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0249", + "type": "meeting", + "date": "2026-03-31", + "subject": "call follow up demo", + "outcome": "offer question follow", + "done": false, + "ownerId": "u-15" + }, + { + "id": "a-0250", + "type": "note", + "date": "2026-05-11", + "subject": "update invoice contract contract", + "outcome": "contract demo up", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0251", + "type": "note", + "date": "2026-07-27", + "subject": "invoice demo call meeting", + "outcome": "demo call call", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0252", + "type": "meeting", + "date": "2026-05-26", + "subject": "question invoice meeting offer", + "outcome": "call renewal update", + "done": false, + "ownerId": "u-12" + }, + { + "id": "a-0253", + "type": "meeting", + "date": "2025-01-18", + "subject": "invoice renewal contract offer", + "outcome": "invoice question renewal", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0254", + "type": "call", + "date": "2025-07-15", + "subject": "support up update meeting", + "outcome": "offer up up", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0255", + "type": "call", + "date": "2025-03-25", + "subject": "question follow offer meeting", + "outcome": "demo follow call", + "done": false, + "ownerId": "u-4" + }, + { + "id": "a-0256", + "type": "call", + "date": "2025-10-18", + "subject": "update support invoice follow", + "outcome": "renewal contract call", + "done": false, + "ownerId": "u-20" + }, + { + "id": "a-0257", + "type": "call", + "date": "2026-07-09", + "subject": "offer contract follow contract", + "outcome": "question follow update", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0258", + "type": "note", + "date": "2025-02-14", + "subject": "meeting invoice contract update", + "outcome": "call offer support", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0259", + "type": "note", + "date": "2025-05-07", + "subject": "up invoice follow demo", + "outcome": "call up call", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0260", + "type": "call", + "date": "2025-12-28", + "subject": "demo demo renewal question", + "outcome": "renewal call contract", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0261", + "type": "meeting", + "date": "2025-05-27", + "subject": "offer follow question support", + "outcome": "offer contract demo", + "done": false, + "ownerId": "u-21" + }, + { + "id": "a-0262", + "type": "call", + "date": "2025-05-14", + "subject": "offer offer invoice up", + "outcome": "question up contract", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0263", + "type": "note", + "date": "2025-06-08", + "subject": "support invoice call support", + "outcome": "follow question support", + "done": false, + "ownerId": "u-7" + }, + { + "id": "a-0264", + "type": "email", + "date": "2025-04-20", + "subject": "renewal demo follow update", + "outcome": "follow offer support", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0265", + "type": "call", + "date": "2025-10-09", + "subject": "question support meeting invoice", + "outcome": "demo up meeting", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0266", + "type": "note", + "date": "2025-01-20", + "subject": "invoice demo meeting contract", + "outcome": "question question contract", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0267", + "type": "email", + "date": "2026-02-15", + "subject": "invoice meeting renewal update", + "outcome": "contract call follow", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0268", + "type": "email", + "date": "2025-01-27", + "subject": "invoice question renewal up", + "outcome": "demo meeting renewal", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0269", + "type": "task", + "date": "2025-04-19", + "subject": "offer contract support support", + "outcome": "update contract support", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0270", + "type": "meeting", + "date": "2025-01-18", + "subject": "contract meeting question call", + "outcome": "support support follow", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0271", + "type": "meeting", + "date": "2025-01-28", + "subject": "invoice demo invoice demo", + "outcome": "demo invoice invoice", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0272", + "type": "email", + "date": "2026-02-04", + "subject": "question up support contract", + "outcome": "contract update support", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0273", + "type": "meeting", + "date": "2025-06-19", + "subject": "question meeting contract up", + "outcome": "meeting meeting meeting", + "done": false, + "ownerId": "u-11" + }, + { + "id": "a-0274", + "type": "task", + "date": "2025-10-04", + "subject": "follow question up update", + "outcome": "demo up call", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0275", + "type": "meeting", + "date": "2026-05-11", + "subject": "demo renewal offer demo", + "outcome": "offer meeting renewal", + "done": false, + "ownerId": "u-30" + }, + { + "id": "a-0276", + "type": "call", + "date": "2025-01-08", + "subject": "offer demo follow renewal", + "outcome": "support renewal meeting", + "done": false, + "ownerId": "u-25" + }, + { + "id": "a-0277", + "type": "task", + "date": "2025-01-10", + "subject": "update contract up follow", + "outcome": "demo call invoice", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0278", + "type": "meeting", + "date": "2025-12-08", + "subject": "meeting demo meeting call", + "outcome": "meeting demo follow", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0279", + "type": "note", + "date": "2026-06-07", + "subject": "up meeting update support", + "outcome": "up meeting question", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0280", + "type": "call", + "date": "2026-08-03", + "subject": "invoice up up renewal", + "outcome": "support support follow", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0281", + "type": "note", + "date": "2026-02-03", + "subject": "follow update support follow", + "outcome": "up call offer", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0282", + "type": "call", + "date": "2025-05-30", + "subject": "demo call demo up", + "outcome": "meeting follow meeting", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0283", + "type": "task", + "date": "2025-06-27", + "subject": "invoice follow follow support", + "outcome": "invoice support support", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0284", + "type": "email", + "date": "2026-03-15", + "subject": "demo follow offer meeting", + "outcome": "support update question", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0285", + "type": "email", + "date": "2025-11-27", + "subject": "call offer contract offer", + "outcome": "invoice follow renewal", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0286", + "type": "meeting", + "date": "2025-07-26", + "subject": "call question offer renewal", + "outcome": "demo up update", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0287", + "type": "email", + "date": "2026-06-02", + "subject": "up question call offer", + "outcome": "up support offer", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0288", + "type": "task", + "date": "2026-05-30", + "subject": "offer demo contract call", + "outcome": "update invoice meeting", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0289", + "type": "task", + "date": "2025-05-16", + "subject": "meeting offer support follow", + "outcome": "follow demo update", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0290", + "type": "email", + "date": "2026-07-30", + "subject": "meeting up question question", + "outcome": "offer meeting question", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0291", + "type": "call", + "date": "2026-05-22", + "subject": "up up question renewal", + "outcome": "demo renewal contract", + "done": false, + "ownerId": "u-25" + }, + { + "id": "a-0292", + "type": "call", + "date": "2025-04-07", + "subject": "renewal support meeting follow", + "outcome": "demo up demo", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0293", + "type": "note", + "date": "2025-07-21", + "subject": "demo offer follow offer", + "outcome": "renewal update contract", + "done": false, + "ownerId": "u-4" + }, + { + "id": "a-0294", + "type": "meeting", + "date": "2026-08-12", + "subject": "update call meeting offer", + "outcome": "up call update", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0295", + "type": "task", + "date": "2025-03-26", + "subject": "follow update call contract", + "outcome": "update update update", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0296", + "type": "email", + "date": "2026-08-20", + "subject": "question meeting renewal follow", + "outcome": "question renewal up", + "done": false, + "ownerId": "u-18" + }, + { + "id": "a-0297", + "type": "task", + "date": "2026-07-16", + "subject": "invoice meeting offer invoice", + "outcome": "invoice offer question", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0298", + "type": "note", + "date": "2025-04-23", + "subject": "follow up contract invoice", + "outcome": "call invoice follow", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0299", + "type": "email", + "date": "2025-08-11", + "subject": "meeting question follow up", + "outcome": "follow meeting offer", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0300", + "type": "call", + "date": "2025-12-17", + "subject": "offer question meeting contract", + "outcome": "offer update follow", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0301", + "type": "call", + "date": "2026-05-06", + "subject": "meeting follow update contract", + "outcome": "contract invoice question", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0302", + "type": "meeting", + "date": "2025-12-13", + "subject": "up support call demo", + "outcome": "call follow up", + "done": false, + "ownerId": "u-16" + }, + { + "id": "a-0303", + "type": "note", + "date": "2026-08-23", + "subject": "follow question demo contract", + "outcome": "question invoice meeting", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0304", + "type": "call", + "date": "2025-10-14", + "subject": "support question question support", + "outcome": "up renewal follow", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0305", + "type": "task", + "date": "2025-05-11", + "subject": "demo question meeting meeting", + "outcome": "renewal support question", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0306", + "type": "email", + "date": "2026-07-16", + "subject": "question support meeting renewal", + "outcome": "demo demo invoice", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0307", + "type": "note", + "date": "2026-01-25", + "subject": "renewal contract update offer", + "outcome": "renewal support renewal", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0308", + "type": "note", + "date": "2025-12-20", + "subject": "call renewal meeting invoice", + "outcome": "update invoice demo", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0309", + "type": "meeting", + "date": "2026-05-31", + "subject": "offer support up demo", + "outcome": "demo offer renewal", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0310", + "type": "call", + "date": "2025-11-14", + "subject": "support offer invoice invoice", + "outcome": "follow call offer", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0311", + "type": "meeting", + "date": "2026-08-21", + "subject": "support up up support", + "outcome": "renewal question follow", + "done": true, + "ownerId": "u-26" + }, + { + "id": "a-0312", + "type": "task", + "date": "2026-04-28", + "subject": "demo meeting invoice contract", + "outcome": "follow update support", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0313", + "type": "task", + "date": "2025-05-01", + "subject": "question up invoice follow", + "outcome": "demo support demo", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0314", + "type": "note", + "date": "2025-12-08", + "subject": "question question invoice invoice", + "outcome": "contract call invoice", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0315", + "type": "task", + "date": "2025-07-25", + "subject": "up support demo renewal", + "outcome": "meeting follow question", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0316", + "type": "note", + "date": "2025-06-04", + "subject": "contract meeting contract follow", + "outcome": "meeting support renewal", + "done": false, + "ownerId": "u-11" + }, + { + "id": "a-0317", + "type": "meeting", + "date": "2026-02-14", + "subject": "meeting invoice renewal contract", + "outcome": "meeting call invoice", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0318", + "type": "email", + "date": "2025-03-07", + "subject": "question update up meeting", + "outcome": "contract up update", + "done": false, + "ownerId": "u-27" + }, + { + "id": "a-0319", + "type": "meeting", + "date": "2025-12-01", + "subject": "update contract contract renewal", + "outcome": "invoice meeting invoice", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0320", + "type": "task", + "date": "2025-08-13", + "subject": "question meeting support call", + "outcome": "meeting renewal call", + "done": true, + "ownerId": "u-6" + }, + { + "id": "a-0321", + "type": "meeting", + "date": "2026-06-07", + "subject": "renewal follow support meeting", + "outcome": "renewal update invoice", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0322", + "type": "meeting", + "date": "2025-06-21", + "subject": "offer contract question demo", + "outcome": "update question update", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0323", + "type": "task", + "date": "2025-10-09", + "subject": "contract contract demo update", + "outcome": "question question demo", + "done": false, + "ownerId": "u-13" + }, + { + "id": "a-0324", + "type": "note", + "date": "2026-08-21", + "subject": "renewal follow update follow", + "outcome": "update support up", + "done": false, + "ownerId": "u-20" + }, + { + "id": "a-0325", + "type": "meeting", + "date": "2026-02-23", + "subject": "invoice up support invoice", + "outcome": "invoice contract invoice", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0326", + "type": "note", + "date": "2026-02-19", + "subject": "invoice demo update invoice", + "outcome": "meeting update support", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0327", + "type": "note", + "date": "2026-01-08", + "subject": "renewal follow invoice up", + "outcome": "demo update invoice", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0328", + "type": "meeting", + "date": "2025-12-10", + "subject": "renewal renewal contract meeting", + "outcome": "demo renewal up", + "done": false, + "ownerId": "u-12" + }, + { + "id": "a-0329", + "type": "meeting", + "date": "2026-05-17", + "subject": "support call up call", + "outcome": "support renewal meeting", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0330", + "type": "email", + "date": "2026-08-14", + "subject": "follow contract renewal contract", + "outcome": "question meeting follow", + "done": false, + "ownerId": "u-22" + }, + { + "id": "a-0331", + "type": "call", + "date": "2025-02-23", + "subject": "meeting question renewal follow", + "outcome": "offer update invoice", + "done": false, + "ownerId": "u-14" + }, + { + "id": "a-0332", + "type": "task", + "date": "2025-12-15", + "subject": "renewal demo renewal invoice", + "outcome": "support follow contract", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0333", + "type": "call", + "date": "2026-03-26", + "subject": "invoice contract meeting call", + "outcome": "meeting demo update", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0334", + "type": "note", + "date": "2025-12-01", + "subject": "update demo update up", + "outcome": "offer demo renewal", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0335", + "type": "meeting", + "date": "2026-04-26", + "subject": "call demo call offer", + "outcome": "follow meeting renewal", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0336", + "type": "meeting", + "date": "2025-01-28", + "subject": "invoice support update offer", + "outcome": "support question invoice", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0337", + "type": "note", + "date": "2025-04-12", + "subject": "renewal question up support", + "outcome": "question renewal offer", + "done": false, + "ownerId": "u-28" + }, + { + "id": "a-0338", + "type": "task", + "date": "2025-05-17", + "subject": "follow renewal follow meeting", + "outcome": "follow offer follow", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0339", + "type": "email", + "date": "2025-12-16", + "subject": "up meeting invoice question", + "outcome": "question meeting up", + "done": false, + "ownerId": "u-11" + }, + { + "id": "a-0340", + "type": "task", + "date": "2026-05-31", + "subject": "offer question contract call", + "outcome": "update demo update", + "done": false, + "ownerId": "u-20" + }, + { + "id": "a-0341", + "type": "task", + "date": "2026-04-19", + "subject": "meeting question follow update", + "outcome": "question update question", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0342", + "type": "email", + "date": "2025-08-12", + "subject": "contract meeting update contract", + "outcome": "offer meeting up", + "done": true, + "ownerId": "u-27" + }, + { + "id": "a-0343", + "type": "call", + "date": "2025-05-25", + "subject": "call meeting demo follow", + "outcome": "renewal demo offer", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0344", + "type": "call", + "date": "2026-04-24", + "subject": "follow update follow support", + "outcome": "offer question call", + "done": true, + "ownerId": "u-9" + }, + { + "id": "a-0345", + "type": "call", + "date": "2025-06-04", + "subject": "demo contract support call", + "outcome": "demo meeting renewal", + "done": true, + "ownerId": "u-26" + }, + { + "id": "a-0346", + "type": "meeting", + "date": "2025-12-03", + "subject": "up demo contract call", + "outcome": "call follow up", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0347", + "type": "note", + "date": "2025-02-25", + "subject": "update update question update", + "outcome": "support renewal up", + "done": false, + "ownerId": "u-20" + }, + { + "id": "a-0348", + "type": "task", + "date": "2025-04-02", + "subject": "up call demo support", + "outcome": "renewal call update", + "done": false, + "ownerId": "u-6" + }, + { + "id": "a-0349", + "type": "call", + "date": "2025-02-05", + "subject": "follow demo call support", + "outcome": "support follow call", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0350", + "type": "email", + "date": "2025-12-27", + "subject": "meeting offer contract update", + "outcome": "update up renewal", + "done": false, + "ownerId": "u-6" + }, + { + "id": "a-0351", + "type": "email", + "date": "2026-02-21", + "subject": "offer offer question offer", + "outcome": "update follow renewal", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0352", + "type": "meeting", + "date": "2026-01-13", + "subject": "follow offer invoice call", + "outcome": "update follow update", + "done": false, + "ownerId": "u-7" + }, + { + "id": "a-0353", + "type": "task", + "date": "2025-10-19", + "subject": "support meeting up renewal", + "outcome": "renewal support question", + "done": true, + "ownerId": "u-25" + }, + { + "id": "a-0354", + "type": "email", + "date": "2025-04-08", + "subject": "meeting update call call", + "outcome": "up demo question", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0355", + "type": "note", + "date": "2025-07-10", + "subject": "call contract offer call", + "outcome": "follow support follow", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0356", + "type": "call", + "date": "2025-07-30", + "subject": "meeting demo demo demo", + "outcome": "follow invoice contract", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0357", + "type": "note", + "date": "2025-04-10", + "subject": "support support offer call", + "outcome": "up offer meeting", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0358", + "type": "meeting", + "date": "2026-07-17", + "subject": "up demo offer demo", + "outcome": "renewal call up", + "done": true, + "ownerId": "u-23" + }, + { + "id": "a-0359", + "type": "call", + "date": "2025-11-25", + "subject": "support support update update", + "outcome": "question up contract", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0360", + "type": "task", + "date": "2026-06-13", + "subject": "update question offer support", + "outcome": "demo up support", + "done": false, + "ownerId": "u-22" + }, + { + "id": "a-0361", + "type": "call", + "date": "2025-08-03", + "subject": "update invoice up meeting", + "outcome": "contract question support", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0362", + "type": "meeting", + "date": "2025-06-24", + "subject": "offer renewal offer renewal", + "outcome": "update invoice follow", + "done": false, + "ownerId": "u-10" + }, + { + "id": "a-0363", + "type": "call", + "date": "2026-01-21", + "subject": "offer call demo update", + "outcome": "contract question offer", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0364", + "type": "task", + "date": "2025-03-12", + "subject": "update demo demo question", + "outcome": "offer up offer", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0365", + "type": "note", + "date": "2026-04-13", + "subject": "question demo offer up", + "outcome": "follow follow meeting", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0366", + "type": "meeting", + "date": "2025-11-29", + "subject": "call renewal renewal support", + "outcome": "support up follow", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0367", + "type": "meeting", + "date": "2026-01-13", + "subject": "call up call call", + "outcome": "offer offer renewal", + "done": false, + "ownerId": "u-16" + }, + { + "id": "a-0368", + "type": "meeting", + "date": "2026-06-17", + "subject": "support update up demo", + "outcome": "follow contract question", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0369", + "type": "meeting", + "date": "2025-01-15", + "subject": "support call follow contract", + "outcome": "question meeting meeting", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0370", + "type": "task", + "date": "2026-07-22", + "subject": "meeting up call call", + "outcome": "update update update", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0371", + "type": "meeting", + "date": "2026-08-22", + "subject": "call invoice update demo", + "outcome": "update update support", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0372", + "type": "call", + "date": "2026-08-16", + "subject": "update meeting offer invoice", + "outcome": "meeting demo demo", + "done": false, + "ownerId": "u-2" + }, + { + "id": "a-0373", + "type": "call", + "date": "2025-07-23", + "subject": "follow demo call contract", + "outcome": "up up invoice", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0374", + "type": "email", + "date": "2025-03-14", + "subject": "invoice invoice update meeting", + "outcome": "up meeting question", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0375", + "type": "task", + "date": "2025-02-08", + "subject": "follow question demo renewal", + "outcome": "question invoice demo", + "done": true, + "ownerId": "u-26" + }, + { + "id": "a-0376", + "type": "call", + "date": "2026-05-09", + "subject": "invoice question support contract", + "outcome": "offer invoice contract", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0377", + "type": "task", + "date": "2025-09-30", + "subject": "question meeting meeting demo", + "outcome": "demo follow demo", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0378", + "type": "email", + "date": "2025-03-22", + "subject": "question contract invoice update", + "outcome": "follow update call", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0379", + "type": "meeting", + "date": "2025-05-22", + "subject": "question support question call", + "outcome": "offer follow update", + "done": true, + "ownerId": "u-24" + }, + { + "id": "a-0380", + "type": "task", + "date": "2025-08-06", + "subject": "up offer follow demo", + "outcome": "demo question demo", + "done": false, + "ownerId": "u-4" + }, + { + "id": "a-0381", + "type": "task", + "date": "2025-07-09", + "subject": "call meeting follow renewal", + "outcome": "update contract demo", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0382", + "type": "task", + "date": "2026-06-14", + "subject": "support offer contract update", + "outcome": "call offer support", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0383", + "type": "email", + "date": "2025-02-14", + "subject": "update question contract offer", + "outcome": "call up invoice", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0384", + "type": "meeting", + "date": "2025-01-02", + "subject": "update update demo meeting", + "outcome": "invoice follow offer", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0385", + "type": "meeting", + "date": "2025-06-28", + "subject": "invoice up offer offer", + "outcome": "call up renewal", + "done": false, + "ownerId": "u-11" + }, + { + "id": "a-0386", + "type": "meeting", + "date": "2025-03-09", + "subject": "update renewal demo follow", + "outcome": "invoice question offer", + "done": true, + "ownerId": "u-1" + }, + { + "id": "a-0387", + "type": "call", + "date": "2026-03-08", + "subject": "invoice meeting question contract", + "outcome": "update offer offer", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0388", + "type": "task", + "date": "2025-03-13", + "subject": "meeting up support up", + "outcome": "up contract renewal", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0389", + "type": "task", + "date": "2026-03-08", + "subject": "call invoice meeting contract", + "outcome": "invoice invoice update", + "done": false, + "ownerId": "u-18" + }, + { + "id": "a-0390", + "type": "meeting", + "date": "2025-10-01", + "subject": "question update call contract", + "outcome": "invoice demo contract", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0391", + "type": "meeting", + "date": "2026-08-02", + "subject": "meeting follow support support", + "outcome": "up invoice update", + "done": false, + "ownerId": "u-24" + }, + { + "id": "a-0392", + "type": "task", + "date": "2026-05-20", + "subject": "call demo meeting question", + "outcome": "meeting support call", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0393", + "type": "meeting", + "date": "2025-07-17", + "subject": "support call update offer", + "outcome": "question meeting follow", + "done": false, + "ownerId": "u-23" + }, + { + "id": "a-0394", + "type": "call", + "date": "2026-04-15", + "subject": "update contract question invoice", + "outcome": "support demo call", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0395", + "type": "call", + "date": "2025-12-03", + "subject": "call invoice demo offer", + "outcome": "contract up up", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0396", + "type": "email", + "date": "2025-08-29", + "subject": "offer invoice update demo", + "outcome": "contract question meeting", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0397", + "type": "task", + "date": "2025-08-13", + "subject": "demo question follow contract", + "outcome": "renewal support call", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0398", + "type": "meeting", + "date": "2025-03-02", + "subject": "demo demo support question", + "outcome": "question call offer", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0399", + "type": "call", + "date": "2025-07-23", + "subject": "demo renewal update call", + "outcome": "up invoice invoice", + "done": true, + "ownerId": "u-18" + }, + { + "id": "a-0400", + "type": "task", + "date": "2025-06-07", + "subject": "update offer follow demo", + "outcome": "follow offer question", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0401", + "type": "email", + "date": "2026-02-24", + "subject": "invoice renewal offer meeting", + "outcome": "update question up", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0402", + "type": "meeting", + "date": "2025-08-03", + "subject": "question invoice up demo", + "outcome": "contract support call", + "done": true, + "ownerId": "u-29" + }, + { + "id": "a-0403", + "type": "email", + "date": "2026-07-30", + "subject": "follow invoice demo meeting", + "outcome": "invoice question offer", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0404", + "type": "task", + "date": "2025-09-27", + "subject": "contract question follow meeting", + "outcome": "question contract up", + "done": true, + "ownerId": "u-15" + }, + { + "id": "a-0405", + "type": "meeting", + "date": "2025-07-15", + "subject": "call invoice update support", + "outcome": "invoice follow follow", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0406", + "type": "task", + "date": "2025-07-01", + "subject": "support question call update", + "outcome": "follow question call", + "done": false, + "ownerId": "u-27" + }, + { + "id": "a-0407", + "type": "note", + "date": "2025-08-14", + "subject": "update follow support offer", + "outcome": "call support call", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0408", + "type": "email", + "date": "2026-03-30", + "subject": "contract offer contract up", + "outcome": "renewal contract demo", + "done": true, + "ownerId": "u-13" + }, + { + "id": "a-0409", + "type": "email", + "date": "2025-10-08", + "subject": "update call question offer", + "outcome": "question offer offer", + "done": false, + "ownerId": "u-15" + }, + { + "id": "a-0410", + "type": "task", + "date": "2025-06-07", + "subject": "demo question offer meeting", + "outcome": "contract follow renewal", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0411", + "type": "email", + "date": "2025-06-15", + "subject": "offer call renewal update", + "outcome": "demo offer demo", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0412", + "type": "call", + "date": "2025-11-08", + "subject": "offer offer contract invoice", + "outcome": "demo support question", + "done": true, + "ownerId": "u-19" + }, + { + "id": "a-0413", + "type": "note", + "date": "2025-03-29", + "subject": "renewal demo follow support", + "outcome": "renewal demo support", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0414", + "type": "call", + "date": "2025-06-19", + "subject": "call follow call update", + "outcome": "invoice update follow", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0415", + "type": "meeting", + "date": "2025-10-26", + "subject": "contract contract meeting call", + "outcome": "contract meeting demo", + "done": false, + "ownerId": "u-21" + }, + { + "id": "a-0416", + "type": "call", + "date": "2025-04-11", + "subject": "question update call meeting", + "outcome": "support support update", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0417", + "type": "note", + "date": "2026-03-21", + "subject": "update offer support invoice", + "outcome": "renewal contract call", + "done": true, + "ownerId": "u-4" + }, + { + "id": "a-0418", + "type": "email", + "date": "2025-10-31", + "subject": "up offer contract call", + "outcome": "invoice support call", + "done": false, + "ownerId": "u-17" + }, + { + "id": "a-0419", + "type": "call", + "date": "2025-03-15", + "subject": "question up offer renewal", + "outcome": "contract contract call", + "done": false, + "ownerId": "u-2" + }, + { + "id": "a-0420", + "type": "call", + "date": "2026-01-19", + "subject": "meeting renewal renewal demo", + "outcome": "demo update question", + "done": false, + "ownerId": "u-10" + }, + { + "id": "a-0421", + "type": "email", + "date": "2025-12-12", + "subject": "demo question call demo", + "outcome": "support up follow", + "done": true, + "ownerId": "u-7" + }, + { + "id": "a-0422", + "type": "task", + "date": "2026-06-23", + "subject": "question meeting question call", + "outcome": "invoice follow question", + "done": true, + "ownerId": "u-16" + }, + { + "id": "a-0423", + "type": "email", + "date": "2025-04-20", + "subject": "demo question support update", + "outcome": "question call follow", + "done": true, + "ownerId": "u-28" + }, + { + "id": "a-0424", + "type": "call", + "date": "2026-07-08", + "subject": "question call question up", + "outcome": "update renewal contract", + "done": true, + "ownerId": "u-12" + }, + { + "id": "a-0425", + "type": "note", + "date": "2025-12-14", + "subject": "question offer call renewal", + "outcome": "support support update", + "done": true, + "ownerId": "u-11" + }, + { + "id": "a-0426", + "type": "note", + "date": "2026-02-06", + "subject": "support invoice meeting meeting", + "outcome": "contract contract question", + "done": true, + "ownerId": "u-5" + }, + { + "id": "a-0427", + "type": "note", + "date": "2026-05-19", + "subject": "contract meeting demo call", + "outcome": "question support contract", + "done": false, + "ownerId": "u-25" + }, + { + "id": "a-0428", + "type": "call", + "date": "2025-06-04", + "subject": "demo support update question", + "outcome": "up question up", + "done": false, + "ownerId": "u-2" + }, + { + "id": "a-0429", + "type": "note", + "date": "2025-01-03", + "subject": "call follow demo contract", + "outcome": "meeting up follow", + "done": true, + "ownerId": "u-14" + }, + { + "id": "a-0430", + "type": "call", + "date": "2025-04-11", + "subject": "up call meeting meeting", + "outcome": "offer question update", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0431", + "type": "task", + "date": "2025-01-30", + "subject": "offer invoice offer offer", + "outcome": "follow update follow", + "done": false, + "ownerId": "u-12" + }, + { + "id": "a-0432", + "type": "meeting", + "date": "2026-05-24", + "subject": "follow demo offer support", + "outcome": "renewal offer demo", + "done": true, + "ownerId": "u-20" + }, + { + "id": "a-0433", + "type": "task", + "date": "2026-05-26", + "subject": "follow call call question", + "outcome": "up meeting follow", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0434", + "type": "note", + "date": "2025-03-20", + "subject": "support offer demo update", + "outcome": "meeting update contract", + "done": false, + "ownerId": "u-14" + }, + { + "id": "a-0435", + "type": "email", + "date": "2025-09-06", + "subject": "update invoice follow support", + "outcome": "follow follow demo", + "done": true, + "ownerId": "u-17" + }, + { + "id": "a-0436", + "type": "email", + "date": "2025-09-21", + "subject": "update meeting contract follow", + "outcome": "update up support", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0437", + "type": "call", + "date": "2026-02-06", + "subject": "question offer offer invoice", + "outcome": "renewal update call", + "done": true, + "ownerId": "u-8" + }, + { + "id": "a-0438", + "type": "meeting", + "date": "2026-08-12", + "subject": "question offer offer support", + "outcome": "update contract follow", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0439", + "type": "call", + "date": "2026-07-12", + "subject": "question support offer question", + "outcome": "follow meeting demo", + "done": false, + "ownerId": "u-10" + }, + { + "id": "a-0440", + "type": "note", + "date": "2026-03-30", + "subject": "meeting meeting follow question", + "outcome": "offer meeting up", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0441", + "type": "task", + "date": "2026-05-02", + "subject": "up contract question call", + "outcome": "renewal update meeting", + "done": true, + "ownerId": "u-22" + }, + { + "id": "a-0442", + "type": "email", + "date": "2025-10-18", + "subject": "renewal invoice offer meeting", + "outcome": "update renewal update", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0443", + "type": "call", + "date": "2026-07-07", + "subject": "contract call up question", + "outcome": "follow follow update", + "done": false, + "ownerId": "u-8" + }, + { + "id": "a-0444", + "type": "task", + "date": "2026-08-08", + "subject": "renewal follow follow contract", + "outcome": "renewal demo question", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0445", + "type": "meeting", + "date": "2025-09-19", + "subject": "question offer call follow", + "outcome": "update renewal support", + "done": true, + "ownerId": "u-3" + }, + { + "id": "a-0446", + "type": "email", + "date": "2025-07-21", + "subject": "invoice invoice call support", + "outcome": "meeting call renewal", + "done": true, + "ownerId": "u-2" + }, + { + "id": "a-0447", + "type": "meeting", + "date": "2025-11-19", + "subject": "question call contract offer", + "outcome": "update offer invoice", + "done": true, + "ownerId": "u-30" + }, + { + "id": "a-0448", + "type": "task", + "date": "2025-01-31", + "subject": "offer support offer demo", + "outcome": "invoice up up", + "done": true, + "ownerId": "u-21" + }, + { + "id": "a-0449", + "type": "email", + "date": "2025-08-09", + "subject": "update meeting offer invoice", + "outcome": "up call demo", + "done": true, + "ownerId": "u-10" + }, + { + "id": "a-0450", + "type": "note", + "date": "2026-05-31", + "subject": "call follow demo follow", + "outcome": "up up call", + "done": true, + "ownerId": "u-11" + } + ], + "customFields": { + "cf_001": null, + "cf_002": "up up", + "cf_003": "update invoice", + "cf_004": null, + "cf_005": false, + "cf_006": "follow invoice", + "cf_007": true, + "cf_008": 655, + "cf_009": 98, + "cf_010": 481, + "cf_011": null, + "cf_012": "meeting meeting", + "cf_013": null, + "cf_014": "support offer", + "cf_015": false, + "cf_016": null, + "cf_017": 320, + "cf_018": 931, + "cf_019": null, + "cf_020": null, + "cf_021": false, + "cf_022": "invoice demo", + "cf_023": null, + "cf_024": null, + "cf_025": false, + "cf_026": null, + "cf_027": true, + "cf_028": null, + "cf_029": 193, + "cf_030": 5, + "cf_031": 751, + "cf_032": null, + "cf_033": null, + "cf_034": 822, + "cf_035": null, + "cf_036": null, + "cf_037": null, + "cf_038": null, + "cf_039": true, + "cf_040": "renewal offer", + "cf_041": 939, + "cf_042": null, + "cf_043": 842, + "cf_044": "contract renewal", + "cf_045": null, + "cf_046": "invoice up", + "cf_047": null, + "cf_048": null, + "cf_049": "invoice offer", + "cf_050": 698, + "cf_051": null, + "cf_052": "support meeting", + "cf_053": 878, + "cf_054": null, + "cf_055": false, + "cf_056": 942, + "cf_057": 669, + "cf_058": 707, + "cf_059": true, + "cf_060": 200, + "cf_061": 639, + "cf_062": null, + "cf_063": 829, + "cf_064": null, + "cf_065": "contract meeting", + "cf_066": false, + "cf_067": "update question", + "cf_068": true, + "cf_069": null, + "cf_070": false, + "cf_071": "offer offer", + "cf_072": null, + "cf_073": false, + "cf_074": "demo contract", + "cf_075": "invoice contract", + "cf_076": "renewal question", + "cf_077": 450, + "cf_078": 24, + "cf_079": true, + "cf_080": "renewal support", + "cf_081": "support question", + "cf_082": null, + "cf_083": null, + "cf_084": null, + "cf_085": null, + "cf_086": null, + "cf_087": "update call", + "cf_088": false, + "cf_089": "follow call", + "cf_090": false, + "cf_091": "follow contract", + "cf_092": "follow up", + "cf_093": 254, + "cf_094": "follow renewal", + "cf_095": 26, + "cf_096": false, + "cf_097": null, + "cf_098": false, + "cf_099": null, + "cf_100": null, + "cf_101": 240, + "cf_102": true, + "cf_103": 345, + "cf_104": "support update", + "cf_105": null, + "cf_106": true, + "cf_107": 144, + "cf_108": false, + "cf_109": false, + "cf_110": false, + "cf_111": 932, + "cf_112": true, + "cf_113": true, + "cf_114": "follow invoice", + "cf_115": 868, + "cf_116": 15, + "cf_117": "question follow", + "cf_118": 179, + "cf_119": true, + "cf_120": 642, + "cf_121": null, + "cf_122": 548, + "cf_123": null, + "cf_124": null, + "cf_125": true, + "cf_126": true, + "cf_127": false, + "cf_128": null, + "cf_129": null, + "cf_130": true, + "cf_131": 450, + "cf_132": null, + "cf_133": "offer offer", + "cf_134": false, + "cf_135": true, + "cf_136": "meeting contract", + "cf_137": null, + "cf_138": "renewal update", + "cf_139": false, + "cf_140": "call renewal", + "cf_141": 983, + "cf_142": true, + "cf_143": null, + "cf_144": null, + "cf_145": null, + "cf_146": "demo offer", + "cf_147": "follow up", + "cf_148": 517, + "cf_149": "support contract", + "cf_150": null, + "cf_151": "call offer", + "cf_152": true, + "cf_153": 153, + "cf_154": "invoice call", + "cf_155": 86, + "cf_156": 721, + "cf_157": "support meeting", + "cf_158": 509, + "cf_159": 504, + "cf_160": null, + "cf_161": "update contract", + "cf_162": 251, + "cf_163": 753, + "cf_164": "support call", + "cf_165": "support invoice", + "cf_166": true, + "cf_167": null, + "cf_168": null, + "cf_169": null, + "cf_170": "up call", + "cf_171": false, + "cf_172": false, + "cf_173": false, + "cf_174": 249, + "cf_175": true, + "cf_176": "support support", + "cf_177": null, + "cf_178": "contract call", + "cf_179": false, + "cf_180": false, + "cf_181": 325, + "cf_182": 988, + "cf_183": true, + "cf_184": null, + "cf_185": 717, + "cf_186": "support demo", + "cf_187": true, + "cf_188": 559, + "cf_189": "renewal support", + "cf_190": 693, + "cf_191": 184, + "cf_192": "meeting update", + "cf_193": "invoice call", + "cf_194": null, + "cf_195": 968, + "cf_196": "contract invoice", + "cf_197": "offer offer", + "cf_198": 323, + "cf_199": false, + "cf_200": 656, + "cf_201": true, + "cf_202": 394, + "cf_203": false, + "cf_204": 120, + "cf_205": "update renewal", + "cf_206": "call follow", + "cf_207": 120, + "cf_208": "follow offer", + "cf_209": 902, + "cf_210": true, + "cf_211": "question up", + "cf_212": 631, + "cf_213": null, + "cf_214": 319, + "cf_215": 440, + "cf_216": null, + "cf_217": "contract up", + "cf_218": true, + "cf_219": 465, + "cf_220": 499, + "cf_221": false, + "cf_222": "call renewal", + "cf_223": false, + "cf_224": 592, + "cf_225": false, + "cf_226": true, + "cf_227": 421, + "cf_228": null, + "cf_229": null, + "cf_230": false, + "cf_231": false, + "cf_232": "update follow", + "cf_233": "renewal question", + "cf_234": null, + "cf_235": 10, + "cf_236": false, + "cf_237": null, + "cf_238": "support question", + "cf_239": "demo call", + "cf_240": null, + "cf_241": null, + "cf_242": false, + "cf_243": false, + "cf_244": "up offer", + "cf_245": 76, + "cf_246": 210, + "cf_247": false, + "cf_248": "follow offer", + "cf_249": true, + "cf_250": null, + "cf_251": "call update", + "cf_252": true, + "cf_253": 155, + "cf_254": true, + "cf_255": "renewal up", + "cf_256": "renewal update", + "cf_257": "offer meeting", + "cf_258": false, + "cf_259": null, + "cf_260": "meeting contract", + "cf_261": "up invoice", + "cf_262": "meeting meeting", + "cf_263": false, + "cf_264": 995, + "cf_265": null, + "cf_266": 819, + "cf_267": 10, + "cf_268": true, + "cf_269": true, + "cf_270": 700, + "cf_271": true, + "cf_272": 204, + "cf_273": false, + "cf_274": "contract contract", + "cf_275": 303, + "cf_276": null, + "cf_277": true, + "cf_278": 398, + "cf_279": true, + "cf_280": null, + "cf_281": false, + "cf_282": null, + "cf_283": null, + "cf_284": 649, + "cf_285": 511, + "cf_286": null, + "cf_287": false, + "cf_288": null, + "cf_289": "invoice up", + "cf_290": false, + "cf_291": 421, + "cf_292": 445, + "cf_293": "question call", + "cf_294": "renewal contract", + "cf_295": null, + "cf_296": 911, + "cf_297": null, + "cf_298": null, + "cf_299": true, + "cf_300": false + } +} diff --git a/apps/maple-playground/scripts/contact-flat-key.js b/apps/maple-playground/scripts/contact-flat-key.js new file mode 100644 index 0000000..436ed2a --- /dev/null +++ b/apps/maple-playground/scripts/contact-flat-key.js @@ -0,0 +1 @@ +return company || firstName; diff --git a/apps/maple-playground/scripts/contact-large-key.js b/apps/maple-playground/scripts/contact-large-key.js new file mode 100644 index 0000000..6421b23 --- /dev/null +++ b/apps/maple-playground/scripts/contact-large-key.js @@ -0,0 +1 @@ +return activities.length; diff --git a/apps/maple-playground/scripts/contact-nested-key.js b/apps/maple-playground/scripts/contact-nested-key.js new file mode 100644 index 0000000..cb3cce5 --- /dev/null +++ b/apps/maple-playground/scripts/contact-nested-key.js @@ -0,0 +1 @@ +return address.city; diff --git a/apps/maple-playground/scripts/context.json b/apps/maple-playground/scripts/context.json deleted file mode 100644 index 9b40565..0000000 --- a/apps/maple-playground/scripts/context.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "project": "Maple", - "app": "Playground" -} diff --git a/apps/maple-playground/scripts/demo-script.js b/apps/maple-playground/scripts/demo-script.js deleted file mode 100644 index 396086d..0000000 --- a/apps/maple-playground/scripts/demo-script.js +++ /dev/null @@ -1 +0,0 @@ -return project + ' ' + app; diff --git a/apps/maple-playground/scripts/measure-memory.ts b/apps/maple-playground/scripts/measure-run.ts similarity index 57% rename from apps/maple-playground/scripts/measure-memory.ts rename to apps/maple-playground/scripts/measure-run.ts index 9562281..91f0850 100644 --- a/apps/maple-playground/scripts/measure-memory.ts +++ b/apps/maple-playground/scripts/measure-run.ts @@ -1,35 +1,40 @@ /** - * Measures how much memory one run of a script allocates and retains, in Maple and in - * js-interpreter. Usage: + * Measures how long one run of a script takes and how much memory it allocates and retains, in + * Maple and in js-interpreter. Usage: * - * Pnpm --filter @netwin/maple-playground measure-memory [context.json] [--runs 5] + * Pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] + * [--time-runs 200] * - * A run "allocates" what the heap grows by while it executes; that is only exact when no garbage - * collection happens in between, so the script restarts itself with a young generation large - * enough for that, and flags every run during which a collection happened anyway. "Retained" is - * what is still alive after a forced collection. + * The context file is read and parsed once, so both engines receive a plain object, as a host's + * `run` call does. A run "allocates" what the heap grows by while it executes; that is only exact + * when no garbage collection happens in between, so the script restarts itself with a young + * generation large enough for that, and flags every run during which a collection happened + * anyway. "Retained" is what is still alive after a forced collection. The forced collections + * disturb timing, so runs are timed in a second loop without them. */ import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { PerformanceEntry, PerformanceObserver, performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; import { Maple } from '../../../packages/maple/src/index.ts'; import { runJsInterpreter } from '../src/app/data-access/js-interpreter-engine.ts'; const V8_FLAGS = ['--expose-gc', '--min-semi-space-size=512', '--max-semi-space-size=1024']; const WARM_UP_RUNS = 3; +const DEFAULT_CONTEXT = fileURLToPath(new URL('contact-context.json', import.meta.url)); declare const gc: (() => void) | undefined; interface Options { code: string; - context: string; + context: Record; runs: number; + timeRuns: number; } interface Sample { allocated: number; retained: number; - durationMs: number; /** A collection ran during the run, so `allocated` undercounts. */ disturbed: boolean; } @@ -37,6 +42,7 @@ interface Sample { interface EngineReport { engine: string; samples: Array; + durations: Array; value: string; } @@ -63,10 +69,10 @@ async function main(options: Options): Promise { ]; const outcomes: Array = []; for (const [engine, execute] of engines) { - outcomes.push(await measure(engine, execute, options.runs, collections)); + outcomes.push(await measure(engine, execute, options, collections)); } observer.disconnect(); - print(options.code, outcomes, options.runs); + print(options, outcomes); } function runMaple({ code, context }: Options): string { @@ -77,7 +83,7 @@ function runMaple({ code, context }: Options): string { async function measure( engine: string, execute: () => string, - runs: number, + { runs, timeRuns }: Options, collections: Array ): Promise { try { @@ -95,11 +101,13 @@ async function measure( const after = process.memoryUsage().heapUsed; collect(); const settled = process.memoryUsage().heapUsed; - timed.push({ - sample: { allocated: after - before, retained: settled - before, durationMs: end - start }, - start, - end - }); + timed.push({ sample: { allocated: after - before, retained: settled - before }, start, end }); + } + const durations: Array = []; + for (let index = 0; index < timeRuns; index++) { + const start = performance.now(); + execute(); + durations.push(performance.now() - start); } // Collection entries arrive asynchronously, so they are matched to the runs afterwards await new Promise((resolve) => setTimeout(resolve, 50)); @@ -107,7 +115,7 @@ async function measure( ...sample, disturbed: collections.some((entry) => entry.startTime >= start && entry.startTime <= end) })); - return { engine, samples, value }; + return { engine, samples, durations, value }; } catch (error) { return { engine, error: error instanceof Error ? error.message : String(error) }; } @@ -118,25 +126,32 @@ function collect(): void { gc?.(); } -function print(code: string, outcomes: ReadonlyArray, runs: number): void { - console.log(`Median of ${runs} runs after ${WARM_UP_RUNS} warm-up runs\n`); +function print({ code, runs, timeRuns }: Options, outcomes: ReadonlyArray): void { + console.log( + `Memory: median of ${runs} runs. Time: median, min and max of ${timeRuns} runs without ` + + `forced collections. Both after ${WARM_UP_RUNS} warm-up runs.\n` + ); const rows = outcomes.map((outcome) => { if ('error' in outcome) { return { engine: outcome.engine, allocated: '–', retained: '–', - time: '–', + median: '–', + min: '–', + max: '–', note: outcome.error }; } - const { samples } = outcome; + const { samples, durations } = outcome; const disturbed = samples.filter((sample) => sample.disturbed).length; return { engine: outcome.engine, allocated: megabytes(median(samples.map((sample) => sample.allocated))), retained: megabytes(median(samples.map((sample) => sample.retained))), - time: `${median(samples.map((sample) => sample.durationMs)).toFixed(2)} ms`, + median: milliseconds(median(durations)), + min: milliseconds(Math.min(...durations)), + max: milliseconds(Math.max(...durations)), note: disturbed === 0 ? '' @@ -144,6 +159,10 @@ function print(code: string, outcomes: ReadonlyArray, runs: number): vo }; }); console.table(rows); + const [maple, jsInterpreter] = outcomes; + if (maple && jsInterpreter && 'durations' in maple && 'durations' in jsInterpreter) { + console.log(`\n${comparison(median(maple.durations), median(jsInterpreter.durations))}`); + } const values = outcomes.flatMap((outcome) => ('value' in outcome ? [outcome.value] : [])); if (values.length === 2 && values[0] !== values[1]) { console.log('\nThe engines returned different results:'); @@ -159,11 +178,25 @@ function print(code: string, outcomes: ReadonlyArray, runs: number): vo ); } +/** How Maple's median time compares with js-interpreter's, phrased so the factor is at least 1. */ +function comparison(maple: number, jsInterpreter: number): string { + if (maple === jsInterpreter) { + return 'Maple is as fast as js-interpreter (median).'; + } + return maple < jsInterpreter + ? `Maple is ${(jsInterpreter / maple).toFixed(2)}× faster than js-interpreter (median).` + : `Maple is ${(maple / jsInterpreter).toFixed(2)}× slower than js-interpreter (median).`; +} + function median(values: ReadonlyArray): number { const sorted = values.toSorted((left, right) => left - right); return sorted[Math.floor(sorted.length / 2)] ?? 0; } +function milliseconds(value: number): string { + return `${value.toFixed(3)} ms`; +} + function megabytes(bytes: number): string { return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } @@ -171,22 +204,39 @@ function megabytes(bytes: number): string { function readOptions(args: ReadonlyArray): Options { const positional: Array = []; let runs = 5; + let timeRuns = 200; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === '--runs') { runs = Number(args[++index]); + } else if (arg === '--time-runs') { + timeRuns = Number(args[++index]); } else if (arg !== undefined) { positional.push(arg); } } - const [scriptPath, contextPath] = positional; - if (scriptPath === undefined || !Number.isSafeInteger(runs) || runs < 1) { - console.error('Usage: measure-memory [context.json] [--runs 5]'); + const [scriptPath, contextPath = DEFAULT_CONTEXT] = positional; + if (scriptPath === undefined || !isCount(runs) || !isCount(timeRuns)) { + console.error('Usage: measure-run [context.json] [--runs 5] [--time-runs 200]'); + process.exit(1); + } + const context: unknown = JSON.parse(readFileSync(contextPath, 'utf8')); + if (!isJsonObject(context)) { + console.error(`${contextPath} must hold a JSON object`); process.exit(1); } return { code: readFileSync(scriptPath, 'utf8'), - context: contextPath === undefined ? '{}' : readFileSync(contextPath, 'utf8'), - runs + context, + runs, + timeRuns }; } + +function isCount(value: number): boolean { + return Number.isSafeInteger(value) && value >= 1; +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/maple-playground/src/app/data-access/benchmark.worker.ts b/apps/maple-playground/src/app/data-access/benchmark.worker.ts index cb9bf6b..082c11e 100644 --- a/apps/maple-playground/src/app/data-access/benchmark.worker.ts +++ b/apps/maple-playground/src/app/data-access/benchmark.worker.ts @@ -12,7 +12,7 @@ addEventListener('message', (event: MessageEvent) => { runs, results: { maple: measure(runs, () => runMaple(code, context, limits)), - 'js-interpreter': measure(runs, () => runJsInterpreter(code, context)) + 'js-interpreter': measure(runs, () => runJsInterpreter(code, JSON.parse(context))) } } satisfies BenchmarkResponse); }); diff --git a/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts b/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts index 593feea..368a905 100644 --- a/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts +++ b/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts @@ -7,13 +7,12 @@ interface InterpreterInternals { } /** - * Runs `code` against the JSON `context` in js-interpreter and returns the result as JSON text. - * The script is wrapped in a function to allow `return`, and each context key becomes a global; + * Runs `code` against the parsed `data` in js-interpreter and returns the result as JSON text. + * The script is wrapped in a function to allow `return`, and each data key becomes a global; * js-interpreter understands ES5 only and has no operation limit. The result is read and - * serialized on the host, as Maple's is, so the context cannot shadow anything this relies on. + * serialized on the host, as Maple's is, so the data cannot shadow anything this relies on. */ -export function runJsInterpreter(code: string, context: string): string { - const data: unknown = JSON.parse(context); +export function runJsInterpreter(code: string, data: unknown): string { const interpreter = new Interpreter(`(function () {\n${code}\n})();`, (instance, scope) => { if (typeof data === 'object' && data !== null) { for (const [key, value] of Object.entries(data)) { From 88dfd6946d3259f7ef674c9653f50dad3c5dba13 Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 19:20:33 +0200 Subject: [PATCH 07/12] feat(input): accept data only as a plain object A JSON string is no longer parsed as data, and run types data as object. A host that holds JSON text parses it first; the playground's workers now do, and the Benchmark worker parses once, outside the timed runs, for both engines. --- .../src/app/data-access/benchmark.worker.ts | 39 +++++++++++++++---- .../app/data-access/maple-worker-protocol.ts | 12 ++++++ .../src/app/data-access/maple.worker.ts | 15 +++---- packages/maple/README.md | 4 +- packages/maple/docs/api.md | 6 +-- .../maple/src/builtins/array/array.spec.ts | 2 +- .../src/builtins/boolean/boolean.spec.ts | 2 +- packages/maple/src/builtins/date/date.spec.ts | 2 +- .../src/builtins/function/function.spec.ts | 2 +- packages/maple/src/builtins/globals.spec.ts | 2 +- .../src/builtins/iterator/iterator.spec.ts | 2 +- packages/maple/src/builtins/json/json.spec.ts | 2 +- packages/maple/src/builtins/locale.spec.ts | 2 +- packages/maple/src/builtins/map/map.spec.ts | 2 +- packages/maple/src/builtins/math/math.spec.ts | 2 +- .../maple/src/builtins/number/number.spec.ts | 2 +- .../maple/src/builtins/object/object.spec.ts | 2 +- .../maple/src/builtins/regexp/regexp.spec.ts | 2 +- packages/maple/src/builtins/set/set.spec.ts | 2 +- .../maple/src/builtins/string/string.spec.ts | 2 +- packages/maple/src/errors.spec.ts | 2 +- packages/maple/src/escapes.spec.ts | 18 +++++---- .../maple/src/evaluator/control-flow.spec.ts | 2 +- .../maple/src/evaluator/destructuring.spec.ts | 4 +- packages/maple/src/evaluator/globals.spec.ts | 4 +- packages/maple/src/evaluator/objects.spec.ts | 13 ++++--- .../maple/src/evaluator/operators.spec.ts | 2 +- packages/maple/src/host-functions.spec.ts | 2 +- packages/maple/src/maple.spec.ts | 25 ++++++------ packages/maple/src/maple.ts | 15 ++----- packages/maple/src/spec-helpers.ts | 8 ++-- 31 files changed, 116 insertions(+), 85 deletions(-) diff --git a/apps/maple-playground/src/app/data-access/benchmark.worker.ts b/apps/maple-playground/src/app/data-access/benchmark.worker.ts index 082c11e..5b33de8 100644 --- a/apps/maple-playground/src/app/data-access/benchmark.worker.ts +++ b/apps/maple-playground/src/app/data-access/benchmark.worker.ts @@ -2,6 +2,7 @@ import { Maple } from '@netwin/maple'; import type { BenchmarkRequest, BenchmarkResponse, EngineResult } from './benchmark-protocol'; import { runJsInterpreter } from './js-interpreter-engine'; +import { parseContext } from './maple-worker-protocol'; /** Unmeasured runs first, so neither engine is timed while the JIT is still cold. */ const WARM_UP_RUNS = 3; @@ -10,16 +11,36 @@ addEventListener('message', (event: MessageEvent) => { const { code, context, limits, runs } = event.data; postMessage({ runs, - results: { - maple: measure(runs, () => runMaple(code, context, limits)), - 'js-interpreter': measure(runs, () => runJsInterpreter(code, JSON.parse(context))) - } + results: benchmark(code, context, limits, runs) } satisfies BenchmarkResponse); }); +/** + * Parses the context once, outside the timed runs, since a host passes data it already holds; + * context that is not JSON fails both engines alike. + */ +function benchmark( + code: string, + context: string, + limits: BenchmarkRequest['limits'], + runs: number +): BenchmarkResponse['results'] { + let data: object; + try { + data = parseContext(context); + } catch (error) { + const failed: EngineResult = { ok: false, error: describe(error) }; + return { maple: failed, 'js-interpreter': failed }; + } + return { + maple: measure(runs, () => runMaple(code, data, limits)), + 'js-interpreter': measure(runs, () => runJsInterpreter(code, data)) + }; +} + /** One complete execution as the host would do it: setup, parse, run and result conversion. */ -function runMaple(code: string, context: string, limits: BenchmarkRequest['limits']): string { - return JSON.stringify(new Maple(limits).run(code, context).value) ?? 'undefined'; +function runMaple(code: string, data: object, limits: BenchmarkRequest['limits']): string { + return JSON.stringify(new Maple(limits).run(code, data).value) ?? 'undefined'; } function measure(runs: number, execute: () => string): EngineResult { @@ -43,6 +64,10 @@ function measure(runs: number, execute: () => string): EngineResult { value }; } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) }; + return { ok: false, error: describe(error) }; } } + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts index 1eca38d..6163799 100644 --- a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts +++ b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts @@ -12,6 +12,18 @@ export interface RunRequest { limits: RunLimits; } +/** + * Turns the JSON `context` of a request back into the data a run takes. Maple rejects an array + * itself, so only what is not an object at all is refused here. + */ +export function parseContext(context: string): object { + const data: unknown = JSON.parse(context); + if (typeof data !== 'object' || data === null) { + throw new TypeError('context must be a JSON object'); + } + return data; +} + /** An error, reduced to what survives `postMessage`. */ export interface RunError { message: string; diff --git a/apps/maple-playground/src/app/data-access/maple.worker.ts b/apps/maple-playground/src/app/data-access/maple.worker.ts index 2d04dae..7d395ff 100644 --- a/apps/maple-playground/src/app/data-access/maple.worker.ts +++ b/apps/maple-playground/src/app/data-access/maple.worker.ts @@ -1,11 +1,12 @@ /// import { Maple, MapleError, parse } from '@netwin/maple'; -import type { - RunError, - RunLimits, - RunRequest, - RunResponse, - StepOutcome +import { + parseContext, + type RunError, + type RunLimits, + type RunRequest, + type RunResponse, + type StepOutcome } from './maple-worker-protocol'; // Runs off the main thread, so an endless loop in the interpreter can be stopped by terminating @@ -29,7 +30,7 @@ addEventListener('message', (event: MessageEvent) => { /** Runs the code within the limits and reports the value as JSON text next to the run's stats. */ function runStep(code: string, context: string, limits: RunLimits): StepOutcome { try { - const { value, stats } = new Maple(limits).run(code, context); + const { value, stats } = new Maple(limits).run(code, parseContext(context)); return { ok: true, value: JSON.stringify(value, null, 2) ?? String(value), stats }; } catch (error) { return { ok: false, error: describeError(error) }; diff --git a/packages/maple/README.md b/packages/maple/README.md index 571e327..d4b7fb6 100644 --- a/packages/maple/README.md +++ b/packages/maple/README.md @@ -58,9 +58,9 @@ scripts such as infinite loops; they are not a CPU or memory limit. const maple = new Maple({ globals: { upper: (s: string) => s.toUpperCase() }, maxOps: 10_000 }); ``` -### `maple.run(code: string, data?: object | string, options?: RunOptions): MapleRunResult` +### `maple.run(code: string, data?: object, options?: RunOptions): MapleRunResult` -Runs `code` against `data` and returns `{ value, stats }`. `data` may also be a JSON string. `options.globals` adds +Runs `code` against `data` and returns `{ value, stats }`. `options.globals` adds bindings for this run only; they shadow the instance's `globals`, and the keys of `data` shadow both. `data` and `options.globals` arrive frozen and read-only; `options.freeze: false` lets the script change and reassign its own copy of them. Every failure inside the run throws a `MapleError` with a `code`, a `message` and, where known, `line` and `column`. diff --git a/packages/maple/docs/api.md b/packages/maple/docs/api.md index 187fdf9..d272844 100644 --- a/packages/maple/docs/api.md +++ b/packages/maple/docs/api.md @@ -25,7 +25,7 @@ const result = maple.run('return "Hello " + upper(name);', { name: 'Steve' }); Instance globals are converted once at construction (see [Boundary](boundary.md)). An instance holds no state between runs. -## `maple.run(code: string, data?: unknown, options?: RunOptions): MapleRunResult` +## `maple.run(code: string, data?: object, options?: RunOptions): MapleRunResult` Runs `code` against `data` and returns the copied result with the run's statistics: @@ -46,8 +46,8 @@ interface MapleRunStats { } ``` -`data` is `undefined`, a plain object, or a JSON string encoding a plain object; its top-level keys -become globals. `options.globals` adds bindings for this run only. `options.freeze: false` lets the +`data` is `undefined` or a plain object; its top-level keys become globals. `options.globals` adds +bindings for this run only. `options.freeze: false` lets the script change and reassign its copy of `data` and of the run globals (see [Boundary](boundary.md#changeable-input)). A `code` that is not a string, or diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index d202ef3..39b33e0 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/boolean/boolean.spec.ts b/packages/maple/src/builtins/boolean/boolean.spec.ts index ddf26f8..de1eba8 100644 --- a/packages/maple/src/builtins/boolean/boolean.spec.ts +++ b/packages/maple/src/builtins/boolean/boolean.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/date/date.spec.ts b/packages/maple/src/builtins/date/date.spec.ts index 1d60409..4b3b554 100644 --- a/packages/maple/src/builtins/date/date.spec.ts +++ b/packages/maple/src/builtins/date/date.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/function/function.spec.ts b/packages/maple/src/builtins/function/function.spec.ts index a8d3828..c86d900 100644 --- a/packages/maple/src/builtins/function/function.spec.ts +++ b/packages/maple/src/builtins/function/function.spec.ts @@ -10,7 +10,7 @@ const maple = new Maple({ } }); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/globals.spec.ts b/packages/maple/src/builtins/globals.spec.ts index b32e01b..b369051 100644 --- a/packages/maple/src/builtins/globals.spec.ts +++ b/packages/maple/src/builtins/globals.spec.ts @@ -3,7 +3,7 @@ import { Maple } from '../maple.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/iterator/iterator.spec.ts b/packages/maple/src/builtins/iterator/iterator.spec.ts index bbc4988..2490e3b 100644 --- a/packages/maple/src/builtins/iterator/iterator.spec.ts +++ b/packages/maple/src/builtins/iterator/iterator.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index 638b409..4807f9d 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/locale.spec.ts b/packages/maple/src/builtins/locale.spec.ts index 394d288..602edca 100644 --- a/packages/maple/src/builtins/locale.spec.ts +++ b/packages/maple/src/builtins/locale.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 657d957..6ad30c7 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/math/math.spec.ts b/packages/maple/src/builtins/math/math.spec.ts index ffb996b..008ce8b 100644 --- a/packages/maple/src/builtins/math/math.spec.ts +++ b/packages/maple/src/builtins/math/math.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/number/number.spec.ts b/packages/maple/src/builtins/number/number.spec.ts index 1d0c1f5..b43485a 100644 --- a/packages/maple/src/builtins/number/number.spec.ts +++ b/packages/maple/src/builtins/number/number.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 193b44b..9cd2509 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -4,7 +4,7 @@ import { errorMessage, runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/regexp/regexp.spec.ts b/packages/maple/src/builtins/regexp/regexp.spec.ts index f6f0293..f801cbc 100644 --- a/packages/maple/src/builtins/regexp/regexp.spec.ts +++ b/packages/maple/src/builtins/regexp/regexp.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/set/set.spec.ts b/packages/maple/src/builtins/set/set.spec.ts index 60310e4..eb4b6f1 100644 --- a/packages/maple/src/builtins/set/set.spec.ts +++ b/packages/maple/src/builtins/set/set.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/builtins/string/string.spec.ts b/packages/maple/src/builtins/string/string.spec.ts index 12b842f..cbc1062 100644 --- a/packages/maple/src/builtins/string/string.spec.ts +++ b/packages/maple/src/builtins/string/string.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/errors.spec.ts b/packages/maple/src/errors.spec.ts index 74c40a1..4865fcf 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from './spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index ed37e1e..eda8e27 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -11,19 +11,19 @@ import { runAndCatch, type UntypedRunner } from './spec-helpers.ts'; */ const maple = new Maple({ globals: { host: (): number => 1 } }); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } -function errorCode(source: string, data?: unknown): string | undefined { +function errorCode(source: string, data?: object): string | undefined { return runAndCatch(maple, source, data).code; } -function errorName(source: string, data?: unknown): string | undefined { +function errorName(source: string, data?: object): string | undefined { return runAndCatch(maple, source, data).sandboxError?.name; } -function runUnfrozen(code: string, data?: unknown, globals?: Record): unknown { +function runUnfrozen(code: string, data?: object, globals?: Record): unknown { return maple.run(code, data, { freeze: false, globals }).value; } @@ -166,9 +166,10 @@ describe('escape attempts', () => { expect(run('return Object.keys(JSON.parse(\'{"__proto__": {"polluted": true}}\'));')).toEqual( ['__proto__'] ); - expect(run('return Object.keys(own);', '{"own": {"__proto__": {"polluted": true}}}')).toEqual( - ['__proto__'] - ); + expect( + // A computed key makes `__proto__` an own property instead of setting the prototype + run('return Object.keys(own);', { own: { ['__proto__']: { polluted: true } } }) + ).toEqual(['__proto__']); expect(run('return ({}).polluted;')).toBeUndefined(); expect(errorName('({ __proto__: { polluted: true } });')).toBe('TypeError'); expect( @@ -311,7 +312,8 @@ describe('escape attempts', () => { }); it('so prototype keys stay out of reach', () => { - const data = '{"obj": {}, "own": {"__proto__": {"polluted": true}}}'; + // A computed key makes `__proto__` an own property instead of setting the prototype + const data = { obj: {}, own: { ['__proto__']: { polluted: true } } }; const errorOf = (code: string): unknown => runUnfrozen(`try { ${code} } catch (e) { return e.name; }`, data); diff --git a/packages/maple/src/evaluator/control-flow.spec.ts b/packages/maple/src/evaluator/control-flow.spec.ts index d70a873..3c8773e 100644 --- a/packages/maple/src/evaluator/control-flow.spec.ts +++ b/packages/maple/src/evaluator/control-flow.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/evaluator/destructuring.spec.ts b/packages/maple/src/evaluator/destructuring.spec.ts index ce6e8ee..9fe4b35 100644 --- a/packages/maple/src/evaluator/destructuring.spec.ts +++ b/packages/maple/src/evaluator/destructuring.spec.ts @@ -4,11 +4,11 @@ import { runAndCatch } from '../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } -function sandboxError(code: string, data?: unknown): { name: string; message: string } | undefined { +function sandboxError(code: string, data?: object): { name: string; message: string } | undefined { return runAndCatch(maple, code, data).sandboxError; } diff --git a/packages/maple/src/evaluator/globals.spec.ts b/packages/maple/src/evaluator/globals.spec.ts index e6007b3..62bc97a 100644 --- a/packages/maple/src/evaluator/globals.spec.ts +++ b/packages/maple/src/evaluator/globals.spec.ts @@ -14,7 +14,6 @@ function assignInData(options: object): unknown { describe('globals', () => { it('exposes each top-level data key as a binding', () => { expect(new Maple().run('return name;', { name: 'maple' }).value).toBe('maple'); - expect(new Maple().run('return name;', '{"name":"maple"}').value).toBe('maple'); }); it('exposes instance and run globals', () => { @@ -97,7 +96,8 @@ describe('globals', () => { }); it('treats a key named __proto__ as a plain binding', () => { - const { value } = new Maple().run('return __proto__;', '{"__proto__": 1}'); + // A computed key makes `__proto__` an own property instead of setting the prototype + const { value } = new Maple().run('return __proto__;', { ['__proto__']: 1 }); expect(value).toBe(1); }); diff --git a/packages/maple/src/evaluator/objects.spec.ts b/packages/maple/src/evaluator/objects.spec.ts index ec600d3..d75df87 100644 --- a/packages/maple/src/evaluator/objects.spec.ts +++ b/packages/maple/src/evaluator/objects.spec.ts @@ -4,11 +4,11 @@ import { runAndCatch } from '../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } -function sandboxError(code: string, data?: unknown): { name: string; message: string } | undefined { +function sandboxError(code: string, data?: object): { name: string; message: string } | undefined { return runAndCatch(maple, code, data).sandboxError; } @@ -132,9 +132,10 @@ describe('member access', () => { expect(sandboxError('return obj.__proto__;', data)?.name).toBe('TypeError'); expect(sandboxError('return obj["__pro" + "to__"];', data)?.name).toBe('TypeError'); expect(sandboxError('return (() => 1).prototype;')?.name).toBe('TypeError'); - expect(sandboxError('return own.__proto__;', '{"own": {"__proto__": 1}}')?.name).toBe( - 'TypeError' - ); + expect( + // A computed key makes `__proto__` an own property instead of setting the prototype + sandboxError('return own.__proto__;', { own: { ['__proto__']: 1 } })?.name + ).toBe('TypeError'); }); it('throws when reading from null or undefined', () => { @@ -230,7 +231,7 @@ describe('delete and in', () => { expect(run('return "toString" in (() => 1);')).toBe(true); expect(run('return "call" in (() => 1);')).toBe(true); expect(run('return "__proto__" in obj;', data)).toBe(false); - expect(run('return "__proto__" in own;', '{"own": {"__proto__": 1}}')).toBe(true); + expect(run('return "__proto__" in own;', { own: { ['__proto__']: 1 } })).toBe(true); expect(sandboxError('return "a" in 5;')).toMatchObject({ name: 'TypeError', message: "Cannot use 'in' operator to search for 'a' in 5" diff --git a/packages/maple/src/evaluator/operators.spec.ts b/packages/maple/src/evaluator/operators.spec.ts index d2ef1b7..9b985de 100644 --- a/packages/maple/src/evaluator/operators.spec.ts +++ b/packages/maple/src/evaluator/operators.spec.ts @@ -4,7 +4,7 @@ import { runAndCatch } from '../spec-helpers.ts'; const maple = new Maple(); -function run(code: string, data?: unknown): unknown { +function run(code: string, data?: object): unknown { return maple.run(code, data).value; } diff --git a/packages/maple/src/host-functions.spec.ts b/packages/maple/src/host-functions.spec.ts index 784bd63..9f75747 100644 --- a/packages/maple/src/host-functions.spec.ts +++ b/packages/maple/src/host-functions.spec.ts @@ -6,7 +6,7 @@ import { runAndCatch } from './spec-helpers.ts'; function sandboxError( maple: Maple, code: string, - data?: unknown + data?: object ): { name: string; message: string } | undefined { return runAndCatch(maple, code, data).sandboxError; } diff --git a/packages/maple/src/maple.spec.ts b/packages/maple/src/maple.spec.ts index 0f6ea2c..28f1c72 100644 --- a/packages/maple/src/maple.spec.ts +++ b/packages/maple/src/maple.spec.ts @@ -144,20 +144,21 @@ describe('Maple.run', () => { }); describe('data argument', () => { - it('accepts undefined, a plain object and a JSON string of a plain object', () => { + it('accepts undefined and a plain object', () => { expect(() => maple.run('return 1;')).not.toThrow(); expect(() => maple.run('return 1;', {})).not.toThrow(); expect(() => maple.run('return 1;', Object.create(null))).not.toThrow(); - expect(() => maple.run('return 1;', '{"a":1}')).not.toThrow(); }); it('rejects anything else with a plain TypeError before running', () => { - expect(() => maple.run('return 1;', 42)).toThrow(TypeError); - expect(() => maple.run('return 1;', [])).toThrow(TypeError); - expect(() => maple.run('return 1;', null)).toThrow(TypeError); - expect(() => maple.run('return 1;', new Date())).toThrow(TypeError); - expect(() => maple.run('return 1;', '[1]')).toThrow(TypeError); - expect(() => maple.run('return 1;', 'not json')).toThrow(TypeError); + const untyped: UntypedRunner = maple; + const error = new TypeError('data must be a plain object'); + + expect(() => untyped.run('return 1;', 42)).toThrow(error); + expect(() => untyped.run('return 1;', [])).toThrow(error); + expect(() => untyped.run('return 1;', null)).toThrow(error); + expect(() => untyped.run('return 1;', new Date())).toThrow(error); + expect(() => untyped.run('return 1;', '{"a":1}')).toThrow(error); }); it('rejects data nested deeper than 1000 levels with a plain TypeError', () => { @@ -166,12 +167,10 @@ describe('Maple.run', () => { for (let i = 0; i < levels; i++) { deep = [deep]; } - // Built by hand, since the host's own JSON.stringify overflows at this depth - const json = `{"v":${'['.repeat(levels + 1)}${']'.repeat(levels + 1)}}`; - expect(() => maple.run('return 1;', { v: deep })).toThrow(/nested deeper than 1000 levels$/); - expect(() => maple.run('return 1;', json)).toThrow(/nested deeper than 1000 levels$/); - expect(() => maple.run('return 1;', json)).toThrow(TypeError); + expect(() => maple.run('return 1;', { v: deep })).toThrow( + new TypeError('Unsupported value at data: nested deeper than 1000 levels') + ); expect(() => new Maple({ globals: { v: deep } })).toThrow( new TypeError('Unsupported value at globals: nested deeper than 1000 levels') ); diff --git a/packages/maple/src/maple.ts b/packages/maple/src/maple.ts index b7fe171..d46bf48 100644 --- a/packages/maple/src/maple.ts +++ b/packages/maple/src/maple.ts @@ -54,7 +54,7 @@ export class Maple { * Runs `code` against `data`. Everything that goes wrong inside the run throws a `MapleError`; * a misuse of this method itself throws a plain `TypeError` before anything runs. */ - public run(code: string, data?: unknown, options: RunOptions = {}): MapleRunResult { + public run(code: string, data?: object, options: RunOptions = {}): MapleRunResult { const budget = new Budget(this.#maxOps, this.#maxDepth); const evaluator: Evaluator = new Evaluator(budget, (nestedCode, nestedData, nestedOptions) => { const before = budget.stats(); @@ -168,18 +168,9 @@ function asPositiveIntegerOrThrow(name: string, value: number): number { } function asDataObjectOrThrow(data: unknown): Record { - const candidate = - typeof data === 'string' ? asParsedJsonOrThrow(data) : data === undefined ? {} : data; + const candidate = data === undefined ? {} : data; if (!isPlainObject(candidate)) { - throw new TypeError('data must be a plain object, or a JSON string of one'); + throw new TypeError('data must be a plain object'); } return candidate; } - -function asParsedJsonOrThrow(text: string): unknown { - try { - return JSON.parse(text); - } catch (error) { - throw new TypeError('data is not valid JSON', { cause: error }); - } -} diff --git a/packages/maple/src/spec-helpers.ts b/packages/maple/src/spec-helpers.ts index 6feba85..f426d35 100644 --- a/packages/maple/src/spec-helpers.ts +++ b/packages/maple/src/spec-helpers.ts @@ -2,15 +2,15 @@ import { MapleError } from './boundary/maple-error.ts'; import type { Maple } from './maple.ts'; /** - * `run` as a JavaScript caller sees it, with any value as the code. `Maple` and `Sandbox` assign to - * it without a cast, since method parameters are compared both ways. + * `run` as a JavaScript caller sees it, with any value as the code and the data. `Maple` and + * `Sandbox` assign to it without a cast, since method parameters are compared both ways. */ export interface UntypedRunner { - run(code: unknown): unknown; + run(code: unknown, data?: unknown): unknown; } /** Runs `code` and returns the `MapleError` it throws, failing the test if it doesn't throw. */ -export function runAndCatch(maple: Maple, code: string, data?: unknown): MapleError { +export function runAndCatch(maple: Maple, code: string, data?: object): MapleError { try { maple.run(code, data); } catch (error) { From 6728bfffcd95645efbaa8fcf950754423628b386 Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 19:25:05 +0200 Subject: [PATCH 08/12] perf(boundary): assign new own properties directly in defineOwn When nothing on the chain answers to a key, assignment creates the same own data property as defineProperty, and no setter can run, so defineOwn assigns directly; every other key, __proto__ and inherited names included, keeps defineProperty. Copying the 75 KB contact record into a run drops from about 0.77 to 0.34 ms and from 1.22 to 0.85 MB allocated, and a 2,000-item filter/map/reduce from 8.0 to 5.6 ms. --- .../maple/src/support/own-property.spec.ts | 81 +++++++++++++++++++ packages/maple/src/support/own-property.ts | 8 ++ 2 files changed, 89 insertions(+) create mode 100644 packages/maple/src/support/own-property.spec.ts diff --git a/packages/maple/src/support/own-property.spec.ts b/packages/maple/src/support/own-property.spec.ts new file mode 100644 index 0000000..92530a5 --- /dev/null +++ b/packages/maple/src/support/own-property.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { defineOwn } from './own-property.ts'; + +const DATA_PROPERTY = { enumerable: true, writable: true, configurable: true }; + +describe('defineOwn', () => { + it('adds a new key as a writable, enumerable, configurable data property', () => { + const target = {}; + defineOwn(target, 'a', 1); + + expect(Object.getOwnPropertyDescriptor(target, 'a')).toEqual({ value: 1, ...DATA_PROPERTY }); + }); + + it('keeps __proto__ a plain own key and leaves the prototype alone', () => { + const target = {}; + defineOwn(target, '__proto__', { polluted: true }); + + expect(Object.getPrototypeOf(target)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(target, '__proto__')).toEqual({ + value: { polluted: true }, + ...DATA_PROPERTY + }); + }); + + it('shadows inherited names with own data properties', () => { + const target = {}; + defineOwn(target, 'toString', 1); + defineOwn(target, 'hasOwnProperty', 2); + defineOwn(target, 'constructor', 3); + + expect(Object.getOwnPropertyDescriptor(target, 'toString')).toEqual({ + value: 1, + ...DATA_PROPERTY + }); + expect(Object.getOwnPropertyDescriptor(target, 'hasOwnProperty')).toEqual({ + value: 2, + ...DATA_PROPERTY + }); + expect(Object.getOwnPropertyDescriptor(target, 'constructor')).toEqual({ + value: 3, + ...DATA_PROPERTY + }); + }); + + it('extends an array when the index is past its end', () => { + const target = [1]; + defineOwn(target, '3', 4); + + expect(target).toHaveLength(4); + expect(Object.getOwnPropertyDescriptor(target, '3')).toEqual({ value: 4, ...DATA_PROPERTY }); + }); + + it('works on an object without a prototype', () => { + const target: object = Object.create(null); + defineOwn(target, 'a', 1); + defineOwn(target, '__proto__', 2); + + expect(Object.getOwnPropertyDescriptor(target, 'a')).toEqual({ value: 1, ...DATA_PROPERTY }); + expect(Object.getOwnPropertyDescriptor(target, '__proto__')).toEqual({ + value: 2, + ...DATA_PROPERTY + }); + }); + + it('throws on a non-extensible target', () => { + expect(() => defineOwn(Object.preventExtensions({}), 'a', 1)).toThrow(TypeError); + expect(() => defineOwn(Object.preventExtensions({}), 'toString', 1)).toThrow(TypeError); + }); + + it('does not call an inherited setter', () => { + const set = vi.fn<(value: unknown) => void>(); + const target: object = Object.create(Object.defineProperty({}, 'probe', { set })); + defineOwn(target, 'probe', 1); + + expect(set).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(target, 'probe')).toEqual({ + value: 1, + ...DATA_PROPERTY + }); + }); +}); diff --git a/packages/maple/src/support/own-property.ts b/packages/maple/src/support/own-property.ts index 5817b9c..98a39cc 100644 --- a/packages/maple/src/support/own-property.ts +++ b/packages/maple/src/support/own-property.ts @@ -1,5 +1,13 @@ /** Adds an own property while building a value; defineProperty keeps `__proto__` data. */ export function defineOwn(target: object, key: string, value: unknown): void { + // Nothing on the chain answers to `key`, so assignment creates the same own data property, + // and no setter can run + if (!(key in target)) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- any object takes a string key, and the `in` check above is the real guard + (target as Record)[key] = value; + return; + } + Object.defineProperty(target, key, { value, enumerable: true, From ce33aa201dac20754906068171eae106a5ed422d Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 19:42:48 +0200 Subject: [PATCH 09/12] perf(input): convert data keys on first read Each top-level key of data that holds an object, array or function is copied when the script first reads it, at most once per run; primitives are bound at once. All keys of a run share one conversion, so shared values and cycles stay intact. An unsupported value inside a key now ends the run with INTERNAL_ERROR at the read instead of a TypeError before the run, and one the script never reads goes unnoticed. A one-key read of the 75 KB contact record drops from 0.34 to 0.04 ms and from 0.87 to 0.09 MB allocated. --- packages/maple/docs/api.md | 21 +- packages/maple/docs/boundary.md | 13 +- packages/maple/docs/security.md | 6 +- packages/maple/src/boundary/from-host.ts | 42 +++- packages/maple/src/boundary/lazy-data.spec.ts | 189 ++++++++++++++++++ packages/maple/src/escapes.spec.ts | 6 +- packages/maple/src/evaluator/evaluator.ts | 13 +- packages/maple/src/evaluator/globals.spec.ts | 12 +- packages/maple/src/evaluator/scope.spec.ts | 17 +- packages/maple/src/evaluator/scope.ts | 11 +- packages/maple/src/maple.spec.ts | 15 +- packages/maple/src/maple.ts | 43 +++- 12 files changed, 339 insertions(+), 49 deletions(-) create mode 100644 packages/maple/src/boundary/lazy-data.spec.ts diff --git a/packages/maple/docs/api.md b/packages/maple/docs/api.md index d272844..6fd8a31 100644 --- a/packages/maple/docs/api.md +++ b/packages/maple/docs/api.md @@ -50,9 +50,10 @@ interface MapleRunStats { bindings for this run only. `options.freeze: false` lets the script change and reassign its copy of `data` and of the run globals (see [Boundary](boundary.md#changeable-input)). -A `code` that is not a string, or -a `data` value Maple cannot copy, throws a plain `TypeError` before the run starts, because that is -a host programming error, not a script error. +A `code` that is not a string, a `data` that is not a plain object, or a symbol or bigint directly +under `data` throws a plain `TypeError` before the run starts, because that is a host programming +error, not a script error. A value Maple cannot copy deeper in `data` ends the run with +`INTERNAL_ERROR` when the script first reads its key (see [Boundary](boundary.md)). `run` is synchronous. It throws a `MapleError` for every failure that originates in the script, the budget or a host function. @@ -103,13 +104,13 @@ class MapleError extends Error { `MapleErrorCode` is exported as a const object, so a consumer compares with `MapleErrorCode.LIMIT_EXCEEDED`: -| Code | Raised when | -| ------------------- | -------------------------------------------------------------------------------- | -| `PARSE_ERROR` | Acorn cannot parse the source | -| `DISALLOWED_SYNTAX` | The evaluator reaches an unsupported construct or a `new` target not on the list | -| `RUNTIME_ERROR` | A sandbox error was not caught (`TypeError`, `ReferenceError`, a script `throw`) | -| `LIMIT_EXCEEDED` | The operation budget or the depth limit was hit | -| `INTERNAL_ERROR` | A host function returned an unsupported value, or Maple itself failed | +| Code | Raised when | +| ------------------- | ---------------------------------------------------------------------------------------------------- | +| `PARSE_ERROR` | Acorn cannot parse the source | +| `DISALLOWED_SYNTAX` | The evaluator reaches an unsupported construct or a `new` target not on the list | +| `RUNTIME_ERROR` | A sandbox error was not caught (`TypeError`, `ReferenceError`, a script `throw`) | +| `LIMIT_EXCEEDED` | The operation budget or the depth limit was hit | +| `INTERNAL_ERROR` | A host function returned an unsupported value, the script read one in `data`, or Maple itself failed | `line` and `column` point into the source of the program that was executing, which for a nested run is the nested source. `sandboxError` has the shape of an error in a result (see diff --git a/packages/maple/docs/boundary.md b/packages/maple/docs/boundary.md index 0085c48..9266d0e 100644 --- a/packages/maple/docs/boundary.md +++ b/packages/maple/docs/boundary.md @@ -20,7 +20,11 @@ globals), the arguments of a host function, its return value, and the result of - "Anything else" is a class instance, a typed array, a boxed primitive, a symbol, a promise or a host `Error`. Nothing is flattened silently. - "`TypeError` before" is a plain `TypeError` thrown by `run` or `new Maple` before the run starts, - naming the path to the value, such as `Unsupported value at data.a.b: Thing`. + naming the path to the value, such as `Unsupported value at globals.a.b: Thing`. Inside a + top-level key of `data` it is an `INTERNAL_ERROR` instead, raised when the script first reads + that key, at the position of the read, with the same message and the `TypeError` as its `cause`. + The script cannot catch it, and a key the script never reads goes unnoticed. A symbol or bigint + directly under `data`, and a `data` that is not a plain object, still fail before the run. - A `TypeError` or `RangeError` on the way to a host function is thrown in the script at the call, so the script can catch it. The host function is not called. - `INTERNAL_ERROR` from a host function's return value means the host function has a bug, so the @@ -36,7 +40,12 @@ The global scope is composed from three layers, a later one shadowing an earlier 1. Instance globals (`new Maple({ globals })`), converted once at construction 2. Run globals (`run(code, data, { globals })`), converted once per run -3. The top-level keys of `data`, converted once per run +3. The top-level keys of `data`, each converted on first read and at most once per run + +Which keys `data` has, and what each one holds, is taken when the run starts: a key the host adds +later, or a value it puts under a key, is not seen. A key holding an object, array or function is +copied when the script first reads it, so what the host changes inside that value before then is. +All keys of a run share one conversion, so a value reached under two keys is still one value. One instance can serve many runs: what every run needs goes into the instance globals, what belongs to one run into its run globals or `data`: diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 67175cc..32f971e 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -47,9 +47,9 @@ it can terminate. around it), or `undefined`. There is no global object, built-ins never pass an internal value as `this`, and host functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see - [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is - frozen unless the host asks for a changeable one for a single run; instance globals are frozen - always. + [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. A key of + `data` is copied when the script first reads it. The copy is frozen unless the host asks for a + changeable one for a single run; instance globals are frozen always. - **No state between runs.** An instance holds only its limits and its frozen globals. What a run adds to a shared built-in, host function or namespace is gone when the next run starts. Host functions are the one channel through which the host may share state between runs, and that is diff --git a/packages/maple/src/boundary/from-host.ts b/packages/maple/src/boundary/from-host.ts index 5755ecd..93c8da6 100644 --- a/packages/maple/src/boundary/from-host.ts +++ b/packages/maple/src/boundary/from-host.ts @@ -33,12 +33,42 @@ export function fromHost( path: string = 'value', options: FromHostOptions = {} ): unknown { - const settings: Settings = { - freeze: options.freeze ?? true, - functions: options.functions ?? true, - root: path - }; - return convert(value, path, new Map(), settings, 0); + return new HostConversion(path, options).convert(value, path, 0); +} + +/** + * One conversion whose values may be copied in several calls, such as the keys of `data` as a run + * reads them. The calls share their copies, so a value reached twice stays shared and a cycle is + * kept across them. + */ +export class HostConversion { + readonly #copies = new Map(); + readonly #settings: Settings; + + /** `root` names a value nested too deep, as `fromHost`'s path does. */ + constructor(root: string, options: FromHostOptions = {}) { + this.#settings = { + freeze: options.freeze ?? true, + functions: options.functions ?? true, + root + }; + } + + /** Copies `value`, found at `path`, `depth` levels below the root; throws as `fromHost` does. */ + public convert(value: unknown, path: string, depth: number): unknown { + return convert(value, path, this.#copies, this.#settings, depth); + } +} + +/** + * Thrown when the script first reads a `data` key that holds a value the model does not cover. + * The input is the host's bug, so the evaluator ends the run at the read, where the script cannot + * catch it. + */ +export class UnsupportedInputError extends Error { + constructor(cause: TypeError) { + super(cause.message, { cause }); + } } function convert( diff --git a/packages/maple/src/boundary/lazy-data.spec.ts b/packages/maple/src/boundary/lazy-data.spec.ts new file mode 100644 index 0000000..76e4635 --- /dev/null +++ b/packages/maple/src/boundary/lazy-data.spec.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { Maple } from '../maple.ts'; +import { runAndCatch } from '../spec-helpers.ts'; +import { intrinsic } from './host.ts'; + +class Thing { + public readonly id = 1; +} + +/** A host address whose `street` getter counts how often a copy of the object is made. */ +function countedAddress(): { address: { street: string; city: string }; reads: () => number } { + let reads = 0; + const address = { + get street(): string { + reads++; + return 'Main St'; + }, + city: 'Berlin' + }; + return { address, reads: () => reads }; +} + +describe('data keys', () => { + const maple = new Maple(); + + describe('are copied on first read', () => { + it('so a key the script never reads is never copied', () => { + const { address, reads } = countedAddress(); + + expect(maple.run('return 1;', { address }).value).toBe(1); + expect(reads()).toBe(0); + }); + + it('and only once per run', () => { + const { address, reads } = countedAddress(); + + expect(maple.run('return address.street || address.street2;', { address }).value).toBe( + 'Main St' + ); + expect(reads()).toBe(1); + }); + + it('so every read sees the same copy', () => { + expect(maple.run('return address === address;', { address: { city: 'Berlin' } }).value).toBe( + true + ); + }); + + it('when typeof reads them', () => { + const { address, reads } = countedAddress(); + const data = { address, format: (): string => '' }; + + expect(maple.run('return [typeof address, typeof format];', data).value).toEqual([ + 'object', + 'function' + ]); + expect(reads()).toBe(1); + }); + + it('unless the script shadows them', () => { + const { address, reads } = countedAddress(); + + expect(maple.run('let address = 1; return address;', { address }).value).toBe(1); + expect(maple.run('function address() {} return typeof address;', { address }).value).toBe( + 'function' + ); + expect(reads()).toBe(0); + }); + }); + + describe('share one conversion per run', () => { + it('so a value under two keys stays one value', () => { + const shared = { n: 1 }; + + expect(maple.run('return a === b;', { a: shared, b: shared }).value).toBe(true); + }); + + it('so a cycle across keys is kept', () => { + const a: Record = {}; + const b = { a }; + a['b'] = b; + + expect(maple.run('return a.b === b && b.a === a;', { a, b }).value).toBe(true); + }); + }); + + describe('fail on first read when unsupported', () => { + it('so a key the script never reads goes unnoticed', () => { + expect(maple.run('return 1;', { thing: new Thing() }).value).toBe(1); + }); + + it('with an INTERNAL_ERROR at the read that the script cannot catch', () => { + const error = runAndCatch(maple, 'try {\n return [thing];\n} catch {\n return 1;\n}', { + thing: { inner: new Thing() } + }); + + expect(error).toMatchObject({ + code: 'INTERNAL_ERROR', + message: 'Unsupported value at data.thing.inner: Thing', + line: 2, + column: 10 + }); + expect(error.cause).toEqual(new TypeError('Unsupported value at data.thing.inner: Thing')); + }); + }); + + describe('when frozen', () => { + it('are read-only bindings to frozen copies', () => { + const address = { city: 'Berlin' }; + + expect(maple.run('return Object.isFrozen(address);', { address }).value).toBe(true); + expect(runAndCatch(maple, 'address.city = "Hamburg";', { address }).sandboxError).toEqual( + expect.objectContaining({ name: 'TypeError' }) + ); + expect(runAndCatch(maple, 'address = {};', { address }).sandboxError?.message).toBe( + "Cannot assign to read-only global 'address'" + ); + expect(address.city).toBe('Berlin'); + }); + }); + + describe('when not frozen', () => { + it('are replaced unread by an assignment', () => { + const { address, reads } = countedAddress(); + const { value } = maple.run('address = 1; return address;', { address }, { freeze: false }); + + expect(value).toBe(1); + expect(reads()).toBe(0); + }); + + it('are copies the script may change', () => { + const address = { city: 'Berlin' }; + const { value } = maple.run( + 'address.city = "Hamburg"; return address;', + { address }, + { freeze: false } + ); + + expect(value).toEqual({ city: 'Hamburg' }); + expect(address.city).toBe('Berlin'); + }); + }); + + describe('are taken as they are when the run starts', () => { + it('so a later key or a replaced value is not seen', () => { + const data: Record = { address: { city: 'Berlin' } }; + data['change'] = (): void => { + data['late'] = 1; + data['address'] = { city: 'Hamburg' }; + }; + + expect(maple.run('change(); return [typeof late, address.city];', data).value).toEqual([ + 'undefined', + 'Berlin' + ]); + }); + + it('but what a key holds is copied when it is read', () => { + const address = { city: 'Berlin' }; + const data = { + address, + move: (): void => { + address.city = 'Hamburg'; + } + }; + + expect(maple.run('move(); return address.city;', data).value).toBe('Hamburg'); + }); + }); + + it('of a nested run are its own and copied on first read', () => { + const { address, reads } = countedAddress(); + const nested = new Maple({ + globals: { + inner: intrinsic( + (_args, sandbox) => + sandbox.run('return [typeof outer, address.city];', { address, unused: new Thing() }) + .value + ) + } + }); + + expect(nested.run('return inner();', { outer: { n: 1 } }).value).toEqual([ + 'undefined', + 'Berlin' + ]); + expect(reads()).toBe(1); + }); +}); diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index eda8e27..60239d1 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -255,9 +255,9 @@ describe('escape attempts', () => { const regexp = /a/g; // @ts-expect-error - assigning a host object to lastIndex is normally not allowed in TypeScript regexp.lastIndex = host; - expect(() => run('r.lastIndex.n = 1;', { r: regexp })).toThrow(TypeError); - expect(() => maple.run('r.lastIndex.n = 1;', { r: regexp }, { freeze: false })).toThrow( - TypeError + expect(errorCode('r.lastIndex.n = 1;', { r: regexp })).toBe('INTERNAL_ERROR'); + expect(() => runUnfrozen('r.lastIndex.n = 1;', { r: regexp })).toThrow( + 'Unsupported value at data.r.lastIndex: Object' ); expect(host.n).toBe(0); }); diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index a2d812c..46ac47b 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -29,7 +29,7 @@ import type { VariableDeclaration, WhileStatement } from 'acorn'; -import { fromHost } from '../boundary/from-host.ts'; +import { fromHost, UnsupportedInputError } from '../boundary/from-host.ts'; import { type HostFunction, type Intrinsic, isExposed, type Sandbox } from '../boundary/host.ts'; import { asMapleError, @@ -1338,7 +1338,7 @@ export class Evaluator { // Strict mode: the receiver as it is, never boxed or replaced by a global object scope.bindReceiver(receiver); // Built on the first read only; copying the arguments costs one operation per argument - scope.defineLazy('arguments', () => { + scope.defineLazy('arguments', 'var', () => { this.charge(args.length, node); return createArgumentsObject(args); }); @@ -1397,7 +1397,14 @@ export class Evaluator { node ); } - return binding.value; + try { + return binding.value; + } catch (error) { + if (error instanceof UnsupportedInputError) { + return this.fail(MapleErrorCode.INTERNAL_ERROR, error.message, node, error.cause); + } + throw error; + } } private assignIdentifier(node: Identifier, value: unknown, scope: Scope): void { diff --git a/packages/maple/src/evaluator/globals.spec.ts b/packages/maple/src/evaluator/globals.spec.ts index 62bc97a..d6b20a9 100644 --- a/packages/maple/src/evaluator/globals.spec.ts +++ b/packages/maple/src/evaluator/globals.spec.ts @@ -116,12 +116,18 @@ describe('globals', () => { expect(Object.isFrozen(data.list)).toBe(false); }); - it('rejects unsupported values in globals with a plain TypeError before the run', () => { + it('rejects unsupported values in globals and directly in data with a plain TypeError before the run', () => { expect(() => new Maple({ globals: { bad: Symbol('x') } })).toThrow( new TypeError('Unsupported value at globals.bad: symbol') ); - expect(() => new Maple().run('return 1;', { bad: new Uint8Array() })).toThrow( - new TypeError('Unsupported value at data.bad: Uint8Array') + expect(() => new Maple({ globals: { bad: new Uint8Array() } })).toThrow( + new TypeError('Unsupported value at globals.bad: Uint8Array') + ); + expect(() => new Maple().run('return 1;', { bad: Symbol('x') })).toThrow( + new TypeError('Unsupported value at data.bad: symbol') + ); + expect(() => new Maple().run('return 1;', { bad: 1n })).toThrow( + new TypeError('Unsupported value at data.bad: bigint') ); expect(() => new Maple().run('return 1;', {}, { globals: { bad: Symbol('x') } })).toThrow( new TypeError('Unsupported value at options.globals.bad: symbol') diff --git a/packages/maple/src/evaluator/scope.spec.ts b/packages/maple/src/evaluator/scope.spec.ts index 46b90ed..c366742 100644 --- a/packages/maple/src/evaluator/scope.spec.ts +++ b/packages/maple/src/evaluator/scope.spec.ts @@ -52,7 +52,7 @@ describe('Scope', () => { it('defines an initialized var binding without creating its value', () => { const scope = new Scope(); const create = vi.fn<() => unknown>(() => 1); - scope.defineLazy('a', create); + scope.defineLazy('a', 'var', create); expect(scope.lookup('a')).toMatchObject({ kind: 'var', initialized: true }); expect(create).not.toHaveBeenCalled(); @@ -61,7 +61,7 @@ describe('Scope', () => { it('creates the value on the first read and keeps it for every later one', () => { const scope = new Scope(); const create = vi.fn<() => unknown>(() => ({ created: true })); - scope.defineLazy('a', create); + scope.defineLazy('a', 'var', create); const first = scope.lookup('a')?.value; @@ -73,7 +73,7 @@ describe('Scope', () => { it('lets an assignment replace the value without ever creating it', () => { const scope = new Scope(); const create = vi.fn<() => unknown>(() => 1); - scope.defineLazy('a', create); + scope.defineLazy('a', 'var', create); const binding = scope.lookup('a'); if (!binding) { throw new Error('expected a binding'); @@ -89,12 +89,21 @@ describe('Scope', () => { const outer = new Scope(); const inner = new Scope(outer); const create = vi.fn<() => unknown>(() => 1); - outer.defineLazy('a', create); + outer.defineLazy('a', 'var', create); expect(inner.lookup('a')?.value).toBe(1); expect(outer.lookup('a')?.value).toBe(1); expect(create).toHaveBeenCalledTimes(1); }); + + it('keeps the kind it is given', () => { + const scope = new Scope(); + const create = vi.fn<() => unknown>(() => 1); + scope.defineLazy('a', 'global', create); + + expect(scope.lookup('a')).toMatchObject({ kind: 'global', initialized: true }); + expect(create).not.toHaveBeenCalled(); + }); }); describe('receiver', () => { diff --git a/packages/maple/src/evaluator/scope.ts b/packages/maple/src/evaluator/scope.ts index e618215..70350c6 100644 --- a/packages/maple/src/evaluator/scope.ts +++ b/packages/maple/src/evaluator/scope.ts @@ -29,14 +29,14 @@ export class Scope { } /** - * A `var` binding whose value `create` makes on the first read, so a value that is costly to - * build and rarely needed is only built when it is. An assignment replaces it unread. + * A binding whose value `create` makes on the first read, so a value that is costly to build and + * rarely needed is only built when it is. An assignment replaces it unread. */ - public defineLazy(name: string, create: () => unknown): void { + public defineLazy(name: string, kind: 'var' | 'global', create: () => unknown): void { let created = false; let value: unknown; this.#bindings.set(name, { - kind: 'var', + kind, initialized: true, get value(): unknown { if (!created) { @@ -89,7 +89,8 @@ export class Scope { /** * A sibling scope with copies of the own bindings, which gives a loop fresh per-iteration - * bindings. + * bindings. Copying reads a lazy binding's value, which is why only loop scopes are copied, + * never a global or function scope that holds one. */ public copy(): Scope { const scope = new Scope(this.#parent); diff --git a/packages/maple/src/maple.spec.ts b/packages/maple/src/maple.spec.ts index 28f1c72..a570c9a 100644 --- a/packages/maple/src/maple.spec.ts +++ b/packages/maple/src/maple.spec.ts @@ -161,16 +161,21 @@ describe('Maple.run', () => { expect(() => untyped.run('return 1;', '{"a":1}')).toThrow(error); }); - it('rejects data nested deeper than 1000 levels with a plain TypeError', () => { + it('rejects data nested deeper than 1000 levels when the script reads it', () => { const levels = 20_000; let deep: unknown = []; for (let i = 0; i < levels; i++) { deep = [deep]; } - - expect(() => maple.run('return 1;', { v: deep })).toThrow( - new TypeError('Unsupported value at data: nested deeper than 1000 levels') - ); + expect(maple.run('return 1;', { v: deep }).value).toBe(1); + const error = runAndCatch(maple, 'const unused = 1;\nreturn v;', { v: deep }); + expect(error).toMatchObject({ + code: 'INTERNAL_ERROR', + message: 'Unsupported value at data: nested deeper than 1000 levels', + line: 2, + column: 7 + }); + expect(error.cause).toBeInstanceOf(TypeError); expect(() => new Maple({ globals: { v: deep } })).toThrow( new TypeError('Unsupported value at globals: nested deeper than 1000 levels') ); diff --git a/packages/maple/src/maple.ts b/packages/maple/src/maple.ts index d46bf48..8948fc6 100644 --- a/packages/maple/src/maple.ts +++ b/packages/maple/src/maple.ts @@ -1,5 +1,5 @@ import type { Program } from 'acorn'; -import { fromHost } from './boundary/from-host.ts'; +import { fromHost, HostConversion, UnsupportedInputError } from './boundary/from-host.ts'; import { asMapleError } from './boundary/maple-error.ts'; import { NestingLimitError, toHost } from './boundary/to-host.ts'; import { STANDARD_GLOBALS } from './builtins/standard-globals.ts'; @@ -76,14 +76,14 @@ export class Maple { // Only an explicit `false` unfreezes, so a JavaScript caller's typo keeps the safe default const freeze = options.freeze !== false; const runGlobals = asGlobalsOrThrow(options.globals ?? {}, 'options.globals', freeze); - const dataGlobals = asGlobalsOrThrow(asDataObjectOrThrow(data), 'data', freeze); - const runKind: BindingKind = freeze ? 'global' : 'var'; + const dataObject = asDataObjectOrThrow(data); + const runKind = freeze ? 'global' : 'var'; const globals = createGlobalScope([ { bindings: STANDARD_GLOBALS, kind: 'global' }, { bindings: this.#globals, kind: 'global' }, - { bindings: runGlobals, kind: runKind }, - { bindings: dataGlobals, kind: runKind } + { bindings: runGlobals, kind: runKind } ]); + defineDataBindings(globals, dataObject, runKind, freeze); return { program, globals }; } @@ -139,6 +139,39 @@ function createGlobalScope(layers: ReadonlyArray): Scope { return scope; } +/** + * Binds the top-level keys of `data` as they are now; a later change to which keys the host's + * object has, or to what one of them holds, is not seen. A primitive is bound at once, so a symbol + * or bigint fails before the run. Anything else is copied when the script first reads it, all keys + * sharing one conversion, so a value reached under two keys stays one value. + */ +function defineDataBindings( + scope: Scope, + data: Record, + kind: 'var' | 'global', + freeze: boolean +): void { + const conversion = new HostConversion('data', { freeze }); + for (const key of Object.keys(data)) { + const value = data[key]; + const path = `data.${key}`; + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) { + scope.define(key, kind, conversion.convert(value, path, 1)); + continue; + } + scope.defineLazy(key, kind, () => { + try { + return conversion.convert(value, path, 1); + } catch (error) { + if (error instanceof TypeError) { + throw new UnsupportedInputError(error); + } + throw error; + } + }); + } +} + // Typed as a string, but a JavaScript caller may pass anything, and the evaluator must only ever // see what `parse` produced function asCodeOrThrow(code: unknown): string { From e53f816cf0392984171f4887fff2c306a78f3658 Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 20:05:32 +0200 Subject: [PATCH 10/12] feat(parse): cache syntax trees per instance when enabled new Maple({ astCache: true }) keeps the syntax trees of up to 200 scripts, keyed by their source text, so a script that runs again is not parsed again; { maxEntries } sets another limit, the least recently used script is dropped when full, and maple.clearAstCache() empties it. Trees are deep-frozen before they are kept, code that does not parse is not kept, and nested runs share the cache. measure-run gains --ast-cache, which reuses one instance: a one-line script drops from 0.017 to 0.008 ms, a 10-line script from 0.55 to 0.51 ms. --- apps/maple-playground/README.md | 11 +- .../scripts/contact-ten-lines.js | 19 ++ apps/maple-playground/scripts/measure-run.ts | 38 +++- packages/maple/README.md | 3 +- packages/maple/docs/api.md | 16 +- packages/maple/docs/security.md | 8 +- packages/maple/src/ast-cache.spec.ts | 166 ++++++++++++++++++ packages/maple/src/ast-cache.ts | 61 +++++++ packages/maple/src/maple.ts | 37 +++- 9 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 apps/maple-playground/scripts/contact-ten-lines.js create mode 100644 packages/maple/src/ast-cache.spec.ts create mode 100644 packages/maple/src/ast-cache.ts diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index b3a2d62..f70245e 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -41,13 +41,14 @@ Browsers have no usable per-run memory measurement, so `measure-run` measures ti and a context from files: ```sh -pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] [--time-runs 200] +pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] [--time-runs 200] [--ast-cache] ``` The context defaults to `scripts/contact-context.json`, a synthetic contact record of 46 flat fields, an `address`, 450 `activities` and 300 `customFields` (about 75 KB as JSON). `contact-flat-key.js`, `contact-nested-key.js` and -`contact-large-key.js` beside it read a flat key, a nested key and the large array. The file is read and parsed once, -before any run, so both engines receive a plain object, as a host's `run` call does. +`contact-large-key.js` beside it read a flat key, a nested key and the large array; `contact-ten-lines.js` is a +10-line script over several keys, written with `const` and arrow functions, which js-interpreter cannot run. The file +is read and parsed once, before any run, so both engines receive a plain object, as a host's `run` call does. ``` ┌──────────────────┬───────────┬───────────┬────────────┬────────────┬─────────────┬──────┐ @@ -80,4 +81,6 @@ itself with a young generation large enough for that (`--expose-gc --min-semi-sp --max-semi-space-size=1024`) and watches V8's collection events. If one happened during a run anyway, the **note** column says so, and that engine's allocation figure is too low. -Maple runs from its sources with no practical operation limit, so long scripts are not cut off. +Maple runs from its sources with no practical operation limit, so long scripts are not cut off. Each run creates its +own `Maple`, so every run parses the script; with `--ast-cache`, all runs share one instance with the AST cache +on, so only the first one parses. diff --git a/apps/maple-playground/scripts/contact-ten-lines.js b/apps/maple-playground/scripts/contact-ten-lines.js new file mode 100644 index 0000000..69f4836 --- /dev/null +++ b/apps/maple-playground/scripts/contact-ten-lines.js @@ -0,0 +1,19 @@ +const fullName = [salutation, firstName, lastName].filter((part) => part !== '').join(' '); +const openActivities = activities.filter((activity) => !activity.done); +const calls = openActivities.filter((activity) => activity.type === 'call').length; +const location = address.city + ' (' + address.country + ')'; + +let priority = 'low'; +if (vip || score > 80) { + priority = 'high'; +} else if (openDeals > 0) { + priority = 'medium'; +} + +return { + name: fullName, + location, + open: openActivities.length, + calls, + priority +}; diff --git a/apps/maple-playground/scripts/measure-run.ts b/apps/maple-playground/scripts/measure-run.ts index 91f0850..95f7953 100644 --- a/apps/maple-playground/scripts/measure-run.ts +++ b/apps/maple-playground/scripts/measure-run.ts @@ -3,14 +3,16 @@ * Maple and in js-interpreter. Usage: * * Pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] - * [--time-runs 200] + * [--time-runs 200] [--ast-cache] * * The context file is read and parsed once, so both engines receive a plain object, as a host's * `run` call does. A run "allocates" what the heap grows by while it executes; that is only exact * when no garbage collection happens in between, so the script restarts itself with a young * generation large enough for that, and flags every run during which a collection happened * anyway. "Retained" is what is still alive after a forced collection. The forced collections - * disturb timing, so runs are timed in a second loop without them. + * disturb timing, so runs are timed in a second loop without them. Each Maple run creates its own + * instance, as a host that creates one per run does; with `--ast-cache`, all runs share one + * instance with the AST cache on, so only the first run parses. */ import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; @@ -22,6 +24,7 @@ import { runJsInterpreter } from '../src/app/data-access/js-interpreter-engine.t const V8_FLAGS = ['--expose-gc', '--min-semi-space-size=512', '--max-semi-space-size=1024']; const WARM_UP_RUNS = 3; const DEFAULT_CONTEXT = fileURLToPath(new URL('contact-context.json', import.meta.url)); +const LIMITS = { maxOps: Number.MAX_SAFE_INTEGER, maxDepth: 10_000 }; declare const gc: (() => void) | undefined; @@ -30,6 +33,7 @@ interface Options { context: Record; runs: number; timeRuns: number; + astCache: boolean; } interface Sample { @@ -63,8 +67,9 @@ async function main(options: Options): Promise { const observer = new PerformanceObserver((list) => collections.push(...list.getEntries())); observer.observe({ entryTypes: ['gc'] }); + const shared = options.astCache ? new Maple({ ...LIMITS, astCache: true }) : undefined; const engines: Array<[string, () => string]> = [ - ['Maple', (): string => runMaple(options)], + ['Maple', (): string => runMaple(options, shared)], ['js-interpreter', (): string => runJsInterpreter(options.code, options.context)] ]; const outcomes: Array = []; @@ -75,8 +80,9 @@ async function main(options: Options): Promise { print(options, outcomes); } -function runMaple({ code, context }: Options): string { - const maple = new Maple({ maxOps: Number.MAX_SAFE_INTEGER, maxDepth: 10_000 }); +/** Runs on `shared` when given, otherwise on an instance of its own. */ +function runMaple({ code, context }: Options, shared: Maple | undefined): string { + const maple = shared ?? new Maple(LIMITS); return JSON.stringify(maple.run(code, context).value) ?? 'undefined'; } @@ -126,10 +132,18 @@ function collect(): void { gc?.(); } -function print({ code, runs, timeRuns }: Options, outcomes: ReadonlyArray): void { +function print( + { code, runs, timeRuns, astCache }: Options, + outcomes: ReadonlyArray +): void { console.log( `Memory: median of ${runs} runs. Time: median, min and max of ${timeRuns} runs without ` + - `forced collections. Both after ${WARM_UP_RUNS} warm-up runs.\n` + `forced collections. Both after ${WARM_UP_RUNS} warm-up runs.` + ); + console.log( + astCache + ? 'Maple: one instance for all runs, AST cache on.\n' + : 'Maple: a new instance for every run, no AST cache.\n' ); const rows = outcomes.map((outcome) => { if ('error' in outcome) { @@ -205,19 +219,24 @@ function readOptions(args: ReadonlyArray): Options { const positional: Array = []; let runs = 5; let timeRuns = 200; + let astCache = false; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === '--runs') { runs = Number(args[++index]); } else if (arg === '--time-runs') { timeRuns = Number(args[++index]); + } else if (arg === '--ast-cache') { + astCache = true; } else if (arg !== undefined) { positional.push(arg); } } const [scriptPath, contextPath = DEFAULT_CONTEXT] = positional; if (scriptPath === undefined || !isCount(runs) || !isCount(timeRuns)) { - console.error('Usage: measure-run [context.json] [--runs 5] [--time-runs 200]'); + console.error( + 'Usage: measure-run [context.json] [--runs 5] [--time-runs 200] [--ast-cache]' + ); process.exit(1); } const context: unknown = JSON.parse(readFileSync(contextPath, 'utf8')); @@ -229,7 +248,8 @@ function readOptions(args: ReadonlyArray): Options { code: readFileSync(scriptPath, 'utf8'), context, runs, - timeRuns + timeRuns, + astCache }; } diff --git a/packages/maple/README.md b/packages/maple/README.md index d4b7fb6..bb8bd82 100644 --- a/packages/maple/README.md +++ b/packages/maple/README.md @@ -52,7 +52,8 @@ come back as native instances; functions never leave the sandbox and become `und Creates a sandbox configuration. `globals` holds bindings every run of this instance can read, such as constants or host functions. `maxOps` (default `1_000_000`) bounds the operations of one run, `maxDepth` (default `100`) bounds call nesting. Exceeding either aborts the run with a `MapleError` of code `LIMIT_EXCEEDED`. They stop runaway -scripts such as infinite loops; they are not a CPU or memory limit. +scripts such as infinite loops; they are not a CPU or memory limit. `astCache: true` keeps the syntax trees of up to +200 scripts, so a script the instance runs again is not parsed again; `maple.clearAstCache()` drops them. ```ts const maple = new Maple({ globals: { upper: (s: string) => s.toUpperCase() }, maxOps: 10_000 }); diff --git a/packages/maple/docs/api.md b/packages/maple/docs/api.md index 6fd8a31..b59ab8a 100644 --- a/packages/maple/docs/api.md +++ b/packages/maple/docs/api.md @@ -21,9 +21,23 @@ const result = maple.run('return "Hello " + upper(name);', { name: 'Steve' }); | `globals` | `{}` | Bindings visible to every run of this instance | | `maxOps` | `1_000_000` | Operations a single run may perform, nested runs included | | `maxDepth` | `100` | Nesting of function calls, callbacks and nested runs | +| `astCache` | `false` | Keeps the syntax trees of scripts this instance ran | Instance globals are converted once at construction (see [Boundary](boundary.md)). An instance -holds no state between runs. +holds no state between runs, except, with `astCache`, the syntax trees of scripts it ran. + +`astCache: true` keeps up to 200 scripts, and `astCache: { maxEntries }` sets another limit. +A script that runs again is then not parsed again, nested runs included; when the cache is full, +the least recently used script is dropped. Entries are keyed by the source text itself and their +trees are deep-frozen, so a run can neither hit another script's tree nor change one. Code that +does not parse is not kept. Hosts that create a new `Maple` for every run get no hits. A +`maxEntries` that is not a positive integer, or an `astCache` that is neither a boolean nor a +plain object, throws a plain `TypeError`. + +## `maple.clearAstCache(): void` + +Drops every cached syntax tree, so each script is parsed again on its next run. Does nothing when +the cache is off. ## `maple.run(code: string, data?: object, options?: RunOptions): MapleRunResult` diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 32f971e..192dff6 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -50,10 +50,10 @@ it can terminate. [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. A key of `data` is copied when the script first reads it. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen always. -- **No state between runs.** An instance holds only its limits and its frozen globals. What a run - adds to a shared built-in, host function or namespace is gone when the next run starts. Host - functions are the one channel through which the host may share state between runs, and that is - the host's decision. +- **No state between runs.** An instance holds only its limits, its frozen globals and, when + enabled, the frozen syntax trees of scripts it ran. What a run adds to a shared built-in, host + function or namespace is gone when the next run starts. Host functions are the one channel + through which the host may share state between runs, and that is the host's decision. - **Host secrets stay out.** An exception from a host function reaches the script as `A host function failed` unless the host marks it with `exposed` (see [Extending](extending.md#errors)). diff --git a/packages/maple/src/ast-cache.spec.ts b/packages/maple/src/ast-cache.spec.ts new file mode 100644 index 0000000..55370ae --- /dev/null +++ b/packages/maple/src/ast-cache.spec.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AstCache } from './ast-cache.ts'; +import { intrinsic } from './boundary/host.ts'; +import { Maple, type MapleOptions } from './maple.ts'; +import { parse } from './parse.ts'; +import { runAndCatch } from './spec-helpers.ts'; + +// The real parser, watched, so a test can tell a cache hit from a parse +vi.mock('./parse.ts', { spy: true }); + +const parsed = vi.mocked(parse); + +function notPositive(value: unknown): TypeError { + return new TypeError(`astCache.maxEntries must be a positive integer, got ${String(value)}`); +} + +beforeEach(() => { + parsed.mockClear(); +}); + +describe('AstCache', () => { + it('returns the tree set for the code', () => { + const cache = new AstCache(2); + const program = parse('return 1;'); + cache.set('return 1;', program); + + expect(cache.get('return 1;')).toBe(program); + expect(cache.get('return 2;')).toBeUndefined(); + }); + + it('drops the least recently set script when full', () => { + const cache = new AstCache(3); + cache.set('a', parse('return "a";')); + cache.set('b', parse('return "b";')); + cache.set('a', parse('return "a";')); + cache.set('c', parse('return "c";')); + cache.set('d', parse('return "d";')); + + expect(cache.get('a')).toBeDefined(); + expect(cache.get('b')).toBeUndefined(); + expect(cache.size).toBe(3); + }); + + it('deep-freezes every tree it keeps', () => { + const program = parse('return a.b + /x/g.source;'); + new AstCache(1).set('code', program); + const statement = program.body[0]; + + expect(Object.isFrozen(program)).toBe(true); + expect(Object.isFrozen(program.loc)).toBe(true); + expect(Object.isFrozen(program.loc?.start)).toBe(true); + expect(Object.isFrozen(program.body)).toBe(true); + expect(Object.isFrozen(statement)).toBe(true); + expect(statement?.type === 'ReturnStatement' && Object.isFrozen(statement.argument)).toBe(true); + }); + + it('empties on clear', () => { + const cache = new AstCache(2); + cache.set('return 1;', parse('return 1;')); + cache.clear(); + + expect(cache.size).toBe(0); + }); +}); + +describe('Maple with astCache', () => { + it('is off by default, where clearing it does nothing', () => { + const maple = new Maple(); + maple.run('return 1;'); + maple.run('return 1;'); + maple.clearAstCache(); + + expect(parsed).toHaveBeenCalledTimes(2); + }); + + it('parses a script once, whatever data it runs against', () => { + const maple = new Maple({ astCache: true }); + + expect(maple.run('return a + 1;', { a: 1 }).value).toBe(2); + expect(maple.run('return a + 1;', { a: 2 }).value).toBe(3); + expect(parsed).toHaveBeenCalledTimes(1); + }); + + it('starts every run of a cached script fresh', () => { + const maple = new Maple({ astCache: {} }); + const code = 'var seen; const before = seen; seen = 1; let n = 0; n++; return [before, n];'; + + expect(maple.run(code).value).toEqual([undefined, 1]); + expect(maple.run(code).value).toEqual([undefined, 1]); + }); + + it('keeps 200 scripts by default, dropping the least recently used', () => { + const maple = new Maple({ astCache: true }); + for (let index = 0; index <= 200; index++) { + maple.run(`return ${index};`); + } + parsed.mockClear(); + + maple.run('return 200;'); + maple.run('return 1;'); + expect(parsed).not.toHaveBeenCalled(); + maple.run('return 0;'); + expect(parsed).toHaveBeenCalledTimes(1); + }); + + it('keeps as many scripts as maxEntries allows', () => { + const maple = new Maple({ astCache: { maxEntries: 1 } }); + maple.run('return 1;'); + maple.run('return 2;'); + maple.run('return 1;'); + + expect(parsed).toHaveBeenCalledTimes(3); + }); + + it('parses again after clearAstCache', () => { + const maple = new Maple({ astCache: true }); + maple.run('return 1;'); + maple.clearAstCache(); + maple.run('return 1;'); + + expect(parsed).toHaveBeenCalledTimes(2); + }); + + it('reports a script that does not parse on every run, without keeping it', () => { + const maple = new Maple({ astCache: { maxEntries: 1 } }); + maple.run('return 1;'); + + expect(runAndCatch(maple, 'return +;').code).toBe('PARSE_ERROR'); + expect(runAndCatch(maple, 'return +;').code).toBe('PARSE_ERROR'); + maple.run('return 1;'); + expect(parsed).toHaveBeenCalledTimes(3); + }); + + it('shares its entries with nested runs', () => { + const maple = new Maple({ + astCache: true, + globals: { + exec: intrinsic(([code], sandbox) => sandbox.run(String(sandbox.toHost(code))).value) + } + }); + + expect(maple.run('return 2;').value).toBe(2); + expect(maple.run('return exec("return 2;");').value).toBe(2); + expect(parsed).toHaveBeenCalledTimes(2); + }); + + it('rejects a astCache option that is not a boolean or a positive maxEntries', () => { + // A JavaScript caller's view: a method compares its parameters both ways, so no cast is needed + const untyped: { create(options: { astCache?: unknown }): Maple } = { + create(options: MapleOptions): Maple { + return new Maple(options); + } + }; + const notAnOption = new TypeError('astCache must be a boolean or a plain object'); + + expect(() => new Maple({ astCache: { maxEntries: 0 } })).toThrow(notPositive(0)); + expect(() => new Maple({ astCache: { maxEntries: -1 } })).toThrow(notPositive(-1)); + expect(() => new Maple({ astCache: { maxEntries: 1.5 } })).toThrow(notPositive(1.5)); + expect(() => new Maple({ astCache: { maxEntries: Number.NaN } })).toThrow(notPositive(NaN)); + expect(() => untyped.create({ astCache: { maxEntries: '5' } })).toThrow(notPositive('5')); + expect(() => untyped.create({ astCache: 'yes' })).toThrow(notAnOption); + expect(() => untyped.create({ astCache: 1 })).toThrow(notAnOption); + expect(() => untyped.create({ astCache: null })).toThrow(notAnOption); + expect(() => untyped.create({ astCache: [] })).toThrow(notAnOption); + }); +}); diff --git a/packages/maple/src/ast-cache.ts b/packages/maple/src/ast-cache.ts new file mode 100644 index 0000000..8235278 --- /dev/null +++ b/packages/maple/src/ast-cache.ts @@ -0,0 +1,61 @@ +import type { Program } from 'acorn'; + +export const DEFAULT_AST_CACHE_ENTRIES = 200; + +/** + * The syntax trees of the scripts an instance ran, keyed by their source text. The key is the text + * itself, not a hash, so no two scripts can share an entry. When full, the least recently set + * script is dropped; a `Map` keeps its keys in insertion order, so setting a key again moves it to + * the end. + */ +export class AstCache { + readonly #programs = new Map(); + readonly #maxEntries: number; + + constructor(maxEntries: number) { + this.#maxEntries = maxEntries; + } + + public get size(): number { + return this.#programs.size; + } + + public get(code: string): Program | undefined { + return this.#programs.get(code); + } + + /** Keeps `program` as the most recently used entry, deep-frozen. */ + public set(code: string, program: Program): void { + // Every later run shares the tree, so a bug that wrote to it would carry state across runs + this.deepfreeze(program); + this.#programs.delete(code); + if (this.#programs.size >= this.#maxEntries) { + const oldest = this.#programs.keys().next().value; + if (oldest !== undefined) { + this.#programs.delete(oldest); + } + } + this.#programs.set(code, program); + } + + /** Clears the AST cache */ + public clear(): void { + this.#programs.clear(); + } + + /** Walks the program and deep-freezes every object within it. */ + private deepfreeze(program: Program): void { + const pending: Array = [program]; + for (let value = pending.pop(); value !== undefined; value = pending.pop()) { + if (Object.isFrozen(value)) { + continue; + } + Object.freeze(value); + for (const child of Object.values(value)) { + if (typeof child === 'object' && child !== null) { + pending.push(child); + } + } + } + } +} diff --git a/packages/maple/src/maple.ts b/packages/maple/src/maple.ts index 8948fc6..b97d881 100644 --- a/packages/maple/src/maple.ts +++ b/packages/maple/src/maple.ts @@ -1,4 +1,5 @@ import type { Program } from 'acorn'; +import { AstCache, DEFAULT_AST_CACHE_ENTRIES } from './ast-cache.ts'; import { fromHost, HostConversion, UnsupportedInputError } from './boundary/from-host.ts'; import { asMapleError } from './boundary/maple-error.ts'; import { NestingLimitError, toHost } from './boundary/to-host.ts'; @@ -17,6 +18,11 @@ export interface MapleOptions { maxOps?: number; /** Nesting of function calls, callbacks and nested runs. */ maxDepth?: number; + /** + * Keeps the syntax trees of scripts this instance ran, so one that runs again is not parsed + * again (default off). `true` keeps up to 200 scripts, dropping the least recently used. + */ + astCache?: boolean | { maxEntries?: number }; } export interface RunOptions { @@ -43,11 +49,18 @@ export class Maple { readonly #globals: Record; readonly #maxOps: number; readonly #maxDepth: number; + readonly #astCache: AstCache | undefined; constructor(options: MapleOptions = {}) { this.#globals = asGlobalsOrThrow(options.globals ?? {}, 'globals'); this.#maxOps = asPositiveIntegerOrThrow('maxOps', options.maxOps ?? DEFAULT_MAX_OPS); this.#maxDepth = asPositiveIntegerOrThrow('maxDepth', options.maxDepth ?? DEFAULT_MAX_DEPTH); + this.#astCache = asAstCacheOrThrow(options.astCache); + } + + /** Drops every cached syntax tree, so each script is parsed again on its next run. */ + public clearAstCache(): void { + this.#astCache?.clear(); } /** @@ -72,7 +85,10 @@ export class Maple { /** Everything a program needs before its first statement: its syntax and its global scope. */ private prepare(code: string, data: unknown, options: RunOptions): Prepared { - const program = parse(asCodeOrThrow(code)); + const source = asCodeOrThrow(code); + const program = this.#astCache?.get(source) ?? parse(source); + // Code that does not parse has thrown already, so it never enters the cache + this.#astCache?.set(source, program); // Only an explicit `false` unfreezes, so a JavaScript caller's typo keeps the safe default const freeze = options.freeze !== false; const runGlobals = asGlobalsOrThrow(options.globals ?? {}, 'options.globals', freeze); @@ -193,13 +209,28 @@ function asGlobalsOrThrow( return converted; } -function asPositiveIntegerOrThrow(name: string, value: number): number { - if (!Number.isSafeInteger(value) || value < 1) { +function asPositiveIntegerOrThrow(name: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { throw new TypeError(`${name} must be a positive integer, got ${String(value)}`); } return value; } +// Typed, but a JavaScript caller may pass anything +function asAstCacheOrThrow(option: unknown): AstCache | undefined { + if (option === undefined || option === false) { + return undefined; + } + if (option === true) { + return new AstCache(DEFAULT_AST_CACHE_ENTRIES); + } + if (!isPlainObject(option)) { + throw new TypeError('astCache must be a boolean or a plain object'); + } + const maxEntries = option['maxEntries'] ?? DEFAULT_AST_CACHE_ENTRIES; + return new AstCache(asPositiveIntegerOrThrow('astCache.maxEntries', maxEntries)); +} + function asDataObjectOrThrow(data: unknown): Record { const candidate = data === undefined ? {} : data; if (!isPlainObject(candidate)) { From ae94d6d483b9601ba57e8c6db71b33f1263ecc86 Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 20:19:56 +0200 Subject: [PATCH 11/12] chore(playground): add a contact summary script to measure-run contact-summary.js builds a CRM summary from the contact fixture and reads every key, activities and customFields included, so it measures a run in which nothing is left unread. --- apps/maple-playground/README.md | 7 +- .../scripts/contact-summary.js | 120 ++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 apps/maple-playground/scripts/contact-summary.js diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index f70245e..2d901c6 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -46,9 +46,10 @@ pnpm --filter @netwin/maple-playground measure-run [context.json] [- The context defaults to `scripts/contact-context.json`, a synthetic contact record of 46 flat fields, an `address`, 450 `activities` and 300 `customFields` (about 75 KB as JSON). `contact-flat-key.js`, `contact-nested-key.js` and -`contact-large-key.js` beside it read a flat key, a nested key and the large array; `contact-ten-lines.js` is a -10-line script over several keys, written with `const` and arrow functions, which js-interpreter cannot run. The file -is read and parsed once, before any run, so both engines receive a plain object, as a host's `run` call does. +`contact-large-key.js` beside it read a flat key, a nested key and the large array. `contact-ten-lines.js` is a +10-line script over several keys, written with `const` and arrow functions, which js-interpreter cannot run. +`contact-summary.js` is a 120-line summary that reads every key, the large ones included. The file is read and parsed +once, before any run, so both engines receive a plain object, as a host's `run` call does. ``` ┌──────────────────┬───────────┬───────────┬────────────┬────────────┬─────────────┬──────┐ diff --git a/apps/maple-playground/scripts/contact-summary.js b/apps/maple-playground/scripts/contact-summary.js new file mode 100644 index 0000000..87e979f --- /dev/null +++ b/apps/maple-playground/scripts/contact-summary.js @@ -0,0 +1,120 @@ +// oxlint-disable eslint/object-shorthand, unicorn/no-array-sort -- ES5 for js-interpreter, which has neither shorthand properties nor toSorted +// Summarises a contact for a CRM side panel: identity, activity statistics, a lead score and +// the custom fields that are still empty. Written in ES5, so js-interpreter can run it too +var today = new Date('2026-10-21'); +var DAY = 24 * 60 * 60 * 1000; + +var fullName = [salutation, title, firstName, middleName, lastName] + .filter(function (part) { + return typeof part === 'string' && part.trim() !== ''; + }) + .join(' '); +var initials = (firstName.charAt(0) + lastName.charAt(0)).toUpperCase(); +var at = email.indexOf('@'); +var emailDomain = at === -1 ? null : email.slice(at + 1); +var location = [address.zip, address.city, address.country].filter(Boolean).join(', '); + +var byType = {}; +var byMonth = {}; +var lastActivity = null; +var overdue = 0; + +for (var index = 0; index < activities.length; index++) { + var activity = activities[index]; + var stats = byType[activity.type] || { total: 0, done: 0 }; + stats.total += 1; + if (activity.done) { + stats.done += 1; + } + byType[activity.type] = stats; + + var month = activity.date.slice(0, 7); + byMonth[month] = (byMonth[month] || 0) + 1; + + var date = new Date(activity.date); + if (!activity.done && date < today) { + overdue += 1; + } + if (activity.done && (lastActivity === null || date > new Date(lastActivity.date))) { + lastActivity = activity; + } +} + +var daysSinceContact = + lastActivity === null + ? null + : Math.floor((today.getTime() - new Date(lastActivity.date).getTime()) / DAY); +var busiestMonths = Object.keys(byMonth) + .map(function (key) { + return { month: key, count: byMonth[key] }; + }) + .sort(function (a, b) { + return b.count - a.count; + }) + .slice(0, 3); +var activityTypes = Object.keys(byType) + .map(function (type) { + var entry = byType[type]; + return { + type: type, + total: entry.total, + done: entry.done, + rate: Math.round((entry.done / entry.total) * 100) + }; + }) + .sort(function (a, b) { + return b.total - a.total; + }); + +var leadScore = score; +if (vip) { + leadScore += 20; +} +if (wonDeals > lostDeals) { + leadScore += 10; +} +leadScore += 5 * openDeals; +if (daysSinceContact !== null && daysSinceContact > 90) { + leadScore -= 15; +} +if (overdue > 10) { + leadScore -= 10; +} +leadScore = Math.max(0, Math.min(100, leadScore)); +var tier = leadScore >= 80 ? 'hot' : leadScore >= 50 ? 'warm' : 'cold'; + +var customKeys = Object.keys(customFields); +var emptyFields = customKeys.filter(function (key) { + var value = customFields[key]; + return value === null || value === ''; +}); + +var tagList = tags + .split(',') + .map(function (tag) { + return tag.trim(); + }) + .filter(function (tag) { + return tag.length > 0; + }); + +var reachable = !doNotCall && /^\+?[\d\s]+$/.test(mobile || phone); + +return { + fullName: fullName, + initials: initials, + company: company + ' · ' + department, + emailDomain: emailDomain, + location: location, + tags: tagList, + reachable: reachable, + activity: { + total: activities.length, + overdue: overdue, + daysSinceContact: daysSinceContact, + busiestMonths: busiestMonths, + types: activityTypes + }, + lead: { score: leadScore, tier: tier, revenue: revenue.toFixed(2) + ' ' + currency }, + customFields: { total: customKeys.length, empty: emptyFields.length } +}; From f037c6591105840ebbea87f588b6daa62ea14dbc Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 20:46:54 +0200 Subject: [PATCH 12/12] fix(playground): reject an array context before either engine runs parseContext refused only what is not an object at all and left an array to Maple, which rejects it, while js-interpreter exposed its indexes, so the Benchmark tab reported two outcomes instead of one input error. From the same review: astCache.maxEntries of null is rejected instead of silently taking the default, the boundary doc's sentence on host mutations is completed, an ast-cache test name gets its article, and the measure-run usage line spells pnpm. --- apps/maple-playground/scripts/measure-run.ts | 4 ++-- .../src/app/data-access/maple-worker-protocol.ts | 6 +++--- packages/maple/docs/boundary.md | 2 +- packages/maple/src/ast-cache.spec.ts | 3 ++- packages/maple/src/maple.ts | 5 ++++- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/maple-playground/scripts/measure-run.ts b/apps/maple-playground/scripts/measure-run.ts index 95f7953..6ac34b3 100644 --- a/apps/maple-playground/scripts/measure-run.ts +++ b/apps/maple-playground/scripts/measure-run.ts @@ -2,8 +2,8 @@ * Measures how long one run of a script takes and how much memory it allocates and retains, in * Maple and in js-interpreter. Usage: * - * Pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] - * [--time-runs 200] [--ast-cache] + * pnpm --filter @netwin/maple-playground measure-run [context.json] [--runs 5] + * [--time-runs 200] [--ast-cache] * * The context file is read and parsed once, so both engines receive a plain object, as a host's * `run` call does. A run "allocates" what the heap grows by while it executes; that is only exact diff --git a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts index 6163799..59d9269 100644 --- a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts +++ b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts @@ -13,12 +13,12 @@ export interface RunRequest { } /** - * Turns the JSON `context` of a request back into the data a run takes. Maple rejects an array - * itself, so only what is not an object at all is refused here. + * Turns the JSON `context` of a request back into the data a run takes. An array is refused here, + * as Maple would, so that both engines of the Benchmark tab see the same input error. */ export function parseContext(context: string): object { const data: unknown = JSON.parse(context); - if (typeof data !== 'object' || data === null) { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { throw new TypeError('context must be a JSON object'); } return data; diff --git a/packages/maple/docs/boundary.md b/packages/maple/docs/boundary.md index 9266d0e..436677d 100644 --- a/packages/maple/docs/boundary.md +++ b/packages/maple/docs/boundary.md @@ -44,7 +44,7 @@ The global scope is composed from three layers, a later one shadowing an earlier Which keys `data` has, and what each one holds, is taken when the run starts: a key the host adds later, or a value it puts under a key, is not seen. A key holding an object, array or function is -copied when the script first reads it, so what the host changes inside that value before then is. +copied when the script first reads it, so what the host changes inside that value before then is seen. All keys of a run share one conversion, so a value reached under two keys is still one value. One instance can serve many runs: what every run needs goes into the instance globals, what diff --git a/packages/maple/src/ast-cache.spec.ts b/packages/maple/src/ast-cache.spec.ts index 55370ae..3e62554 100644 --- a/packages/maple/src/ast-cache.spec.ts +++ b/packages/maple/src/ast-cache.spec.ts @@ -144,7 +144,7 @@ describe('Maple with astCache', () => { expect(parsed).toHaveBeenCalledTimes(2); }); - it('rejects a astCache option that is not a boolean or a positive maxEntries', () => { + it('rejects an astCache option that is not a boolean or a positive maxEntries', () => { // A JavaScript caller's view: a method compares its parameters both ways, so no cast is needed const untyped: { create(options: { astCache?: unknown }): Maple } = { create(options: MapleOptions): Maple { @@ -158,6 +158,7 @@ describe('Maple with astCache', () => { expect(() => new Maple({ astCache: { maxEntries: 1.5 } })).toThrow(notPositive(1.5)); expect(() => new Maple({ astCache: { maxEntries: Number.NaN } })).toThrow(notPositive(NaN)); expect(() => untyped.create({ astCache: { maxEntries: '5' } })).toThrow(notPositive('5')); + expect(() => untyped.create({ astCache: { maxEntries: null } })).toThrow(notPositive(null)); expect(() => untyped.create({ astCache: 'yes' })).toThrow(notAnOption); expect(() => untyped.create({ astCache: 1 })).toThrow(notAnOption); expect(() => untyped.create({ astCache: null })).toThrow(notAnOption); diff --git a/packages/maple/src/maple.ts b/packages/maple/src/maple.ts index b97d881..0b08218 100644 --- a/packages/maple/src/maple.ts +++ b/packages/maple/src/maple.ts @@ -227,7 +227,10 @@ function asAstCacheOrThrow(option: unknown): AstCache | undefined { if (!isPlainObject(option)) { throw new TypeError('astCache must be a boolean or a plain object'); } - const maxEntries = option['maxEntries'] ?? DEFAULT_AST_CACHE_ENTRIES; + const maxEntries = option['maxEntries']; + if (maxEntries === undefined) { + return new AstCache(DEFAULT_AST_CACHE_ENTRIES); + } return new AstCache(asPositiveIntegerOrThrow('astCache.maxEntries', maxEntries)); }