From 54e170e0e0f25adcd2a500febbed961b8e9a1129 Mon Sep 17 00:00:00 2001 From: NetWin Date: Wed, 21 Oct 2026 19:11:40 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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)); }