diff --git a/.changeset/2031-objectstack-18-next-0.md b/.changeset/2031-objectstack-18-next-0.md new file mode 100644 index 000000000..7ed208538 --- /dev/null +++ b/.changeset/2031-objectstack-18-next-0.md @@ -0,0 +1,56 @@ +--- +'hotcrm': minor +--- + +Upgrade the ObjectStack platform to 18.0.0-next.0 (prerelease) + +All 24 `@objectstack/*` dependencies move from 17.7.0 to 18.0.0-next.0 together, pinned exact: +20 in `dependencies` and 4 in `devDependencies`. `specVersion` and `engines.protocol` follow to +`^18.0.0-next.0` in `objectstack.manifest.json` and in both package manifests (`src/sales`, +`src/service`). `pnpm install` regenerated `pnpm-lock.yaml` against the public registry: 790 +`packages:` entries before, 791 after. The 54 `@objectstack/*` and `create-objectstack` keys moved +one for one, `@objectstack/service-mailbox@18.0.0-next.0` was added, and no third-party package +changed version. + +**This release needs an ObjectStack 18 runtime.** 18.0.0-next.0 is a prerelease. The protocol +handshake compares majors, so a 17.x host refuses to load this artifact before it reads a single +object, and reports why. Upgrade the host first. + +The platform changes that reached this app: + +- **Every flow is rewritten in the two 18.0.0-next.0 dialects.** The platform no longer accepts + `{…}` templates in a flow's value fields. A value is now a CEL expression, and a text field + (screen titles and descriptions, end messages, action links, HTTP URLs and headers) takes + `{{ … }}` holes. All 32 flows and the shared billing payload were rewritten, and `os validate` + goes from 340 refusals to none. Each site keeps what it did on 17.7.0, with one exception. On + **Close Case**, an agent who leaves *Resolved by Article* unanswered now clears a link that was + set earlier on the record form. On 17.7.0 that link stayed. +- **Flow dialogs translate more of their text.** 18.0.0-next.0 makes screen descriptions, select + options and refusal messages translatable, and `os lint` reports the missing keys. All 32 new + keys are translated in zh-CN, es-ES and ja-JP, using the words the packs already use for the + same objects. +- **Tenant administrators no longer hold `manage_org_presentation`.** 18.0.0-next.0 retires the + capability together with per-organization overlays (ADR-0131 D6). The platform has no + organization-bounded authoring capability now, so a tenant administrator authors no metadata. +- **Email templates no longer declare `category`.** The platform retires the key and refuses it. + +**What operators should know:** + +- **Scheduled flows still need `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`.** Without it, none of + the eight scheduled flows runs: no contract or quote expiry, no SLA monitor, no reminders, no + forecast snapshots. +- **Every install now has an organization.** Under the `single` posture, the platform creates the + Default Organization at boot and loads the seed data into it. +- **A position assigned through Setup carries that organization.** A position row written without + one, such as a 17.x row an upgrade carries over, does not staff the case-escalation pool, which + stays inside each case's organization. A sales manager or service manager whose 17.x assignment + carries no organization should be assigned again. +- **Deleting an account removes its draft, expired and terminated contracts with it.** An activated + contract still blocks the delete, as the account documentation says. +- **An elevated scheduled or sub-flow can no longer be started by name**, not even by an + administrator. It starts from its own trigger, or as a sub-flow of a parent flow. + +The test suite moved with the platform. Every suite runs on the SQL datasource, by maintainer +ruling. The legs whose only subject was the absent-column shape of the in-memory driver are retired, +because that driver no longer serves an app with organizations. The token ceilings are raised 50% +by maintainer ruling and recorded as a grant. diff --git a/content/docs/whats-new.mdx b/content/docs/whats-new.mdx index 138a638c2..bbcf1f153 100644 --- a/content/docs/whats-new.mdx +++ b/content/docs/whats-new.mdx @@ -65,11 +65,12 @@ http://localhost:4001 and create the first admin account. ## v4.0.1 — Latest release -**HotCRM 4.0.1**, running on **ObjectStack 17.7.0** across every -`@objectstack/*` package. The app manifest declares the matching -`^17.7.0` protocol range, so a runtime on a different protocol major -refuses the load up front with a structured diagnostic instead of failing deep -in a schema parse. +**HotCRM 4.0.1** shipped on **ObjectStack 17.7.0** across every +`@objectstack/*` package. The release after it runs on **ObjectStack +18.0.0-next.0**, a prerelease of the platform's next major, and its manifest +declares the matching `^18.0.0-next.0` protocol range, so a runtime on a +different protocol major — a 17.x host included — refuses the load up front +with a structured diagnostic instead of failing deep in a schema parse. > **This page does not keep a second release table.** The release-by-release > history is `CHANGELOG.md` in the repository, compiled from the `.changeset/` diff --git a/content/docs/whats-new.zh-Hans.mdx b/content/docs/whats-new.zh-Hans.mdx index 6b9301c8b..c49d8bee5 100644 --- a/content/docs/whats-new.zh-Hans.mdx +++ b/content/docs/whats-new.zh-Hans.mdx @@ -47,9 +47,11 @@ HOTCRM_TOKEN=... ./scripts/wow1-live-schema.sh ## v4.0.1 —— 最新发布 -**HotCRM 4.0.1**,全线 `@objectstack/*` 包运行在 **ObjectStack 17.7.0** 上。 -应用清单声明了与之匹配的 `^17.7.0` 协议区间,因此协议大版本不同的运行时会在 -装载之初就带着结构化诊断拒绝加载,而不是在某次 schema 解析的深处才失败。 +**HotCRM 4.0.1** 发布时,全线 `@objectstack/*` 包运行在 **ObjectStack 17.7.0** 上。 +它之后的版本运行在 **ObjectStack 18.0.0-next.0** 上(平台下一个大版本的预发布版), +应用清单声明了与之匹配的 `^18.0.0-next.0` 协议区间,因此协议大版本不同的运行时 +(包括 17.x 的宿主)会在装载之初就带着结构化诊断拒绝加载,而不是在某次 schema +解析的深处才失败。 > **这一页不维护第二张发布表。** 逐版本的发布历史在仓库的 `CHANGELOG.md` 里,由每个 > 拉取请求补充的 `.changeset/` 条目编译而成。v1.0 与 4.0.1 之间的各个版本都记录在那里, diff --git a/content/docs/whats-new.zh-Hant.mdx b/content/docs/whats-new.zh-Hant.mdx index b990e19e2..88f1a7054 100644 --- a/content/docs/whats-new.zh-Hant.mdx +++ b/content/docs/whats-new.zh-Hant.mdx @@ -49,9 +49,11 @@ HOTCRM_TOKEN=... ./scripts/wow1-live-schema.sh ## v4.0.1 —— 最新發行 -**HotCRM 4.0.1**,全線 `@objectstack/*` 套件執行在 **ObjectStack 17.7.0** 上。 -應用清單宣告了與之相符的 `^17.7.0` 協定區間,因此協定大版本不同的執行時會在 -載入之初就帶著結構化診斷拒絕載入,而不是在某次 schema 解析的深處才失敗。 +**HotCRM 4.0.1** 發行時,全線 `@objectstack/*` 套件執行在 **ObjectStack 17.7.0** 上。 +它之後的版本執行在 **ObjectStack 18.0.0-next.0** 上(平台下一個大版本的預發行版), +應用清單宣告了與之相符的 `^18.0.0-next.0` 協定區間,因此協定大版本不同的執行時 +(包括 17.x 的宿主)會在載入之初就帶著結構化診斷拒絕載入,而不是在某次 schema +解析的深處才失敗。 > **這一頁不維護第二張發行表。** 逐版本的發行歷史在儲存庫的 `CHANGELOG.md` 裡,由每個 > 拉取請求補上的 `.changeset/` 條目編譯而成。v1.0 與 4.0.1 之間的各個版本都記錄在那裡, diff --git a/docs/STATUS.md b/docs/STATUS.md index e721f0ca6..6fc9a106e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -73,7 +73,7 @@ pnpm verify | --- | --- | | Node.js | `^22.11 \|\| ^24 \|\| >=26` | | pnpm | `>=10.0.0` | -| ObjectStack packages | `17.7.0` | +| ObjectStack packages | `18.0.0-next.0` | | Local dev port | `4001` | Each row above is asserted against `package.json` (`engines`, the `@objectstack/*` diff --git a/objectstack.manifest.json b/objectstack.manifest.json index 858104fbd..0467ecf25 100644 --- a/objectstack.manifest.json +++ b/objectstack.manifest.json @@ -1,9 +1,9 @@ { "$schema": "https://schemas.objectstack.dev/template-manifest.json", "name": "hotcrm", - "specVersion": "^17.7.0", + "specVersion": "^18.0.0-next.0", "engines": { - "protocol": "^17.7.0" + "protocol": "^18.0.0-next.0" }, "manifestId": "app.objectstack.hotcrm", "displayName": "HotCRM", diff --git a/package.json b/package.json index 8c1931b9b..c6e4bf788 100644 --- a/package.json +++ b/package.json @@ -36,36 +36,36 @@ }, "packageManager": "pnpm@10.33.0", "dependencies": { - "@objectstack/account": "17.7.0", - "@objectstack/cli": "17.7.0", - "@objectstack/core": "17.7.0", - "@objectstack/driver-memory": "17.7.0", - "@objectstack/driver-sql": "17.7.0", - "@objectstack/driver-sqlite-wasm": "17.7.0", - "@objectstack/metadata": "17.7.0", - "@objectstack/objectql": "17.7.0", - "@objectstack/platform-objects": "17.7.0", - "@objectstack/plugin-approvals": "17.7.0", - "@objectstack/plugin-auth": "17.7.0", - "@objectstack/plugin-security": "17.7.0", - "@objectstack/plugin-sharing": "17.7.0", - "@objectstack/runtime": "17.7.0", - "@objectstack/service-analytics": "17.7.0", - "@objectstack/service-automation": "17.7.0", - "@objectstack/service-messaging": "17.7.0", - "@objectstack/service-storage": "17.7.0", - "@objectstack/spec": "17.7.0", - "@objectstack/trigger-record-change": "17.7.0" + "@objectstack/account": "18.0.0-next.0", + "@objectstack/cli": "18.0.0-next.0", + "@objectstack/core": "18.0.0-next.0", + "@objectstack/driver-memory": "18.0.0-next.0", + "@objectstack/driver-sql": "18.0.0-next.0", + "@objectstack/driver-sqlite-wasm": "18.0.0-next.0", + "@objectstack/metadata": "18.0.0-next.0", + "@objectstack/objectql": "18.0.0-next.0", + "@objectstack/platform-objects": "18.0.0-next.0", + "@objectstack/plugin-approvals": "18.0.0-next.0", + "@objectstack/plugin-auth": "18.0.0-next.0", + "@objectstack/plugin-security": "18.0.0-next.0", + "@objectstack/plugin-sharing": "18.0.0-next.0", + "@objectstack/runtime": "18.0.0-next.0", + "@objectstack/service-analytics": "18.0.0-next.0", + "@objectstack/service-automation": "18.0.0-next.0", + "@objectstack/service-messaging": "18.0.0-next.0", + "@objectstack/service-storage": "18.0.0-next.0", + "@objectstack/spec": "18.0.0-next.0", + "@objectstack/trigger-record-change": "18.0.0-next.0" }, "optionalDependencies": { "better-sqlite3": "^13.0.3" }, "devDependencies": { "@changesets/cli": "^3.0.3", - "@objectstack/formula": "17.7.0", - "@objectstack/plugin-audit": "17.7.0", - "@objectstack/plugin-email": "17.7.0", - "@objectstack/verify": "17.7.0", + "@objectstack/formula": "18.0.0-next.0", + "@objectstack/plugin-audit": "18.0.0-next.0", + "@objectstack/plugin-email": "18.0.0-next.0", + "@objectstack/verify": "18.0.0-next.0", "@playwright/test": "^1.63.0", "@vitest/coverage-v8": "^4.1.10", "fumadocs-core": "16.15.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b586b791..6a48addd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,81 +9,81 @@ importers: .: dependencies: '@objectstack/account': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/cli': - specifier: 17.7.0 - version: 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) '@objectstack/core': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 '@objectstack/driver-memory': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 '@objectstack/driver-sql': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 '@objectstack/driver-sqlite-wasm': - specifier: 17.7.0 - version: 17.7.0(better-sqlite3@13.0.3) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(better-sqlite3@13.0.3) '@objectstack/metadata': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/objectql': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/platform-objects': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/plugin-approvals': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/plugin-auth': - specifier: 17.7.0 - version: 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) '@objectstack/plugin-security': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/plugin-sharing': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/runtime': - specifier: 17.7.0 - version: 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) '@objectstack/service-analytics': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 '@objectstack/service-automation': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/service-messaging': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/service-storage': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/spec': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 '@objectstack/trigger-record-change': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 devDependencies: '@changesets/cli': specifier: ^3.0.3 version: 3.0.3 '@objectstack/formula': - specifier: 17.7.0 - version: 17.7.0 + specifier: 18.0.0-next.0 + version: 18.0.0-next.0 '@objectstack/plugin-audit': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/plugin-email': - specifier: 17.7.0 - version: 17.7.0(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(vitest@4.1.10) '@objectstack/verify': - specifier: 17.7.0 - version: 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + specifier: 18.0.0-next.0 + version: 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) '@playwright/test': specifier: ^1.63.0 version: 1.63.0 @@ -550,7 +550,6 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -575,45 +574,45 @@ packages: '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} - '@objectstack/account@17.7.0': - resolution: {integrity: sha512-nPeR21ndVZajDaiy/g0RBupG3NGuDXlzN7daag+cxl+iDKS4TzWvfqnp8Pnr8UnRJ+Et93CSaVLjUZRxHhBVow==} + '@objectstack/account@18.0.0-next.0': + resolution: {integrity: sha512-ksyL5zNdABm0JTNpC9CYc5woT9YfT1Vzyh7DrN/TfyrhkSLb7ZEzoxMxRk0Njz4MpAaFcPjCxl2TMBAnrxyhxg==} engines: {node: '>=22.0.0'} - '@objectstack/cli@17.7.0': - resolution: {integrity: sha512-WTJ5B/uYSmqlMmqiGy7Acu8M45kHfouothcedRV1XldbKE+1lDGp+Gxg7bNXgqoLU/dmuvYz3JvMs2TPedKNkQ==} + '@objectstack/cli@18.0.0-next.0': + resolution: {integrity: sha512-B8BWNfsBaXEaUUpVjsGIGxGxZRFnASHhie/Xfpn2OZEeh7WGnwFrYlN5apNZzE6aQIoDv54PsF4owutMNgKpTw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@objectstack/driver-turso': ^17.7.0 + '@objectstack/driver-turso': ^18.0.0-next.0 peerDependenciesMeta: '@objectstack/driver-turso': optional: true - '@objectstack/client@17.7.0': - resolution: {integrity: sha512-PFnr58tb1usCSMP+jNyuMBn9a6fEcaVFOiGRox9B88VQvttXo99akGzI8utFX8DsEobLQbh9YZBtXR51VI+JQg==} + '@objectstack/client@18.0.0-next.0': + resolution: {integrity: sha512-gFflUycBVPIvc1a44psjRBQ3z1NuaDkmfBOcVRhc6nvc7paVXr4kqYG1NdlmnmCwm8qfAvGL7BuftWCYUWqtYg==} engines: {node: '>=22.0.0'} - '@objectstack/cloud-connection@17.7.0': - resolution: {integrity: sha512-NGBWESNZVX63NIwiwZOF8tNlsqw6KQARtET6oHd+BaTLzRs9Bk1bCkRifoDgrlom9029rRgWM/v80JiOPWgkGQ==} + '@objectstack/cloud-connection@18.0.0-next.0': + resolution: {integrity: sha512-bfsOi+wuokLt1eCEEOtn7Fn37HoV9kX2htdBw1+4JaNfAhxrZRYLbmQiXl9gIPTgin+tVXM56Jk3dB8q2ll3KQ==} engines: {node: '>=22.0.0'} - '@objectstack/console@17.7.0': - resolution: {integrity: sha512-4Z8Fy47VHzHtLk82sxf5I6J3SLUhT5NetZLI1YccBvkyMe4stzwx7GKCFAzK+MSKGE3Arv+PGIdqCuvNCd4neA==} + '@objectstack/console@18.0.0-next.0': + resolution: {integrity: sha512-XJmwroBfyAoWqxoSX1M3YB0IFJXEoV+fMUlOsk5nTSB//dGGJSz4QkKZN8e6tuluwDTUMvMk1X/VDlQZa1fjhQ==} - '@objectstack/core@17.7.0': - resolution: {integrity: sha512-2/NqNk41eNo0nPgBsClefme2HwtLhwCdxKZER+XTOD345KAb4L0suyu2vGxHAN82MGkfNu4ticZzrRAHDT7tYQ==} + '@objectstack/core@18.0.0-next.0': + resolution: {integrity: sha512-6Ms8bsOdnIKxY4EwXyVl2OfQ3ydB5eH0J/u7KA1lWXquZgSDggLk05upOzf7XtuUeLb8JSLbmG25OIKuamBi3Q==} engines: {node: '>=22.0.0'} - '@objectstack/driver-memory@17.7.0': - resolution: {integrity: sha512-AkSMFi+CJeNxEvLhSVc2I9H9KUHEZgNC6ESxJE36SWxYjD6EsbFoHEjWUvUoiOVr0ETOJIvgjySIms3Ix2vaLQ==} + '@objectstack/driver-memory@18.0.0-next.0': + resolution: {integrity: sha512-71UKxu9viBa+msMqXNLkjuwAOXZd8scXoIadAVYSM94U6WPIh9wNyZq41YeueykCPBoQbAuc2wXyKq8jAum8Bg==} engines: {node: '>=22.0.0'} - '@objectstack/driver-mongodb@17.7.0': - resolution: {integrity: sha512-6iBxiXol0Bb93P1X7DM60e1Br7omycl65aSVUWr+3g9rr9tw+MYOK0R+SwmKfszW6TrWJxw6NN0OmNbl8/g+3Q==} + '@objectstack/driver-mongodb@18.0.0-next.0': + resolution: {integrity: sha512-8VLJ7fhjErvvkHlmh7XmRlfex/Zqu79vwKnj3JVxjJGoOJIp7kRKtiSArsefXcCgbfHr9xg5uny7l7ZNdkGa5A==} engines: {node: '>=22.0.0'} - '@objectstack/driver-sql@17.7.0': - resolution: {integrity: sha512-4gYyyvW5UFPzc0im+Q49EVLAK1HS8voAex69Pvlm/TIFXFKzSVmKwGy9xVYgFxISkHBjoCcdji+WRYSab0hIig==} + '@objectstack/driver-sql@18.0.0-next.0': + resolution: {integrity: sha512-/ivgbTVBhiytmR8vMjFlXJOJUb3f/IiXvXr62LdZ0xFuCHZmM0Eu0/I0WzjymFMfz8GwOoHJNVUY6WGcLxlsnA==} engines: {node: '>=22.0.0'} peerDependencies: mysql2: ^3.0.0 @@ -627,23 +626,23 @@ packages: tedious: optional: true - '@objectstack/driver-sqlite-wasm@17.7.0': - resolution: {integrity: sha512-47ZZRIjCvLYOVOlSPsqyN+fLmKqnR5EoB0Xq5us+xZb2x+Qp32AjDRB+goz+HbJy69YmLq217hWCqmbLYfFH7w==} + '@objectstack/driver-sqlite-wasm@18.0.0-next.0': + resolution: {integrity: sha512-Wy51R7MSxNkeKkMt59w4uPrCmFb/VVMXDsMPfXtB0IixBaStxKdijqfCr94Q4Owbr5X9a/3iGmy96XosEHQ9Lg==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.7.0': - resolution: {integrity: sha512-jf7SA/8QawVEKmQhwxjtyGxz+DIX+3V7M/Kt3z79jMhby5tjh5vwTbJI3PdyWCKQ1pS0epO6sSK7MddwLbEJ9Q==} + '@objectstack/formula@18.0.0-next.0': + resolution: {integrity: sha512-UyVOqJz1QLvjnr6qSCFnex6BiU1Ly3N6vLBiB6/8FmqHdnboozfNGdLgjhmS/m3wWq0ZGoY9d5RYkpDfC65UDQ==} - '@objectstack/lint@17.7.0': - resolution: {integrity: sha512-ddCHHRJKRSEirb74UNWd8DwdpqvQtMzsKH/bBiUslwXlYucQJPlQMd+ZVYN4V0IJoeD+IpD5el3P6S5H/wqPuQ==} + '@objectstack/lint@18.0.0-next.0': + resolution: {integrity: sha512-cxEbba3qAbQ2Pvc76tcY+pqTMiO79XoruOo3uQCttR7w8abrikFFCXv2cprawXIEgYkshQeJaAExCdqp01aG6Q==} engines: {node: '>=22.0.0'} - '@objectstack/mcp@17.7.0': - resolution: {integrity: sha512-WfAFbOjECCbXPUcEciSHd35P87Eagxzkz9gQm6YwbdIz7dvOJxikaqvQpG/Vq64rDG6K7u9nvL/BSYklbH9BEQ==} + '@objectstack/mcp@18.0.0-next.0': + resolution: {integrity: sha512-/Kd4IV3AhlY1BQ4ce4r+9+k0un0jP2Wiuv8JnbA4H/oTGK6InckqpuZBmud7eOYTk5cSQ4UtZCpy6uPkaAUhug==} engines: {node: '>=22.0.0'} - '@objectstack/metadata-core@17.7.0': - resolution: {integrity: sha512-dMDA0wZ0x6HBy+A9C4CjriK/NnUPKybSY/e/kPa7XUxe7mpoqfJlgwCC7RJs2HuHlKVJHL4VOn4NBcYaYzkwQg==} + '@objectstack/metadata-core@18.0.0-next.0': + resolution: {integrity: sha512-ujKVTmJpMSSAMskggarxIaFqXONfPq4+XlWNS078gtSVCokwYLTBPETUDQ+GwfNOMul6mlho5i4ImGx6Zn6ezA==} engines: {node: '>=22.0.0'} peerDependencies: vitest: ^4.0.0 @@ -651,98 +650,98 @@ packages: vitest: optional: true - '@objectstack/metadata-fs@17.7.0': - resolution: {integrity: sha512-jTn/xEGy8R9RbTnNalKxymHubjvloglYneRcFa/1I1/JuUllWgJsgogMHK2hG/pHwVOj1dN/WxgvmxTyMi36Lw==} + '@objectstack/metadata-fs@18.0.0-next.0': + resolution: {integrity: sha512-UN5oFrxi/poqoDfPQS7wrr7ZhUMmfksOuSVsYGpbQzn6s+WtGa1tkdWhSCwVrLO41g7GJWo2tj8Stz4eO1PCYQ==} engines: {node: '>=22.0.0'} - '@objectstack/metadata-protocol@17.7.0': - resolution: {integrity: sha512-fQIviSz1DxMeq8+2Tg83pU6EcSVkoQKagsB9XdepJPmDtPzdiPSgZMidLwTuVSQziVBy8RcOKGAuMEW2g4XF+g==} + '@objectstack/metadata-protocol@18.0.0-next.0': + resolution: {integrity: sha512-br3HL2FFYKnyX2NuMNUEcyqCK0L4j5vHDwMekmjyu0HSBH19CtX2DQlhVBSEsyYo/vohZVPolNyzvVeWHfiPtQ==} engines: {node: '>=22.0.0'} - '@objectstack/metadata@17.7.0': - resolution: {integrity: sha512-mkdXagkRy/ZpJiH15JOvYy/07vz1tLHdGIxv6EmWt8aeAYQyqoVsGdoLi1TY8MmI4tkG0w9qAWt2Myv9JPbZWQ==} + '@objectstack/metadata@18.0.0-next.0': + resolution: {integrity: sha512-lwWwUj6vDZl+6iD7MPRg0lOGAvH//ANiPJAUmMD56L7tTEyKCJ406Z0bhPkw0P6op6v1QDl6oymqrt1kGHRzFw==} engines: {node: '>=22.0.0'} - '@objectstack/objectql@17.7.0': - resolution: {integrity: sha512-3TZlfQ1PUdC7+vPzipYHHgEAVIZlpHOB/vchkqoJaLDCuJ+nSfh1YwLfA6NN+Z3MxvNZfmZ1RWtMXsClX0nkeg==} + '@objectstack/objectql@18.0.0-next.0': + resolution: {integrity: sha512-vwbswk/ueXy7roAE5x76NRxtqKocj1QphLR/U6u8V23+shSRFaOD+XmBiRomLPRolQX/DdO7xGVRCnPILswmfw==} engines: {node: '>=22.0.0'} - '@objectstack/observability@17.7.0': - resolution: {integrity: sha512-7B5WhLcrW9iekKDikFwnMYxF4b2A8GKH/VxLTDVxaf1JMzzHXxxoq/9+MothmvDZpNWs1l6RuzhumjScKp82cw==} + '@objectstack/observability@18.0.0-next.0': + resolution: {integrity: sha512-EVK0fHAf7rAeDpazkW385oQMKXnJ+VdXDhv1DPHrv1C5Tsf1nekRcassbONuC6etZ2sgJGbbrDki/2liZ/2nPw==} engines: {node: '>=22.0.0'} - '@objectstack/platform-objects@17.7.0': - resolution: {integrity: sha512-nYn2NRBj0TWhmdPMIRnWHunaoSKVyzJwzj2eRFcuV0+lEWkF6dTzJp6rTCMUelJ1qNqUKrTyN/8oD+JIaBke/g==} + '@objectstack/platform-objects@18.0.0-next.0': + resolution: {integrity: sha512-nv+jOTCEn1i/BysfBjv+mzg4Hmh5NZ5ivwxy8ycMhHagI99jfEn5dRObaH+aCc52X08lz5Ts3I1leJPPXeEmEg==} engines: {node: '>=22.0.0'} - '@objectstack/plugin-approvals@17.7.0': - resolution: {integrity: sha512-je8T0dTSoTjqE1J140evxfzsvMiSpLoP3DEdu4NDFFJEWJDw/A/rBVHyQbVV0tLpW5j1pysze1myvF2Sc+MXlw==} + '@objectstack/plugin-approvals@18.0.0-next.0': + resolution: {integrity: sha512-6bYzbriwnwX6bN8Y1CX5LglOKb39VM1N9TFGDwaLvKlxl/hIlJv4IesMWZ3pQ2FZmGvkSxQcW047x7xQcV0LVg==} - '@objectstack/plugin-audit@17.7.0': - resolution: {integrity: sha512-Z0Wpx4W7oVPUwelmAXUckpABP0m+X9NwborOF/JDEmLOEQdi+2GCaWo2vazMJIx5ItyYLR7RYTvglwHa+ZSLQQ==} + '@objectstack/plugin-audit@18.0.0-next.0': + resolution: {integrity: sha512-wZK9hekRSMtAXtod2vNcFOBP0oMChVQJ6/Bp+dE1gm38fdX15tHn7X3NOK9RC75w/ZDBCPAtIXpO5mvYIzDZeA==} engines: {node: '>=22.0.0'} - '@objectstack/plugin-auth@17.7.0': - resolution: {integrity: sha512-JUOgGB6Riizw5QEZ5TXutRT1+PpaM/XpXMbtubAjk9dBGiR8NcfVoLoEFNz+Z5O6x82tdD8bcbt7GMDgUZ5wXA==} + '@objectstack/plugin-auth@18.0.0-next.0': + resolution: {integrity: sha512-r0nM8gdrz5Vj9SVA/0ZgS9mH3nyV77Pg7VOPhcTCVOsdcSY9L9iiSVnkkFwZB9IZDIX6djTDEhmtWjloVHyDDg==} engines: {node: '>=22.0.0'} - '@objectstack/plugin-email@17.7.0': - resolution: {integrity: sha512-58vsGtSv0z6u+W3HqvESIW4OGxbB1/GpR1kY+PdlR0c6JfDc+/YuR0afWm6xrApBoaBPHoMWhtRyoyubgHTJeg==} + '@objectstack/plugin-email@18.0.0-next.0': + resolution: {integrity: sha512-rdawCbrUhRqLC1R9d30bGgx58n2VgQritHFLJTuN0E/yOfuzSNqIpPPeTW3c6Jg07HWn9ckNrgU4i2+BN12VEA==} - '@objectstack/plugin-hono-server@17.7.0': - resolution: {integrity: sha512-VunIMCOQjdJE0J9dq4ebXGnUaLcOttvBNdWM74+J+/W8BflmyuHP/o3JkGo6wgP4e8WwwyTQjJ3BxNi4KrjlKA==} + '@objectstack/plugin-hono-server@18.0.0-next.0': + resolution: {integrity: sha512-uZ9Y2LWCIzuxdqLhNY84UYAoCmDYk/YLpNQtDH8X9QRt8Ia6sPSPPZ6eHkdYjFB0GyKHI2S1cupENr53tUqd3A==} engines: {node: '>=22.0.0'} - '@objectstack/plugin-pinyin-search@17.7.0': - resolution: {integrity: sha512-2HYOdHNlUULzVS00ENv0EVrCA2yA4AWo3oHlCCJV1NHIqOJwimoIPzSu/FpBa+Z0y9T7cX4VxA4hYQO+/Giy8w==} + '@objectstack/plugin-pinyin-search@18.0.0-next.0': + resolution: {integrity: sha512-QFPU4McLBnbExf3ZGDt8ValGvkmEtmZ/7rznSEKIpNUCOPi3O9ipTgiZgNUpFVZtVLsfro9UURHBtRX1FdO5EQ==} - '@objectstack/plugin-security@17.7.0': - resolution: {integrity: sha512-VlUT30iqnD39rAktDCqyAwJbtJTeWkJsFSZ+TFLE9pxcDIJqrCYQJuazn65QcyeQ+xdtkvnkzyPe3JsUz8i4sA==} + '@objectstack/plugin-security@18.0.0-next.0': + resolution: {integrity: sha512-pReeT3XXcLV3SRYlytRcTSEpArPou/7m+i1pLhGd/JEcmYsQJbq1HVWgVK1NAMPOU/Oehg+g0vbSfDYgHc7xiQ==} engines: {node: '>=22.0.0'} - '@objectstack/plugin-sharing@17.7.0': - resolution: {integrity: sha512-oAfwXmP2kQ6mBepPxYxsS6PUmi+XY15LX6kVgVJIwOGT/7iwsY6C4uejWNN7ZwFZ6j3970O43Ia/a73iY3gdbA==} + '@objectstack/plugin-sharing@18.0.0-next.0': + resolution: {integrity: sha512-mrtFslNwIpFrl3js01neH/F25mJ6teZ7X2SXFwVfSQtDeZvWwoYIC9NM3UtaC5xY+F5padMD1Pdv6wHNa0jx9Q==} - '@objectstack/plugin-webhooks@17.7.0': - resolution: {integrity: sha512-VxGfFzJldxl5hUn8c8mlE7mrWwNnQJu3M8WPe2F76i5Pp7Hel6Q+yoQU566aechFPQLDkgsJNbVQbwYGAJr/DA==} + '@objectstack/plugin-webhooks@18.0.0-next.0': + resolution: {integrity: sha512-rbZFpJfFeI8azq2EupmruRQ+1TOI0enJpibXBwSWzx9+NOxkYJMy+cmooOTQUoHpXobxC7MrCGtNf/1rqCtv7w==} - '@objectstack/rest@17.7.0': - resolution: {integrity: sha512-zCRPpyBAgdIAKEsgdfign2JmalkNCcCI98Kk6xlbnRg06E9NMIn4+D3Q1tsG7DsDtIY3Hao2vZlr6/59yhDy+w==} + '@objectstack/rest@18.0.0-next.0': + resolution: {integrity: sha512-XftRfR8YvITE+o48LtZblA7rk1UlhJQHqP3U4kYf5GJ9feXD0KklkCW/EQ3YyW90bsQfAoJ8bQh9B3hIZzrgWg==} engines: {node: '>=22.0.0'} - '@objectstack/runtime@17.7.0': - resolution: {integrity: sha512-RVyleEk68NXBtdXCFtAarMEbP2JqoG+M9u8GwKERhgyKSfapXSOjfrcBXrAWJDNNXm9r5rrAH6P1Ap1rjUpoeA==} + '@objectstack/runtime@18.0.0-next.0': + resolution: {integrity: sha512-pQG/YApKiOumT4PW7I+TE85x38BJh1rRAaFTesUMP12cvvTspwJeCCP83DzyqZI0LO+G5y53/su6W3ACLxdhsA==} engines: {node: '>=22.0.0'} peerDependencies: - '@objectstack/driver-turso': ^17.7.0 + '@objectstack/driver-turso': ^18.0.0-next.0 peerDependenciesMeta: '@objectstack/driver-turso': optional: true - '@objectstack/sdui-parser@17.7.0': - resolution: {integrity: sha512-BaEt37YGK2lBgBXrM+w/tVI+kz5Gz6xhbmCDwgdeGCj8qKALNO8l/oQh/3qdsf6pLIBMfIi+IMqryOICgqwUdw==} + '@objectstack/sdui-parser@18.0.0-next.0': + resolution: {integrity: sha512-fACYC57H+MbpW8cfjtY4J+0HDgaxBjnVgNTMVrJ742JP2FtGn3zsgLt34EMYYoMS41w+spwaBq+5s/sWt3xheA==} - '@objectstack/service-analytics@17.7.0': - resolution: {integrity: sha512-EyVxpQykWckb9vPrxwpSDV9zpWCh5KmqVMPnXflZ0OO9ux4p9dwCFUoFvOzzYW7o3DjVmul7yxKCTY49A9b09w==} + '@objectstack/service-analytics@18.0.0-next.0': + resolution: {integrity: sha512-EiuZ4QqJV9ZTvvnTwJdO8QWLdTfXYB7QGRtIEbNsFEO+Y1pM6c6rmyHzy2uMjbfb8M2/qztJ/EnSG88fcIGzGQ==} engines: {node: '>=22.0.0'} - '@objectstack/service-automation@17.7.0': - resolution: {integrity: sha512-AZ1Ob0RrAe+kzjHJcLFUf393EQdgcmrHvW3q1MHU7FcF65CJsDs+9jcI40EE7vPz9kcqw/S/pUNkj+EwrLOfew==} + '@objectstack/service-automation@18.0.0-next.0': + resolution: {integrity: sha512-86gr4wvanGrEkGqvNoCsIguPRCgTHuhdT2rTGofYerBx0jbmzq2hX9I6HzEBiJ/0hGVSUtxTrfdrTViGTAVyCg==} engines: {node: '>=22.0.0'} - '@objectstack/service-cache@17.7.0': - resolution: {integrity: sha512-VP5MJQahN0tdkMuhKL/kfOa/czpZIUJK6n5V5Bmehg4ZEpYzIrzEP3oN0xSUDd8f3KTzHCSz6flKoD2Dtb48rA==} + '@objectstack/service-cache@18.0.0-next.0': + resolution: {integrity: sha512-7Y1gtXKCQHKf648Ijvqxrl0/i52IT+qTv/DBSXy0rEC/CHY6LDnHk6zuHjMzhd1nGa0JNNHWotwtBSPS7rZXHg==} engines: {node: '>=22.0.0'} - '@objectstack/service-cluster@17.7.0': - resolution: {integrity: sha512-btbcHBv9rPX3dB2Y0LqwsCsFCOiyqA8TmdTuv0CtLD/oc92l7JsIfPVP537w5E9i2H3UQ5wm1Epq7k9jCUlf+Q==} + '@objectstack/service-cluster@18.0.0-next.0': + resolution: {integrity: sha512-COO7cAKo02n0LnqUEIMlDEiZ+vu2ftm4xvrwK59xX94wesqgUcGnGZJeFs9Ravt+OuzGORTpLqCLTz0UFfbzZg==} - '@objectstack/service-datasource@17.7.0': - resolution: {integrity: sha512-SOGSLaKFjXt7N7BPU2H5VAFkuEZ8bt9vvdbkaNDbj9x04oKkdFcaxn8QNluAwwI00W/5quutvim9symwnaIB7g==} + '@objectstack/service-datasource@18.0.0-next.0': + resolution: {integrity: sha512-RdXgW0LxCT3clftK/UHS/LJPkhwU/OaVtY5LI4AXoPGyCBudXkZ5vP7ZaWPpcn0IEHx6z4MzP8PZtQq6siRPpA==} peerDependencies: - '@objectstack/driver-mongodb': ^17.7.0 - '@objectstack/driver-sqlite-wasm': ^17.7.0 - '@objectstack/driver-turso': ^17.7.0 + '@objectstack/driver-mongodb': ^18.0.0-next.0 + '@objectstack/driver-sqlite-wasm': ^18.0.0-next.0 + '@objectstack/driver-turso': ^18.0.0-next.0 peerDependenciesMeta: '@objectstack/driver-mongodb': optional: true @@ -751,39 +750,43 @@ packages: '@objectstack/driver-turso': optional: true - '@objectstack/service-i18n@17.7.0': - resolution: {integrity: sha512-A8RAETkXLh7qeni8YROBZbBU4h85i+D6YppUCBAw2MgTUSirTqYCVta/0MC1NTBd3NvZO0HlQk72BpSFzuNQ2g==} + '@objectstack/service-i18n@18.0.0-next.0': + resolution: {integrity: sha512-ANTeJALVngZFGiCXpfvWGCHjcodjdunpATcwuOGrZ6LNsCYRTAGYiyr/0xkPQZ27C//+gCYFnVpVB9Gu0YfF2w==} engines: {node: '>=22.0.0'} - '@objectstack/service-job@17.7.0': - resolution: {integrity: sha512-NcqnTI+5OrgkfOMtSKlNFcvQJqRxW4SLl7ttRn1HDeedwWnEjFQ8LvfMrGGkACgWHOyyLrnwm+t4QazhzPvWOg==} + '@objectstack/service-job@18.0.0-next.0': + resolution: {integrity: sha512-yll94nlwK058TBToPa2EIRg08BaFkq5RXkVkZ1ZeEwN0aq2VhVzydmkGFgyId5n3MniU1DsqJPBvwr7xTKQ1iQ==} engines: {node: '>=22.0.0'} - '@objectstack/service-messaging@17.7.0': - resolution: {integrity: sha512-p66HSnoBXNeicnTT81dfT4tRsy5fjtSD1jcn+X3pRzEvQf56oFP/wn6eJHjcyjDvZP7zSe6joGIrQKxz6h6tjA==} + '@objectstack/service-mailbox@18.0.0-next.0': + resolution: {integrity: sha512-FC+YJET3G7iVdoEiGFRW6mtADWlWzs745irP0u5SQuxwrXIjYrs6AWUUNARgnFQfWGpp7iy9lBZ8qKgF48rhDA==} engines: {node: '>=22.0.0'} - '@objectstack/service-package@17.7.0': - resolution: {integrity: sha512-Jj3hYcs9+fG4bMGvoeKYrixOyY+ZKm92QowV2M9zsT921rUw4kyxgJgq6R+VfFmucEdy0KqTYVdK0jzP5dYiWw==} + '@objectstack/service-messaging@18.0.0-next.0': + resolution: {integrity: sha512-GghKMb1JBLIIwlJZWDP4wrURglIqpliB+IvHkasYQzihpe684P/O7vIF48vn4GkTA2GMU2gXarNXaeGAND8Mzw==} engines: {node: '>=22.0.0'} - '@objectstack/service-queue@17.7.0': - resolution: {integrity: sha512-gqt0OZSTJha3XJMYwgSVS5Rfzb2pSRahSN4hc5UBpywXI0zpiFP9H9fzCpv++7eNmy2jSkjSIgIE+xy6FxlU6A==} + '@objectstack/service-package@18.0.0-next.0': + resolution: {integrity: sha512-rN67O0wb4DBYe5FN+qahgZkE7S0hScc3+isM/6jMyFwYNPWxwDmPw0nGUTuREf6aBmyTPVT3/byNzVXNjH0blA==} engines: {node: '>=22.0.0'} - '@objectstack/service-realtime@17.7.0': - resolution: {integrity: sha512-r+0UzUKvGGOxpDb9+dLoLh992uiM60d0q+KCiq/6E3pT/i8T3bfzTn3dSpYxZKQ3bxspXhP0FCaF8s30c0+FFQ==} + '@objectstack/service-queue@18.0.0-next.0': + resolution: {integrity: sha512-FtJyRwRzSsRMf6uk2Ia1k8bc8dS+PhGo3TXfrAp8XlX8396kdadD01gPGkZRDjQloUSU39Q4kvSIBvyp/6Pl2w==} engines: {node: '>=22.0.0'} - '@objectstack/service-settings@17.7.0': - resolution: {integrity: sha512-qv/2OnV8Eq/h/GiUqACvt9UZLuV78zkjdZMPygpZhxTKlsLsA1o30XkdpEQUXTHGzMRW+wJkIkvHYhl/JT0yeA==} + '@objectstack/service-realtime@18.0.0-next.0': + resolution: {integrity: sha512-IbFcKvitdvrSlOCdr0wQuZLPiWPWoVtKGYGWf8a/41IwiLAobxgfz+zezTwaw3aHBWgs4/uV8YcA0XeyYFa4+g==} engines: {node: '>=22.0.0'} - '@objectstack/service-sms@17.7.0': - resolution: {integrity: sha512-LDNB2iHEdiD0th90hzzkPOwaCKkin4zxo0tB5lrNwYJ9mEtkPFR7e7KqNMpCmCHLj6squcdrjQZDPQi9HIEfGg==} + '@objectstack/service-settings@18.0.0-next.0': + resolution: {integrity: sha512-4CDlu6VhI9I9GCIZFyxplypx6O3rqd94GgMucOWhw/kqLzOR6d+j2klcHNJXphBJfh2F9PTjKAnutkUGp9d4KA==} + engines: {node: '>=22.0.0'} + + '@objectstack/service-sms@18.0.0-next.0': + resolution: {integrity: sha512-/53uIsALyPIcXE9KSddK7jnCt17Nw0CQPkFr5plJ2d7JsU97U6sqVmRSBFC4bwXC+eB0hvb7GFSgoZZzUUrC6A==} - '@objectstack/service-storage@17.7.0': - resolution: {integrity: sha512-G4waSSMDtAq7+ChI0/39xSAUT1AbnN+XkrCZmtLuokcU7MUOA78GOVl1J2nzHiWrY+fOSggqSdVPfOy9oNom+g==} + '@objectstack/service-storage@18.0.0-next.0': + resolution: {integrity: sha512-SBt2R0Rz8iYfOZBtquctxPOwjhITjbSCGLUvzECZ+WK9LuSu6lYhWUh47L23Ii56cR5uG0Xaqc4NVgRlFvkgzg==} engines: {node: '>=22.0.0'} peerDependencies: '@aws-sdk/client-s3': ^3.0.0 @@ -794,12 +797,12 @@ packages: '@aws-sdk/s3-request-presigner': optional: true - '@objectstack/setup@17.7.0': - resolution: {integrity: sha512-8JwpJ0QjDHPpK/0en/hLVnqP4G52VWKg8gNauc2yI5NDr4xJjkkrmrn+q2FWnRB3fSHvzxZ138Sbj6lQj9ikWg==} + '@objectstack/setup@18.0.0-next.0': + resolution: {integrity: sha512-TM0lorY5ICTV8AcAX93hwpqowvuNZzMyTv/xq35qRKFTEgBcF4QS5D79fIGAVJQ3S5AxAmdW7kl0+8V42h7ANA==} engines: {node: '>=22.0.0'} - '@objectstack/spec@17.7.0': - resolution: {integrity: sha512-PmKFK2C+NHiVoCAC+hYifJJWKiiJxZxN/FlpCQKwqPwajPxCcEUk0oVH0Af0yG3AFRQVCXxwIgEHGGeAa8Z4tA==} + '@objectstack/spec@18.0.0-next.0': + resolution: {integrity: sha512-ASTh6+q5jo2y8VhYT5osOb4H0JUyGAVYHIUPE39OnxSAD3fYg12x1YiNz09JA0aP+8JxigeA0otHWB3j16i2Ow==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -807,23 +810,23 @@ packages: ai: optional: true - '@objectstack/trigger-api@17.7.0': - resolution: {integrity: sha512-YiTgJN9ewxfdL0Q1U/bQho1EDfPZpGAEIgzYAFli+keVRaQyKrH7BjKTxW5RKb4rwCplCFwkNO+AsRQHpJ1K3Q==} + '@objectstack/trigger-api@18.0.0-next.0': + resolution: {integrity: sha512-uByXoueumOequeQHJOdy+iSFaxLbQ157lqubRJvIJs8w/0rZHFb9L1PPSA6WMfWuAdYhBLv5jfQDNwz+iszsjw==} - '@objectstack/trigger-record-change@17.7.0': - resolution: {integrity: sha512-H7zpibqfOpk8PsmBpKPSkVdyAM/JzxUUakYGY5/Wpg1pMFFx0g0cCREFYcHWCyv2s8YGmwN52gkEsc1QaoaCYw==} + '@objectstack/trigger-record-change@18.0.0-next.0': + resolution: {integrity: sha512-IOqQXcT38ngQlBXc3LH6oIpiUGLUlwqiffNfTeY4TQo8mosWoDO2yK1rcyOMv1CwOw0Cudn/VG8GSNi4XXFDhQ==} engines: {node: '>=22.0.0'} - '@objectstack/trigger-schedule@17.7.0': - resolution: {integrity: sha512-eMZgTyXRQk9vrG3p5aY68xW+QN0G+59unsGHwzCzWig7jleOZQlPf48E6WrXAbTEWKYM36bXmC6CU/44bIwG/g==} + '@objectstack/trigger-schedule@18.0.0-next.0': + resolution: {integrity: sha512-bw2SuDAflsJrHiR9uiLG/N5ORabXxkxBLEdZwX5TuuJ12ZZEj62LV3z1XM9yujf+qj/qgh8c4RI8gDvvrYZqAA==} engines: {node: '>=22.0.0'} - '@objectstack/types@17.7.0': - resolution: {integrity: sha512-7EIBYXZsZrBMihmmhwfcafI7F8WUvWNRAylJsZ25mBO7okQLhL4J3GX4wYBiyAJUmroIF3R9lCi+noaoWj5E5g==} + '@objectstack/types@18.0.0-next.0': + resolution: {integrity: sha512-rR+m5ZnBB4zSKn3RKiTKmiksTHjUa66Iz1etHVxUf57rW2qFrp8tQtH7UJlX4V5+5O0HjiRPvPnOgOK2mvV9Qw==} engines: {node: '>=22.0.0'} - '@objectstack/verify@17.7.0': - resolution: {integrity: sha512-2CG2DlPHSvS9E0Sr0/h3MGG20drHI9XwUomICyYZJU5cKlC97PWarIlzpDdUvkA3/LGARDxX7eZPaDyxsU81Ew==} + '@objectstack/verify@18.0.0-next.0': + resolution: {integrity: sha512-CQDr+H+T5G8GCOxewkKd6WAl4YcyA4wXus4ucAQGBDeEF7mn1etW/33mCFzLecZwE1ijpd0O2e5R9LY8nNUZeg==} engines: {node: '>=22.0.0'} '@oclif/core@5.1.2': @@ -1536,8 +1539,8 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} - create-objectstack@17.7.0: - resolution: {integrity: sha512-TLrwK23OcDR7K4JpR6FbBK+U5SfQhPyONNea0cgydfcfpjQaBYULmmuhDARvoTsUZGOSuVrVWxSETnKYg2lKvA==} + create-objectstack@18.0.0-next.0: + resolution: {integrity: sha512-3z0Mezp+DOSxW3PoJ0ubpZ1cqidDgjxkSl7FMsuycQfASfmgLqN2Y8N6Ad8FXwYBdS52urnPjDHIkTVCYXTbxA==} engines: {node: '>=22.0.0'} hasBin: true @@ -3611,7 +3614,7 @@ snapshots: '@marcbachmann/cel-js@8.0.0': {} - '@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.31.0': dependencies: '@hono/node-server': 2.1.1(hono@4.13.11) ajv: 8.20.0 @@ -3650,69 +3653,70 @@ snapshots: '@nodable/entities@3.0.0': {} - '@objectstack/account@17.7.0(vitest@4.1.10)': + '@objectstack/account@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/cli@17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': - dependencies: - '@objectstack/account': 17.7.0(vitest@4.1.10) - '@objectstack/client': 17.7.0 - '@objectstack/cloud-connection': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/console': 17.7.0 - '@objectstack/core': 17.7.0 - '@objectstack/driver-memory': 17.7.0 - '@objectstack/driver-mongodb': 17.7.0 - '@objectstack/driver-sql': 17.7.0 - '@objectstack/driver-sqlite-wasm': 17.7.0(better-sqlite3@13.0.3) - '@objectstack/formula': 17.7.0 - '@objectstack/lint': 17.7.0 - '@objectstack/mcp': 17.7.0 - '@objectstack/metadata': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-protocol': 17.7.0(vitest@4.1.10) - '@objectstack/objectql': 17.7.0(vitest@4.1.10) - '@objectstack/observability': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-approvals': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-audit': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-auth': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/plugin-email': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-hono-server': 17.7.0 - '@objectstack/plugin-pinyin-search': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-security': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-sharing': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-webhooks': 17.7.0(vitest@4.1.10) - '@objectstack/rest': 17.7.0(vitest@4.1.10) - '@objectstack/runtime': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/service-analytics': 17.7.0 - '@objectstack/service-automation': 17.7.0(vitest@4.1.10) - '@objectstack/service-cache': 17.7.0 - '@objectstack/service-datasource': 17.7.0(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(vitest@4.1.10) - '@objectstack/service-job': 17.7.0(vitest@4.1.10) - '@objectstack/service-messaging': 17.7.0(vitest@4.1.10) - '@objectstack/service-package': 17.7.0(vitest@4.1.10) - '@objectstack/service-queue': 17.7.0(vitest@4.1.10) - '@objectstack/service-realtime': 17.7.0(vitest@4.1.10) - '@objectstack/service-settings': 17.7.0(vitest@4.1.10) - '@objectstack/service-sms': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/service-storage': 17.7.0(vitest@4.1.10) - '@objectstack/setup': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/trigger-api': 17.7.0 - '@objectstack/trigger-record-change': 17.7.0 - '@objectstack/trigger-schedule': 17.7.0(vitest@4.1.10) - '@objectstack/types': 17.7.0 - '@objectstack/verify': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/cli@18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': + dependencies: + '@objectstack/account': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/client': 18.0.0-next.0 + '@objectstack/cloud-connection': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/console': 18.0.0-next.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/driver-memory': 18.0.0-next.0 + '@objectstack/driver-mongodb': 18.0.0-next.0 + '@objectstack/driver-sql': 18.0.0-next.0 + '@objectstack/driver-sqlite-wasm': 18.0.0-next.0(better-sqlite3@13.0.3) + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/lint': 18.0.0-next.0 + '@objectstack/mcp': 18.0.0-next.0 + '@objectstack/metadata': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-protocol': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-approvals': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-audit': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-auth': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/plugin-email': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-hono-server': 18.0.0-next.0 + '@objectstack/plugin-pinyin-search': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-security': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-sharing': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-webhooks': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/rest': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/runtime': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/service-analytics': 18.0.0-next.0 + '@objectstack/service-automation': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-cache': 18.0.0-next.0 + '@objectstack/service-datasource': 18.0.0-next.0(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(vitest@4.1.10) + '@objectstack/service-job': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-mailbox': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-messaging': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-package': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-queue': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-realtime': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-settings': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-sms': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/service-storage': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/setup': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/trigger-api': 18.0.0-next.0 + '@objectstack/trigger-record-change': 18.0.0-next.0 + '@objectstack/trigger-schedule': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/types': 18.0.0-next.0 + '@objectstack/verify': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) '@oclif/core': 5.1.2 bundle-require: 5.1.0(esbuild@0.28.2) chalk: 6.0.1 chokidar: 5.0.0 - create-objectstack: 17.7.0 + create-objectstack: 18.0.0-next.0 dotenv-flow: 4.1.0 esbuild: 0.28.2 ts-morph: 28.0.0 @@ -3766,21 +3770,22 @@ snapshots: - vitest - vue - '@objectstack/client@17.7.0': + '@objectstack/client@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/cloud-connection@17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': + '@objectstack/cloud-connection@18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-protocol': 17.7.0(vitest@4.1.10) - '@objectstack/runtime': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-protocol': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/runtime': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@better-auth/utils' @@ -3825,30 +3830,30 @@ snapshots: - vitest - vue - '@objectstack/console@17.7.0': {} + '@objectstack/console@18.0.0-next.0': {} - '@objectstack/core@17.7.0': + '@objectstack/core@18.0.0-next.0': dependencies: - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 zod: 4.6.5 transitivePeerDependencies: - ai - '@objectstack/driver-memory@17.7.0': + '@objectstack/driver-memory@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 mingo: 7.2.4 transitivePeerDependencies: - ai - '@objectstack/driver-mongodb@17.7.0': + '@objectstack/driver-mongodb@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 mongodb: 7.7.0 nanoid: 6.0.1 transitivePeerDependencies: @@ -3861,12 +3866,12 @@ snapshots: - snappy - socks - '@objectstack/driver-sql@17.7.0': + '@objectstack/driver-sql@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/observability': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 knex: 3.3.0(better-sqlite3@13.0.3) nanoid: 6.0.1 optionalDependencies: @@ -3880,11 +3885,11 @@ snapshots: - sqlite3 - supports-color - '@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3)': + '@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/driver-sql': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/driver-sql': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 knex: 3.3.0(better-sqlite3@13.0.3) nanoid: 6.0.1 sql.js: 1.14.2 @@ -3901,18 +3906,18 @@ snapshots: - supports-color - tedious - '@objectstack/formula@17.7.0': + '@objectstack/formula@18.0.0-next.0': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.7.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/lint@17.7.0': + '@objectstack/lint@18.0.0-next.0': dependencies: - '@objectstack/formula': 17.7.0 - '@objectstack/sdui-parser': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/sdui-parser': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 ajv: 8.20.0 ajv-formats: 3.0.1 sucrase: 3.35.1 @@ -3920,58 +3925,58 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/mcp@17.7.0': + '@objectstack/mcp@18.0.0-next.0': dependencies: - '@modelcontextprotocol/sdk': 1.31.0(zod@4.6.5) - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@modelcontextprotocol/sdk': 1.31.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 zod: 4.6.5 transitivePeerDependencies: - '@cfworker/json-schema' - ai - supports-color - '@objectstack/metadata-core@17.7.0(vitest@4.1.10)': + '@objectstack/metadata-core@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/spec': 17.7.0 + '@objectstack/spec': 18.0.0-next.0 zod: 4.6.5 optionalDependencies: vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1) transitivePeerDependencies: - ai - '@objectstack/metadata-fs@17.7.0(vitest@4.1.10)': + '@objectstack/metadata-fs@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) chokidar: 5.0.0 transitivePeerDependencies: - ai - vitest - '@objectstack/metadata-protocol@17.7.0(vitest@4.1.10)': + '@objectstack/metadata-protocol@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/lint': 17.7.0 - '@objectstack/metadata': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/sdui-parser': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/lint': 18.0.0-next.0 + '@objectstack/metadata': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/sdui-parser': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 zod: 4.6.5 transitivePeerDependencies: - ai - vitest - '@objectstack/metadata@17.7.0(vitest@4.1.10)': + '@objectstack/metadata@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-fs': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-fs': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 chokidar: 5.0.0 glob: 13.0.6 js-yaml: 5.4.2 @@ -3980,15 +3985,15 @@ snapshots: - ai - vitest - '@objectstack/objectql@17.7.0(vitest@4.1.10)': + '@objectstack/objectql@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/metadata': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-protocol': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/metadata': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-protocol': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 ajv: 8.20.0 ajv-formats: 3.0.1 zod: 4.6.5 @@ -3996,57 +4001,57 @@ snapshots: - ai - vitest - '@objectstack/observability@17.7.0': + '@objectstack/observability@18.0.0-next.0': dependencies: - '@objectstack/spec': 17.7.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/platform-objects@17.7.0(vitest@4.1.10)': + '@objectstack/platform-objects@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-approvals@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-approvals@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-audit@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-audit@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/objectql': 17.7.0(vitest@4.1.10) - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-auth@17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': + '@objectstack/plugin-auth@18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/oauth-provider': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(mongodb@7.7.0)(vitest@4.1.10))(better-call@1.4.0(zod@4.6.5)) '@better-auth/scim': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(better-auth@1.7.3(better-sqlite3@13.0.3)(mongodb@7.7.0)(vitest@4.1.10))(better-call@1.4.0(zod@4.6.5)) '@better-auth/sso': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(mongodb@7.7.0)(vitest@4.1.10))(better-call@1.4.0(zod@4.6.5)) '@noble/hashes': 2.4.0 - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/rest': 17.7.0(vitest@4.1.10) - '@objectstack/service-messaging': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/rest': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-messaging': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 better-auth: 1.7.3(better-sqlite3@13.0.3)(mongodb@7.7.0)(vitest@4.1.10) jose: 6.2.12 transitivePeerDependencies: @@ -4078,113 +4083,115 @@ snapshots: - vitest - vue - '@objectstack/plugin-email@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-email@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 nodemailer: 10.0.12 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-hono-server@17.7.0': + '@objectstack/plugin-hono-server@18.0.0-next.0': dependencies: '@hono/node-server': 2.1.1(hono@4.13.11) - '@objectstack/core': 17.7.0 - '@objectstack/observability': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 hono: 4.13.11 transitivePeerDependencies: - ai - '@objectstack/plugin-pinyin-search@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-pinyin-search@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/objectql': 17.7.0(vitest@4.1.10) - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/types': 18.0.0-next.0 pinyin-pro: 3.29.4 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-security@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-security@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-sharing@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-sharing@18.0.0-next.0(vitest@4.1.10)': dependencies: '@noble/hashes': 2.4.0 - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/objectql': 17.7.0(vitest@4.1.10) - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/plugin-webhooks@17.7.0(vitest@4.1.10)': + '@objectstack/plugin-webhooks@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/service-messaging': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-messaging': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/rest@17.7.0(vitest@4.1.10)': + '@objectstack/rest@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/observability': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/service-package': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/metadata': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-package': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 exceljs: 4.4.0 zod: 4.6.5 transitivePeerDependencies: - ai - vitest - '@objectstack/runtime@17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': - dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/driver-memory': 17.7.0 - '@objectstack/driver-sql': 17.7.0 - '@objectstack/driver-sqlite-wasm': 17.7.0(better-sqlite3@13.0.3) - '@objectstack/formula': 17.7.0 - '@objectstack/metadata': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-protocol': 17.7.0(vitest@4.1.10) - '@objectstack/objectql': 17.7.0(vitest@4.1.10) - '@objectstack/observability': 17.7.0 - '@objectstack/plugin-auth': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/plugin-security': 17.7.0(vitest@4.1.10) - '@objectstack/rest': 17.7.0(vitest@4.1.10) - '@objectstack/service-cluster': 17.7.0 - '@objectstack/service-datasource': 17.7.0(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(vitest@4.1.10) - '@objectstack/service-i18n': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/runtime@18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': + dependencies: + '@objectstack/core': 18.0.0-next.0 + '@objectstack/driver-memory': 18.0.0-next.0 + '@objectstack/driver-sql': 18.0.0-next.0 + '@objectstack/driver-sqlite-wasm': 18.0.0-next.0(better-sqlite3@13.0.3) + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/metadata': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-protocol': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/plugin-auth': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/plugin-security': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/rest': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-cluster': 18.0.0-next.0 + '@objectstack/service-datasource': 18.0.0-next.0(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(vitest@4.1.10) + '@objectstack/service-i18n': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 quickjs-emscripten: 0.32.0 zod: 4.6.5 optionalDependencies: - '@objectstack/driver-mongodb': 17.7.0 + '@objectstack/driver-mongodb': 18.0.0-next.0 transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@better-auth/utils' @@ -4228,56 +4235,56 @@ snapshots: - vitest - vue - '@objectstack/sdui-parser@17.7.0': {} + '@objectstack/sdui-parser@18.0.0-next.0': {} - '@objectstack/service-analytics@17.7.0': + '@objectstack/service-analytics@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/service-automation@17.7.0(vitest@4.1.10)': + '@objectstack/service-automation@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/formula': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/metadata-protocol': 17.7.0(vitest@4.1.10) - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/formula': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/metadata-protocol': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-cache@17.7.0': + '@objectstack/service-cache@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/observability': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/service-cluster@17.7.0': + '@objectstack/service-cluster@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/service-datasource@17.7.0(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(vitest@4.1.10)': + '@objectstack/service-datasource@18.0.0-next.0(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/driver-memory': 17.7.0 - '@objectstack/driver-sql': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/driver-memory': 18.0.0-next.0 + '@objectstack/driver-sql': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 pg-connection-string: 2.14.0 optionalDependencies: - '@objectstack/driver-mongodb': 17.7.0 - '@objectstack/driver-sqlite-wasm': 17.7.0(better-sqlite3@13.0.3) + '@objectstack/driver-mongodb': 18.0.0-next.0 + '@objectstack/driver-sqlite-wasm': 18.0.0-next.0(better-sqlite3@13.0.3) transitivePeerDependencies: - ai - mariadb @@ -4291,77 +4298,86 @@ snapshots: - tedious - vitest - '@objectstack/service-i18n@17.7.0': + '@objectstack/service-i18n@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/service-job@17.7.0(vitest@4.1.10)': + '@objectstack/service-job@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 croner: 10.0.1 transitivePeerDependencies: - ai - vitest - '@objectstack/service-messaging@17.7.0(vitest@4.1.10)': + '@objectstack/service-mailbox@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-package@17.7.0(vitest@4.1.10)': + '@objectstack/service-messaging@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-queue@17.7.0(vitest@4.1.10)': + '@objectstack/service-package@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-realtime@17.7.0(vitest@4.1.10)': + '@objectstack/service-queue@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-settings@17.7.0(vitest@4.1.10)': + '@objectstack/service-realtime@18.0.0-next.0(vitest@4.1.10)': + dependencies: + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + transitivePeerDependencies: + - ai + - vitest + + '@objectstack/service-settings@18.0.0-next.0(vitest@4.1.10)': dependencies: '@noble/ciphers': 2.4.0 - '@objectstack/core': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/service-sms@17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': + '@objectstack/service-sms@18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/plugin-auth': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/plugin-auth': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -4391,82 +4407,104 @@ snapshots: - vitest - vue - '@objectstack/service-storage@17.7.0(vitest@4.1.10)': + '@objectstack/service-storage@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/observability': 17.7.0 - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/observability': 18.0.0-next.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/setup@17.7.0(vitest@4.1.10)': + '@objectstack/setup@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - vitest - '@objectstack/spec@17.7.0': + '@objectstack/spec@18.0.0-next.0': dependencies: pg-connection-string: 2.14.0 zod: 4.6.5 - '@objectstack/trigger-api@17.7.0': + '@objectstack/trigger-api@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/trigger-record-change@17.7.0': + '@objectstack/trigger-record-change@18.0.0-next.0': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/spec': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/trigger-schedule@17.7.0(vitest@4.1.10)': + '@objectstack/trigger-schedule@18.0.0-next.0(vitest@4.1.10)': dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/metadata-core': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/core': 18.0.0-next.0 + '@objectstack/metadata-core': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/types': 18.0.0-next.0 croner: 10.0.1 transitivePeerDependencies: - ai - vitest - '@objectstack/types@17.7.0': + '@objectstack/types@18.0.0-next.0': dependencies: - '@objectstack/spec': 17.7.0 + '@objectstack/spec': 18.0.0-next.0 transitivePeerDependencies: - ai - '@objectstack/verify@17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': - dependencies: - '@objectstack/core': 17.7.0 - '@objectstack/objectql': 17.7.0(vitest@4.1.10) - '@objectstack/platform-objects': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-auth': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/plugin-hono-server': 17.7.0 - '@objectstack/plugin-security': 17.7.0(vitest@4.1.10) - '@objectstack/plugin-sharing': 17.7.0(vitest@4.1.10) - '@objectstack/rest': 17.7.0(vitest@4.1.10) - '@objectstack/runtime': 17.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) - '@objectstack/service-analytics': 17.7.0 - '@objectstack/service-automation': 17.7.0(vitest@4.1.10) - '@objectstack/service-datasource': 17.7.0(@objectstack/driver-mongodb@17.7.0)(@objectstack/driver-sqlite-wasm@17.7.0(better-sqlite3@13.0.3))(vitest@4.1.10) - '@objectstack/service-settings': 17.7.0(vitest@4.1.10) - '@objectstack/spec': 17.7.0 - '@objectstack/types': 17.7.0 + '@objectstack/verify@18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10)': + dependencies: + '@objectstack/core': 18.0.0-next.0 + '@objectstack/mcp': 18.0.0-next.0 + '@objectstack/metadata': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/objectql': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/platform-objects': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-approvals': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-audit': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-auth': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/plugin-email': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-hono-server': 18.0.0-next.0 + '@objectstack/plugin-pinyin-search': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-security': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-sharing': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/plugin-webhooks': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/rest': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/runtime': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/service-analytics': 18.0.0-next.0 + '@objectstack/service-automation': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-cache': 18.0.0-next.0 + '@objectstack/service-datasource': 18.0.0-next.0(@objectstack/driver-mongodb@18.0.0-next.0)(@objectstack/driver-sqlite-wasm@18.0.0-next.0(better-sqlite3@13.0.3))(vitest@4.1.10) + '@objectstack/service-job': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-mailbox': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-messaging': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-package': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-queue': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-realtime': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-settings': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/service-sms': 18.0.0-next.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.5))(better-sqlite3@13.0.3)(kysely@0.29.4)(mongodb@7.7.0)(nanostores@1.4.2)(vitest@4.1.10) + '@objectstack/service-storage': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/spec': 18.0.0-next.0 + '@objectstack/trigger-api': 18.0.0-next.0 + '@objectstack/trigger-record-change': 18.0.0-next.0 + '@objectstack/trigger-schedule': 18.0.0-next.0(vitest@4.1.10) + '@objectstack/types': 18.0.0-next.0 transitivePeerDependencies: + - '@aws-sdk/client-s3' - '@aws-sdk/credential-providers' + - '@aws-sdk/s3-request-presigner' - '@better-auth/utils' - '@better-fetch/fetch' + - '@cfworker/json-schema' - '@cloudflare/workers-types' - '@lynx-js/react' - '@mongodb-js/zstd' @@ -5102,7 +5140,7 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 - create-objectstack@17.7.0: + create-objectstack@18.0.0-next.0: dependencies: chalk: 6.0.1 commander: 15.0.0 diff --git a/scripts/check-source-token-ratchet.mjs b/scripts/check-source-token-ratchet.mjs index 7a3dbfe3b..45b3ed9d5 100644 --- a/scripts/check-source-token-ratchet.mjs +++ b/scripts/check-source-token-ratchet.mjs @@ -457,18 +457,18 @@ export const anchor = (tokens) => Math.ceil((tokens * (1 + BUFFER)) / 1000) * 10 * src/revenue business semantics ~15,169 · interaction layer ~2,136 · authored total ~18,279 * src/marketing business semantics ~8,001 · interaction layer ~876 · authored total ~9,096 * - * src/sales business semantics 55,986 × 1.05 = 58,785 -> ceil 1k -> 59,000 (headroom 3,014, 5.4%) 2026-09-18 - * src/sales interaction layer 29,477 × 1.05 = 30,951 -> ceil 1k -> 31,000 (headroom 1,523, 5.2%) 2026-09-16 - * src/sales authored total 101,395 × 1.05 = 106,465 -> ceil 1k -> 107,000 (headroom 5,605, 5.5%) 2026-09-18 - * src/service business semantics 12,593 × 1.05 = 13,223 -> ceil 1k -> 14,000 (headroom 1,407, 11.2%) 2026-09-18 - * src/service interaction layer 6,228 × 1.05 = 6,539 -> ceil 1k -> 7,000 (headroom 772, 12.4%) 2026-09-18 - * src/service authored total 20,423 × 1.05 = 21,444 -> ceil 1k -> 22,000 (headroom 1,577, 7.7%) 2026-09-16 - * src/revenue business semantics 15,169 × 1.05 = 15,927 -> ceil 1k -> 16,000 (headroom 831, 5.5%) 2026-09-18 - * src/revenue interaction layer 2,136 × 1.05 = 2,243 -> ceil 1k -> 3,000 (headroom 864, 40.4%) 2026-09-18 - * src/revenue authored total 17,485 × 1.05 = 18,359 -> ceil 1k -> 19,000 (headroom 1,515, 8.7%) 2026-09-16 - * src/marketing business semantics 8,001 × 1.05 = 8,401 -> ceil 1k -> 9,000 (headroom 999, 12.5%) 2026-09-18 - * src/marketing interaction layer 876 × 1.05 = 920 -> ceil 1k -> 1,000 (headroom 124, 14.2%) 2026-09-18 - * src/marketing authored total 9,096 × 1.05 = 9,551 -> ceil 1k -> 10,000 (headroom 904, 9.9%) 2026-09-18 + * src/sales business semantics 55,986 × 1.05 = 58,785 -> ceil 1k -> 59,000 (headroom 3,014, 5.4%) 2026-09-18 superseded (#2031) + * src/sales interaction layer 29,477 × 1.05 = 30,951 -> ceil 1k -> 31,000 (headroom 1,523, 5.2%) 2026-09-16 superseded (#2031) + * src/sales authored total 101,395 × 1.05 = 106,465 -> ceil 1k -> 107,000 (headroom 5,605, 5.5%) 2026-09-18 superseded (#2031) + * src/service business semantics 12,593 × 1.05 = 13,223 -> ceil 1k -> 14,000 (headroom 1,407, 11.2%) 2026-09-18 superseded (#2031) + * src/service interaction layer 6,228 × 1.05 = 6,539 -> ceil 1k -> 7,000 (headroom 772, 12.4%) 2026-09-18 superseded (#2031) + * src/service authored total 20,423 × 1.05 = 21,444 -> ceil 1k -> 22,000 (headroom 1,577, 7.7%) 2026-09-16 superseded (#2031) + * src/revenue business semantics 15,169 × 1.05 = 15,927 -> ceil 1k -> 16,000 (headroom 831, 5.5%) 2026-09-18 superseded (#2031) + * src/revenue interaction layer 2,136 × 1.05 = 2,243 -> ceil 1k -> 3,000 (headroom 864, 40.4%) 2026-09-18 superseded (#2031) + * src/revenue authored total 17,485 × 1.05 = 18,359 -> ceil 1k -> 19,000 (headroom 1,515, 8.7%) 2026-09-16 superseded (#2031) + * src/marketing business semantics 8,001 × 1.05 = 8,401 -> ceil 1k -> 9,000 (headroom 999, 12.5%) 2026-09-18 superseded (#2031) + * src/marketing interaction layer 876 × 1.05 = 920 -> ceil 1k -> 1,000 (headroom 124, 14.2%) 2026-09-18 superseded (#2031) + * src/marketing authored total 9,096 × 1.05 = 9,551 -> ceil 1k -> 10,000 (headroom 904, 9.9%) 2026-09-18 superseded (#2031) * * `headroom` is the headroom **at anchor time** (`ceiling - reading`, on that * row's own run): it is a derivation of the constant beside it, not a live @@ -482,9 +482,9 @@ export const anchor = (tokens) => Math.ceil((tokens * (1 + BUFFER)) / 1000) * 10 * from, and the row saying why is the record that they were weighed rather than * overlooked: * - * src/sales interaction layer anchor( 29,725) = 32,000 > ceiling 31,000 2026-09-18 - * src/service authored total anchor( 21,127) = 23,000 > ceiling 22,000 2026-09-18 - * src/revenue authored total anchor( 18,279) = 20,000 > ceiling 19,000 2026-09-18 + * src/sales interaction layer anchor( 29,725) = 32,000 > ceiling 31,000 2026-09-18 superseded (#2031) + * src/service authored total anchor( 21,127) = 23,000 > ceiling 22,000 2026-09-18 superseded (#2031) + * src/revenue authored total anchor( 18,279) = 20,000 > ceiling 19,000 2026-09-18 superseded (#2031) * * ⚠️ The small modules carry headroom well past 5% and there is no way around * it: `anchor()` rounds up to the next 1,000, and on a reading of 876 that step @@ -510,13 +510,46 @@ export const anchor = (tokens) => Math.ceil((tokens * (1 + BUFFER)) / 1000) * 10 * whole tree interaction layer 37,424 × 1.05 = 39,295 -> ceil 1k -> 40,000 2026-08-26 (#1316) * whole tree authored total 133,302 × 1.05 = 139,967 -> ceil 1k -> 140,000 2026-08-17 (#1189) * - * ⚠️ The RULED kind is still a kind this gate knows, and `isAnchored()` is - * still what the opportunistic-tightening advisory asks (#1607). No committed - * ceiling declares it today. That is the finished state of this table, not a - * disabled mechanism: the next grant declares `CEILING_KIND.RULED` and the - * advisory drops for it without another change here. ⛔ Do not delete the kind - * because nothing currently uses it — #1607 is the card that explains what - * breaks when a grant is silently read as anchored. + * ## 2026-10-13 — #2031 grants every ceiling ×1.5, by ruling + * + * The ruling that authorises this grant, verbatim and untranslated, recorded on + * objectstack-ai/hotcrm#2031 (comment 6143114246): + * + * 「hotcrm token 上限提升 50%」 + * 「暂时只优先处理升级相关的任务」 + * + * Each of the twelve committed ceilings is multiplied by exactly 1.5. None of + * the products is `anchor()` of a reading (88,500 is not even a multiple of + * 1,000), so all twelve are RULED: a maintainer grant, with no worked + * arithmetic that could honestly be written beside it. The warning in the + * 2026-09-18 section against filing an anchored raise as RULED still stands + * for an anchoring; this is not one, and the ruling is what pins these numbers + * against future tightening. Lowering one now takes a ruling quoted in the + * lowering PR, exactly as this raise did, and the opportunistic-tightening + * advisory stands down for every row (#1607). The anchored rows they replace + * stay above, marked `superseded (#2031)`, so the base each grant multiplies + * can still be re-derived. + * + * src/sales business semantics ruled 88,500 — ×1.5 of the anchored 59,000, by the #2031 ruling 2026-10-13 + * src/sales interaction layer ruled 46,500 — ×1.5 of the anchored 31,000, by the #2031 ruling 2026-10-13 + * src/sales authored total ruled 160,500 — ×1.5 of the anchored 107,000, by the #2031 ruling 2026-10-13 + * src/service business semantics ruled 21,000 — ×1.5 of the anchored 14,000, by the #2031 ruling 2026-10-13 + * src/service interaction layer ruled 10,500 — ×1.5 of the anchored 7,000, by the #2031 ruling 2026-10-13 + * src/service authored total ruled 33,000 — ×1.5 of the anchored 22,000, by the #2031 ruling 2026-10-13 + * src/revenue business semantics ruled 24,000 — ×1.5 of the anchored 16,000, by the #2031 ruling 2026-10-13 + * src/revenue interaction layer ruled 4,500 — ×1.5 of the anchored 3,000, by the #2031 ruling 2026-10-13 + * src/revenue authored total ruled 28,500 — ×1.5 of the anchored 19,000, by the #2031 ruling 2026-10-13 + * src/marketing business semantics ruled 13,500 — ×1.5 of the anchored 9,000, by the #2031 ruling 2026-10-13 + * src/marketing interaction layer ruled 1,500 — ×1.5 of the anchored 1,000, by the #2031 ruling 2026-10-13 + * src/marketing authored total ruled 15,000 — ×1.5 of the anchored 10,000, by the #2031 ruling 2026-10-13 + * + * ⚠️ Since #2031 RULED is the kind every committed ceiling declares, and + * `isAnchored()` is still what the opportunistic-tightening advisory asks + * (#1607). ANCHORED stays a kind this gate knows: a re-anchoring a later ruling + * allows declares it again, and the advisory returns for that row without + * another change here. ⛔ Do not delete either kind because the committed table + * holds only one of them today — #1607 is the card that explains what breaks + * when a grant is silently read as anchored. * * Lower them whenever the tree shrinks — that is free and encouraged. Raising * one requires a maintainer ruling quoted in the raising PR's body. Both of @@ -545,18 +578,18 @@ const CEILING_KIND = { ANCHORED: 'anchored', RULED: 'ruled' }; * ceiling, it is four, and a table keyed by label alone could not hold them. */ const COMMITTED = [ - { module: 'src/sales', label: 'business semantics', ceiling: 59000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/sales', label: 'interaction layer', ceiling: 31000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/sales', label: 'authored total', ceiling: 107000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/service', label: 'business semantics', ceiling: 14000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/service', label: 'interaction layer', ceiling: 7000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/service', label: 'authored total', ceiling: 22000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/revenue', label: 'business semantics', ceiling: 16000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/revenue', label: 'interaction layer', ceiling: 3000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/revenue', label: 'authored total', ceiling: 19000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/marketing', label: 'business semantics', ceiling: 9000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/marketing', label: 'interaction layer', ceiling: 1000, kind: CEILING_KIND.ANCHORED }, - { module: 'src/marketing', label: 'authored total', ceiling: 10000, kind: CEILING_KIND.ANCHORED }, + { module: 'src/sales', label: 'business semantics', ceiling: 88500, kind: CEILING_KIND.RULED }, + { module: 'src/sales', label: 'interaction layer', ceiling: 46500, kind: CEILING_KIND.RULED }, + { module: 'src/sales', label: 'authored total', ceiling: 160500, kind: CEILING_KIND.RULED }, + { module: 'src/service', label: 'business semantics', ceiling: 21000, kind: CEILING_KIND.RULED }, + { module: 'src/service', label: 'interaction layer', ceiling: 10500, kind: CEILING_KIND.RULED }, + { module: 'src/service', label: 'authored total', ceiling: 33000, kind: CEILING_KIND.RULED }, + { module: 'src/revenue', label: 'business semantics', ceiling: 24000, kind: CEILING_KIND.RULED }, + { module: 'src/revenue', label: 'interaction layer', ceiling: 4500, kind: CEILING_KIND.RULED }, + { module: 'src/revenue', label: 'authored total', ceiling: 28500, kind: CEILING_KIND.RULED }, + { module: 'src/marketing', label: 'business semantics', ceiling: 13500, kind: CEILING_KIND.RULED }, + { module: 'src/marketing', label: 'interaction layer', ceiling: 1500, kind: CEILING_KIND.RULED }, + { module: 'src/marketing', label: 'authored total', ceiling: 15000, kind: CEILING_KIND.RULED }, ]; // Every committed ceiling declares a kind this module recognises. A ceiling diff --git a/src/marketing/flows/campaign-completion.flow.ts b/src/marketing/flows/campaign-completion.flow.ts index 7243349d8..304f752ce 100644 --- a/src/marketing/flows/campaign-completion.flow.ts +++ b/src/marketing/flows/campaign-completion.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from '../../sales/flows/_guarded-iteration'; type Flow = Automation.Flow; @@ -35,7 +36,7 @@ export const CampaignCompletionFlow: Flow = { id: 'query_campaigns', type: 'get_record', label: 'Find Ended Campaigns', config: { objectName: 'crm_campaign', - filter: { status: 'in_progress', end_date: { $lt: '{TODAY()}' } }, + filter: { status: 'in_progress', end_date: { $lt: cel`isoDate(today())` } }, limit: 500, outputVariable: 'campaignList', }, @@ -43,13 +44,13 @@ export const CampaignCompletionFlow: Flow = { { id: 'loop_campaigns', type: 'loop', label: 'For Each Campaign', config: { - collection: '{campaignList}', + collection: cel`campaignList`, iteratorVariable: 'currentCampaign', body: guarded('campaign', { nodes: [ { id: 'mark_completed', type: 'update_record', label: 'Mark Completed', - config: { objectName: 'crm_campaign', filter: { id: '{currentCampaign.id}' }, fields: { status: 'completed' } }, + config: { objectName: 'crm_campaign', filter: { id: cel`currentCampaign.id` }, fields: { status: 'completed' } }, }, ], edges: [], diff --git a/src/marketing/flows/campaign-enrollment.flow.ts b/src/marketing/flows/campaign-enrollment.flow.ts index 4bdf125e7..18fa9364f 100644 --- a/src/marketing/flows/campaign-enrollment.flow.ts +++ b/src/marketing/flows/campaign-enrollment.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from '../../sales/flows/_guarded-iteration'; type Flow = Automation.Flow; @@ -55,7 +55,7 @@ export const CampaignEnrollmentFlow: Flow = { * * ⚠️ Measured on the 17.1.0 spec, with the schema key re-checked on 17.2.0. * Neither that key nor the seeding behaviour was re-checked on 17.3.0 or - * 17.4.0 (PR #1814, #1807), nor on 17.5.0, 17.6.0 or 17.7.0 (the pin since the 17.7.0 bump). + * 17.4.0 (PR #1814, #1807), nor on 17.5.0, 17.6.0, 17.7.0 or 18.0.0-next.0 (the pin since #2031). * * ⛔ Never seed this with an assignment node instead. An assignment is * unconditional, so it CLOBBERS a supplied param — measured: launching with @@ -70,26 +70,27 @@ export const CampaignEnrollmentFlow: Flow = { * path cannot reach an unbound read at all — unlike `lead_conversion`'s * checkbox, which is deliberately not `required` and therefore has an * unanswered state. The one path that skips that check is a resume carrying - * NO signal object; measured, it fails one node later at `query_leads` - * regardless of this binding, because `{leadStatus}` resolves to nothing and - * `get_record` refuses to run rather than widen the query. + * NO signal object; measured on 17.x, it fails one node later at + * `query_leads` regardless of this binding, because `leadStatus` is unbound + * and the query never runs widened. Since 18.0.0-next.0 the filter operand + * is a CEL envelope, and an unbound variable fails the run before the query + * (the platform's `flow-value-slot-template-dialect-refused` contract; not + * re-measured here). * * The literal lives HERE and nowhere else: the screen field derives its - * prefill from this variable (`defaultValue: '{memberSource}'`). + * prefill from this variable (its `defaultValue` envelope reads `memberSource`). */ { name: 'memberSource', type: 'text', isInput: true, isOutput: false, defaultValue: 'leads' }, /** * `leadStatus` / `contactDepartment` deliberately carry NO `defaultValue`, * and the asymmetry with `memberSource` is the point. No condition in this - * flow reads either one — they are interpolated into a `get_record` - * `filter` — so the binding analysis in - * `test/flow-variable-conditions.test.ts` does not cover them, and the CEL - * abort it exists to prevent cannot happen here. An unresolved - * interpolation makes `get_record` REFUSE TO RUN and name the offending - * condition, because an absent condition widens a query instead of - * narrowing it. Seeding these two would turn that loud refusal into a - * silent enrolment of a segment nobody chose; their `required: true` - * screen fields are what supply them. + * flow reads either one — each is a `get_record` `filter` operand — so the + * binding analysis in `test/flow-variable-conditions.test.ts` does not + * cover them. An unbound one fails the run before the query is made (the + * 18.0.0-next.0 filter-operand contract), because an absent condition + * widens a query instead of narrowing it. Seeding these two would turn that + * loud failure into a silent enrolment of a segment nobody chose; their + * `required: true` screen fields are what supply them. */ { name: 'leadStatus', type: 'text', isInput: true, isOutput: false }, { name: 'contactDepartment', type: 'text', isInput: true, isOutput: false }, @@ -104,11 +105,12 @@ export const CampaignEnrollmentFlow: Flow = { { // Prefill DERIVED from the variable, so the default is written // once (on the declaration) rather than restated here — the - // single-authority form. It reaches the client - // interpolated to the raw value, the string `leads`, which is what - // a literal `defaultValue: 'leads'` used to send. + // single-authority form. The envelope is evaluated before the + // screen goes on the wire, so the client receives the raw value, + // the string `leads`, which is what a literal + // `defaultValue: 'leads'` used to send. name: 'memberSource', label: 'Enroll', type: 'select', required: true, - defaultValue: '{memberSource}', + defaultValue: cel`memberSource`, options: [ { label: 'Leads', value: 'leads' }, { label: 'Contacts', value: 'contacts' }, @@ -125,7 +127,7 @@ export const CampaignEnrollmentFlow: Flow = { }, { // Required, like `leadStatus`, and for the same reason: a blank - // segment would interpolate into the filter as an empty string and + // segment would reach the filter as an empty string and // silently match the contacts with NO department rather than all of // them. Both branches make the caller name a segment. name: 'contactDepartment', label: 'Enroll contacts in department', @@ -146,7 +148,7 @@ export const CampaignEnrollmentFlow: Flow = { }, { id: 'get_campaign', type: 'get_record', label: 'Get Campaign', - config: { objectName: 'crm_campaign', filter: { id: '{recordId}' }, outputVariable: 'campaignRecord' }, + config: { objectName: 'crm_campaign', filter: { id: cel`recordId` }, outputVariable: 'campaignRecord' }, }, { // The gate itself is on edges `e4` / `e7` — see the notes there. This node @@ -161,7 +163,7 @@ export const CampaignEnrollmentFlow: Flow = { config: { objectName: 'crm_lead', filter: { - status: '{leadStatus}', + status: cel`leadStatus`, is_converted: false, email: { $ne: null }, // This is email-campaign enrollment — honour the opt-out flag. @@ -176,7 +178,7 @@ export const CampaignEnrollmentFlow: Flow = { { id: 'loop_leads', type: 'loop', label: 'Process Each Lead', config: { - collection: '{leadList}', + collection: cel`leadList`, iteratorVariable: 'currentLead', body: guarded('lead', { nodes: [ @@ -187,7 +189,7 @@ export const CampaignEnrollmentFlow: Flow = { id: 'find_existing_member', type: 'get_record', label: 'Already Enrolled?', config: { objectName: 'crm_campaign_member', - filter: { crm_campaign: '{recordId}', crm_lead: '{currentLead.id}' }, + filter: { crm_campaign: cel`recordId`, crm_lead: cel`currentLead.id` }, outputVariable: 'existingMember', }, }, @@ -211,7 +213,7 @@ export const CampaignEnrollmentFlow: Flow = { id: 'create_campaign_member', type: 'subflow', label: 'Add to Campaign', config: { flowName: 'campaign_lead_member_enroll', - input: { campaignId: '{recordId}', leadId: '{currentLead.id}' }, + input: { campaignId: cel`recordId`, leadId: cel`currentLead.id` }, }, }, ], @@ -230,7 +232,7 @@ export const CampaignEnrollmentFlow: Flow = { config: { objectName: 'crm_contact', filter: { - department: '{contactDepartment}', + department: cel`contactDepartment`, email: { $ne: null }, // Same opt-out honour as the lead branch. There is no `is_converted` // twin here: a contact IS the converted end state. @@ -243,7 +245,7 @@ export const CampaignEnrollmentFlow: Flow = { { id: 'loop_contacts', type: 'loop', label: 'Process Each Contact', config: { - collection: '{contactList}', + collection: cel`contactList`, iteratorVariable: 'currentContact', body: guarded('contact', { nodes: [ @@ -255,7 +257,7 @@ export const CampaignEnrollmentFlow: Flow = { id: 'find_existing_contact_member', type: 'get_record', label: 'Already Enrolled?', config: { objectName: 'crm_campaign_member', - filter: { crm_campaign: '{recordId}', crm_contact: '{currentContact.id}' }, + filter: { crm_campaign: cel`recordId`, crm_contact: cel`currentContact.id` }, outputVariable: 'existingContactMember', }, }, @@ -269,7 +271,7 @@ export const CampaignEnrollmentFlow: Flow = { id: 'create_contact_member', type: 'subflow', label: 'Add Contact to Campaign', config: { flowName: 'campaign_contact_member_enroll', - input: { campaignId: '{recordId}', contactId: '{currentContact.id}' }, + input: { campaignId: cel`recordId`, contactId: cel`currentContact.id` }, }, }, ], diff --git a/src/marketing/flows/campaign-member-enroll.flow.ts b/src/marketing/flows/campaign-member-enroll.flow.ts index 70ddf13a8..d66aada69 100644 --- a/src/marketing/flows/campaign-member-enroll.flow.ts +++ b/src/marketing/flows/campaign-member-enroll.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -65,7 +66,7 @@ type Flow = Automation.Flow; const MEMBERSHIP_FIELDS = { status: 'sent', // The readonly stamp this elevation exists for. - added_date: '{NOW()}', + added_date: cel`isoDatetime(now())`, } as const; export const CampaignLeadMemberEnrollFlow: Flow = { @@ -98,7 +99,7 @@ export const CampaignLeadMemberEnrollFlow: Flow = { // ⛔ Do not add fields here. Every column in this node is written with // the elevated context; anything a marketer may legitimately type // belongs in `campaign_enrollment` instead. - fields: { crm_campaign: '{campaignId}', crm_lead: '{leadId}', ...MEMBERSHIP_FIELDS }, + fields: { crm_campaign: cel`campaignId`, crm_lead: cel`leadId`, ...MEMBERSHIP_FIELDS }, }, }, { id: 'end', type: 'end', label: 'End' }, @@ -131,7 +132,7 @@ export const CampaignContactMemberEnrollFlow: Flow = { id: 'create_contact_member', type: 'create_record', label: 'Add Contact to Campaign', config: { objectName: 'crm_campaign_member', - fields: { crm_campaign: '{campaignId}', crm_contact: '{contactId}', ...MEMBERSHIP_FIELDS }, + fields: { crm_campaign: cel`campaignId`, crm_contact: cel`contactId`, ...MEMBERSHIP_FIELDS }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/revenue/flows/billing-handoff-contract-activated.flow.ts b/src/revenue/flows/billing-handoff-contract-activated.flow.ts index 21711c75d..7796e14ea 100644 --- a/src/revenue/flows/billing-handoff-contract-activated.flow.ts +++ b/src/revenue/flows/billing-handoff-contract-activated.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -8,11 +8,10 @@ import { BILLING_HANDOFF_ENDPOINT, BILLING_HANDOFF_EVENT, BILLING_HANDOFF_LINE_ITEM_LIMIT, - BILLING_HANDOFF_PAYLOAD_VERSION, BILLING_HANDOFF_SIGNING_SECRET, BILLING_HANDOFF_TIMEOUT_MS, LINE_ITEM_FIELDS, - accountBlock, + billingBody, loadAccountNode, } from '../../sales/flows/_billing-endpoint'; @@ -133,9 +132,9 @@ export const BillingHandoffContractActivatedFlow: Flow = { // Contracts carry no line items of their own; what was sold sits on the // opportunity the contract came from. Reached only via the guarded edge // below, because `crm_opportunity` is OPTIONAL on a contract and a - // filter token that resolves to nothing makes `get_record` refuse the + // filter operand that evaluates to null makes `get_record` refuse the // step (an absent condition widens a query rather than narrowing it). - filter: { crm_opportunity: '{record.crm_opportunity}' }, + filter: { crm_opportunity: cel`record.crm_opportunity` }, fields: LINE_ITEM_FIELDS, limit: BILLING_HANDOFF_LINE_ITEM_LIMIT, outputVariable: 'billingLineItems', @@ -151,33 +150,15 @@ export const BillingHandoffContractActivatedFlow: Flow = { durable: true, timeoutMs: BILLING_HANDOFF_TIMEOUT_MS, signingSecret: BILLING_HANDOFF_SIGNING_SECRET, - body: { - event: BILLING_HANDOFF_EVENT.contractActivated, - version: BILLING_HANDOFF_PAYLOAD_VERSION, - occurred_at: '{NOW()}', - contract: { - id: '{record.id}', - contract_number: '{record.contract_number}', - status: '{record.status}', - contract_type: '{record.contract_type}', - start_date: '{record.start_date}', - end_date: '{record.end_date}', - contract_term_months: '{record.contract_term_months}', - contract_value: '{record.contract_value}', - // This flow is the consumer of these two fields: they are what the - // billing system needs to raise the schedule HotCRM deliberately - // does not model. - billing_frequency: '{record.billing_frequency}', - payment_terms: '{record.payment_terms}', - auto_renewal: '{record.auto_renewal}', - signed_date: '{record.signed_date}', - account_id: '{record.crm_account}', - opportunity_id: '{record.crm_opportunity}', - owner_id: '{record.owner_id}', - }, - account: accountBlock, - line_items: '{billingLineItems}', - }, + // One envelope building the whole payload (see `billingBody`). This + // flow is the consumer of `billing_frequency` and `payment_terms`: they + // are what the billing system needs to raise the schedule HotCRM + // deliberately does not model. + body: billingBody(BILLING_HANDOFF_EVENT.contractActivated, 'contract', [ + 'id', 'contract_number', 'status', 'contract_type', 'start_date', 'end_date', 'contract_term_months', + 'contract_value', 'billing_frequency', 'payment_terms', 'auto_renewal', 'signed_date', + ['account_id', 'crm_account'], ['opportunity_id', 'crm_opportunity'], 'owner_id', + ]), }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/revenue/flows/contract-expiration.flow.ts b/src/revenue/flows/contract-expiration.flow.ts index 11a13db81..c8d09f2b0 100644 --- a/src/revenue/flows/contract-expiration.flow.ts +++ b/src/revenue/flows/contract-expiration.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from '../../sales/flows/_guarded-iteration'; type Flow = Automation.Flow; @@ -28,7 +29,7 @@ export const ContractExpirationFlow: Flow = { id: 'query_contracts', type: 'get_record', label: 'Find Expired Contracts', config: { objectName: 'crm_contract', - filter: { status: 'activated', end_date: { $lt: '{TODAY()}' } }, + filter: { status: 'activated', end_date: { $lt: cel`isoDate(today())` } }, limit: 500, outputVariable: 'contractList', }, @@ -36,23 +37,23 @@ export const ContractExpirationFlow: Flow = { { id: 'loop_contracts', type: 'loop', label: 'For Each Contract', config: { - collection: '{contractList}', + collection: cel`contractList`, iteratorVariable: 'currentContract', body: guarded('contract', { nodes: [ { id: 'mark_expired', type: 'update_record', label: 'Mark Expired', - config: { objectName: 'crm_contract', filter: { id: '{currentContract.id}' }, fields: { status: 'expired' } }, + config: { objectName: 'crm_contract', filter: { id: cel`currentContract.id` }, fields: { status: 'expired' } }, }, { id: 'notify_owner', type: 'notify', label: 'Notify Owner', config: { - recipients: ['{currentContract.owner_id}'], + recipients: [cel`has(currentContract.owner_id) ? currentContract.owner_id : null`], channels: ['inbox', 'email'], topic: 'contract_expired', template: 'crm.contract_expired', - templateData: { contract_number: '{currentContract.contract_number}' }, - actionUrl: '/crm_contract/{currentContract.id}', + templateData: { contract_number: cel`currentContract.contract_number` }, + actionUrl: '/crm_contract/{{currentContract.id}}', }, }, ], diff --git a/src/revenue/flows/contract-renewal.flow.ts b/src/revenue/flows/contract-renewal.flow.ts index 2c26093d0..ec20aaa4f 100644 --- a/src/revenue/flows/contract-renewal.flow.ts +++ b/src/revenue/flows/contract-renewal.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from '../../sales/flows/_guarded-iteration'; type Flow = Automation.Flow; @@ -44,7 +44,7 @@ export const ContractRenewalFlow: Flow = { id: 'query_contracts', type: 'get_record', label: 'Find Expiring Contracts', config: { objectName: 'crm_contract', - filter: { status: 'activated', end_date: { $gte: '{TODAY()}', $lte: '{TODAY() + 120}' } }, + filter: { status: 'activated', end_date: { $gte: cel`isoDate(today())`, $lte: cel`isoDate(daysFromNow(120))` } }, limit: 500, outputVariable: 'contractList', }, @@ -52,7 +52,7 @@ export const ContractRenewalFlow: Flow = { { id: 'loop_contracts', type: 'loop', label: 'For Each Contract', config: { - collection: '{contractList}', + collection: cel`contractList`, iteratorVariable: 'currentContract', body: guarded('contract', { nodes: [ @@ -70,8 +70,8 @@ export const ContractRenewalFlow: Flow = { config: { objectName: 'crm_task', filter: { - related_to_account: '{currentContract.crm_account}', - subject: 'Renewal due: contract {currentContract.contract_number}', + related_to_account: cel`currentContract.crm_account`, + subject: cel`'Renewal due: contract ' + currentContract.contract_number`, status: { $nin: ['completed'] }, }, outputVariable: 'existingRenewalTask', @@ -86,10 +86,10 @@ export const ContractRenewalFlow: Flow = { config: { objectName: 'crm_task', fields: { - subject: 'Renewal due: contract {currentContract.contract_number}', + subject: cel`'Renewal due: contract ' + currentContract.contract_number`, type: 'follow_up', priority: 'high', status: 'not_started', - due_date: '{currentContract.end_date}', - owner_id: '{currentContract.owner_id}', + due_date: cel`currentContract.end_date`, + owner_id: cel`has(currentContract.owner_id) ? currentContract.owner_id : null`, // ORG PARTITION. A schedule trigger carries no // organization, so the engine has nothing to fill this from // and the row would be born `organization_id` NULL — outside @@ -99,27 +99,30 @@ export const ContractRenewalFlow: Flow = { // with the flow author: a renewal task belongs to the org of // the contract that spawned it. Fill-only precedence means an // author-set value wins over the engine (objectstack#6153). - // The source MUST be a row that actually carries the column — - // an absent key interpolates to `undefined` and lands as NULL, - // which satisfies the publish guard while reproducing the bug. - // `crm_contract` carries it; `sys_user` does not. - organization_id: '{currentContract.organization_id}', + // The source MUST be a row that actually carries the column. + // On 17.x an absent key interpolated to `undefined` and landed + // as NULL, which satisfied the publish guard while reproducing + // the bug; the CEL read below fails the iteration instead, and + // it is deliberately left UNGUARDED — a `has()` guard would + // write that NULL back. `crm_contract` carries it; `sys_user` + // does not. + organization_id: cel`currentContract.organization_id`, related_to_type: 'crm_account', - related_to_account: '{currentContract.crm_account}', + related_to_account: cel`currentContract.crm_account`, }, }, }, { id: 'notify_owner', type: 'notify', label: 'Notify Owner', config: { - recipients: ['{currentContract.owner_id}'], + recipients: [cel`has(currentContract.owner_id) ? currentContract.owner_id : null`], channels: ['inbox', 'email'], topic: 'contract_renewal', template: 'crm.contract_renewal', templateData: { - contract_number: '{currentContract.contract_number}', end_date: '{currentContract.end_date}', + contract_number: cel`currentContract.contract_number`, end_date: cel`currentContract.end_date`, }, - actionUrl: '/crm_contract/{currentContract.id}', + actionUrl: '/crm_contract/{{currentContract.id}}', }, }, { @@ -134,7 +137,7 @@ export const ContractRenewalFlow: Flow = { config: { objectName: 'crm_opportunity', filter: { - crm_account: '{currentContract.crm_account}', + crm_account: cel`currentContract.crm_account`, type: 'existing_renewal', stage: { $nin: ['closed_won', 'closed_lost'] }, }, @@ -150,16 +153,16 @@ export const ContractRenewalFlow: Flow = { config: { objectName: 'crm_opportunity', fields: { - name: 'Renewal — {currentContract.contract_number}', - crm_account: '{currentContract.crm_account}', - amount: '{currentContract.contract_value}', + name: cel`'Renewal — ' + currentContract.contract_number`, + crm_account: cel`currentContract.crm_account`, + amount: cel`currentContract.contract_value`, stage: 'proposal', type: 'existing_renewal', - close_date: '{currentContract.end_date}', - owner_id: '{currentContract.owner_id}', + close_date: cel`currentContract.end_date`, + owner_id: cel`has(currentContract.owner_id) ? currentContract.owner_id : null`, // ORG PARTITION — see `create_renewal_task` above. // The renewal deal belongs to the same org as the contract. - organization_id: '{currentContract.organization_id}', + organization_id: cel`currentContract.organization_id`, next_step: 'Confirm renewal terms with customer', }, }, diff --git a/src/revenue/flows/quote-expiration.flow.ts b/src/revenue/flows/quote-expiration.flow.ts index fffafa53f..be355b562 100644 --- a/src/revenue/flows/quote-expiration.flow.ts +++ b/src/revenue/flows/quote-expiration.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from '../../sales/flows/_guarded-iteration'; type Flow = Automation.Flow; @@ -27,7 +28,7 @@ export const QuoteExpirationFlow: Flow = { id: 'query_quotes', type: 'get_record', label: 'Find Expired Quotes', config: { objectName: 'crm_quote', - filter: { status: { $nin: ['accepted', 'rejected', 'expired'] }, expiration_date: { $lt: '{TODAY()}' } }, + filter: { status: { $nin: ['accepted', 'rejected', 'expired'] }, expiration_date: { $lt: cel`isoDate(today())` } }, limit: 500, outputVariable: 'quoteList', }, @@ -35,13 +36,13 @@ export const QuoteExpirationFlow: Flow = { { id: 'loop_quotes', type: 'loop', label: 'For Each Quote', config: { - collection: '{quoteList}', + collection: cel`quoteList`, iteratorVariable: 'currentQuote', body: guarded('quote', { nodes: [ { id: 'mark_expired', type: 'update_record', label: 'Mark Expired', - config: { objectName: 'crm_quote', filter: { id: '{currentQuote.id}' }, fields: { status: 'expired' } }, + config: { objectName: 'crm_quote', filter: { id: cel`currentQuote.id` }, fields: { status: 'expired' } }, }, ], edges: [], diff --git a/src/revenue/flows/quote-generation.flow.ts b/src/revenue/flows/quote-generation.flow.ts index d9da710a9..198f62e3f 100644 --- a/src/revenue/flows/quote-generation.flow.ts +++ b/src/revenue/flows/quote-generation.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P, expression } from '@objectstack/spec'; +import { P, expression, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { QUOTE_DISCOUNT_CEILING } from '../../sales/objects/_thresholds'; type Flow = Automation.Flow; @@ -77,7 +77,7 @@ export const QuoteGenerationFlow: Flow = { // the #2015 approval branch (`e4a` / `e4b`) see the deal as it is at // submit. The verdict cannot drift toward held while the form is open: // `not_required` and `approved` are never written back to `pending`. - { id: 'get_held', type: 'get_record', label: 'Get Held Deal', config: { objectName: 'crm_opportunity', filter: { id: '{recordId}', qualification_approval_status: { $in: ['pending', 'rejected'] } }, outputVariable: 'held' } }, + { id: 'get_held', type: 'get_record', label: 'Get Held Deal', config: { objectName: 'crm_opportunity', filter: { id: cel`recordId`, qualification_approval_status: { $in: ['pending', 'rejected'] } }, outputVariable: 'held' } }, // A first-class refusal (`EndConfigSchema` `outcome: 'refused'`): the run // ends `refused`, never `failed` and never parked, nothing is written, // and the console shows the message with Close only — no Submit and no @@ -85,12 +85,12 @@ export const QuoteGenerationFlow: Flow = { // names the next step the `opportunity_lifecycle` refusal of this gate // names. // - // ⚠️ English only, and not by choice: at 17.7.0 the `flows` translation - // group carries a flow's `label` and its screens' `title` / field copy, - // and no key for an `end` node's `message` (a screen's `description` is - // refused by name). The console titles the dialog with the action's - // translated label. A platform gap, raised from #2032; ⛔ do not - // re-route the refusal through a screen to borrow a translatable title. + // Translated: since 18.0.0-next.0 the `flows` translation group keys an + // `end` node's refusal as `refusals.refuse_held.message`, which the + // engine renders in the run's locale (at 17.7.0 it had no such key, the + // gap raised from #2032). The console titles the dialog with the action's + // translated label. ⛔ Do not re-route the refusal through a screen to + // borrow a translatable title. { id: 'refuse_held', type: 'end', label: 'Quote Refused', config: { outcome: 'refused', message: 'Qualification approval comes first: tick Request Qualification Approval. Nothing was created.' } }, { id: 'screen_1', type: 'screen', label: 'Quote Details', @@ -131,21 +131,24 @@ export const QuoteGenerationFlow: Flow = { }, { id: 'get_opportunity', type: 'get_record', label: 'Get Opportunity', - config: { objectName: 'crm_opportunity', filter: { id: '{recordId}' }, outputVariable: 'oppRecord' }, + config: { objectName: 'crm_opportunity', filter: { id: cel`recordId` }, outputVariable: 'oppRecord' }, }, { id: 'create_quote', type: 'create_record', label: 'Create Quote', config: { objectName: 'crm_quote', fields: { - name: '{quoteName}', crm_opportunity: '{recordId}', - crm_account: '{oppRecord.crm_account}', crm_contact: '{oppRecord.primary_contact}', - owner_id: '{$User.Id}', status: 'draft', - quote_date: '{TODAY()}', expiration_date: '{TODAY() + expirationDays}', + name: cel`quoteName`, crm_opportunity: cel`recordId`, + crm_account: cel`oppRecord.crm_account`, crm_contact: cel`has(oppRecord.primary_contact) ? oppRecord.primary_contact : null`, + owner_id: cel`current_user.id`, status: 'draft', + quote_date: cel`isoDate(today())`, expiration_date: cel`isoDate(addDays(today(), expirationDays))`, // `subtotal` is a bare path pass-through and needs no rounding: it // copies `crm_opportunity.amount` as stored and does no arithmetic, // so it adds no floating-point tail of its own. - subtotal: '{oppRecord.amount}', discount: '{discount}', + // `discount` is an optional screen answer: a resume may leave it + // unbound, which the template wrote as nothing and the guard writes + // as null (the quote field has no default, so the two agree). + subtotal: cel`oppRecord.amount`, discount: cel`has(vars.discount) ? discount : null`, // ⛔ A currency × percentage MUST be rounded to whole cents inside the // expression — the flow hands the engine a money amount, never an // unrounded double. `discount / 100` is inexact for every percentage @@ -211,7 +214,7 @@ export const QuoteGenerationFlow: Flow = { config: { // ⛔ No `last_activity_date` write: `crm_opportunity` has no such field // (it lives on `crm_account`), and an unknown column fails this node. - objectName: 'crm_opportunity', filter: { id: '{recordId}' }, + objectName: 'crm_opportunity', filter: { id: cel`recordId` }, fields: { stage: 'proposal' }, }, }, @@ -220,12 +223,12 @@ export const QuoteGenerationFlow: Flow = { // `script` + `actionType:'email'` shape is a no-op stub in 7.4. id: 'notify_owner', type: 'notify', label: 'Send Notification', config: { - recipients: ['{$User.Id}'], + recipients: [cel`current_user.id`], channels: ['inbox', 'email'], topic: 'quote_created', template: 'crm.quote_created', - templateData: { quote_name: '{quoteName}' }, - actionUrl: '/crm_quote/{quoteId.id}', + templateData: { quote_name: cel`quoteName` }, + actionUrl: '/crm_quote/{{quoteId.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/email-templates/_bundle.ts b/src/sales/email-templates/_bundle.ts index c3335fa5d..3fd4565e4 100644 --- a/src/sales/email-templates/_bundle.ts +++ b/src/sales/email-templates/_bundle.ts @@ -9,7 +9,7 @@ import type { EmailTemplateDefinition } from '@objectstack/spec/system'; * `EmailTemplateDefinition` carries ONE `locale` per row, so an n-language * bundle is n rows of the same `name` — that is the spec's own key, not a * convention this repo picked. Writing those rows out by hand would mean - * repeating `name`, `category` and the row scaffolding four times per + * repeating `name` and the row scaffolding four times per * template; here each locale pack states only what actually differs between * languages (the label and the two bodies) and this helper produces the rows. * @@ -25,8 +25,8 @@ import type { EmailTemplateDefinition } from '@objectstack/spec/system'; * `EmailTemplateDefinition` whose value is genuinely per-locale. * * `label` is in here because it is the Studio display name an administrator - * reads, so it localizes with the content; `name`, `category` and `active` are - * not, and {@link bundle} supplies them once per row. + * reads, so it localizes with the content; `name` and `locale` are not, and + * {@link bundle} supplies them once per row. */ export interface LocalizedEmailTemplate { /** Studio display label for this template, in this language. */ @@ -58,4 +58,4 @@ export type EmailTemplatePack = Record; */ export const bundle = (packs: Record): EmailTemplateDefinition[] => Object.entries(packs).flatMap(([locale, pack]) => - Object.entries(pack).map(([name, row]) => ({ name, locale, category: 'workflow' as const, ...row }))); + Object.entries(pack).map(([name, row]) => ({ name, locale, ...row }))); diff --git a/src/sales/flows/_billing-endpoint.ts b/src/sales/flows/_billing-endpoint.ts index 3a04d7d73..a65309ef5 100644 --- a/src/sales/flows/_billing-endpoint.ts +++ b/src/sales/flows/_billing-endpoint.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel, expression } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; /** @@ -122,31 +123,62 @@ export const LINE_ITEM_FIELDS = [ 'total_price', ]; +/** An optional column of the account row, read as `null` where the row lacks it. */ +const accountField = (field: string) => `has(billingAccount.${field}) ? billingAccount.${field} : null`; + /** - * The account block, shared by both payloads. + * The account block, shared by both payloads: a CEL map literal over the + * `billingAccount` row {@link loadAccountNode} binds, spliced into each body by + * {@link billingBody}. * * Authored field-by-field rather than passing the row whole: this object IS the * contract with the billing system, so it must not silently grow a column the * day someone adds a field to `crm_account`. + * + * `name` is required and `account_number` an autonumber, so both are read + * bare. The other four are optional, and a sparse driver (memory, mongodb) + * hands back a column the row was never written with ABSENT, which CEL refuses, + * so each is guarded to `null`. The 17.x template dropped such a key from the + * JSON instead; on the SQL drivers the column is always present and the two + * agree. + */ +export const ACCOUNT_BLOCK = + `{'id': billingAccount.id, 'name': billingAccount.name, 'account_number': billingAccount.account_number, ` + + `'billing_address': ${accountField('billing_address')}, 'billing_country': ${accountField('billing_country')}, ` + + `'phone': ${accountField('phone')}, 'website': ${accountField('website')}}`; + +/** + * The http `body` of both hand-offs: ONE CEL value envelope building the whole + * payload. `key` names the record's own block, and `fields` lists what it + * carries, read straight off the triggering `record`: a field under its own + * name, or `[key, field]` where the payload names it differently. `record` is + * the record-change trigger's, which hands the run every declared field (null + * where unset), so these reads take no guard. + * + * Since 18.0.0-next.0 an http body is one value, evaluated once before the + * call, so the durable outbox enqueues exactly what an inline call would send. + * A `{…}` token in a nested string is refused there, and an envelope nested in + * a literal object would be sent as the object it spells — so the payload is + * built by one envelope, not by a literal with envelopes inside it. CEL holds a + * map's values to one type, so each literal and each nested map is wrapped in + * `dyn()`. `billingLineItems` is a variable read and already dynamic. */ -export const accountBlock = { - id: '{billingAccount.id}', - name: '{billingAccount.name}', - account_number: '{billingAccount.account_number}', - billing_address: '{billingAccount.billing_address}', - billing_country: '{billingAccount.billing_country}', - phone: '{billingAccount.phone}', - website: '{billingAccount.website}', -}; +export const billingBody = (event: string, key: string, fields: (string | [string, string])[]) => + expression( + `{'event': dyn('${event}'), 'version': dyn(${BILLING_HANDOFF_PAYLOAD_VERSION}), ` + + `'occurred_at': dyn(isoDatetime(now())), ` + + `'${key}': dyn({${fields.map((f) => (typeof f === 'string' ? [f, f] : f)).map(([k, f]) => `'${k}': record.${f}`).join(', ')}}), ` + + `'account': dyn(${ACCOUNT_BLOCK}), 'line_items': billingLineItems}`, + ); /** * Read the account named by the triggering record. * * `crm_account` is `required` + `storage.notNull` on both `crm_opportunity` and - * `crm_contract`, so the token always resolves on a real row. Left UNGUARDED on - * purpose: if it ever did resolve to nothing, `get_record` refuses the step and - * the run fails loudly in Studio's Flow Runs page. For a required field that is the - * right failure — a silent skip would drop a hand-off with no trace. + * `crm_contract`, so the value is always there on a real row. Left UNGUARDED on + * purpose: if it were ever absent, the envelope fails the run loudly in Studio's + * Flow Runs page. For a required field that is the right failure — a silent skip + * would drop a hand-off with no trace. */ export const loadAccountNode = (): Automation.FlowNode => ({ id: 'load_account', @@ -154,10 +186,10 @@ export const loadAccountNode = (): Automation.FlowNode => ({ label: 'Load Account', config: { objectName: 'crm_account', - filter: { id: '{record.crm_account}' }, + filter: { id: cel`record.crm_account` }, fields: ACCOUNT_FIELDS, // No `limit`: `get_record` calls `findOne` below 2 and binds the single row - // itself, which is what `{billingAccount.}` needs. + // itself, which is what the account block's `billingAccount.FIELD` reads need. outputVariable: 'billingAccount', }, }); diff --git a/src/sales/flows/account-approval.flow.ts b/src/sales/flows/account-approval.flow.ts index b881ee8e9..8eb4bc26b 100644 --- a/src/sales/flows/account-approval.flow.ts +++ b/src/sales/flows/account-approval.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -103,7 +103,7 @@ export const AccountApprovalFlow: Flow = { label: 'Get Account', config: { objectName: 'crm_account', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, outputVariable: 'accountRecord', }, }, @@ -139,7 +139,7 @@ export const AccountApprovalFlow: Flow = { label: 'Mark Approved', config: { objectName: 'crm_account', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { approval_status: 'approved' }, }, }, @@ -148,12 +148,12 @@ export const AccountApprovalFlow: Flow = { type: 'notify', label: 'Notify Owner — Approved', config: { - recipients: ['{accountRecord.owner_id}'], + recipients: [cel`has(accountRecord.owner_id) ? accountRecord.owner_id : null`], channels: ['inbox'], topic: 'account_approved', template: 'crm.account_approved', - templateData: { name: '{accountRecord.name}' }, - actionUrl: '/crm_account/{record.id}', + templateData: { name: cel`accountRecord.name` }, + actionUrl: '/crm_account/{{record.id}}', }, }, { @@ -162,7 +162,7 @@ export const AccountApprovalFlow: Flow = { label: 'Mark Rejected', config: { objectName: 'crm_account', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { approval_status: 'rejected' }, }, }, @@ -171,13 +171,13 @@ export const AccountApprovalFlow: Flow = { type: 'notify', label: 'Notify Owner — Rejected', config: { - recipients: ['{accountRecord.owner_id}'], + recipients: [cel`has(accountRecord.owner_id) ? accountRecord.owner_id : null`], channels: ['inbox'], severity: 'warning', topic: 'account_rejected', template: 'crm.account_rejected', - templateData: { name: '{accountRecord.name}' }, - actionUrl: '/crm_account/{record.id}', + templateData: { name: cel`accountRecord.name` }, + actionUrl: '/crm_account/{{record.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/billing-handoff-closed-won.flow.ts b/src/sales/flows/billing-handoff-closed-won.flow.ts index f8b2c20eb..859363a86 100644 --- a/src/sales/flows/billing-handoff-closed-won.flow.ts +++ b/src/sales/flows/billing-handoff-closed-won.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -8,11 +8,10 @@ import { BILLING_HANDOFF_ENDPOINT, BILLING_HANDOFF_EVENT, BILLING_HANDOFF_LINE_ITEM_LIMIT, - BILLING_HANDOFF_PAYLOAD_VERSION, BILLING_HANDOFF_SIGNING_SECRET, BILLING_HANDOFF_TIMEOUT_MS, LINE_ITEM_FIELDS, - accountBlock, + billingBody, loadAccountNode, } from './_billing-endpoint'; @@ -131,7 +130,7 @@ export const BillingHandoffClosedWonFlow: Flow = { label: 'Load Line Items', config: { objectName: 'crm_opportunity_line_item', - filter: { crm_opportunity: '{record.id}' }, + filter: { crm_opportunity: cel`record.id` }, fields: LINE_ITEM_FIELDS, // > 1 is what makes `get_record` issue a `find` and bind an ARRAY; // omit it and the node calls `findOne` and binds a single row. @@ -154,25 +153,12 @@ export const BillingHandoffClosedWonFlow: Flow = { durable: true, timeoutMs: BILLING_HANDOFF_TIMEOUT_MS, signingSecret: BILLING_HANDOFF_SIGNING_SECRET, - body: { - event: BILLING_HANDOFF_EVENT.opportunityClosedWon, - version: BILLING_HANDOFF_PAYLOAD_VERSION, - occurred_at: '{NOW()}', - opportunity: { - id: '{record.id}', - name: '{record.name}', - amount: '{record.amount}', - close_date: '{record.close_date}', - stage: '{record.stage}', - type: '{record.type}', - account_id: '{record.crm_account}', - owner_id: '{record.owner_id}', - }, - account: accountBlock, - // A whole-string single token resolves to the VALUE, not to its - // stringification, so the array survives as an array. - line_items: '{billingLineItems}', - }, + // One envelope building the whole payload (see `billingBody`). Each + // read is the value itself, never its stringification, so the amount + // stays a number and the line items an array. + body: billingBody(BILLING_HANDOFF_EVENT.opportunityClosedWon, 'opportunity', [ + 'id', 'name', 'amount', 'close_date', 'stage', 'type', ['account_id', 'crm_account'], 'owner_id', + ]), }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/contact-welcome.flow.ts b/src/sales/flows/contact-welcome.flow.ts index c80fdb71e..bae87ceb0 100644 --- a/src/sales/flows/contact-welcome.flow.ts +++ b/src/sales/flows/contact-welcome.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -66,12 +66,12 @@ export const ContactWelcomeFlow: Flow = { { id: 'send_welcome', type: 'notify', label: 'Prompt Owner to Welcome', config: { - recipients: ['{record.owner_id}'], + recipients: [cel`record.owner_id`], channels: ['inbox', 'email'], topic: 'contact_welcome', template: 'crm.contact_welcome', - templateData: { first_name: '{record.first_name}', last_name: '{record.last_name}' }, - actionUrl: '/crm_contact/{record.id}', + templateData: { first_name: cel`record.first_name`, last_name: cel`record.last_name` }, + actionUrl: '/crm_contact/{{record.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/forecast-snapshot.flow.ts b/src/sales/flows/forecast-snapshot.flow.ts index 96f675977..e67515743 100644 --- a/src/sales/flows/forecast-snapshot.flow.ts +++ b/src/sales/flows/forecast-snapshot.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P, expression } from '@objectstack/spec'; +import { P, expression, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from './_guarded-iteration'; type Flow = Automation.Flow; @@ -42,10 +42,10 @@ type Flow = Automation.Flow; * Per owner: ensure the current-period row exists, re-read it, then sum four * scoped queries into it. Two things force that ordering. * - * 1. The period window is DATA, not arithmetic. A flow template resolves - * `{TODAY()}` and day offsets only — it cannot say "the first day of this - * calendar quarter". So the flow selects the row whose window CONTAINS - * today (`period_start <= today <= period_end`), lets `forecast.hook.ts` + * 1. The period window is DATA, not arithmetic. The 17.x flow template + * resolved `{TODAY()}` and day offsets only — it could not say "the first + * day of this calendar quarter". So the flow selects the row whose window + * CONTAINS today (`period_start <= today <= period_end`), lets `forecast.hook.ts` * derive a calendar-true window on create, and then reads the boundaries * back off the row to scope the opportunity queries. One definition of "the * current period", owned by the object. @@ -119,10 +119,10 @@ const OPEN_STAGES = { $nin: ['closed_won', 'closed_lost'] }; * a scheduled one does, and the sweep opens nothing beside it. */ const CURRENT_PERIOD_FILTER = { - owner_id: '{currentOwner.id}', + owner_id: cel`currentOwner.id`, period: SNAPSHOT_PERIOD, - period_start: { $lte: '{TODAY()}' }, - period_end: { $gte: '{TODAY()}' }, + period_start: { $lte: cel`isoDate(today())` }, + period_end: { $gte: cel`isoDate(today())` }, }; /** @@ -156,7 +156,7 @@ const OWNED_PERIOD_FILTER = { ...CURRENT_PERIOD_FILTER, source: 'scheduled' }; * * The pin is the target row's own organization, read back off * `currentForecast`. That is the SAME "prove the source carries the right - * organization" mechanism `{ownerAnyDeal.organization_id}` establishes on + * organization" mechanism `ownerAnyDeal.organization_id` establishes on * `create_forecast`, inverted — not a second one. It is in scope by * construction: `currentForecast` already supplies the window boundaries in * this very filter, and `reload_forecast` binds it before any bucket runs. @@ -169,11 +169,11 @@ const OWNED_PERIOD_FILTER = { ...CURRENT_PERIOD_FILTER, source: 'scheduled' }; * deliberately not undertaken here. */ const inPeriod = (extra: Record) => ({ - owner_id: '{currentOwner.id}', - organization_id: '{currentForecast.organization_id}', + owner_id: cel`currentOwner.id`, + organization_id: cel`currentForecast.organization_id`, close_date: { - $gte: '{currentForecast.period_start}', - $lte: '{currentForecast.period_end}', + $gte: cel`currentForecast.period_start`, + $lte: cel`currentForecast.period_end`, }, ...extra, }); @@ -207,7 +207,7 @@ const sumBucket = (key: string, label: string, filter: Record, type: 'loop' as const, label: `Sum ${label}`, config: { - collection: `{${key}Opps}`, + collection: expression(`${key}Opps`, 'cel'), iteratorVariable: item, body: { nodes: [ @@ -292,7 +292,7 @@ export const ForecastSnapshotFlow: Flow = { { id: 'loop_owners', type: 'loop', label: 'For Each Owner', config: { - collection: '{ownerList}', + collection: cel`ownerList`, iteratorVariable: 'currentOwner', body: guarded('owner', { nodes: [ @@ -303,7 +303,7 @@ export const ForecastSnapshotFlow: Flow = { id: 'find_any_deal', type: 'get_record', label: 'Owns Any Live Deal?', config: { objectName: 'crm_opportunity', - filter: { owner_id: '{currentOwner.id}', stage: { $nin: ['closed_lost'] } }, + filter: { owner_id: cel`currentOwner.id`, stage: { $nin: ['closed_lost'] } }, outputVariable: 'ownerAnyDeal', }, }, @@ -337,7 +337,7 @@ export const ForecastSnapshotFlow: Flow = { config: { objectName: 'crm_forecast', fields: { - owner_id: '{currentOwner.id}', + owner_id: cel`currentOwner.id`, // ORG PARTITION. A schedule trigger carries no // organization, so without this the snapshot row is born // `organization_id` NULL — outside every org partition, where @@ -349,19 +349,20 @@ export const ForecastSnapshotFlow: Flow = { // item here is a `sys_user` row, and `sys_user` declares NO // `organization_id` — identity is global, org-partitioned it // is not (it carries `primary_business_unit_id` instead). - // `{currentOwner.organization_id}` would interpolate to - // `undefined` and land as NULL: the publish guard, which only - // checks that the key is declared, would go green over a row - // still born outside every partition. Measured, not assumed. + // On 17.x `{currentOwner.organization_id}` interpolated to + // `undefined` and landed as NULL: the publish guard, which only + // checks that the key is declared, went green over a row still + // born outside every partition (measured, not assumed). Under + // CEL that read fails the iteration instead. // // `ownerAnyDeal` is the `crm_opportunity` from `find_any_deal` // — it carries the column, and edge `b2` (`ownerAnyDeal != // null`) is the only route to this node, so it is never null // here. Semantically right too: an owner's forecast belongs to // the org their pipeline is in. - organization_id: '{ownerAnyDeal.organization_id}', + organization_id: cel`ownerAnyDeal.organization_id`, period: SNAPSHOT_PERIOD, - snapshot_date: '{TODAY()}', + snapshot_date: cel`isoDate(today())`, source: 'scheduled', pipeline_amount: 0, best_case_amount: 0, @@ -421,13 +422,13 @@ export const ForecastSnapshotFlow: Flow = { id: 'write_snapshot', type: 'update_record', label: 'Write Snapshot', config: { objectName: 'crm_forecast', - filter: { id: '{currentForecast.id}' }, + filter: { id: cel`currentForecast.id` }, fields: { - pipeline_amount: '{pipelineTotal}', - best_case_amount: '{bestCaseTotal}', - commit_amount: '{commitTotal}', - closed_amount: '{closedTotal}', - snapshot_date: '{TODAY()}', + pipeline_amount: cel`pipelineTotal`, + best_case_amount: cel`bestCaseTotal`, + commit_amount: cel`commitTotal`, + closed_amount: cel`closedTotal`, + snapshot_date: cel`isoDate(today())`, source: 'scheduled', }, }, diff --git a/src/sales/flows/lead-assignment.flow.ts b/src/sales/flows/lead-assignment.flow.ts index ba99a8639..bf401d7a0 100644 --- a/src/sales/flows/lead-assignment.flow.ts +++ b/src/sales/flows/lead-assignment.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -22,7 +22,7 @@ type Flow = Automation.Flow; * so the record-change trigger provider binds it to the ObjectQL `afterInsert` * lifecycle hook. Firing on insert only means the SLA write does not re-trigger * the flow. The full record is seeded into the run, so fields are read via - * `{record.*}` (no get_record round-trip needed). + * `record.*` (no get_record round-trip needed). */ export const LeadAssignmentFlow: Flow = { name: 'lead_assignment', @@ -62,7 +62,7 @@ export const LeadAssignmentFlow: Flow = { // ── Hot path: 1-day SLA, high-severity alert ─────────────────── { id: 'sla_hot', type: 'update_record', label: 'Set 1-Day SLA', - config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, fields: { next_followup_date: '{TODAY() + 1}' } }, + config: { objectName: 'crm_lead', filter: { id: cel`record.id` }, fields: { next_followup_date: cel`isoDate(daysFromNow(1))` } }, }, { id: 'notify_hot', type: 'notify', label: 'Alert — Hot Lead', @@ -73,37 +73,37 @@ export const LeadAssignmentFlow: Flow = { // 'sales_manager' was stored verbatim as sys_inbox_message.user_id, // matching no real user, so these alerts reached nobody. owner is set // on insert (defaultValue os.user.id) and is the accountable party. - recipients: ['{record.owner_id}'], + recipients: [cel`record.owner_id`], channels: ['inbox', 'email'], severity: 'warning', topic: 'lead_routing', template: 'crm.lead_routing_hot', templateData: { - first_name: '{record.first_name}', last_name: '{record.last_name}', - company: '{record.company}', rating: '{record.rating}', + first_name: cel`record.first_name`, last_name: cel`record.last_name`, + company: cel`record.company`, rating: cel`record.rating`, }, - actionUrl: '/crm_lead/{record.id}', + actionUrl: '/crm_lead/{{record.id}}', }, }, // ── Standard path: 3-day SLA, normal alert ───────────────────── { id: 'sla_std', type: 'update_record', label: 'Set 3-Day SLA', - config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, fields: { next_followup_date: '{TODAY() + 3}' } }, + config: { objectName: 'crm_lead', filter: { id: cel`record.id` }, fields: { next_followup_date: cel`isoDate(daysFromNow(3))` } }, }, { id: 'notify_std', type: 'notify', label: 'Alert — New Lead', config: { // Route to the lead's owner — see notify_hot for why a bare // 'sales_manager' position never reached anyone. - recipients: ['{record.owner_id}'], + recipients: [cel`record.owner_id`], channels: ['inbox'], topic: 'lead_routing', template: 'crm.lead_routing_new', templateData: { - first_name: '{record.first_name}', last_name: '{record.last_name}', company: '{record.company}', + first_name: cel`record.first_name`, last_name: cel`record.last_name`, company: cel`record.company`, }, - actionUrl: '/crm_lead/{record.id}', + actionUrl: '/crm_lead/{{record.id}}', }, }, diff --git a/src/sales/flows/lead-conversion-approval.flow.ts b/src/sales/flows/lead-conversion-approval.flow.ts index f3e1e0429..f7b60b115 100644 --- a/src/sales/flows/lead-conversion-approval.flow.ts +++ b/src/sales/flows/lead-conversion-approval.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -89,7 +89,7 @@ export const LeadConversionApprovalFlow: Flow = { id: 'get_lead', type: 'get_record', label: 'Get Lead', - config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, outputVariable: 'leadRecord' }, + config: { objectName: 'crm_lead', filter: { id: cel`record.id` }, outputVariable: 'leadRecord' }, }, { id: 'conversion_review', @@ -126,7 +126,7 @@ export const LeadConversionApprovalFlow: Flow = { label: 'Mark Approved', config: { objectName: 'crm_lead', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { conversion_approval_status: 'approved' }, }, }, @@ -135,12 +135,12 @@ export const LeadConversionApprovalFlow: Flow = { type: 'notify', label: 'Notify Owner — Approved', config: { - recipients: ['{leadRecord.owner_id}'], + recipients: [cel`has(leadRecord.owner_id) ? leadRecord.owner_id : null`], channels: ['inbox'], topic: 'lead_conversion_approved', template: 'crm.lead_conversion_approved', - templateData: { first_name: '{leadRecord.first_name}', last_name: '{leadRecord.last_name}' }, - actionUrl: '/crm_lead/{record.id}', + templateData: { first_name: cel`leadRecord.first_name`, last_name: cel`leadRecord.last_name` }, + actionUrl: '/crm_lead/{{record.id}}', }, }, { @@ -149,7 +149,7 @@ export const LeadConversionApprovalFlow: Flow = { label: 'Mark Rejected', config: { objectName: 'crm_lead', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { conversion_approval_status: 'rejected' }, }, }, @@ -158,13 +158,13 @@ export const LeadConversionApprovalFlow: Flow = { type: 'notify', label: 'Notify Owner — Rejected', config: { - recipients: ['{leadRecord.owner_id}'], + recipients: [cel`has(leadRecord.owner_id) ? leadRecord.owner_id : null`], channels: ['inbox'], severity: 'warning', topic: 'lead_conversion_rejected', template: 'crm.lead_conversion_rejected', - templateData: { first_name: '{leadRecord.first_name}', last_name: '{leadRecord.last_name}' }, - actionUrl: '/crm_lead/{record.id}', + templateData: { first_name: cel`leadRecord.first_name`, last_name: cel`leadRecord.last_name` }, + actionUrl: '/crm_lead/{{record.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index b305394b9..a2de52b3e 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -45,7 +45,7 @@ export const LeadConversionFlow: Flow = { // `defaultValue`, and that is measured rather than chosen: the engine // binds a DECLARATION's default raw — `seedDeclaredVariables` does a plain // `variables.set(name, defaultValue)` with no `interpolate` call — so - // `'{TODAY() + 90}'` written here would bind that string verbatim and + // `{TODAY() + 90}` written here would bind that string verbatim and // `create_opportunity` would post the braces into a date column. Measured // on 17.3.0 by running a probe flow, not by reading the engine. The screen // field is therefore the only layer that can carry the expression, which @@ -70,22 +70,24 @@ export const LeadConversionFlow: Flow = { // the conversion decision is actually taken, and the one surface that // can say something TRUE about THIS lead. // - // Mechanism, measured on the shipped 17.1.0 bundles rather than - // assumed: the executor interpolates `config.description` - // (`interp(cfg.description)`) into the `ScreenSpec` it puts on the wire, - // and `FlowRunner.tsx` renders it — `{screen.description && - // {screen.description}}`. A - // whole-string sole token returns the RAW value and `interp` maps null - // to `undefined`, so the clean-lead branch below (which assigns `null`) - // renders NO description at all rather than an empty paragraph. That is - // why the conditionality lives in an assignment and not in the copy: a - // screen `description` has no `visibleWhen`. - description: '{duplicateWarning}', + // Mechanism: the executor renders `config.description` — a text slot, + // written as a `{{ }}` hole since 18.0.0-next.0 — into the `ScreenSpec` + // it puts on the wire, and `FlowRunner.tsx` renders it — + // `{screen.description && …}`. A hole over `null` + // renders the empty string, which the executor's `renderTextSlot` + // turns into no description at all (read in the installed + // 18.0.0-next.0 `service-automation`; on the 17.1.0 bundles it was + // measured as `interp` mapping null to `undefined`), so the clean-lead + // branch below (which assigns `null`) renders NO description rather + // than an empty paragraph. That is why the conditionality lives in an + // assignment and not in the copy: a screen `description` has no + // `visibleWhen`. + description: '{{duplicateWarning}}', // `visibleWhen` on a screen field is BARE CEL over the screen's own - // field names — not the `{var}` template dialect the rest of this flow - // uses for filters, `update_record` fields and decision conditions. The + // field names — not the CEL value envelopes the rest of this flow uses + // for filters and `update_record` fields, evaluated on the server. The // client re-evaluates the predicate against the values collected so far, - // which is why it cannot be a server-interpolated template. + // which is why it cannot be a server-evaluated envelope. fields: [ // ⛔ NEVER `required` on a boolean screen field. A checkbox has no // unanswered state — clear IS an answer, and "convert this lead @@ -98,29 +100,30 @@ export const LeadConversionFlow: Flow = { // the user touched cannot convert a lead at all. The variable's // declared `defaultValue` is what supplies the answer. // - // DERIVED, not restated. `{createOpportunity}` reads the flow - // variable, so the literal `false` is written exactly once in this + // DERIVED, not restated. The envelope reads the flow variable + // `createOpportunity`, so the literal `false` is written exactly once in this // file — on the declaration — and the widget prefill cannot drift from // the value the engine binds. // - // A screen field's `defaultValue` is server-interpolated before the - // descriptor goes on the wire, and a whole-string sole token returns - // the RAW value rather than a stringification — measured on GA, the - // client receives boolean `false`, byte-identical to what the literal - // sent. (`visibleWhen` below is the opposite case: forwarded raw, - // never interpolated. Same node, two different dialects.) - { name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean', defaultValue: '{createOpportunity}' }, + // A screen field's `defaultValue` is a CEL value envelope evaluated + // before the descriptor goes on the wire, so the client receives the + // value, never the envelope: boolean `false`, which is what the + // 17.0.0 GA template was measured to send. (`visibleWhen` below is the + // opposite case: forwarded raw, never evaluated on the server. Same + // node, two different dialects.) + { name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean', defaultValue: cel`createOpportunity` }, // PREFILLED FROM THE LEAD (REQ-0005 acceptance 2). Both defaults are - // whole-string sole tokens over `leadRecord`, which `get_lead` bound - // before this screen was reached — the fetch sits AHEAD of the screen - // for the duplicate warning, and these two ride the same ordering. + // CEL envelopes over `leadRecord`, which `get_lead` bound before this + // screen was reached — the fetch sits AHEAD of the screen for the + // duplicate warning, and these two ride the same ordering. // - // ⚠️ A screen field's `defaultValue` is server-interpolated before the - // descriptor goes on the wire and a whole-string sole token returns - // the RAW value, so `estimated_amount` arrives as a NUMBER and - // `interp` maps a null to `undefined` — a lead with no estimate - // renders the empty box it always did, not the string "null". That is - // the same mechanism the `duplicateWarning` description depends on. + // ⚠️ Each envelope is evaluated before the descriptor goes on the + // wire, so `estimated_amount` arrives as a NUMBER. `company` is + // required on a lead; `estimated_amount` is not, so its read is + // guarded: a lead with no estimate is served `null` where 17.x served + // nothing (the migration entry: a field's `defaultValue` seeds the + // field with null), and an ABSENT column on a sparse datasource no + // longer fails the run at this screen. // // ⛔ The amount is prefilled, never copied silently. `estimated_amount` // is a pre-qualification guess and `crm_opportunity.amount` is a @@ -136,8 +139,8 @@ export const LeadConversionFlow: Flow = { // `create_opportunity` after the account and contact were written, // leaving the lead half-converted (#2060 item 2). Visible-only, so // an unticked box still converts with no amount. - { name: 'opportunityName', label: 'Opportunity Name', type: 'text', required: true, defaultValue: '{leadRecord.company}', visibleWhen: 'createOpportunity == true' }, - { name: 'opportunityAmount', label: 'Opportunity Amount', type: 'currency', required: true, defaultValue: '{leadRecord.estimated_amount}', visibleWhen: 'createOpportunity == true' }, + { name: 'opportunityName', label: 'Opportunity Name', type: 'text', required: true, defaultValue: cel`leadRecord.company`, visibleWhen: 'createOpportunity == true' }, + { name: 'opportunityAmount', label: 'Opportunity Amount', type: 'currency', required: true, defaultValue: cel`has(leadRecord.estimated_amount) ? leadRecord.estimated_amount : null`, visibleWhen: 'createOpportunity == true' }, // The close date, DEFAULTED AND VISIBLE. `close_date` is what files // an opportunity into a forecast PERIOD, so ⛔ never stamp it silently // inside `create_opportunity`: a hidden +90 days files every converted @@ -145,14 +148,13 @@ export const LeadConversionFlow: Flow = { // // The +90 is written HERE and nowhere else, and here the layer is // FORCED rather than preferred. A screen field's `defaultValue` is - // interpolated before the descriptor goes on the wire and a flow - // variable's is not (see the declaration above), so this is the only - // place the expression can be written at all. `resolveToken` - // recognises exactly one function form — `NOW()` / `TODAY()` with an - // optional `+`/`-` offset — and this is that form, not an arbitrary - // expression: measured on 17.3.0, the client receives the - // `YYYY-MM-DD` string 90 days out, and the value the rep sees is the - // value `create_opportunity` writes. + // evaluated before the descriptor goes on the wire and a flow + // variable's default is bound raw (see the declaration above), so this + // is the only place the expression can be written at all. + // `isoDate(daysFromNow(90))` writes the `YYYY-MM-DD` string 90 days out + // on the UTC calendar — what 17.3.0's `{TODAY() + 90}` was measured to + // send — and the value the rep sees is the value + // `create_opportunity` writes. // // `required` mirrors the column — `crm_opportunity.close_date` is // `required` + `notNull` — and it is what keeps the refusal at the @@ -168,7 +170,7 @@ export const LeadConversionFlow: Flow = { // when `visibleWhen` evaluates false, and so does the console, which // is the untouched-checkbox path this screen has always had to keep // working. - { name: 'closeDate', label: 'Close Date', type: 'date', required: true, defaultValue: '{TODAY() + 90}', visibleWhen: 'createOpportunity == true' }, + { name: 'closeDate', label: 'Close Date', type: 'date', required: true, defaultValue: cel`isoDate(daysFromNow(90))`, visibleWhen: 'createOpportunity == true' }, ], }, }, @@ -176,9 +178,9 @@ export const LeadConversionFlow: Flow = { // ⛔ This fetch must stay AHEAD of the screen: the duplicate warning // reads `leadRecord`, and a screen that suspends before the fetch has // nothing to read. The fetch itself never depended on the screen — - // `{recordId}` is an input variable, seeded before the run starts. + // `recordId` is an input variable, seeded before the run starts. id: 'get_lead', type: 'get_record', label: 'Get Lead Record', - config: { objectName: 'crm_lead', filter: { id: '{recordId}' }, outputVariable: 'leadRecord' }, + config: { objectName: 'crm_lead', filter: { id: cel`recordId` }, outputVariable: 'leadRecord' }, }, { // Branching is on edges `e21` / `e22` / `e25` — see `decision_account`. @@ -202,24 +204,24 @@ export const LeadConversionFlow: Flow = { // reads "Suspected = flagged automatically at intake" in all four // locales, and `lead_duplicate_check` is insert-only by design. // - // Flow copy is English-only in this repo, deliberately and consistently: - // flows carry no entry in the locale packs (`src/sales/translations/*.ts` - // translate objects, fields, views and actions), which - // `test/automation-docs-coverage.test.ts` records as the reason its - // Chinese flow labels are authored in the test itself. The banner on the - // record page — which DOES have a locale channel — carries all four. + // This warning's TEXT is English-only: it is the value of a variable, + // and no locale pack reaches a variable — the screen's translated + // `description` can only carry the `{{duplicateWarning}}` hole that + // shows it. The screens' own headings and bodies are keyed in the packs + // (`flows.lead_conversion` in `src/sales/translations//app.ts`), + // and the banner on the record page carries the warning in all four. id: 'warn_duplicate', type: 'assignment', label: 'Compose Duplicate Warning', config: { assignments: { duplicateWarning: - 'Suspected duplicate — intake flagged this lead as repeating an existing record with this email address ({leadRecord.email}). Converting creates a second account, contact and opportunity for the same buyer; compare the linked record on the lead page before you continue.', + cel`'Suspected duplicate — intake flagged this lead as repeating an existing record with this email address (' + leadRecord.email + '). Converting creates a second account, contact and opportunity for the same buyer; compare the linked record on the lead page before you continue.'`, }, }, }, { // The other branch assigns `null`, which is what makes the warning - // CONDITIONAL: `interp` maps it to `undefined` and the dialog renders no - // description. Same shape as `no_opportunity` below — both branches + // CONDITIONAL: the description hole renders it as no description at + // all (see `screen_1`). Same shape as `no_opportunity` below — both branches // write the variable so no downstream read ever meets an unset one. id: 'no_duplicate_warning', type: 'assignment', label: 'No Duplicate Warning', config: { assignments: { duplicateWarning: null } }, @@ -243,8 +245,8 @@ export const LeadConversionFlow: Flow = { // explain itself and `disabled` greys one out with no reason attached. // `errorMessage` on the Action is a single static string — what the // console toasts on a FAILED run — and cannot say anything about THIS - // lead. A screen node's `description` is interpolated per run and - // rendered by `FlowRunner` as the dialog body. + // lead. A screen node's `description` is a text slot rendered per run + // and shown by `FlowRunner` as the dialog body. // // The flow is also the only choke point covering every door: the // record-header button, the list-row button and the `action_convert_lead` @@ -282,8 +284,8 @@ export const LeadConversionFlow: Flow = { // section this line points at, and a hand-copied machine list in prose is // the drift AGENTS.md documentation rule 5 forbids. // - // Flow copy is English-only in this repo — see `warn_duplicate` above for - // the measurement; a flow has no entry in `src/sales/translations/*.ts`. + // The title and the body are translated in the packs + // (`flows.lead_conversion.screens.refuse_confirmed_duplicate`). id: 'refuse_confirmed_duplicate', type: 'screen', label: 'Conversion Refused', config: { title: 'Conversion refused', @@ -346,8 +348,8 @@ export const LeadConversionFlow: Flow = { // The copy claims only what holds on every path into it: the key is // derived from `company` on every write that carries it // (`lead_duplicate_check`), and nothing has been written, because every - // create and update in this flow sits behind `screen_1`. Flow copy is - // English-only in this repo — see `warn_duplicate`. + // create and update in this flow sits behind `screen_1`. Its title and + // body are translated in the packs, like the other two refusals. // // Field-less screen: `waitForInput` is what turns it from a server-side // pass-through into the dialog the rep reads. See @@ -365,8 +367,8 @@ export const LeadConversionFlow: Flow = { // one — "Acme Corp" and "ACME Corp" are the same account. // // Both sides of this comparison are stored, hook-maintained columns, and - // that is forced rather than chosen: a flow template cannot normalize - // ANYTHING. `service-automation`'s `resolveToken` recognises exactly one + // on 17.x that was forced rather than chosen: a flow template could not + // normalize ANYTHING. `service-automation`'s `resolveToken` recognises exactly one // function form — `NOW()` / `TODAY()` — and every bare identifier in the // expression fallback is substituted before evaluation, so no string // method is reachable either: `{LOWER(x)}`, `{TRIM(x)}` and @@ -406,12 +408,19 @@ export const LeadConversionFlow: Flow = { // wrong account or creates a duplicate one, so the answer is the // refusal the rep reads in `refuse_no_match_key`. // + // Since 18.0.0-next.0 the operand is a CEL envelope: an absent key fails + // the run before the query, and a null one refuses the node instead of + // matching (the platform's `flow-value-slot-template-dialect-refused` + // entry; not re-measured here). Neither shape converts onto another + // account any more, and `decision_match_key` still refuses first, with + // copy the rep can read. + // // ⛔ Never paper this over with a second, case-sensitive lookup on the // raw `name`: a missing key means the producer could not derive one, and // a tolerant consumer path would hide that while restoring the exact bug // this node exists to fix. id: 'find_account', type: 'get_record', label: 'Find Existing Account', - config: { objectName: 'crm_account', filter: { name_normalized: '{leadRecord.company_normalized}' }, outputVariable: 'matchedAccount' }, + config: { objectName: 'crm_account', filter: { name_normalized: cel`leadRecord.company_normalized` }, outputVariable: 'matchedAccount' }, }, { // Branching is on edges `e5` / `e6`. No `config.condition` here: a @@ -432,23 +441,27 @@ export const LeadConversionFlow: Flow = { config: { objectName: 'crm_account', fields: { - name: '{leadRecord.company}', phone: '{leadRecord.phone}', - website: '{leadRecord.website}', industry: '{leadRecord.industry}', - annual_revenue: '{leadRecord.annual_revenue}', - number_of_employees: '{leadRecord.number_of_employees}', - billing_address: '{leadRecord.address}', - owner_id: '{$User.Id}', is_active: true, + // `company` is required on a lead; every other copied fact is + // optional, so its read is guarded to null (an insert treats null as + // it treated the key the template left out). + name: cel`leadRecord.company`, phone: cel`has(leadRecord.phone) ? leadRecord.phone : null`, + website: cel`has(leadRecord.website) ? leadRecord.website : null`, + industry: cel`has(leadRecord.industry) ? leadRecord.industry : null`, + annual_revenue: cel`has(leadRecord.annual_revenue) ? leadRecord.annual_revenue : null`, + number_of_employees: cel`has(leadRecord.number_of_employees) ? leadRecord.number_of_employees : null`, + billing_address: cel`has(leadRecord.address) ? leadRecord.address : null`, + owner_id: cel`current_user.id`, is_active: true, }, outputVariable: 'createdAccount', }, }, { id: 'use_new_account', type: 'assignment', label: 'Use New Account', - config: { assignments: { accountId: '{createdAccount.id}' } }, + config: { assignments: { accountId: cel`createdAccount.id` } }, }, { id: 'use_existing_account', type: 'assignment', label: 'Reuse Existing Account', - config: { assignments: { accountId: '{matchedAccount.id}' } }, + config: { assignments: { accountId: cel`matchedAccount.id` } }, }, { // Contact dedupe by email — GLOBAL, not per-account. ⛔ Never scope this @@ -460,7 +473,7 @@ export const LeadConversionFlow: Flow = { id: 'find_contact', type: 'get_record', label: 'Find Existing Contact', config: { objectName: 'crm_contact', - filter: { email: '{leadRecord.email}' }, + filter: { email: cel`leadRecord.email` }, outputVariable: 'matchedContact', }, }, @@ -483,24 +496,31 @@ export const LeadConversionFlow: Flow = { // `contact_welcome`, `do_not_call` refuses a scheduled call. A // REUSED contact does not come through here: see // `decision_contact_do_not_call`. - salutation: '{leadRecord.salutation}', mobile: '{leadRecord.mobile}', - lead_source: '{leadRecord.lead_source}', - do_not_call: '{leadRecord.do_not_call}', email_opt_out: '{leadRecord.email_opt_out}', - first_name: '{leadRecord.first_name}', last_name: '{leadRecord.last_name}', - email: '{leadRecord.email}', phone: '{leadRecord.phone}', - title: '{leadRecord.title}', crm_account: '{accountId}', - is_primary: true, owner_id: '{$User.Id}', + // + // Names and email are required on a lead and read bare; the rest + // are optional and guarded to null, which an insert reads as the + // omitted key the template wrote (each opt-out then takes its + // `false` default, as before). + salutation: cel`has(leadRecord.salutation) ? leadRecord.salutation : null`, + mobile: cel`has(leadRecord.mobile) ? leadRecord.mobile : null`, + lead_source: cel`has(leadRecord.lead_source) ? leadRecord.lead_source : null`, + do_not_call: cel`has(leadRecord.do_not_call) ? leadRecord.do_not_call : null`, + email_opt_out: cel`has(leadRecord.email_opt_out) ? leadRecord.email_opt_out : null`, + first_name: cel`leadRecord.first_name`, last_name: cel`leadRecord.last_name`, + email: cel`leadRecord.email`, phone: cel`has(leadRecord.phone) ? leadRecord.phone : null`, + title: cel`has(leadRecord.title) ? leadRecord.title : null`, crm_account: cel`accountId`, + is_primary: true, owner_id: cel`current_user.id`, }, outputVariable: 'createdContact', }, }, { id: 'use_new_contact', type: 'assignment', label: 'Use New Contact', - config: { assignments: { contactId: '{createdContact.id}' } }, + config: { assignments: { contactId: cel`createdContact.id` } }, }, { id: 'use_existing_contact', type: 'assignment', label: 'Reuse Existing Contact', - config: { assignments: { contactId: '{matchedContact.id}' } }, + config: { assignments: { contactId: cel`matchedContact.id` } }, }, { // ── A REUSED contact keeps the stricter consent (#2068) ────────────── @@ -509,8 +529,8 @@ export const LeadConversionFlow: Flow = { // reused contact already holds its own answer, and an opt-out is a // promise to a person that a conversion never takes back — so this path // only ever RAISES a flag, one node per flag, and only when the lead - // carries it. The `raise_*` nodes write the literal `true`, ⛔ never - // `{leadRecord.x}`: a copy would write the lead's `false` over a contact + // carries it. The `raise_*` nodes write the literal `true`, ⛔ never a + // copy of `leadRecord.x`: a copy would write the lead's `false` over a contact // who had opted out. `crm_account` carries neither flag, so the account // half of "the stricter value wins" has nothing to write. // @@ -528,14 +548,14 @@ export const LeadConversionFlow: Flow = { }, { id: 'raise_contact_do_not_call', type: 'update_record', label: 'Carry Do-Not-Call to Contact', - config: { objectName: 'crm_contact', filter: { id: '{contactId}' }, fields: { do_not_call: true } }, + config: { objectName: 'crm_contact', filter: { id: cel`contactId` }, fields: { do_not_call: true } }, }, { id: 'decision_contact_email_opt_out', type: 'decision', label: 'Lead Opted Out of Email?', }, { id: 'raise_contact_email_opt_out', type: 'update_record', label: 'Carry Email Opt-Out to Contact', - config: { objectName: 'crm_contact', filter: { id: '{contactId}' }, fields: { email_opt_out: true } }, + config: { objectName: 'crm_contact', filter: { id: cel`contactId` }, fields: { email_opt_out: true } }, }, { // Branching is on edges `e16` / `e17` — see `decision_account`. @@ -543,17 +563,18 @@ export const LeadConversionFlow: Flow = { }, { // `close_date` comes from the screen field, which is the only layer that - // can carry the `{TODAY() + 90}` expression — see the `closeDate` - // declaration. An unbound `{closeDate}` resolves to nothing and the - // platform refuses the write rather than inventing a quarter, which is - // the failure this node is allowed to have. + // can carry the `isoDate(daysFromNow(90))` expression — see the + // `closeDate` declaration. An unbound `closeDate` fails the run at this + // node rather than inventing a quarter, which is the failure this node + // is allowed to have. id: 'create_opportunity', type: 'create_record', label: 'Create Opportunity', config: { objectName: 'crm_opportunity', fields: { - name: '{opportunityName}', crm_account: '{accountId}', primary_contact: '{contactId}', - amount: '{opportunityAmount}', stage: 'prospecting', probability: 10, - lead_source: '{leadRecord.lead_source}', close_date: '{closeDate}', owner_id: '{$User.Id}', + name: cel`opportunityName`, crm_account: cel`accountId`, primary_contact: cel`contactId`, + amount: cel`opportunityAmount`, stage: 'prospecting', probability: 10, + lead_source: cel`has(leadRecord.lead_source) ? leadRecord.lead_source : null`, + close_date: cel`closeDate`, owner_id: cel`current_user.id`, }, outputVariable: 'createdOpportunity', }, @@ -561,11 +582,12 @@ export const LeadConversionFlow: Flow = { { // Normalize both opportunity branches onto a single `opportunityId`, the // same pattern as accountId/contactId. ⛔ Never reference - // `{createdOpportunity.id}` downstream instead: on the "No" branch the - // create node never ran, and the unresolved token interpolates into the - // `converted_opportunity` lookup as a placeholder. + // `createdOpportunity.id` downstream instead: on the "No" branch the + // create node never ran, and the unbound read fails the run (on 17.x the + // unresolved token interpolated into the `converted_opportunity` lookup + // as a placeholder). id: 'use_new_opportunity', type: 'assignment', label: 'Use New Opportunity', - config: { assignments: { opportunityId: '{createdOpportunity.id}' } }, + config: { assignments: { opportunityId: cel`createdOpportunity.id` } }, }, { id: 'no_opportunity', type: 'assignment', label: 'No Opportunity', @@ -574,14 +596,14 @@ export const LeadConversionFlow: Flow = { { id: 'mark_converted', type: 'update_record', label: 'Mark Lead as Converted', config: { - objectName: 'crm_lead', filter: { id: '{recordId}' }, + objectName: 'crm_lead', filter: { id: cel`recordId` }, fields: { // `status: 'converted'` rides along so list views agree with the // conversion flags (the qualified → converted transition is legal // per the lead_status_progression state machine). - is_converted: true, status: 'converted', converted_date: '{NOW()}', - converted_account: '{accountId}', converted_contact: '{contactId}', - converted_opportunity: '{opportunityId}', + is_converted: true, status: 'converted', converted_date: cel`isoDatetime(now())`, + converted_account: cel`accountId`, converted_contact: cel`contactId`, + converted_opportunity: cel`opportunityId`, }, }, }, @@ -590,12 +612,12 @@ export const LeadConversionFlow: Flow = { // `script` + `actionType:'email'` shape is a no-op stub in 7.4. id: 'send_notification', type: 'notify', label: 'Send Confirmation', config: { - recipients: ['{$User.Id}'], + recipients: [cel`current_user.id`], channels: ['inbox', 'email'], topic: 'lead_converted', template: 'crm.lead_converted', - templateData: { first_name: '{leadRecord.first_name}', last_name: '{leadRecord.last_name}' }, - actionUrl: '/crm_account/{accountId}', + templateData: { first_name: cel`leadRecord.first_name`, last_name: cel`leadRecord.last_name` }, + actionUrl: '/crm_account/{{accountId}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/opportunity-approval.flow.ts b/src/sales/flows/opportunity-approval.flow.ts index 6db5e87f8..21f8bb81c 100644 --- a/src/sales/flows/opportunity-approval.flow.ts +++ b/src/sales/flows/opportunity-approval.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { HIGH_VALUE_DEAL_AMOUNT, LARGE_DEAL_AMOUNT } from '../objects/_thresholds'; type Flow = Automation.Flow; @@ -97,7 +97,7 @@ export const OpportunityApprovalFlow: Flow = { id: 'get_opportunity', type: 'get_record', label: 'Get Opportunity', - config: { objectName: 'crm_opportunity', filter: { id: '{record.id}' }, outputVariable: 'oppRecord' }, + config: { objectName: 'crm_opportunity', filter: { id: cel`record.id` }, outputVariable: 'oppRecord' }, }, // ── Tier 1: Sales Manager review (all deals at $100K or more) ─── @@ -157,8 +157,8 @@ export const OpportunityApprovalFlow: Flow = { label: 'Mark Approved', config: { objectName: 'crm_opportunity', - filter: { id: '{record.id}' }, - fields: { approval_status: 'approved', approved_date: '{NOW()}' }, + filter: { id: cel`record.id` }, + fields: { approval_status: 'approved', approved_date: cel`isoDatetime(now())` }, }, }, { @@ -166,12 +166,12 @@ export const OpportunityApprovalFlow: Flow = { type: 'notify', label: 'Notify Owner — Approved', config: { - recipients: ['{oppRecord.owner_id}'], + recipients: [cel`has(oppRecord.owner_id) ? oppRecord.owner_id : null`], channels: ['inbox', 'email'], topic: 'opportunity_approved', template: 'crm.opportunity_approved', - templateData: { name: '{oppRecord.name}' }, - actionUrl: '/crm_opportunity/{record.id}', + templateData: { name: cel`oppRecord.name` }, + actionUrl: '/crm_opportunity/{{record.id}}', }, }, @@ -182,7 +182,7 @@ export const OpportunityApprovalFlow: Flow = { label: 'Mark Rejected', config: { objectName: 'crm_opportunity', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { approval_status: 'rejected' }, }, }, @@ -191,13 +191,13 @@ export const OpportunityApprovalFlow: Flow = { type: 'notify', label: 'Notify Owner — Rejected', config: { - recipients: ['{oppRecord.owner_id}'], + recipients: [cel`has(oppRecord.owner_id) ? oppRecord.owner_id : null`], channels: ['inbox', 'email'], severity: 'warning', topic: 'opportunity_rejected', template: 'crm.opportunity_rejected', - templateData: { name: '{oppRecord.name}' }, - actionUrl: '/crm_opportunity/{record.id}', + templateData: { name: cel`oppRecord.name` }, + actionUrl: '/crm_opportunity/{{record.id}}', }, }, diff --git a/src/sales/flows/opportunity-qualification-approval.flow.ts b/src/sales/flows/opportunity-qualification-approval.flow.ts index 7e5644f5b..95966d378 100644 --- a/src/sales/flows/opportunity-qualification-approval.flow.ts +++ b/src/sales/flows/opportunity-qualification-approval.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -112,7 +112,7 @@ export const OpportunityQualificationApprovalFlow: Flow = { id: 'get_opportunity', type: 'get_record', label: 'Get Opportunity', - config: { objectName: 'crm_opportunity', filter: { id: '{record.id}' }, outputVariable: 'oppRecord' }, + config: { objectName: 'crm_opportunity', filter: { id: cel`record.id` }, outputVariable: 'oppRecord' }, }, { id: 'qualification_review', @@ -147,7 +147,7 @@ export const OpportunityQualificationApprovalFlow: Flow = { label: 'Mark Qualified', config: { objectName: 'crm_opportunity', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { qualification_approval_status: 'approved' }, }, }, @@ -160,7 +160,7 @@ export const OpportunityQualificationApprovalFlow: Flow = { label: 'Clear Rejected Request', config: { objectName: 'crm_opportunity', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { qualification_approval_status: 'rejected', qualification_requested: false }, }, }, diff --git a/src/sales/flows/opportunity-stagnation.flow.ts b/src/sales/flows/opportunity-stagnation.flow.ts index 88a1547ff..97d4f215f 100644 --- a/src/sales/flows/opportunity-stagnation.flow.ts +++ b/src/sales/flows/opportunity-stagnation.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from './_guarded-iteration'; type Flow = Automation.Flow; @@ -44,13 +44,14 @@ export const OpportunityStagnationFlow: Flow = { // `days_in_stage`: the latter is a formula, computed after the query, so // as a filter key it addresses a column that does not exist. // `entry < today − N` - // is the same test as `days_in_stage > N`, resolved by the flow - // template engine (same `{TODAY() ± n}` token as contract-renewal). + // is the same test as `days_in_stage > N`, computed by a CEL value + // envelope (the `isoDate(daysAgo(n))` spelling; contract-renewal uses + // its `daysFromNow` twin). // A row with a null `stage_entry_date` does not satisfy `$lt` and is // skipped, preserving "nothing has stagnated yet" for unstamped rows. filter: { stage: { $nin: ['closed_won', 'closed_lost'] }, - stage_entry_date: { $lt: `{TODAY() - ${STALE_THRESHOLD_DAYS}}` }, + stage_entry_date: { $lt: cel`isoDate(daysAgo(${STALE_THRESHOLD_DAYS}))` }, }, limit: 500, outputVariable: 'oppList', @@ -59,7 +60,7 @@ export const OpportunityStagnationFlow: Flow = { { id: 'loop_opps', type: 'loop', label: 'For Each Stalled Deal', config: { - collection: '{oppList}', + collection: cel`oppList`, iteratorVariable: 'currentOpp', body: guarded('opp', { nodes: [ @@ -72,8 +73,8 @@ export const OpportunityStagnationFlow: Flow = { config: { objectName: 'crm_task', filter: { - related_to_opportunity: '{currentOpp.id}', - subject: 'Advance stalled deal: {currentOpp.name}', + related_to_opportunity: cel`currentOpp.id`, + subject: cel`'Advance stalled deal: ' + currentOpp.name`, status: { $nin: ['completed'] }, }, outputVariable: 'existingStallTask', @@ -89,15 +90,15 @@ export const OpportunityStagnationFlow: Flow = { // "undefined" (cf. opportunity_won_alert). id: 'notify_owner', type: 'notify', label: 'Nudge Owner', config: { - recipients: ['{currentOpp.owner_id}'], + recipients: [cel`has(currentOpp.owner_id) ? currentOpp.owner_id : null`], channels: ['inbox', 'email'], topic: 'deal_stalled', template: 'crm.deal_stalled', templateData: { - name: '{currentOpp.name}', stage: '{currentOpp.stage}', - days_in_stage: '{currentOpp.days_in_stage}', + name: cel`currentOpp.name`, stage: cel`currentOpp.stage`, + days_in_stage: cel`currentOpp.days_in_stage`, }, - actionUrl: '/crm_opportunity/{currentOpp.id}', + actionUrl: '/crm_opportunity/{{currentOpp.id}}', }, }, { @@ -105,21 +106,23 @@ export const OpportunityStagnationFlow: Flow = { config: { objectName: 'crm_task', fields: { - subject: 'Advance stalled deal: {currentOpp.name}', + subject: cel`'Advance stalled deal: ' + currentOpp.name`, type: 'follow_up', priority: 'high', status: 'not_started', - due_date: '{TODAY() + 2}', - owner_id: '{currentOpp.owner_id}', + due_date: cel`isoDate(daysFromNow(2))`, + owner_id: cel`has(currentOpp.owner_id) ? currentOpp.owner_id : null`, // ORG PARTITION. A schedule trigger carries no // organization, so without this the nudge task is born // `organization_id` NULL — outside every org partition. // Upstream ruling objectstack#6155 Q2=A assigns the answer to // the flow author; the task belongs to the org of the deal it - // nudges. `crm_opportunity` carries the column, so the token - // resolves — a source lacking it would interpolate to - // `undefined` and land as NULL while looking declared. - organization_id: '{currentOpp.organization_id}', + // nudges. `crm_opportunity` carries the column, so the read + // resolves. It is deliberately UNGUARDED: on 17.x a source + // lacking it interpolated to `undefined` and landed as NULL + // while looking declared; CEL fails the iteration instead, and + // a `has()` guard would write that NULL back. + organization_id: cel`currentOpp.organization_id`, related_to_type: 'crm_opportunity', - related_to_opportunity: '{currentOpp.id}', + related_to_opportunity: cel`currentOpp.id`, }, }, }, diff --git a/src/sales/flows/opportunity-status-change-approval.flow.ts b/src/sales/flows/opportunity-status-change-approval.flow.ts index 3ceb73772..ae3cce155 100644 --- a/src/sales/flows/opportunity-status-change-approval.flow.ts +++ b/src/sales/flows/opportunity-status-change-approval.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -40,8 +40,8 @@ type Flow = Automation.Flow; * * ⛔ Do NOT reach for `status: 'draft'` as the off switch instead, and ⛔ not * `'obsolete'` either. Both readings are measured and recorded in - * `lead-conversion-approval.flow.ts`'s docstring on the pinned - * `@objectstack/service-automation` 17.4.0: draft flows still fire their + * `lead-conversion-approval.flow.ts`'s docstring on + * `@objectstack/service-automation` 17.4.0, the pin at that taking: draft flows still fire their * triggers, and `isFlowEnabled` composes `obsolete` with the installation's * activation ledger into a gate no install can ever turn ON. ⛔ Do not * re-derive it here. @@ -119,7 +119,7 @@ export const OpportunityStatusChangeApprovalFlow: Flow = { id: 'get_opportunity', type: 'get_record', label: 'Get Opportunity', - config: { objectName: 'crm_opportunity', filter: { id: '{record.id}' }, outputVariable: 'oppRecord' }, + config: { objectName: 'crm_opportunity', filter: { id: cel`record.id` }, outputVariable: 'oppRecord' }, }, { id: 'status_review', @@ -164,11 +164,11 @@ export const OpportunityStatusChangeApprovalFlow: Flow = { label: 'Apply Requested Status', config: { objectName: 'crm_opportunity', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { - stage: '{oppRecord.requested_status}', + stage: cel`oppRecord.requested_status`, status_change_approval_status: 'approved', - approved_date: '{NOW()}', + approved_date: cel`isoDatetime(now())`, }, }, }, @@ -181,7 +181,7 @@ export const OpportunityStatusChangeApprovalFlow: Flow = { label: 'Clear Rejected Request', config: { objectName: 'crm_opportunity', - filter: { id: '{record.id}' }, + filter: { id: cel`record.id` }, fields: { status_change_approval_status: 'rejected', requested_status: null }, }, }, diff --git a/src/sales/flows/opportunity-won-alert.flow.ts b/src/sales/flows/opportunity-won-alert.flow.ts index 49f2eca9e..a768c3194 100644 --- a/src/sales/flows/opportunity-won-alert.flow.ts +++ b/src/sales/flows/opportunity-won-alert.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { LARGE_DEAL_AMOUNT } from '../objects/_thresholds'; type Flow = Automation.Flow; @@ -85,13 +85,13 @@ export const OpportunityWonAlertFlow: Flow = { // Owner only: `{record.owner_id.manager}` cannot traverse a lookup on the // raw trigger snapshot — it interpolates to the literal "undefined" // and the message is delivered to a phantom user. - recipients: ['{record.owner_id}'], + recipients: [cel`record.owner_id`], channels: ['inbox', 'email'], severity: 'info', topic: 'large_deal_won', template: 'crm.large_deal_won', - templateData: { name: '{record.name}', amount: '{record.amount}' }, - actionUrl: '/crm_opportunity/{record.id}', + templateData: { name: cel`record.name`, amount: cel`record.amount` }, + actionUrl: '/crm_opportunity/{{record.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/schedule-followup.flow.ts b/src/sales/flows/schedule-followup.flow.ts index b29dbac01..8bdc7ebc1 100644 --- a/src/sales/flows/schedule-followup.flow.ts +++ b/src/sales/flows/schedule-followup.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { TASK_TYPE_OPTIONS, plainOptions } from '../objects/_picklists'; type Flow = Automation.Flow; @@ -65,24 +66,29 @@ export const ScheduleFollowUpFlow: Flow = { }, { id: 'get_lead', type: 'get_record', label: 'Get Lead Record', - config: { objectName: 'crm_lead', filter: { id: '{recordId}' }, outputVariable: 'leadRecord' }, + config: { objectName: 'crm_lead', filter: { id: cel`recordId` }, outputVariable: 'leadRecord' }, }, { id: 'create_task', type: 'create_record', label: 'Create Follow-up Task', config: { objectName: 'crm_task', fields: { - subject: '{subject}', - due_date: '{dueDate}', - type: '{activityType}', - priority: '{priority}', - description: '{notes}', + subject: cel`subject`, + due_date: cel`dueDate`, + // The three optional answers: a resume may leave one unbound, which + // the template wrote as nothing and the guard writes as null. On an + // insert the two are the same — the engine applies a field's default + // to an explicit null exactly as to an omitted key, so an unanswered + // priority still lands as `normal`. + type: cel`has(vars.activityType) ? activityType : null`, + priority: cel`has(vars.priority) ? priority : null`, + description: cel`has(vars.notes) ? notes : null`, status: 'not_started', - owner_id: '{$User.Id}', + owner_id: cel`current_user.id`, // Polymorphic parent: both halves are required for the lead's // Related tab to pick the task up. related_to_type: 'crm_lead', - related_to_lead: '{recordId}', + related_to_lead: cel`recordId`, }, outputVariable: 'createdTask', }, @@ -94,8 +100,8 @@ export const ScheduleFollowUpFlow: Flow = { id: 'stamp_lead', type: 'update_record', label: 'Stamp Next Follow-up Date', config: { objectName: 'crm_lead', - filter: { id: '{recordId}' }, - fields: { next_followup_date: '{dueDate}' }, + filter: { id: cel`recordId` }, + fields: { next_followup_date: cel`dueDate` }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/flows/task-due-reminder.flow.ts b/src/sales/flows/task-due-reminder.flow.ts index ed8e57c78..e79bfe47b 100644 --- a/src/sales/flows/task-due-reminder.flow.ts +++ b/src/sales/flows/task-due-reminder.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from './_guarded-iteration'; type Flow = Automation.Flow; @@ -49,7 +50,7 @@ export const TaskDueReminderFlow: Flow = { // sweep reliable; `reminder_sent` is still stamped for the audit trail. filter: { is_completed: false, - reminder_date: { $lte: '{NOW()}' }, + reminder_date: { $lte: cel`isoDatetime(now())` }, }, limit: 500, outputVariable: 'reminderList', @@ -62,29 +63,29 @@ export const TaskDueReminderFlow: Flow = { // `{currentTask.*}` resolves empty. Verified empirically. id: 'loop_tasks', type: 'loop', label: 'For Each Due Task', config: { - collection: '{reminderList}', + collection: cel`reminderList`, iteratorVariable: 'currentTask', body: guarded('task', { nodes: [ { id: 'notify_owner', type: 'notify', label: 'Notify Owner', config: { - recipients: ['{currentTask.owner_id}'], + recipients: [cel`has(currentTask.owner_id) ? currentTask.owner_id : null`], channels: ['inbox', 'email'], severity: 'warning', topic: 'task_reminder', template: 'crm.task_reminder', templateData: { - subject: '{currentTask.subject}', reminder_date: '{currentTask.reminder_date}', + subject: cel`currentTask.subject`, reminder_date: cel`currentTask.reminder_date`, }, - actionUrl: '/crm_task/{currentTask.id}', + actionUrl: '/crm_task/{{currentTask.id}}', }, }, { id: 'mark_sent', type: 'update_record', label: 'Mark Reminder Sent', config: { objectName: 'crm_task', - filter: { id: '{currentTask.id}' }, + filter: { id: cel`currentTask.id` }, // Clearing reminder_date is what de-dups (the row stops matching // the sweep's `$lte` next tick); reminder_sent is the audit flag. fields: { reminder_sent: true, reminder_date: null }, diff --git a/src/sales/flows/task-urgent-alert.flow.ts b/src/sales/flows/task-urgent-alert.flow.ts index fa6cc5173..628b6b8be 100644 --- a/src/sales/flows/task-urgent-alert.flow.ts +++ b/src/sales/flows/task-urgent-alert.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -54,13 +54,13 @@ export const TaskUrgentAlertFlow: Flow = { { id: 'notify_owner', type: 'notify', label: 'Notify Owner', config: { - recipients: ['{record.owner_id}'], + recipients: [cel`record.owner_id`], channels: ['inbox', 'email'], severity: 'warning', topic: 'urgent_task', template: 'crm.urgent_task', - templateData: { subject: '{record.subject}' }, - actionUrl: '/crm_task/{record.id}', + templateData: { subject: cel`record.subject` }, + actionUrl: '/crm_task/{{record.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/sales/index.ts b/src/sales/index.ts index a7947bf7f..5ac76fd4d 100644 --- a/src/sales/index.ts +++ b/src/sales/index.ts @@ -90,7 +90,7 @@ export const createHotCrmAppStack = ( // rc.3) moved the manifest and left this line behind (#728). Every package // of this artifact states the same protocol; `src/service/index.ts` // repeats it for the same reason. - engines: { protocol: '^17.7.0' }, + engines: { protocol: '^18.0.0-next.0' }, }, // ─── Platform capabilities this app needs ───────────────────────── diff --git a/src/sales/profiles/tenant-admin.profile.ts b/src/sales/profiles/tenant-admin.profile.ts index defbbf7d9..c820494a5 100644 --- a/src/sales/profiles/tenant-admin.profile.ts +++ b/src/sales/profiles/tenant-admin.profile.ts @@ -91,16 +91,19 @@ import { SystemAdminProfile } from './system-admin.profile'; * and no session produces. Keeping the two grants therefore says the true * thing about a tenant admin: inside their org, they see and edit everything. * - * ### What a tenant admin may AUTHOR: their own org's presentation, no more - * - * `manage_org_presentation` IS granted. The platform declares it `scope: 'org'` - * (objectstack-ai/objectstack#12702): it admits `/meta` item writes only for - * the types whose registry entry declares `allowOrgOverride: true` — on the - * installed 17.6.0 that is view, dashboard, report, translation and - * email_template — and only as overlays in the caller's own active - * organization. Measured on 17.6.0 against the platform's own write verdict - * (`metaWriteCapabilityVerdict`): with this grant those saves are allowed when - * the session carries an active organization, and refused when it does not. + * ### What a tenant admin may AUTHOR: nothing, since 18.0.0-next.0 + * + * Through 17.7.0 this set held `manage_org_presentation`, the platform's + * `scope: 'org'` capability (objectstack-ai/objectstack#12702) that admitted + * `/meta` writes of the `allowOrgOverride: true` types — view, dashboard, + * report, translation, email_template — as overlays in the caller's own + * organization. 18.0.0-next.0 RETIRES it with the per-organization overlay + * axis (ADR-0131 D6, the spec's `manage-org-presentation-retired` entry): it is + * no longer in `PLATFORM_CAPABILITIES`, nothing consults it, and the entry's + * remedy is to delete it from every set. There is no organization-bounded + * authoring capability left; the only key that admits those writes is + * `manage_metadata`, whose writes land environment-wide, so it stays + * ungranted for the reason below. * * `customize_application`, `manage_profiles` and `manage_roles` stay * ungranted. They describe authoring the app's structure and security model — @@ -133,8 +136,5 @@ export const TenantAdminProfile = { // Org-bounded by the driver's tenant predicate — see the audit above. 'view_all_data', 'modify_all_data', 'manage_sharing', - // Org-scoped `/meta` writes, only for `allowOrgOverride: true` types and - // only in the caller's own active org — see the AUTHOR note above. - 'manage_org_presentation', ], }; diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 9b9ec4039..e55003fb7 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -595,9 +595,13 @@ export const appSurface: Omit = { // — the keys `os lint`'s `i18n/missing-flow` names, read by the console's // flow runner since @objectstack 17.7.0 (the spec's `flows` liveness row is // `live` there). A screen with no authored `title` shows its flow's label in - // every locale, so each `screen_1.title` repeats the flow label. Select - // option labels and screen descriptions are not translatable keys on a - // screen (`FLOW_SCREEN_FIELD_NO_OPTIONS`), so they stay as authored. + // every locale, so each `screen_1.title` repeats the flow label. Since + // 18.0.0-next.0 three more surfaces are keys the engine reads in the run's + // locale, and `i18n/missing-flow` names each missing one: a select field's + // `options.` (worded as the object field's own option in this pack), + // an authored screen `description`, and a refusing `end` node's + // `refusals..message`. A translated description or message is a text + // slot like the one it translates, so a hole in it is `{{ }}`. // The discount ceiling is interpolated from `QUOTE_DISCOUNT_CEILING`, never // retyped: the number lives in `_thresholds.ts` alone (#1711). flows: { @@ -607,9 +611,12 @@ export const appSurface: Omit = { screen_1: { title: 'Inscribir miembros en la campaña', fields: { - memberSource: { label: 'Tipo de miembro a inscribir' }, - leadStatus: { label: 'Inscribir prospectos con estado' }, - contactDepartment: { label: 'Inscribir contactos del departamento' }, + memberSource: { label: 'Tipo de miembro a inscribir', options: { leads: 'Prospectos', contacts: 'Contactos' } }, + leadStatus: { label: 'Inscribir prospectos con estado', options: { new: 'Nuevo', contacted: 'Contactado', qualified: 'Calificado' } }, + contactDepartment: { + label: 'Inscribir contactos del departamento', + options: { executive: 'Ejecutivo', sales: 'Ventas', marketing: 'Marketing', engineering: 'Ingeniería', support: 'Soporte', finance: 'Finanzas', hr: 'Recursos Humanos', operations: 'Operaciones' }, + }, }, }, }, @@ -632,13 +639,26 @@ export const appSurface: Omit = { }, claim_case: { label: 'Tomar Caso', - screens: { screen_1: { title: 'Tomar Caso', fields: { claimStatus: { label: 'Estado de trabajo' } } } }, + screens: { + screen_1: { + title: 'Tomar Caso', + fields: { + claimStatus: { + label: 'Estado de trabajo', + options: { in_progress: 'En Curso', waiting_customer: 'Esperando al Cliente', waiting_support: 'Esperando a Soporte' }, + }, + }, + }, + }, }, lead_conversion: { label: 'Proceso de conversión de prospecto', screens: { screen_1: { title: 'Proceso de conversión de prospecto', + // The duplicate warning's text is a variable the flow assigns; this + // key can only carry the hole that shows it. + description: '{{duplicateWarning}}', fields: { createOpportunity: { label: '¿Crear oportunidad?' }, opportunityName: { label: 'Nombre de Oportunidad' }, @@ -646,8 +666,21 @@ export const appSurface: Omit = { closeDate: { label: 'Fecha de Cierre' }, }, }, - refuse_confirmed_duplicate: { title: 'Conversión rechazada' }, - refuse_unapproved: { title: 'Conversión rechazada' }, + refuse_confirmed_duplicate: { + title: 'Conversión rechazada', + description: + 'El Estado del Duplicado de este prospecto es Confirmado: un revisor lo comparó con un registro existente y registró que lo repite. Convertirlo crearía una segunda cuenta, contacto y oportunidad para el mismo comprador. La sección Gestión de Duplicados de este prospecto indica el registro que se conserva; descalifique este prospecto como duplicado. Solo un revisor que cambie ese veredicto vuelve a habilitar la conversión.', + }, + refuse_unapproved: { + title: 'Conversión rechazada', + description: + 'Este prospecto no ha sido aprobado para la conversión. Un aprobador debe darle el visto bueno antes de que pueda convertirse en una cuenta, un contacto y una oportunidad; el campo Aprobación de Conversión del prospecto muestra si la decisión sigue pendiente o fue rechazada. Mientras tanto, siga trabajando el prospecto: nada de él queda bloqueado.', + }, + refuse_no_match_key: { + title: 'Conversión rechazada', + description: + 'La empresa de este prospecto no tiene clave de coincidencia, así que la conversión no puede saber a qué cuenta pertenece y no lo va a adivinar: una suposición podría archivar el contacto y la oportunidad bajo otro cliente. Guardar la Empresa del prospecto reconstruye la clave, así que guárdela de nuevo y luego conviértalo. No se ha creado nada.', + }, }, }, schedule_followup: { @@ -658,8 +691,11 @@ export const appSurface: Omit = { fields: { subject: { label: '¿Cuál es el siguiente paso?' }, dueDate: { label: 'Fecha Límite' }, - activityType: { label: 'Tipo de Actividad' }, - priority: { label: 'Prioridad' }, + activityType: { + label: 'Tipo de Actividad', + options: { call: 'Llamada', email: 'Email', meeting: 'Reunión', follow_up: 'Seguimiento', demo: 'Demostración', other: 'Otro' }, + }, + priority: { label: 'Prioridad', options: { low: 'Baja', normal: 'Normal', high: 'Alta', urgent: 'Urgente' } }, notes: { label: 'Notas' }, }, }, @@ -667,6 +703,7 @@ export const appSurface: Omit = { }, quote_generation: { label: 'Generar cotización desde la oportunidad', + refusals: { refuse_held: { message: 'Primero va la aprobación de calificación: marque Solicitar Aprobación de Calificación. No se creó nada.' } }, screens: { screen_1: { title: 'Generar cotización desde la oportunidad', diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 345e0aec1..33df2202d 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -597,9 +597,13 @@ export const appSurface: Omit = { // — the keys `os lint`'s `i18n/missing-flow` names, read by the console's // flow runner since @objectstack 17.7.0 (the spec's `flows` liveness row is // `live` there). A screen with no authored `title` shows its flow's label in - // every locale, so each `screen_1.title` repeats the flow label. Select - // option labels and screen descriptions are not translatable keys on a - // screen (`FLOW_SCREEN_FIELD_NO_OPTIONS`), so they stay as authored. + // every locale, so each `screen_1.title` repeats the flow label. Since + // 18.0.0-next.0 three more surfaces are keys the engine reads in the run's + // locale, and `i18n/missing-flow` names each missing one: a select field's + // `options.` (worded as the object field's own option in this pack), + // an authored screen `description`, and a refusing `end` node's + // `refusals..message`. A translated description or message is a text + // slot like the one it translates, so a hole in it is `{{ }}`. // The discount ceiling is interpolated from `QUOTE_DISCOUNT_CEILING`, never // retyped: the number lives in `_thresholds.ts` alone (#1711). flows: { @@ -609,9 +613,12 @@ export const appSurface: Omit = { screen_1: { title: 'キャンペーンにメンバーを登録', fields: { - memberSource: { label: '登録するメンバーの種類' }, - leadStatus: { label: '登録するリードのステータス' }, - contactDepartment: { label: '登録する取引先責任者の部門' }, + memberSource: { label: '登録するメンバーの種類', options: { leads: 'リード', contacts: '取引先責任者' } }, + leadStatus: { label: '登録するリードのステータス', options: { new: '新規', contacted: 'コンタクト済み', qualified: '適格' } }, + contactDepartment: { + label: '登録する取引先責任者の部門', + options: { executive: '経営層', sales: '営業部', marketing: 'マーケティング部', engineering: 'エンジニアリング部', support: 'サポート部', finance: '経理部', hr: '人事部', operations: 'オペレーション部' }, + }, }, }, }, @@ -634,13 +641,26 @@ export const appSurface: Omit = { }, claim_case: { label: 'ケースを引き受ける', - screens: { screen_1: { title: 'ケースを引き受ける', fields: { claimStatus: { label: '対応状況' } } } }, + screens: { + screen_1: { + title: 'ケースを引き受ける', + fields: { + claimStatus: { + label: '対応状況', + options: { in_progress: '対応中', waiting_customer: '顧客の回答待ち', waiting_support: 'サポートの回答待ち' }, + }, + }, + }, + }, }, lead_conversion: { label: 'リード変換プロセス', screens: { screen_1: { title: 'リード変換プロセス', + // The duplicate warning's text is a variable the flow assigns; this + // key can only carry the hole that shows it. + description: '{{duplicateWarning}}', fields: { createOpportunity: { label: '商談を作成しますか?' }, opportunityName: { label: '商談名' }, @@ -648,8 +668,21 @@ export const appSurface: Omit = { closeDate: { label: '完了予定日' }, }, }, - refuse_confirmed_duplicate: { title: '変換は拒否されました' }, - refuse_unapproved: { title: '変換は拒否されました' }, + refuse_confirmed_duplicate: { + title: '変換は拒否されました', + description: + 'このリードの重複ステータスは「重複確定」です。レビュー担当者が既存のレコードと照合し、重複であると記録しました。変換すると、同じ購入者に対して取引先・取引先責任者・商談がもう 1 件ずつ作成されます。残すレコードはこのリードの「重複管理」セクションに表示されています。代わりに、このリードを重複として不適格にしてください。変換を再び可能にできるのは、その判定を見直すレビュー担当者だけです。', + }, + refuse_unapproved: { + title: '変換は拒否されました', + description: + 'このリードはまだ変換の承認を得ていません。取引先・取引先責任者・商談に変換するには、先に承認者の承認が必要です。リードの「コンバージョン承認」項目で、判断がまだ承認待ちか、却下されたかを確認できます。それまでの間もリードへの対応は続けてください。凍結されるものはありません。', + }, + refuse_no_match_key: { + title: '変換は拒否されました', + description: + 'このリードの会社名には照合キーがないため、変換ではどの取引先に属するかを判断できず、推測もしません。推測すると、取引先責任者と商談が別の顧客の下に登録されるおそれがあります。リードの「会社名」を保存するとキーが再作成されるので、もう一度保存してから変換してください。何も作成されていません。', + }, }, }, schedule_followup: { @@ -660,8 +693,11 @@ export const appSurface: Omit = { fields: { subject: { label: '次のステップは?' }, dueDate: { label: '期限' }, - activityType: { label: '活動種別' }, - priority: { label: '優先度' }, + activityType: { + label: '活動種別', + options: { call: '電話', email: 'メール', meeting: '会議', follow_up: 'フォローアップ', demo: 'デモ', other: 'その他' }, + }, + priority: { label: '優先度', options: { low: '低', normal: '通常', high: '高', urgent: '緊急' } }, notes: { label: 'メモ' }, }, }, @@ -669,6 +705,7 @@ export const appSurface: Omit = { }, quote_generation: { label: '商談から見積を作成', + refusals: { refuse_held: { message: '先に案件化承認が必要です。「案件化承認を申請」にチェックを入れてください。何も作成されていません。' } }, screens: { screen_1: { title: '商談から見積を作成', diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 18ad255cb..0f8eca7cb 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -596,9 +596,13 @@ export const appSurface: Omit = { // — the keys `os lint`'s `i18n/missing-flow` names, read by the console's // flow runner since @objectstack 17.7.0 (the spec's `flows` liveness row is // `live` there). A screen with no authored `title` shows its flow's label in - // every locale, so each `screen_1.title` repeats the flow label. Select - // option labels and screen descriptions are not translatable keys on a - // screen (`FLOW_SCREEN_FIELD_NO_OPTIONS`), so they stay as authored. + // every locale, so each `screen_1.title` repeats the flow label. Since + // 18.0.0-next.0 three more surfaces are keys the engine reads in the run's + // locale, and `i18n/missing-flow` names each missing one: a select field's + // `options.` (worded as the object field's own option in this pack), + // an authored screen `description`, and a refusing `end` node's + // `refusals..message`. A translated description or message is a text + // slot like the one it translates, so a hole in it is `{{ }}`. // The discount ceiling is interpolated from `QUOTE_DISCOUNT_CEILING`, never // retyped: the number lives in `_thresholds.ts` alone (#1711). flows: { @@ -608,9 +612,12 @@ export const appSurface: Omit = { screen_1: { title: '将成员加入营销活动', fields: { - memberSource: { label: '加入的成员类型' }, - leadStatus: { label: '加入以下状态的线索' }, - contactDepartment: { label: '加入以下部门的联系人' }, + memberSource: { label: '加入的成员类型', options: { leads: '线索', contacts: '联系人' } }, + leadStatus: { label: '加入以下状态的线索', options: { new: '新建', contacted: '已联系', qualified: '已确认' } }, + contactDepartment: { + label: '加入以下部门的联系人', + options: { executive: '管理层', sales: '销售部', marketing: '市场部', engineering: '工程部', support: '支持部', finance: '财务部', hr: '人力资源', operations: '运营部' }, + }, }, }, }, @@ -633,13 +640,26 @@ export const appSurface: Omit = { }, claim_case: { label: '认领工单', - screens: { screen_1: { title: '认领工单', fields: { claimStatus: { label: '处理状态' } } } }, + screens: { + screen_1: { + title: '认领工单', + fields: { + claimStatus: { + label: '处理状态', + options: { in_progress: '处理中', waiting_customer: '等待客户回复', waiting_support: '等待支持回复' }, + }, + }, + }, + }, }, lead_conversion: { label: '线索转化流程', screens: { screen_1: { title: '线索转化流程', + // The duplicate warning's text is a variable the flow assigns; this + // key can only carry the hole that shows it. + description: '{{duplicateWarning}}', fields: { createOpportunity: { label: '是否创建商机?' }, opportunityName: { label: '商机名称' }, @@ -647,8 +667,21 @@ export const appSurface: Omit = { closeDate: { label: '预计成交日期' }, }, }, - refuse_confirmed_duplicate: { title: '转化被拒绝' }, - refuse_unapproved: { title: '转化被拒绝' }, + refuse_confirmed_duplicate: { + title: '转化被拒绝', + description: + '该线索的「重复状态」为「已确认重复」:审核人已将其与现有记录比对,并记录为重复。转化会为同一买家再创建一个客户、联系人和商机。本线索的「重复线索管理」部分列出了保留的记录;请改为以重复为由将此线索设为「未通过」。只有审核人修改该结论后才能重新转化。', + }, + refuse_unapproved: { + title: '转化被拒绝', + description: + '该线索尚未获准转化。必须由审批人批准后,才能转化为客户、联系人和商机;线索上的「转化审批」字段显示该决定仍在待审批还是已被驳回。在此期间请继续跟进该线索——它的任何内容都没有被冻结。', + }, + refuse_no_match_key: { + title: '转化被拒绝', + description: + '该线索的公司没有匹配键,因此转化无法判断它属于哪个客户,也不会去猜测:猜测可能会把联系人和商机归到另一个客户名下。保存线索的「公司」会重新生成匹配键,请重新保存后再转化。尚未创建任何记录。', + }, }, }, schedule_followup: { @@ -659,8 +692,11 @@ export const appSurface: Omit = { fields: { subject: { label: '下一步做什么?' }, dueDate: { label: '截止日期' }, - activityType: { label: '活动类型' }, - priority: { label: '优先级' }, + activityType: { + label: '活动类型', + options: { call: '电话', email: '邮件', meeting: '会议', follow_up: '跟进', demo: '演示', other: '其他' }, + }, + priority: { label: '优先级', options: { low: '低', normal: '普通', high: '高', urgent: '紧急' } }, notes: { label: '备注' }, }, }, @@ -668,6 +704,7 @@ export const appSurface: Omit = { }, quote_generation: { label: '从商机生成报价单', + refusals: { refuse_held: { message: '需先完成立项审批:请勾选「申请立项审批」。尚未创建任何内容。' } }, screens: { screen_1: { title: '从商机生成报价单', diff --git a/src/service/flows/case-actions.flow.ts b/src/service/flows/case-actions.flow.ts index b8e41b9d0..0d0fb55f7 100644 --- a/src/service/flows/case-actions.flow.ts +++ b/src/service/flows/case-actions.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { CLAIMABLE_TARGET_STATUSES } from '../objects/_case-assignment'; type Flow = Automation.Flow; @@ -79,9 +80,9 @@ export const EscalateCaseFlow: Flow = { id: 'escalate', type: 'update_record', label: 'Escalate Case', config: { objectName: 'crm_case', - filter: { id: '{recordId}' }, + filter: { id: cel`recordId` }, fields: { - escalation_reason: '{reason}', + escalation_reason: cel`reason`, status: 'escalated', priority: 'critical', }, @@ -107,7 +108,7 @@ export const EscalateCaseFlow: Flow = { id: 'stamp_escalation', type: 'subflow', label: 'Stamp Escalation Flags', config: { flowName: 'case_escalation_stamp', - input: { recordId: '{recordId}' }, + input: { recordId: cel`recordId` }, }, }, { id: 'end', type: 'end', label: 'End' }, @@ -192,15 +193,21 @@ export const CloseCaseFlow: Flow = { // `decision` node could not have branched it either — this repo has // measured `decision.config.condition` to be inert metadata // (`test/win-loss-capture.test.ts`'s table of five such surfaces). + // + // The read is guarded because the field is optional: a resume that omits + // it leaves the variable unbound, which the 17.x template wrote as nothing + // (the stored link left alone) and CEL refuses. The guard writes NULL + // there — the value a blank field already lands as after the normalize + // hook — so an omitted answer and an empty one now close the case alike. id: 'close', type: 'update_record', label: 'Close Case', config: { objectName: 'crm_case', - filter: { id: '{recordId}' }, + filter: { id: cel`recordId` }, fields: { is_closed: true, - resolution: '{resolution}', + resolution: cel`resolution`, status: 'closed', - resolved_by_article: '{resolved_by_article}', + resolved_by_article: cel`has(vars.resolved_by_article) ? resolved_by_article : null`, }, }, }, @@ -308,9 +315,9 @@ export const ClaimCaseFlow: Flow = { id: 'claim', type: 'update_record', label: 'Claim Case', config: { objectName: 'crm_case', - filter: { id: '{recordId}' }, + filter: { id: cel`recordId` }, fields: { - status: '{claimStatus}', + status: cel`claimStatus`, }, }, }, diff --git a/src/service/flows/case-escalation-stamp.flow.ts b/src/service/flows/case-escalation-stamp.flow.ts index 0c90d3029..0ce1e9215 100644 --- a/src/service/flows/case-escalation-stamp.flow.ts +++ b/src/service/flows/case-escalation-stamp.flow.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -90,7 +91,7 @@ export const CaseEscalationStampFlow: Flow = { runAs: 'system', variables: [ - // Supplied by the caller's `subflow` node as `input: { recordId: '{recordId}' }`. + // Supplied by the caller's `subflow` node, whose `input` envelope reads its own `recordId`. { name: 'recordId', type: 'text', isInput: true, isOutput: false }, ], @@ -104,13 +105,13 @@ export const CaseEscalationStampFlow: Flow = { id: 'stamp', type: 'update_record', label: 'Stamp Escalation Flags', config: { objectName: 'crm_case', - filter: { id: '{recordId}' }, + filter: { id: cel`recordId` }, // ⛔ Do not add fields here. Every column in this node is written with // the elevated context; anything a user can legitimately type belongs // in `escalate_case` instead. fields: { is_escalated: true, - escalated_date: '{NOW()}', + escalated_date: cel`isoDatetime(now())`, }, }, }, diff --git a/src/service/flows/case-escalation.flow.ts b/src/service/flows/case-escalation.flow.ts index ccf7b9a52..4b1c262de 100644 --- a/src/service/flows/case-escalation.flow.ts +++ b/src/service/flows/case-escalation.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; type Flow = Automation.Flow; @@ -81,7 +81,7 @@ export const CaseEscalationFlow: Flow = { }, { id: 'get_case', type: 'get_record', label: 'Get Case Record', - config: { objectName: 'crm_case', filter: { id: '{record.id}' }, outputVariable: 'caseRecord' }, + config: { objectName: 'crm_case', filter: { id: cel`record.id` }, outputVariable: 'caseRecord' }, }, { // The `label` names what this node actually does: it flags the case as @@ -92,7 +92,7 @@ export const CaseEscalationFlow: Flow = { // fix. id: 'assign_senior_agent', type: 'update_record', label: 'Flag as Escalated', config: { - objectName: 'crm_case', filter: { id: '{record.id}' }, + objectName: 'crm_case', filter: { id: cel`record.id` }, // `escalation_reason` must be set whenever `is_escalated` flips true — the // object's `escalation_reason_required` validation rejects the write // otherwise (which silently aborted this flow until it was supplied). @@ -108,7 +108,7 @@ export const CaseEscalationFlow: Flow = { // which the app can resolve, rather than a manager chain, which it // cannot. With that pool unstaffed the case keeps its owner and this // write is unchanged. - fields: { is_escalated: true, escalation_reason: 'Auto-escalated: critical priority', escalated_date: '{NOW()}', status: 'escalated' }, + fields: { is_escalated: true, escalation_reason: 'Auto-escalated: critical priority', escalated_date: cel`isoDatetime(now())`, status: 'escalated' }, }, }, // No `create_task` node here: the escalation write above flips `status` to @@ -176,7 +176,7 @@ export const CaseEscalationFlow: Flow = { // `{caseRecord.owner_id.manager}` and `{caseRecord.crm_account.name}` // both interpolate to the literal string "undefined" — a phantom // recipient and a garbled body. - recipients: ['{caseRecord.owner_id}'], + recipients: [cel`caseRecord.owner_id`], channels: ['inbox', 'email'], severity: 'critical', topic: 'case_escalated', @@ -188,8 +188,8 @@ export const CaseEscalationFlow: Flow = { // So it states the rule and points at the record for the answer, in // every language. template: 'crm.case_escalated', - templateData: { case_number: '{caseRecord.case_number}', priority: '{caseRecord.priority}' }, - actionUrl: '/crm_case/{record.id}', + templateData: { case_number: cel`caseRecord.case_number`, priority: cel`caseRecord.priority` }, + actionUrl: '/crm_case/{{record.id}}', }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/src/service/flows/case-sla-monitor.flow.ts b/src/service/flows/case-sla-monitor.flow.ts index 256c9c45e..ce4107e4a 100644 --- a/src/service/flows/case-sla-monitor.flow.ts +++ b/src/service/flows/case-sla-monitor.flow.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { guarded } from '../../sales/flows/_guarded-iteration'; type Flow = Automation.Flow; @@ -106,10 +106,16 @@ export const CaseSlaMonitorFlow: Flow = { // `case_sla_defaults`, which applies the same rule on the write that // settles a case — so a case settled late is flagged even when this // sweep never reached it, or never runs (#2070). + // + // "Now" is the query engine's own `{now}` placeholder, which an + // 18.0.0-next.0 filter operand leaves to the engine as written. It + // renders the instant's ISO text, as the retired `{NOW()}` flow token + // did, and it is the spelling the SLA Violations tile reads, so the + // sweep and the tile say "past due" in one wording. filter: { status: { $nin: ['resolved', 'closed'] }, is_sla_violated: false, - sla_due_date: { $lt: '{NOW()}' }, + sla_due_date: { $lt: '{now}' }, }, limit: 500, outputVariable: 'caseList', @@ -118,7 +124,7 @@ export const CaseSlaMonitorFlow: Flow = { { id: 'loop_cases', type: 'loop', label: 'For Each Breached Case', config: { - collection: '{caseList}', + collection: cel`caseList`, iteratorVariable: 'currentCase', body: guarded('case', { nodes: [ @@ -126,7 +132,7 @@ export const CaseSlaMonitorFlow: Flow = { id: 'flag_breach', type: 'update_record', label: 'Flag SLA Breach', config: { objectName: 'crm_case', - filter: { id: '{currentCase.id}' }, + filter: { id: cel`currentCase.id` }, // `escalation_reason` must accompany `is_escalated: true` — the // object's `escalation_reason_required` validation (severity: // error) rejects the whole write otherwise, turning this sweep @@ -135,7 +141,7 @@ export const CaseSlaMonitorFlow: Flow = { is_sla_violated: true, is_escalated: true, status: 'escalated', - escalated_date: '{NOW()}', + escalated_date: cel`isoDatetime(now())`, escalation_reason: 'Auto-escalated: SLA due date breached', }, }, @@ -207,7 +213,7 @@ export const CaseSlaMonitorFlow: Flow = { id: 'reload_case', type: 'get_record', label: 'Re-read Case After Escalation', config: { objectName: 'crm_case', - filter: { id: '{currentCase.id}' }, + filter: { id: cel`currentCase.id` }, outputVariable: 'currentCase', }, }, @@ -264,15 +270,15 @@ export const CaseSlaMonitorFlow: Flow = { config: { // Owner only — `{currentCase.owner_id.manager}` dot-walks a // lookup, which flow templates interpolate as "undefined". - recipients: ['{currentCase.owner_id}'], + recipients: [cel`currentCase.owner_id`], channels: ['inbox', 'email'], severity: 'critical', topic: 'case_sla_breach', template: 'crm.case_sla_breach', templateData: { - case_number: '{currentCase.case_number}', priority: '{currentCase.priority}', + case_number: cel`currentCase.case_number`, priority: cel`currentCase.priority`, }, - actionUrl: '/crm_case/{currentCase.id}', + actionUrl: '/crm_case/{{currentCase.id}}', }, }, ], diff --git a/src/service/index.ts b/src/service/index.ts index 2e5d33306..0fd10d064 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -135,7 +135,7 @@ export const createHotCrmServiceStack = ( description: 'HotCRM support module — cases, knowledge articles, and the SLA surface.', // The same protocol as the app package (ADR-0087 handshake); bump it with // `specVersion` on every platform upgrade, exactly as the app's is. - engines: { protocol: '^17.7.0' }, + engines: { protocol: '^18.0.0-next.0' }, // The App package this module extends. dependencies: { 'app.objectstack.hotcrm': '^4.0.1' }, diff --git a/test/account-approval-gate.test.ts b/test/account-approval-gate.test.ts index a42c89832..3ec8c9261 100644 --- a/test/account-approval-gate.test.ts +++ b/test/account-approval-gate.test.ts @@ -1,13 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll } from 'vitest'; -import { bootStackOnce, type BootOptions, type VerifyStack } from '@objectstack/verify'; +import { bootStack, type BootOptions, type VerifyStack } from '@objectstack/verify'; import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; import artifact from '../objectstack.config'; import stack from './helpers/composed-stack'; import { AccountApprovalFlow } from '../src/sales/flows/account-approval.flow'; import { CrmSeedData } from '../objectstack.composition'; -import { hotcrmStack, bootOptions, conditionHolds as holdsOn, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, buildArtifactAgain, bootOptions, conditionHolds as holdsOn, signUpPerson, type Person } from './helpers/verify-stack'; type Rec = Record; @@ -141,14 +141,32 @@ describe('a seeded account is already signed off (#2042)', () => { /** * The app on a composition WITHOUT the `approvals` capability: the app's own * boot minus `ApprovalsServicePlugin` and nothing else, so the two boots differ - * in exactly the capability under test. A module-level constant because - * `bootStackOnce` memoises by options identity. + * in exactly the capability under test. + * + * Since 18.0.0-next.0 `bootStack` also mounts the provider of every capability + * the configuration's `requires` names, as `objectstack serve` does (verify + * 97610a5), so leaving the plugin out of `extraPlugins` alone no longer leaves + * approvals out. The composition without it is the app's own configuration + * with `approvals` dropped from the one package `requires` that names it, + * taken from a build of its own: one configuration object holds one live + * kernel (objectstack#22301), and a copy of a live one is not a configuration + * of its own. */ const APP_BOOT = bootOptions(); const WITHOUT_APPROVALS_BOOT: BootOptions = { ...APP_BOOT, extraPlugins: (APP_BOOT.extraPlugins ?? []).filter((p) => !(p instanceof ApprovalsServicePlugin)), }; +const withoutApprovals = (base: typeof artifact): typeof artifact => { + const a = base as unknown as Rec; + return { + ...a, + packages: (a.packages as Rec[]).map((p) => ({ + ...p, + manifest: { ...p.manifest, requires: ((p.manifest.requires ?? []) as string[]).filter((token) => token !== 'approvals') }, + })), + } as typeof artifact; +}; /** * The two ways a user creates an account (#2067 acceptance 1), each returning @@ -231,7 +249,7 @@ describe('WITHOUT approvals in the composition — a new account is not stranded let bare: VerifyStack; let who: { rep: Person; manager: Person }; beforeAll(async () => { - bare = await bootStackOnce(artifact, WITHOUT_APPROVALS_BOOT); + bare = await bootStack(withoutApprovals(await buildArtifactAgain()), WITHOUT_APPROVALS_BOOT); who = await people(bare, 'without'); }, 120_000); diff --git a/test/account-name-normalized-match.test.ts b/test/account-name-normalized-match.test.ts index 8dcd58f45..4cc52f01a 100644 --- a/test/account-name-normalized-match.test.ts +++ b/test/account-name-normalized-match.test.ts @@ -60,42 +60,59 @@ beforeAll(async () => { // ══════════════════════════════════ premise 1: a flow cannot normalize ══ /** - * `service-automation`'s value expressions support a small closed vocabulary — - * `round`, `floor`, `ceil`, `abs`, `min`, `max` (1:1 with the CEL stdlib) plus - * the whole-token date macros `NOW()` / `TODAY()`. There is no `LOWER`, no - * `TRIM`, and no string method. + * A flow cannot compute the match key `lead_duplicate_check` stores — lower + * case, trimmed, internal whitespace collapsed to one space — so it must READ + * the stored key rather than fold the company itself. * - * Run on the real engine: each candidate spelling is the value of a - * `create_record` field in a probe flow of its own, registered through the - * platform's flow-authoring door (`POST /automation`, as the admin) and run - * through the trigger door against a real lead. Since 17.3.0 - * (objectstack#11060) a function INSIDE a template hole that the vocabulary - * does not declare REFUSES the run and names itself — nothing is written. A - * function written OUTSIDE the braces is not an expression at all: it is - * template text around a hole, and lands verbatim (measured on 17.7.0 — - * `LOWER({x})` writes the literal `LOWER(ACME Corp)`). Neither folds; the one - * form that does anything is the bare pass-through. + * Through 17.7.0 the reason was the template dialect: its value expressions + * knew `round` / `floor` / `ceil` / `abs` / `min` / `max` and the date macros, + * no string function, and refused an unknown one inside a hole (17.3.0, + * objectstack#11060) while one written outside the braces landed verbatim + * (`LOWER({x})` wrote `LOWER(ACME Corp)`). + * + * 18.0.0-next.0 retires that dialect from flow value slots (the spec's + * `flow-value-slot-template-dialect-refused` entry), so every one of those + * spellings is now refused at the flow-authoring door and the run never + * starts. The value slot speaks CEL instead, and CEL CAN do part of the fold — + * `lowerAscii()` and `trim()` exist — but not the third step: there is no + * string replacement, so `"ACME Corp"` folds to `"acme corp"`, never to the + * stored `"acme corp"`. The premise therefore still holds, for a narrower + * reason, and both halves of that reason are measured below. + * + * Run on the real engine: each candidate is the value of a `create_record` + * field in a probe flow of its own, registered through the platform's + * flow-authoring door (`POST /automation`, as the admin) and run through the + * trigger door against a real lead. */ -describe('premise: a flow template cannot fold a string', () => { - const REFUSED: Record = { - fn_lower: ['{LOWER(leadRecord.company)}', /LOWER/], - fn_trim: ['{TRIM(leadRecord.company)}', /TRIM/], - method_lower: ['{leadRecord.company.toLowerCase()}', /toLowerCase/], - parenthesised_method: ['{(leadRecord.company).toLowerCase()}', /toLowerCase/], +describe('premise: a flow cannot fold a company into its match key', () => { + /** The retired template spellings — each refused at the door on 18.0.0-next.0. */ + const RETIRED: Record = { + template_passthrough: '{leadRecord.company}', + fn_lower: '{LOWER(leadRecord.company)}', + fn_trim: '{TRIM(leadRecord.company)}', + method_lower: '{leadRecord.company.toLowerCase()}', + unwrapped_lower: 'LOWER({leadRecord.company})', }; - const UNWRAPPED = 'LOWER({leadRecord.company})'; - const PASSTHROUGH = '{leadRecord.company}'; + const cel = (source: string) => ({ dialect: 'cel', source }); let leadId: string; - /** Run a probe writing `value` into a task's subject; the run's refusal (or null) and what it wrote. */ - const probe = async (key: string, value: string) => { + /** + * Register a probe writing `value` into a task's subject and, if the door + * admits it, run it. Answers the door's status, the run's refusal (or null) + * and what it wrote. + */ + const probe = async (key: string, value: unknown) => { const name = `probe_fold_${key}`; + // Run as the admin, not elevated: since 18.0.0-next.0 the trigger door + // refuses a self-triggered `runAs: 'system'` flow to every caller but the + // system principal (objectstack ca135dc, #22310), and what this probe + // measures is the value slot, not the elevation. const flow = { - name, label: 'probe', type: 'autolaunched', status: 'active', runAs: 'system', + name, label: 'probe', type: 'autolaunched', status: 'active', runAs: 'user', variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], nodes: [ { id: 'start', type: 'start', label: 'Start', config: {} }, - { id: 'get_lead', type: 'get_record', label: 'Get Lead', config: { objectName: 'crm_lead', filter: { id: '{recordId}' }, outputVariable: 'leadRecord' } }, + { id: 'get_lead', type: 'get_record', label: 'Get Lead', config: { objectName: 'crm_lead', filter: { id: cel('recordId') }, outputVariable: 'leadRecord' } }, { id: 'probe', type: 'create_record', label: 'Probe', config: { objectName: 'crm_task', fields: { subject: value, description: `probe ${key}`, type: 'follow_up', priority: 'normal', status: 'not_started' } }, @@ -109,17 +126,19 @@ describe('premise: a flow template cannot fold a string', () => { ], }; const registered = await verify.apiAs(admin, 'POST', '/automation', flow); - expect(registered.status, await registered.clone().text()).toBe(200); + const door = { status: registered.status, body: await registered.clone().text() }; let refusal: string | null = null; - try { - await verify.flows.run(name, { recordId: leadId }, { as: admin }); - } catch (e: unknown) { - refusal = String((e as Rec).message ?? e); - } finally { - expect((await verify.apiAs(admin, 'DELETE', `/automation/${name}`)).status).toBe(200); + if (registered.status === 200) { + try { + await verify.flows.run(name, { recordId: leadId }, { as: admin }); + } catch (e: unknown) { + refusal = String((e as Rec).message ?? e); + } finally { + expect((await verify.apiAs(admin, 'DELETE', `/automation/${name}`)).status).toBe(200); + } } const written = (await verify.rows('crm_task', { description: `probe ${key}` })).map((t) => t.subject); - return { refusal, written }; + return { door, refusal, written }; }; beforeAll(async () => { @@ -129,28 +148,34 @@ describe('premise: a flow template cannot fold a string', () => { leadId = String(probeLead!.id); }); - it('passes the raw value through unchanged — the only thing that works', async () => { - expect((await probe('passthrough', PASSTHROUGH)).written).toEqual(['ACME Corp']); + it('passes the raw value through unchanged', async () => { + const { door, refusal, written } = await probe('passthrough', cel('leadRecord.company')); + expect(door.status, door.body).toBe(200); + expect(refusal).toBeNull(); + expect(written).toEqual(['ACME Corp']); }); - it.each(Object.entries(REFUSED))('refuses the run on an unknown function, naming it — %s', async (key, [value, named]) => { - // Through 17.2.0 this run SUCCEEDED and wrote a row of silent wrong answers. - const { refusal } = await probe(key, value); - expect(refusal).toMatch(/unknown function/i); - expect(refusal).toMatch(named); + it.each(Object.entries(RETIRED))('refuses the retired template spelling at the door, writing nothing — %s', async (key, value) => { + const { door, written } = await probe(key, value); + expect(door.status, 'a single-brace template in a value slot was admitted').toBe(400); + expect(door.body).toMatch(/invalid expression/i); + expect(written).toEqual([]); }); - it.each(Object.entries(REFUSED))('writes NOTHING when it refuses — no row of wrong answers — %s', async (key, [value]) => { - expect((await probe(`${key}_nothing`, value)).written).toEqual([]); + it('folds case and the outer spaces in CEL — but cannot collapse the inner ones', async () => { + // Two of the key's three steps exist as CEL functions. The written value + // keeps the double space the stored key collapses, so it can never equal + // `company_normalized`. + const { door, refusal, written } = await probe('lower_trim', cel('leadRecord.company.lowerAscii().trim()')); + expect(door.status, door.body).toBe(200); + expect(refusal).toBeNull(); + expect(written).toEqual(['acme corp']); }); - it('a function OUTSIDE the braces is template text — it lands verbatim, unfolded', async () => { - // The worst-looking shape, and still no fold: `LOWER(` is literal text - // around the `{leadRecord.company}` hole. Pinned as measured, so nobody - // mistakes it for a working spelling. - const { refusal, written } = await probe('unwrapped_lower', UNWRAPPED); - expect(refusal).toBeNull(); - expect(written).toEqual(['LOWER(ACME Corp)']); + it('has no string replacement to collapse them with — the door refuses the call', async () => { + const { door, written } = await probe('replace', cel('leadRecord.company.replace(" ", " ")')); + expect(door.status, 'a string replace was admitted — premise 1 may no longer hold; re-measure').toBe(400); + expect(written).toEqual([]); }); }); diff --git a/test/account-renewal-model.test.ts b/test/account-renewal-model.test.ts index beb224a83..be8cba3bb 100644 --- a/test/account-renewal-model.test.ts +++ b/test/account-renewal-model.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { cel } from '@objectstack/spec'; import { Account } from '../src/sales/objects/account.object'; import { AccountViews } from '../src/sales/views/account.view'; import { ContractRenewalFlow } from '../src/revenue/flows/contract-renewal.flow'; @@ -120,10 +121,10 @@ describe('renewal stays a contract-level process (#1181)', () => { */ it('notifies the contract owner, and names no account renewal owner', () => { const notify = nodesOf(ContractRenewalFlow).find((n) => n.type === 'notify'); - expect(notify?.config?.recipients).toEqual(['{currentContract.owner_id}']); + expect(notify?.config?.recipients).toEqual([cel`has(currentContract.owner_id) ? currentContract.owner_id : null`]); const task = nodesOf(ContractRenewalFlow).find((n) => n.id === 'create_renewal_task'); - expect(task?.config?.fields?.owner_id).toBe('{currentContract.owner_id}'); + expect(task?.config?.fields?.owner_id).toEqual(cel`has(currentContract.owner_id) ? currentContract.owner_id : null`); const whole = JSON.stringify(ContractRenewalFlow); for (const name of RETIRED) expect(whole).not.toContain(name); diff --git a/test/actions-flows-integrity.test.ts b/test/actions-flows-integrity.test.ts index 2c740585a..63be07b3d 100644 --- a/test/actions-flows-integrity.test.ts +++ b/test/actions-flows-integrity.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; +import { cel } from '@objectstack/spec'; import stack from './helpers/composed-stack'; import { flowNodesDeep } from './helpers/flow-regions'; import { join } from 'node:path'; @@ -104,10 +105,10 @@ describe('lead_conversion flow contracts', () => { it('normalizes both account branches onto a bare {accountId} id (no {accountId.id})', () => { const contact = nodeOf(f, 'create_contact'); - expect(contact?.config?.fields?.crm_account).toBe('{accountId}'); + expect(contact?.config?.fields?.crm_account).toEqual(cel`accountId`); // Guard against the earlier full-record vs id confusion resurfacing. const json = JSON.stringify(f); - expect(json, 'stale {accountId.id} ref — accountId is now a bare id string').not.toContain('{accountId.id}'); + expect(json, 'stale {accountId.id} ref — accountId is now a bare id string').not.toContain('accountId.id'); }); it('every select value copied verbatim from the lead is legal on the target object (#531)', () => { @@ -119,13 +120,21 @@ describe('lead_conversion flow contracts', () => { // superset relation itself so drift in either object re-fails CI. const leadFields = objects.find((o) => o.name === 'crm_lead')?.fields ?? {}; const bad: string[] = []; + let copies = 0; for (const node of f?.nodes ?? []) { if (node.type !== 'create_record') continue; const targetObject: string = node.config?.objectName ?? ''; const targetFields = objects.find((o) => o.name === targetObject)?.fields ?? {}; for (const [target, template] of Object.entries(node.config?.fields ?? {})) { - const src = typeof template === 'string' && /^\{leadRecord\.(\w+)\}$/.exec(template)?.[1]; + // A copied lead field, in either spelling: the 17.x `{leadRecord.x}` + // template, or since 18.0.0-next.0 a CEL envelope reading + // `leadRecord.x` (guarded to null where the field is optional). + const source = typeof template === 'string' ? template : (template as AnyRec | null)?.source; + const src = typeof source === 'string' + && (/^\{leadRecord\.(\w+)\}$/.exec(source)?.[1] + ?? /^(?:has\(leadRecord\.(\w+)\) \? )?leadRecord\.(\w+)(?: : null)?$/.exec(source)?.[2]); if (!src) continue; + copies++; const srcOptions = leadFields[src]?.options; const dstOptions = targetFields[target]?.options; // Only select→select copies carry enum constraints on both sides. @@ -138,6 +147,9 @@ describe('lead_conversion flow contracts', () => { } } } + // Non-vacuity: a spelling this reader no longer recognises would skip every + // copy and pass over nothing. + expect(copies, 'no lead field copy was recognised — the reader is stale').toBeGreaterThan(0); expect(bad, bad.join('\n')).toEqual([]); }); }); @@ -152,6 +164,7 @@ describe('notify recipients resolve to real audiences', () => { for (const spec of to) { const s = String(spec); const ok = + (typeof spec === 'object' && (spec as AnyRec)?.dialect === 'cel') || // CEL envelope → a user id s.includes('{') || // template → a real user id / email /^(user|role|team|owner_of):/.test(s) || // explicit messaging audience selector s.includes('@'); // email @@ -194,7 +207,7 @@ describe('lead conversion dedupes the contact too', () => { expect(find.type).toBe('get_record'); expect(find.config?.objectName).toBe('crm_contact'); // Converge on a bare {contactId} id (no stale {contactId.id}). - expect(JSON.stringify(f)).not.toContain('{contactId.id}'); + expect(JSON.stringify(f)).not.toContain('contactId.id'); }); }); diff --git a/test/audit-stamp-readonly.test.ts b/test/audit-stamp-readonly.test.ts index c9101271a..e57cc48bb 100644 --- a/test/audit-stamp-readonly.test.ts +++ b/test/audit-stamp-readonly.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; +import { cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import stack from './helpers/composed-stack'; import { OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow } from '../src/sales/flows/opportunity-approval.flow'; @@ -229,7 +230,7 @@ describe('#1666 — crm_opportunity approval stamps are declared readonly', () = 'pin_mark_approved', (OpportunityApprovalFlow as AnyRec).runAs, node, - { filter: { id: '{rowId}' } }, + { filter: { id: cel`rowId` } }, ), }); await engine.execute('pin_mark_approved', { params: { rowId: id }, userId: 'user_1', event: 'manual' } as never); @@ -249,7 +250,7 @@ describe('#1666 — crm_opportunity approval stamps are declared readonly', () = 'pin_mark_rejected', (OpportunityApprovalFlow as AnyRec).runAs, node, - { filter: { id: '{rowId}' } }, + { filter: { id: cel`rowId` } }, ), }); await engine.execute('pin_mark_rejected', { params: { rowId: id }, userId: 'user_1', event: 'manual' } as never); @@ -344,7 +345,7 @@ describe('#1667 — crm_campaign_member.added_date is declared readonly', () => expect(node!.type, 'an INSERT on an elevated callee. If this ever becomes update_record it ' + 'is a different write with different ordering — that is the failure #1667 trades against.').toBe('create_record'); expect(node!.config.objectName).toBe('crm_campaign_member'); - expect(node!.config.fields.added_date, 'the stamp itself, taken from source').toBe('{NOW()}'); + expect(node!.config.fields.added_date, 'the stamp itself, taken from source').toEqual(cel`isoDatetime(now())`); } }); @@ -374,7 +375,7 @@ describe('#1667 — crm_campaign_member.added_date is declared readonly', () => (CampaignLeadMemberEnrollFlow as AnyRec).runAs ?? 'user', node, // Addressing only — `status` and `added_date` stay exactly as authored. - { fields: { ...node.config.fields, crm_campaign: '{rowId}', crm_lead: '{linkId}' } }, + { fields: { ...node.config.fields, crm_campaign: cel`rowId`, crm_lead: cel`linkId` } }, ), }); await engine.execute('pin_enrol', { diff --git a/test/cascade-guard-messages.test.ts b/test/cascade-guard-messages.test.ts index 13abf0713..a5c6dd0f0 100644 --- a/test/cascade-guard-messages.test.ts +++ b/test/cascade-guard-messages.test.ts @@ -6,8 +6,7 @@ import { DefaultDatasourcePlugin, AppPlugin } from '@objectstack/runtime'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { MetadataPlugin } from '@objectstack/metadata'; import stack from '../objectstack.config'; -import { SysUser, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; -import { identityObjects } from './helpers/identity-objects'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; /** * A guard's refusal must name the record that is refusing — never the record @@ -100,18 +99,30 @@ const uniq = (): string => `${++seq}`; beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } } as never); - await kernel.use(new DefaultDatasourcePlugin({ driver: 'memory', config: {} } as never)); + // The SQL in-memory driver: `driver-memory` refuses every tenant-scoped call + // on 18.0.0-next.0, and a system write here now carries the install's one + // organization (below), so this kernel left it (`@objectstack/verify` 34dba5a + // names the same move for its own boots). + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } } as never)); await kernel.use(new MetadataPlugin({ watch: false, artifactWatch: false, environmentId: 'proj_693' } as never)); await kernel.use(new ObjectQLPlugin({ environmentId: 'proj_693' } as never)); // 17.7.0 refuses an object name the registry does not hold (objectstack#21545): - // register the identity objects `plugin-auth` would (`test/helpers/identity-objects.ts`). - await kernel.use(identityObjects(SysUser, SysMember, SysOrganization) as never); + // register the identity objects `plugin-auth` would, through its own preset + // (`createIdentityObjectsPlugin`, objectstack#22074). + await kernel.use(createIdentityObjectsPlugin() as never); // The app's own metadata is the subject — objects and hooks exactly as // `objectstack.config.ts` declares them. Seed data is skipped; each test // builds the population it needs. await kernel.use(new AppPlugin(stack as never, undefined as never, { skipSeedData: true } as never)); await kernel.bootstrap(); ql = kernel.getService('objectql'); + // The install's one organization. Since 18.0.0-next.0 a system write on a + // tenant-scoped object takes its organization from the install's ONE + // organization and is refused when there is none (ADR-0131 D3/D9, + // `SystemWriteOrganizationRequiredError`). `plugin-auth` creates it at boot + // under the `single` posture; this kernel mounts no `plugin-auth`, so it + // writes the row itself. + await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); const user = await insert('sys_user', { name: 'Acceptance Rep', email: 'rep@cascade-guards.test' }); // `isSystem` keeps the fixture out of the sharing model (not what this file @@ -357,20 +368,21 @@ describe('deleting a customer account with activated contracts (#549)', () => { expect(await rowsOf('crm_contract', contract.id)).toHaveLength(1); }); - it('a draft contract is NOT what the guard refuses — the platform referential pass is', async () => { - // MEASURED, and the reason the docs say "an account carrying any contract - // cannot be deleted" rather than "drafts cascade": the guard stands down - // (no activated contract), then the engine's cascade reaches the CONTACTS - // first and stops on `crm_contract.crm_contact`, a required lookup that - // cannot be cleared. So the delete is still refused, by the platform and - // in its words, and every row stays. If this case starts passing with - // `null`, the engine reordered its cascade (contracts before contacts) and - // the docs' delete paragraph must be re-taken. + it('a draft contract is NOT what the guard refuses — it goes with the account', async () => { + // MEASURED. The guard stands down on a draft (no activated contract), and + // what follows is the engine's cascade. Through 17.7.0 that cascade reached + // the CONTACTS first and stopped on `crm_contract.crm_contact`, a required + // lookup that cannot be cleared, so the delete was refused in the platform's + // words ("still referenced by 1 Contract record") and every row stayed — the + // reason this case once asked to be told if it started answering `null`. On + // 18.0.0-next.0 it does: the delete goes through and the draft contract goes + // with the account, the cascade `hooks-runtime-sales`'s account_protection + // case pins for every settled status. The account docs promise only that an + // ACTIVATED contract blocks the delete, which still holds (the case above). const { account, contract } = await accountWithContract('draft'); const message = await deleteAndCatch('crm_account', account.id); - expect(message).not.toContain('activated contract'); - expect(message).toMatch(/still referenced by 1 Contract record/); - expect(await rowsOf('crm_account', account.id)).toHaveLength(1); - expect(await rowsOf('crm_contract', contract.id)).toHaveLength(1); + expect(message, 'the delete of an account carrying only a draft contract was refused').toBeNull(); + expect(await rowsOf('crm_account', account.id)).toEqual([]); + expect(await rowsOf('crm_contract', contract.id), 'the draft contract outlived its account').toEqual([]); }); }); diff --git a/test/case-assignment.test.ts b/test/case-assignment.test.ts index 4ac23b14f..78d5d3898 100644 --- a/test/case-assignment.test.ts +++ b/test/case-assignment.test.ts @@ -15,7 +15,7 @@ import { } from '../src/service/objects/_case-assignment'; import artifact from '../objectstack.config'; import { - hotcrmStack, bootOptions, signUpPerson, guestInsert, systemUpdate, recordEngineWrites, type Person, + hotcrmStack, bootOptions, buildArtifactAgain, signUpPerson, guestInsert, systemUpdate, recordEngineWrites, type Person, } from './helpers/verify-stack'; type Rec = Record; @@ -763,7 +763,9 @@ describe('case_escalation_reassign', () => { // On a boot of its own: once an install holds two organizations the engine // refuses every system insert that names none (it cannot tell which one // owns the row), which is every other fixture in this file. - const twoOrgs = await bootStack(artifact, bootOptions()); + // (A build of its own, too: one configuration object holds one live kernel + // since 18.0.0-next.0, objectstack#22301.) + const twoOrgs = await bootStack(await buildArtifactAgain(), bootOptions()); const orgs = await twoOrgs.seed('sys_organization', [ { name: 'Case Org', slug: 'case-org' }, { name: 'Other Org', slug: 'other-org' }, ]); diff --git a/test/case-creation-date.test.ts b/test/case-creation-date.test.ts index 4083f7f4b..e6a06595d 100644 --- a/test/case-creation-date.test.ts +++ b/test/case-creation-date.test.ts @@ -7,13 +7,12 @@ import { ObjectQLPlugin } from '@objectstack/objectql'; import { MetadataPlugin } from '@objectstack/metadata'; import { AnalyticsService } from '@objectstack/service-analytics'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; -import { SysUser, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; import artifact from '../objectstack.config'; import stack from './helpers/composed-stack'; import { CaseDataset } from '../src/service/datasets/case.dataset'; import { CasesOpenedByDayPriorityReport } from '../src/service/reports/case.report'; import { ServiceDashboard } from '../src/service/dashboards/service.dashboard'; -import { identityObjects } from './helpers/identity-objects'; /** * `crm_case` has one creation stamp, and the platform owns it (#1992). @@ -125,10 +124,17 @@ describe('a case created without any date is counted', () => { await kernel.use(new MetadataPlugin({ watch: false, artifactWatch: false, environmentId: 'proj_test' } as never)); await kernel.use(new ObjectQLPlugin({ environmentId: 'proj_test' } as never)); // 17.7.0 refuses an object name the registry does not hold (objectstack#21545). - await kernel.use(identityObjects(SysUser, SysMember, SysOrganization) as never); + await kernel.use(createIdentityObjectsPlugin() as never); await kernel.use(new AppPlugin(artifact as never, undefined as never, { skipSeedData: true } as never)); await kernel.bootstrap(); ql = kernel.getService('objectql'); + // The install's one organization. Since 18.0.0-next.0 a system write on a + // tenant-scoped object takes its organization from the install's ONE + // organization and is refused when there is none (ADR-0131 D3/D9, + // `SystemWriteOrganizationRequiredError`). `plugin-auth` creates it at boot + // under the `single` posture; this kernel mounts no `plugin-auth`, so it + // writes the row itself. + await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); const read = async (id: string): Promise => ((await ql.findOne('crm_case', { where: { id } }, { context: { isSystem: true } })) ?? {}) as AnyRec; diff --git a/test/case-guest-branch-leftovers.test.ts b/test/case-guest-branch-leftovers.test.ts index cc723e468..272071aa7 100644 --- a/test/case-guest-branch-leftovers.test.ts +++ b/test/case-guest-branch-leftovers.test.ts @@ -159,9 +159,11 @@ const hoursFromNow = (iso: unknown): number => beforeAll(async () => { // The platform's boot — `@objectstack/verify`'s `bootStack` over the shipped // artifact (objectql, auth and its `tenancy` service, security with the app's - // own default profile, sharing) — on the in-memory datasource this file has - // always measured on. - verify = await bootStack(artifact, { databaseDriver: 'memory' }); + // own default profile, sharing) — on the SQL in-memory datasource. Through 17.7.0 this file measured on + // `driver-memory`; on 18.0.0-next.0 that driver refuses every tenant-scoped + // call, and the boot now creates the Default Organization (ADR-0131 D3), so + // `@objectstack/verify` 34dba5a moves such a leg to the SQL driver. + verify = await bootStack(artifact); // A fresh sign-up is an ordinary member (the stack's first user is its own // platform admin), so the agent's writes go through the same enforcement a diff --git a/test/case-sla-breach-on-close.test.ts b/test/case-sla-breach-on-close.test.ts index 8db72eedb..e7ef44ed0 100644 --- a/test/case-sla-breach-on-close.test.ts +++ b/test/case-sla-breach-on-close.test.ts @@ -5,7 +5,7 @@ import caseHooks from '../src/service/objects/case.hook'; import { caseSlaBreached } from '../src/service/objects/_case-sla'; import { CLOSED_CASE_STATUSES } from '../src/service/objects/_case-assignment'; import type { VerifyStack } from '@objectstack/verify'; -import { hotcrmStack, signUpPerson, systemUpdate, runShippedHook, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, systemUpdate, runShippedHook, runScheduledFlow, type Person } from './helpers/verify-stack'; /** * A case that finishes late records its breach when its clock stops (#2070). @@ -277,18 +277,19 @@ describe('the sweep, the close-time stamp and the tile draw one boundary', () => it('case_sla_monitor selects STRICTLY past-due cases, as caseSlaBreached does', () => { // A `$lte` here would flag a case exactly on its deadline as long as it is // open, while the close-time stamp calls the same instant "met". - expect(sweepSelection().sla_due_date).toEqual({ $lt: '{NOW()}' }); + expect(sweepSelection().sla_due_date).toEqual({ $lt: '{now}' }); }); it('the SLA Violations tile counts the flag, or exactly the sweep\'s own selection', () => { // The sweep selects "open, past due, not yet flagged"; the tile counts // "flagged, or open and past due" — the same selection with the flag - // clause folded into the other branch. Only the instant's SPELLING - // differs, because the two slots are two dialects: a flow template reads - // `{NOW()}`, and a query filter refuses it (FILTER_TOKEN_UNKNOWN) and - // spells the current instant `{now}`. The operator is read off the sweep, - // so a `$lte` on either side, a different status set, or a third clause - // on the sweep turns this red. + // clause folded into the other branch. Since 18.0.0-next.0 the two spell + // the instant the same way too: a flow filter operand refuses the + // `{NOW()}` template token, and the sweep reads the query engine's own + // `{now}` placeholder, which the tile reads. So the tile's second branch + // IS the sweep's selection, compared whole: a `$lte` on either side, a + // different status set, another spelling of the instant, or a third + // clause on the sweep turns this red. const { is_sla_violated: unflagged, ...selection } = sweepSelection(); expect(unflagged, 'the sweep no longer selects unflagged cases only').toBe(false); const operators = Object.keys(selection.sla_due_date ?? {}); @@ -296,7 +297,7 @@ describe('the sweep, the close-time stamp and the tile draw one boundary', () => expect(tileFilter()).toEqual({ $or: [ { is_sla_violated: true }, - { ...selection, sla_due_date: { [operators[0]!]: '{now}' } }, + selection, ], }); }); @@ -358,8 +359,7 @@ describe('the SLA Violations tile counts open breaches at read time, with no swe it('the sweep then flags exactly the open cases the tile already counted, and the number holds', async () => { // The behavioural half of section 4: whatever the two spellings, the // sweep's selection and the tile's open-case branch are one set of rows. - const admin = await verify.signIn(); - await verify.flows.run('case_sla_monitor', {}, { as: admin }); + await runScheduledFlow(verify, 'case_sla_monitor'); expect(await flagged()).toEqual([...COUNTED].sort()); expect(await tileCount()).toBe(COUNTED.length); }, 120_000); diff --git a/test/contact-email-tenant-scope.test.ts b/test/contact-email-tenant-scope.test.ts index 785b09e9b..fdff2c709 100644 --- a/test/contact-email-tenant-scope.test.ts +++ b/test/contact-email-tenant-scope.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import artifact from '../objectstack.config'; -import { bootOptions, hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { bootOptions, buildArtifactAgain, hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; type Rec = Record; @@ -56,7 +56,16 @@ type Rec = Record; const ORG_A = 'org_alpha'; const ORG_B = 'org_beta'; -const JOHN = 'john.smith@acme.example.com'; +/** + * The address the cases share. NOT the seed's own `john.smith@acme.example.com` + * (the address of the seed-replay refusal quoted above): since 18.0.0-next.0 + * the boot creates the Default Organization and replays the seed data into it + * (ADR-0131 D3, verify 34dba5a), so that seed contact now lives in the admin's + * organization and an insert of its address there is a genuine duplicate. The + * cases are about one address in several organizations, so they take one no + * seed row carries — checked in `beforeAll`. + */ +const JOHN = 'john.smith@org-scope.example.com'; /** The org-bound boot, its admin and the organization the platform bound them to. */ let orgBound: VerifyStack; @@ -66,10 +75,13 @@ let accountA: Rec; let accountB: Rec; let k = 0; beforeAll(async () => { - orgBound = await bootStack(artifact, bootOptions({ orgContext: true })); + // A build of its own: one configuration object holds one live kernel since + // 18.0.0-next.0 (objectstack#22301), and the shared boot below is booted too. + orgBound = await bootStack(await buildArtifactAgain(), bootOptions({ orgContext: true })); admin = await orgBound.signIn(); adminOrg = String((await orgBound.contextFor(admin)).tenantId ?? ''); expect(adminOrg, 'the org-bound boot bound the admin to no organization').not.toBe(''); + expect(await orgBound.rows('crm_contact', { email: JOHN }), 'a seed row already carries the fixture address').toEqual([]); [accountA, accountB] = (await orgBound.seed('crm_account', [{ name: 'Acme A' }, { name: 'Acme B' }])) as [Rec, Rec]; // One contact of ORG_A, the row the first cases try to duplicate. await orgBound.seed('crm_contact', [{ first_name: 'John', last_name: 'Smith', email: JOHN, crm_account: accountA.id, organization_id: ORG_A }]); diff --git a/test/contract-write-depth.test.ts b/test/contract-write-depth.test.ts index 2aa6837a6..17bf6c68f 100644 --- a/test/contract-write-depth.test.ts +++ b/test/contract-write-depth.test.ts @@ -200,13 +200,15 @@ describe('the spec gate accepts the pair and refuses the half (#880)', () => { beforeAll(async () => { // The platform's boot: `@objectstack/verify`'s `bootStack` over the shipped // artifact — objectql, auth (and its `tenancy` service), security with the - // app's own default profile, sharing. On the in-memory datasource this file - // has always measured on. + // app's own default profile, sharing. On the SQL in-memory datasource — through 17.7.0 + // `driver-memory`, which on 18.0.0-next.0 refuses every tenant-scoped call + // once the boot creates the Default Organization (ADR-0131 D3; `@objectstack/verify` + // 34dba5a moves such a leg to the SQL driver). // // NB: no `hierarchy-scope-resolver` is registered — that service ships in // `@objectstack/security-enterprise`, which this repo does not depend on. // The manager's edit below must succeed WITHOUT it. - verify = await bootStack(artifact, { databaseDriver: 'memory' }); + verify = await bootStack(artifact); // Fresh sign-ups are plain members (the stack's first user is its own // platform admin), given exactly the standing the fixture names. diff --git a/test/do-not-call-enforcement.test.ts b/test/do-not-call-enforcement.test.ts index 08e201949..5b6958875 100644 --- a/test/do-not-call-enforcement.test.ts +++ b/test/do-not-call-enforcement.test.ts @@ -1,11 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { cel } from '@objectstack/spec'; import stack, { hookNamed } from './helpers/composed-stack'; import { REFUSAL_CODES } from '../src/sales/objects/_refusal'; import { ScheduleFollowUpFlow } from '../src/sales/flows/schedule-followup.flow'; import type { VerifyStack } from '@objectstack/verify'; -import { hotcrmMemoryStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; /** * `do_not_call` is enforced (#1180). @@ -48,9 +49,12 @@ import { hotcrmMemoryStack, signUpPerson, type Person } from './helpers/verify-s * `ReferenceError` once lowered) is the build's to refuse: `objectstack build` * lowers every registered hook through the platform's `extractHookBody`. * - * On the SPARSE datasource (`driver-memory`) on purpose: `contact_ok` is - * written without a `do_not_call` key, and only a sparse driver hands that back - * ABSENT rather than NULL — the shape a guard reading the flag must survive. + * On the SQL datasource. Until 18.0.0-next.0 this suite ran on `driver-memory`, + * which hands back a column a row was never written with as ABSENT; that driver + * now refuses every tenant-scoped call, and the maintainer's ruling on #2031 + * (「就应该在 sql 上测试,作废内存驱动的测试」) retires its legs. `contact_ok` + * is still written without a `do_not_call` key, which is the shape a person + * created without the flag has on the datasource the app runs on. */ type AnyRec = Record; @@ -67,7 +71,7 @@ let rep: Person; const person: Record = {}; beforeAll(async () => { - verify = await hotcrmMemoryStack(); + verify = await hotcrmStack(); rep = await signUpPerson(verify, 'rep@do-not-call.test', { name: 'DNC Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], }); @@ -79,10 +83,9 @@ beforeAll(async () => { * scheduling against them. * * `lead_ok` carries `do_not_call: false` and `contact_ok` OMITS the key - * entirely — the two shapes a real driver produces for "not flagged" - * (`driver-memory` and `driver-mongodb` return a column that was never written - * as ABSENT, not null; see AGENTS.md on predicate totality). Both must pass, or - * the guard blocks every ordinary call in the app. + * entirely — the two ways a person comes to be "not flagged": set false, or + * never written. Both must pass, or the guard blocks every ordinary call in + * the app. */ person.account = await create('crm_account', { name: 'DNC Holdings' }); person.lead_dnc = await create('crm_lead', { @@ -307,7 +310,7 @@ describe('an unflagged person is unaffected', () => { ).resolves.toBeTruthy(); }); - it('allows an open Call task on a contact whose do_not_call key is ABSENT', async () => { + it('allows an open Call task on a contact whose do_not_call was never written', async () => { await expect( runTask({ type: 'call', status: 'not_started', related_to_contact: OK_CONTACT }), ).resolves.toBeTruthy(); @@ -351,7 +354,7 @@ describe('the schedule_followup screen flow is covered by the task guard', () => const create = ScheduleFollowUpFlow.nodes.find((n) => n.id === 'create_task') as AnyRec; expect(create).toBeTruthy(); expect(create.config.objectName).toBe('crm_task'); - expect(create.config.fields.type).toBe('{activityType}'); + expect(create.config.fields.type).toEqual(cel`has(vars.activityType) ? activityType : null`); const screen = ScheduleFollowUpFlow.nodes.find((n) => n.id === 'screen_1') as AnyRec; const typeField = screen.config.fields.find((f: AnyRec) => f.name === 'activityType'); diff --git a/test/docs-readme-token-figures.test.ts b/test/docs-readme-token-figures.test.ts index f7dc85bac..0a133a868 100644 --- a/test/docs-readme-token-figures.test.ts +++ b/test/docs-readme-token-figures.test.ts @@ -61,8 +61,8 @@ import { REPO_ROOT } from './helpers/repo-root'; * a size the app does not have — the ratchet owns the growth budget, alone, and * a raise there sits on the maintainer floor where it always did. * - * business semantics banner ~57k -> band 54,150–59,850 · ceiling 59,000 - * interaction layer banner ~29k -> band 27,550–30,450 · ceiling 31,000 + * business semantics banner ~57k -> band 54,150–59,850 · ceiling 88,500 + * interaction layer banner ~29k -> band 27,550–30,450 · ceiling 46,500 * * ⚠️ The ceiling column is still worked out per row, and still pinned to * `CEILINGS` below — that is what stops the README quoting a ceiling the gate no @@ -111,7 +111,8 @@ import { REPO_ROOT } from './helpers/repo-root'; * exactly the decoupling that lets one move without the other, and #1951 is * that decoupling paying off: the ceiling rose 4,000 tokens and this rule had * nothing to restate, because the reading the banner is pinned to did not - * move at all. + * move at all. #2031's ×1.5 grant (「hotcrm token 上限提升 50%」) moved the + * column again, to 88,500 and 46,500, and again no band edge. * * ## The measurement is the gate's, not a copy of it * diff --git a/test/flow-case-actions.test.ts b/test/flow-case-actions.test.ts index dc5af8f04..f8c10a748 100644 --- a/test/flow-case-actions.test.ts +++ b/test/flow-case-actions.test.ts @@ -167,21 +167,24 @@ describe('case_escalation_stamp — the elevated stamping subflow (#1434)', () = expect(names).toContain('recordId'); }); - it('stamps the case when run directly', async () => { + it('is refused to an agent who starts it directly — the stamp comes only through its parent', async () => { + // Through 17.7.0 an agent could start this elevated subflow by name through + // the trigger door, and it stamped the case. Since 18.0.0-next.0 that door + // refuses a self-triggered `runAs: 'system'` flow to every caller but the + // system principal (objectstack ca135dc, #22310), so the stamp reaches a case + // only through `escalate_case`'s subflow node — measured by the cases above. const kase = await openCase({ escalation_reason: 'Breach' }); - // No runId assertion here: this flow declares no screen, so it runs to - // completion synchronously and the engine returns no paused run to resume - // (unlike the screen flows above). The stored row is the evidence it ran — - // the case was opened with `is_escalated: false`, so a flow that never - // executed leaves it false and the assertions below fail. expect(kase.is_escalated).toBe(false); - await verify.flows.run(CaseEscalationStampFlow.name, { recordId: kase.id }, { as: agent.token }); - const updated = await stored(kase.id); - expect(updated.is_escalated).toBe(true); - expect(updated.escalated_date).toBeTruthy(); - // ⛔ It must not touch the agent's own columns. - expect(updated.escalation_reason).toBe('Breach'); - expect(updated.priority).toBe('medium'); + const refusal = await verify.flows + .run(CaseEscalationStampFlow.name, { recordId: kase.id }, { as: agent.token }) + .then(() => null, (e: unknown) => e); + expect(refusal, 'an agent started the elevated stamping subflow by name').toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + // Nothing ran: the case is exactly as the agent left it. + const after = await stored(kase.id); + expect(after.is_escalated).toBe(false); + expect(after.escalated_date ?? null).toBeNull(); + expect(after.escalation_reason).toBe('Breach'); + expect(after.priority).toBe('medium'); }); }); diff --git a/test/flow-decision-authority.test.ts b/test/flow-decision-authority.test.ts index 5c0cdd208..bbe7b3e0e 100644 --- a/test/flow-decision-authority.test.ts +++ b/test/flow-decision-authority.test.ts @@ -6,7 +6,7 @@ import { P } from '@objectstack/spec'; import { OpportunityStagnationFlow } from '../src/sales/flows/opportunity-stagnation.flow'; import { allFlows } from '../objectstack.composition'; import { flowGraphDeep } from './helpers/flow-regions'; -import { hotcrmStack, signUpPerson, daysFromNow, notificationsTo } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, daysFromNow, notificationsTo, runScheduledFlow } from './helpers/verify-stack'; /** * ═══ HOUSE RULE: a `decision` node's OUT-EDGES are the only branch site ═════ @@ -199,8 +199,9 @@ describe('decision nodes state no predicate of their own', () => { describe('the mechanism, on the real engine', () => { // The shipped app booted through `@objectstack/verify`. A flow variant is // registered beside the shipped ones through the platform's own - // flow-authoring door (`POST /automation`, as the admin), run through the - // trigger door (`flows.run`), and removed again (`DELETE /automation/:name`). + // flow-authoring door (`POST /automation`, as the admin), run the way its + // schedule trigger runs it (`runScheduledFlow`), and removed again + // (`DELETE /automation/:name`). let verify: VerifyStack; let admin: string; let n = 0; @@ -250,8 +251,8 @@ describe('the mechanism, on the real engine', () => { }; const sweepTwice = async (flowName: string, deal: Awaited>) => { - await verify.flows.run(flowName, {}, { as: admin }); - await verify.flows.run(flowName, {}, { as: admin }); + await runScheduledFlow(verify, flowName); + await runScheduledFlow(verify, flowName); const tasks = await verify.rows('crm_task', { related_to_opportunity: deal.opp.id }); const notifications = await notificationsTo(verify, deal.rep.id, 'deal_stalled'); return { tasks: tasks.length, notifications: notifications.length }; @@ -337,7 +338,7 @@ describe('the mechanism, on the real engine', () => { const nudge = `NUDGED ${i}`; await verify.seed('crm_task', [{ subject: stall, type: 'follow_up', priority: 'high', status: 'not_started' }]); const name = `decision_probe_${i}`; - await withFlow(name, gateFlow(stall, nudge, nodeAuthoritative), () => verify.flows.run(name, {}, { as: admin })); + await withFlow(name, gateFlow(stall, nudge, nodeAuthoritative), () => runScheduledFlow(verify, name)); return (await verify.rows('crm_task', { subject: nudge })).map(() => 'NUDGED'); }; diff --git a/test/flow-escalation-ownerless-case.test.ts b/test/flow-escalation-ownerless-case.test.ts index b9be759bf..160b87e20 100644 --- a/test/flow-escalation-ownerless-case.test.ts +++ b/test/flow-escalation-ownerless-case.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { CaseEscalationFlow, CaseEscalationOnCreateFlow } from '../src/service/flows/case-escalation.flow'; import { - hotcrmStack, hotcrmMemoryStack, signUpPerson, guestInsert, systemUpdate, flowRuns, notificationsTo, + hotcrmStack, signUpPerson, guestInsert, systemUpdate, flowRuns, notificationsTo, runRecordFlow, type Person, } from './helpers/verify-stack'; @@ -50,12 +50,10 @@ type FlowName = 'case_escalation' | 'case_escalation_on_create'; const FLOW_NAMES: FlowName[] = ['case_escalation', 'case_escalation_on_create']; let sql: VerifyStack; -let sparse: VerifyStack; let agent: Person; let k = 0; beforeAll(async () => { sql = await hotcrmStack(); - sparse = await hotcrmMemoryStack(); // An agent with the service grants but NOT on the `service_agent` rota: the // cases below are raised while that rota is empty, so `case_auto_assign` // leaves them ownerless — and a case the agent opens stays theirs. @@ -103,11 +101,11 @@ async function raise(stack: VerifyStack, flowName: FlowName, owner?: unknown) { * terms and none of them is decoration. */ const OWNERLESS: Record VerifyStack; owner?: unknown; flows: FlowName[] }> = { - // The key is ABSENT. A driver row never written with the column is sparse - // (driver-memory / driver-mongodb store only the columns a row was written - // with) — an unguarded `vars.caseRecord.owner_id != null` aborts here with - // `No such key` and takes the run down exactly the way the empty slate did. - 'owner column never written (sparse datasource)': { stack: () => sparse, flows: FLOW_NAMES }, + // (An ABSENT key, the shape `driver-memory` stored for a column a row was + // never written with, was a leg here until 18.0.0-next.0. That driver now + // refuses every tenant-scoped call, and the #2031 ruling retired the leg: + // 「就应该在 sql 上测试,作废内存驱动的测试」.) + // // An explicit NULL — the nullable column, as a SQL datasource stores an // ownerless row. `string(null)` has no overload, so the `!= null` term must // short-circuit before the wrap. diff --git a/test/flow-filter-today-token.test.ts b/test/flow-filter-today-token.test.ts index 5dc51b822..345c9f83d 100644 --- a/test/flow-filter-today-token.test.ts +++ b/test/flow-filter-today-token.test.ts @@ -65,6 +65,19 @@ type AnyRec = Record; * disagreement is load-bearing: the automation template evaluator knows * `TODAY()`, ObjectQL's filter vocabulary knows `{today}`. Either half moving * breaks the flows, so both halves are pinned below. + * + * ### Since 18.0.0-next.0: the token is gone, the question is not + * + * 18.0.0-next.0 retires the template dialect from flow value slots, a filter + * operand included (the spec's `flow-value-slot-template-dialect-refused` + * entry): `{TODAY()}` there is refused at registration, and every shipped date + * window is the CEL value envelope the entry prescribes — `isoDate(today())`, + * `isoDate(daysAgo(n))`, `isoDate(daysFromNow(n))` — evaluated before the + * query, on the UTC calendar the macro used. What #1107 asked is unchanged: + * does the query layer receive a date it understands, or a token it refuses? + * So everything below still EXECUTES; what the census reads is the envelope, + * and the refusal of the retired spelling is pinned where its pass-through + * used to be. */ // ══════════════════════════════════════════════════ shipped-metadata census ══ @@ -74,19 +87,27 @@ interface TokenSite { node: string; /** Dotted path to the condition inside `config.filter`. */ path: string; - /** The template string as authored, e.g. `{TODAY() - 45}`. */ + /** The date as authored: a CEL envelope's source, e.g. `isoDate(daysAgo(14))`. */ template: string; } +/** The retired template token — refused at registration since 18.0.0-next.0. */ const TODAY_TOKEN = /\{\s*TODAY\s*\(\s*\)/; -/** Every `{TODAY()…}` string inside a node's `config.filter`, with its path. */ +/** A CEL value envelope computing "today" or a day offset from it. */ +const DATE_ENVELOPE = /\b(?:today|daysAgo|daysFromNow)\s*\(/; + +/** Every date envelope (or surviving `{TODAY()…}` string) inside a node's `config.filter`, with its path. */ const filterTokens = (value: unknown, path: string[] = []): { path: string; template: string }[] => { if (typeof value === 'string') { return TODAY_TOKEN.test(value) ? [{ path: path.join('.') || '(root)', template: value }] : []; } if (Array.isArray(value)) return value.flatMap((v, i) => filterTokens(v, [...path, String(i)])); if (value && typeof value === 'object') { + const source = (value as AnyRec).dialect === 'cel' ? (value as AnyRec).source : undefined; + if (typeof source === 'string') { + return DATE_ENVELOPE.test(source) ? [{ path: path.join('.') || '(root)', template: source }] : []; + } return Object.entries(value as AnyRec).flatMap(([k, v]) => filterTokens(v, [...path, k])); } return []; @@ -116,8 +137,13 @@ const flows: Flow[] = allFlows as unknown as Flow[]; const CENSUS: TokenSite[] = flows.flatMap((f) => censusOf(f.name, (f as AnyRec).nodes ?? [])); -describe('census: which shipped flow filters carry {TODAY()}', () => { - it('at least one live sweep filters on the token', () => { +describe('census: which shipped flow filters compute a date', () => { + it('no shipped flow filter still carries the retired {TODAY()} token', () => { + // 18.0.0-next.0 refuses it at registration, so a survivor would not load. + expect(CENSUS.filter((s) => TODAY_TOKEN.test(s.template))).toEqual([]); + }); + + it('at least one live sweep filters on a computed date', () => { // The premise of every execution below. If this ever empties out, the // executions stop proving anything and this file must be re-scoped rather // than left passing over an empty set. @@ -447,10 +473,11 @@ describe('{TODAY()} through a real ObjectQL + real AutomationEngine', () => { expect(notifications.length).toBe(2); }); - it('ANSWER A: every shipped {TODAY()} filter site resolves before the query', async () => { - // The census, executed. A new flow that filters on the token is covered the - // day it is written — including offset spellings (`{TODAY() - 45}`), which - // are a different branch of the template evaluator than the bare call. + it('ANSWER A: every shipped computed-date filter site resolves before the query', async () => { + // The census, executed. A new flow that filters on a computed date is + // covered the day it is written — including offset spellings + // (`isoDate(daysAgo(14))`), which call a different function than the bare + // `isoDate(today())`. expect(CENSUS.length).toBeGreaterThan(0); for (const site of CENSUS) { @@ -513,24 +540,33 @@ describe('{TODAY()} through a real ObjectQL + real AutomationEngine', () => { expect(JSON.stringify(issued!.where)).toMatch(/\{[^"{}]*\}/); }); - it('TEETH: a token NEITHER layer knows fails the run instead of querying', async () => { - // The other outcome, and the one `{TODAY()}` would fall into if a future - // release dropped it from the template evaluator: unresolved AND not a - // known filter token, so `resolveNodeFilter` refuses the node rather than - // silently dropping the condition and widening the sweep. The `success: - // false` assertion in the census case above is what would catch that — here - // it is, catching it. - // ⚠️ The refusal MESSAGE changed on the 17.2.0 -> 17.3.0 upgrade; the - // refusal itself did not. Through 17.2.0 the node was refused for being - // unresolved ("resolved to nothing and were dropped"). Platform 17.3.0 - // (objectstack#11060) refuses one step earlier and more precisely: the - // value-expression evaluator now knows its own closed vocabulary, so an - // unknown NAME is named as such instead of being reported as an empty - // resolution. What this case guards — `success: false`, and nothing - // reaching the query layer — is asserted on both sides of that wording. - const { run, issued } = await runProbe({ $lt: '{TOMORROW()}' }); + it.each([['{TOMORROW()}'], ['{TODAY()}']])( + 'TEETH: a single-brace flow token in a filter, %s, is refused before any query', + async (token) => { + // The other outcome. Through 17.x a token NEITHER layer knew + // (`{TOMORROW()}`) refused the node at run time (`unknown function`, + // 17.3.0's objectstack#11060) and `{TODAY()}` was resolved by the + // template pre-pass. 18.0.0-next.0 refuses BOTH one step earlier still: + // a single-brace token in a filter operand is the retired template + // dialect, so the flow is refused at registration and never runs — which + // is what the census case above would meet if a shipped site regressed to + // the token. Nothing reaches the query layer either way. + const before = seen.length; + await expect(runProbe({ $lt: token })).rejects.toThrow(/invalid expression/); + expect(seen.slice(before).find((q) => q.object === 't_probe'), 'a refused flow must not reach the query layer').toBeUndefined(); + }, + ); + + it('TEETH: a date envelope that fails to evaluate fails the run instead of querying', async () => { + // The run-time half the registration refusal cannot cover: a well-formed + // envelope whose evaluation faults (here, an unbound variable) fails the + // run before the query, rather than dropping the condition and widening + // the sweep. The `success` assertion in the census case catches exactly + // this. + const before = seen.length; + const run: AnyRec = await runProbe({ $lt: { dialect: 'cel', source: 'isoDate(addDays(today(), offsetDays))' } }) + .then((probe) => probe.run, (e: unknown) => ({ success: false, error: String(e) })); expect(run?.success).toBe(false); - expect(String(run?.error)).toMatch(/unknown function 'TOMORROW'|resolved to nothing and were dropped/); - expect(issued, 'a refused node must not reach the query layer at all').toBeUndefined(); + expect(seen.slice(before).find((q) => q.object === 't_probe'), 'a failed operand must not reach the query layer').toBeUndefined(); }); }); diff --git a/test/flow-run-summary.test.ts b/test/flow-run-summary.test.ts index c9147dfa1..cd5625ff5 100644 --- a/test/flow-run-summary.test.ts +++ b/test/flow-run-summary.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { OpportunityStagnationFlow } from '../src/sales/flows/opportunity-stagnation.flow'; import { edgesUnder } from './helpers/flow-regions'; -import { hotcrmStack, signUpPerson, daysFromNow, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, daysFromNow, runScheduledFlow, type Person } from './helpers/verify-stack'; type Rec = Record; @@ -37,8 +37,8 @@ type Rec = Record; /** * On the shipped app booted by `@objectstack/verify`, each sweep is started - * through the trigger door (`flows.run`, as the admin) and its terminal result - * — the summary — read off what the door returns. + * the way its schedule trigger starts it (`runScheduledFlow`) and its terminal + * result — the summary — read off what the run returns. * * A sweep selects EVERY matching record in the database, and the boot replays * the app's seed deals and contracts. So the counts here are read as what THIS @@ -80,8 +80,8 @@ beforeAll(async () => { accountId = String(account!.id); }, 120_000); -/** Run a sweep as the admin and return its summary. */ -const sweep = async (flowName: string): Promise => summaryOf(await verify.flows.run(flowName, {}, { as: admin })); +/** Run a sweep as its schedule trigger does and return its summary. */ +const sweep = async (flowName: string): Promise => summaryOf(await runScheduledFlow(verify, flowName)); /** Two stalled deals of the rep's — 30 and 40 days in stage. */ const stalledDeals = async (): Promise => verify.seed('crm_opportunity', [ diff --git a/test/flow-scheduled-org-partition.test.ts b/test/flow-scheduled-org-partition.test.ts index 0908d944b..c21b1e550 100644 --- a/test/flow-scheduled-org-partition.test.ts +++ b/test/flow-scheduled-org-partition.test.ts @@ -1,10 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll } from 'vitest'; +import { cel } from '@objectstack/spec'; import type { VerifyStack } from '@objectstack/verify'; import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; import { CrmFlows as allFlows } from './helpers/src-roster'; -import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, runScheduledFlow, type Person } from './helpers/verify-stack'; type Rec = Record; @@ -195,17 +196,16 @@ describe('scheduled create_record declares organization_id (#700)', () => { */ /** * The runtime half, on the shipped app booted by `@objectstack/verify`: each - * sweep is started through the trigger door as the admin (`flows.run`) — a - * schedule run carries no organization of its own — over rows written by the + * sweep is started the way its schedule trigger starts it (`runScheduledFlow`) + * — a schedule run carries no organization of its own — over rows written by the * system into organizations created for the purpose, and what the sweep wrote * is read back off the engine. */ let verify: VerifyStack; -let admin: string; let k = 0; beforeAll(async () => { verify = await hotcrmStack(); - admin = await verify.signIn(); + await verify.signIn(); // the harness admin, as before: the sweeps no longer run as anyone }, 120_000); /** A fresh organization row, written as the system. */ @@ -254,7 +254,7 @@ describe('the declared organization_id actually resolves (#700)', () => { contract_type: 'subscription', contract_value: 90_000, auto_renewal: true, renewal_notice_days: 30, contract_term_months: 12, start_date: day(-345), end_date: day(+20), billing_frequency: 'monthly', payment_terms: 'net_30', }]); - await verify.flows.run('contract_renewal', {}, { as: admin }); + await runScheduledFlow(verify, 'contract_renewal'); const tasks = await verify.rows('crm_task', { related_to_account: acct.id, subject: `Renewal due: contract ${contract!.contract_number}`, @@ -274,7 +274,7 @@ describe('the declared organization_id actually resolves (#700)', () => { name: `Stalled Deal ${++k}`, stage: 'proposal', stage_entry_date: day(-30), amount: 50_000, close_date: '2030-06-30', crm_account: acct.id, owner_id: who.id, organization_id: org, }]); - await verify.flows.run('opportunity_stagnation', {}, { as: admin }); + await runScheduledFlow(verify, 'opportunity_stagnation'); const tasks = await verify.rows('crm_task', { related_to_opportunity: deal!.id }); expect(tasks, 'the sweep created no nudge task').toHaveLength(1); @@ -292,7 +292,7 @@ describe('the declared organization_id actually resolves (#700)', () => { name: `Forecast Deal ${++k}`, owner_id: who.id, stage: 'negotiation', amount: 200_000, approval_status: 'approved', close_date: quarterStart(), crm_account: acct.id, organization_id: org, }]); - await verify.flows.run('forecast_snapshot', {}, { as: admin }); + await runScheduledFlow(verify, 'forecast_snapshot'); const rows = await verify.rows('crm_forecast', { owner_id: who.id }); expect(rows, 'the sweep opened no snapshot row').toHaveLength(1); @@ -360,7 +360,7 @@ describe('forecast_snapshot sums only the target row\u2019s organization (#1372) it('writes its own organization\u2019s totals, not the sum across both', async () => { const { who, orgA } = await crossOrganizationOwner(); - await verify.flows.run('forecast_snapshot', {}, { as: admin }); + await runScheduledFlow(verify, 'forecast_snapshot'); const rows = await verify.rows('crm_forecast', { owner_id: who.id }); expect(rows, 'the sweep opened no snapshot row').toHaveLength(1); @@ -389,7 +389,7 @@ describe('forecast_snapshot sums only the target row\u2019s organization (#1372) // than the cross-tenant total above. Pinned here so a later change to that // key has to edit this expectation deliberately rather than drift past it. const { who, orgA } = await crossOrganizationOwner(); - await verify.flows.run('forecast_snapshot', {}, { as: admin }); + await runScheduledFlow(verify, 'forecast_snapshot'); expect((await verify.rows('crm_forecast', { owner_id: who.id })).map((r) => r.organization_id)).toEqual([orgA]); }); @@ -526,11 +526,19 @@ function collectBindings(flow: Rec): Map { return out; } -/** The inner expression of every `{…}` interpolation in a value, recursively. */ +/** + * The expression inside every computed value, recursively: a CEL value + * envelope's `source`, the spelling every flow value slot takes since + * 18.0.0-next.0 (which refuses the single-brace template dialect), or the + * inner expression of a `{…}` hole wherever a string still carries one. + */ function interpolationsIn(value: unknown): string[] { if (typeof value === 'string') return [...value.matchAll(/\{([^{}]*)\}/g)].map((m) => m[1]); if (Array.isArray(value)) return value.flatMap((v) => interpolationsIn(v)); - if (isRec(value)) return Object.values(value).flatMap((v) => interpolationsIn(v)); + if (isRec(value)) { + if (value.dialect === 'cel' && typeof value.source === 'string') return [value.source]; + return Object.values(value).flatMap((v) => interpolationsIn(v)); + } return []; } @@ -543,11 +551,17 @@ function interpolationsIn(value: unknown): string[] { * `current_pipeline.amount` reads `current_pipeline`. */ function rootVariablesIn(expression: string): string[] { + // A CEL string literal reads no variable, and neither does a CEL literal + // keyword; `vars.x` is the flow variable `x` read through the variables map. + const bare = expression.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, "''"); const roots: string[] = []; - for (const match of expression.matchAll(/[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z0-9_$]+)*/g)) { + for (const match of bare.matchAll(/[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z0-9_$]+)*/g)) { const path = match[0]; - if (expression.slice((match.index ?? 0) + path.length).trimStart().startsWith('(')) continue; - roots.push(path.split('.')[0]); + if (bare.slice((match.index ?? 0) + path.length).trimStart().startsWith('(')) continue; + const segments = path.split('.'); + const root = segments[0] === 'vars' && segments.length > 1 ? segments[1] : segments[0]; + if (root === 'true' || root === 'false' || root === 'null') continue; + roots.push(root); } return roots; } @@ -671,7 +685,7 @@ function analyseUpdate(node: WriteNode, flow: Rec): Violation[] { flow: node.flow, nodeId: node.nodeId, field, - token: `{${expression}}`, + token: expression, reason: verdict.because, }); } @@ -727,7 +741,7 @@ describe('scheduled update_record writes only organization-neutral values (#1363 const tokens = scheduledUpdateNodes .flatMap((n) => Object.values(n.fields)) .flatMap((v) => interpolationsIn(v)); - expect(tokens.length, 'no update_record field interpolates anything').toBeGreaterThan(0); + expect(tokens.length, 'no update_record field computes anything').toBeGreaterThan(0); expect( tokens.some((t) => rootVariablesIn(t).length > 0), 'every token parsed as a bare function call — the variable reader is broken', @@ -817,7 +831,7 @@ const CROSS_ROW_SWEEP_FIXTURE: Rec = { id: 'pick_user', type: 'assignment', label: 'Pick the first user', - config: { assignments: { chosenUser: '{userList.0}' } }, + config: { assignments: { chosenUser: cel`userList[0]` } }, }, { id: 'find_leads', @@ -830,7 +844,7 @@ const CROSS_ROW_SWEEP_FIXTURE: Rec = { type: 'loop', label: 'For Each Lead', config: { - collection: '{leadList}', + collection: cel`leadList`, iteratorVariable: 'currentLead', body: { nodes: [ @@ -840,18 +854,18 @@ const CROSS_ROW_SWEEP_FIXTURE: Rec = { label: 'Stamp Owner', config: { objectName: 'crm_lead', - filter: { id: '{currentLead.id}' }, + filter: { id: cel`currentLead.id` }, fields: { // The crossing: one identity, fetched independently of the // row being written, stamped into whatever organization the // system context happened to select. - owner_id: '{chosenUser.id}', + owner_id: cel`chosenUser.id`, // Must NOT be flagged — a literal carries no tenant. status: 'working', - // Must NOT be flagged — a template function reads no row. - last_touched: '{NOW()}', + // Must NOT be flagged — a function of the clock reads no row. + last_touched: cel`isoDatetime(now())`, // Must NOT be flagged — taken off the swept row itself. - company: '{currentLead.company}', + company: cel`currentLead.company`, }, }, }, @@ -876,7 +890,7 @@ describe('the rule goes red on a cross-row write (#1363)', () => { it('flags the value sourced outside the swept row', () => { expect(found.map((v) => v.field)).toEqual(['owner_id']); - expect(found[0].token).toBe('{chosenUser.id}'); + expect(found[0].token).toBe('chosenUser.id'); }); it('names the whole provenance chain, not just the token', () => { @@ -913,7 +927,7 @@ describe('the rule goes red on a cross-row write (#1363)', () => { // out of a red would be the exemption register. const pinned = JSON.parse(JSON.stringify(CROSS_ROW_SWEEP_FIXTURE)) as Rec; const getUser = (pinned.nodes as Rec[])[1]; - (getUser.config as Rec).filter = { organization_id: '{currentLead.organization_id}' }; + (getUser.config as Rec).filter = { organization_id: cel`currentLead.organization_id` }; const cleared = walkNodes(pinned, 'cross_row_sweep_fixture') .filter((n) => n.type === 'update_record') diff --git a/test/flow-scheduled.test.ts b/test/flow-scheduled.test.ts index 439d3eacd..5a1196b1a 100644 --- a/test/flow-scheduled.test.ts +++ b/test/flow-scheduled.test.ts @@ -10,7 +10,7 @@ import artifact from '../objectstack.config'; import { CrmFlows as allFlows } from './helpers/src-roster'; import { regionsOf } from './helpers/flow-regions'; import { - hotcrmStack, bootOptions, signUpPerson, systemUpdate, notificationsTo, type Person, + hotcrmStack, bootOptions, buildArtifactAgain, signUpPerson, systemUpdate, notificationsTo, runScheduledFlow, type Person, } from './helpers/verify-stack'; type Rec = Record; @@ -29,7 +29,8 @@ type Rec = Record; * These run on the shipped app booted by `@objectstack/verify`: the fixture * rows are written through the real engine (as a person, or as the system * where the row's state is not one a person can produce), each sweep is - * started through the trigger door as the admin (`flows.run`), and its effects + * started the way its schedule trigger starts it (`runScheduledFlow`: the + * trigger door refuses a system sweep to a person since 18.0.0-next.0), and its effects * are read back off the engine — rows, and the notifications it handed the * messaging outbox (`notificationsTo`). The boot replays the app's seed data * and a sweep processes every match, seed rows included; so every reading is @@ -52,13 +53,12 @@ const iso = (daysFromNow: number): string => { const day = (daysFromNow: number): string => iso(daysFromNow).slice(0, 10); let verify: VerifyStack; -let admin: string; let rep: Person; let agent: Person; let k = 0; beforeAll(async () => { verify = await hotcrmStack(); - admin = await verify.signIn(); + await verify.signIn(); // the harness admin, as before: the sweeps no longer run as anyone rep = await signUpPerson(verify, 'rep@flow-scheduled.test', { name: 'Sweep Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], }); @@ -69,8 +69,8 @@ beforeAll(async () => { }); }, 120_000); -/** Run a scheduled sweep once, as the admin, through the trigger door. */ -const sweep = (flowName: string) => verify.flows.run(flowName, {}, { as: admin }); +/** Run a scheduled sweep once, as its schedule trigger does. */ +const sweep = (flowName: string) => runScheduledFlow(verify, flowName); /** The notifications of `topic` addressed to `userId` about the record at `url`. */ const notified = async (userId: string, topic: string, url: string) => @@ -902,7 +902,9 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' * live current-quarter deal each, and one person the sweep must skip. */ const bootWorld = async () => { - const stack = await bootStack(artifact, bootOptions()); + // A build of its own (one live kernel per configuration since + // 18.0.0-next.0, objectstack#22301). + const stack = await bootStack(await buildArtifactAgain(), bootOptions()); const adminToken = await stack.signIn(); const adminId = String((await stack.contextFor(adminToken)).userId); const repTwo = await signUpPerson(stack, 'rep.two@reseed.flow-scheduled.test', { name: 'Rep Two' }); @@ -915,7 +917,7 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' const ql = stack.kernel.getService('objectql'); const steps: Record Promise> = { claim: () => claimSeedOwnership(ql, adminId, { logger: silent }), - forecast_snapshot: () => stack.flows.run('forecast_snapshot', {}, { as: adminToken }), + forecast_snapshot: () => runScheduledFlow(stack, 'forecast_snapshot'), }; const replaySeeds = () => new SeedLoaderService(ql as never, stack.kernel.getService('metadata') as never, silent as never) .load(SeedLoaderRequestSchema.parse({ seeds: [forecastSeed], config: { defaultMode: 'upsert', multiPass: true } })); diff --git a/test/flow-sla-ownerless-assignment.test.ts b/test/flow-sla-ownerless-assignment.test.ts index c81968d5f..b86a72418 100644 --- a/test/flow-sla-ownerless-assignment.test.ts +++ b/test/flow-sla-ownerless-assignment.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; -import { hotcrmStack, signUpPerson, guestInsert, systemUpdate, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, guestInsert, systemUpdate, runScheduledFlow, type Person } from './helpers/verify-stack'; type Rec = Record; @@ -38,7 +38,7 @@ type Rec = Record; * `service_manager` in it, which is the one input the ruling turns on. The * EMPTY branch runs first; the pool is staffed for the NON-EMPTY one after. * - * The sweep is started through the trigger door as the admin (`flows.run`), + * The sweep is started the way its schedule trigger starts it (`runScheduledFlow`), * and it processes EVERY breached case in the database — the boot's seed * cases included — so each branch first runs it once to SETTLE them (flagged, * they no longer match), then writes its own cases and runs it again. The @@ -70,14 +70,13 @@ const iso = (daysFromNow: number): string => { const OWNERLESS = ['c_unwritten', 'c_blank'] as const; let verify: VerifyStack; -let admin: string; let rep1: Person; let rep2: Person; let rep3: Person; let k = 0; beforeAll(async () => { verify = await hotcrmStack(); - admin = await verify.signIn(); + await verify.signIn(); // the harness admin, as before: the sweeps no longer run as anyone rep1 = await signUpPerson(verify, 'rep1@flow-sla-ownerless-assignment.test', { name: 'Rep One' }); rep2 = await signUpPerson(verify, 'rep2@flow-sla-ownerless-assignment.test', { name: 'Rep Two' }); rep3 = await signUpPerson(verify, 'rep3@flow-sla-ownerless-assignment.test', { name: 'Rep Three' }); @@ -110,12 +109,13 @@ const seedCases = async () => ({ /** Settle the database, write the cases, run the sweep; what it did. */ const runSweep = async (between: (ids: Record) => Promise = async () => undefined) => { - await verify.flows.run('case_sla_monitor', {}, { as: admin }); + await runScheduledFlow(verify, 'case_sla_monitor'); const ids: Record = await seedCases(); await between(ids); // A terminal failure is recorded, not thrown by the run — and the trigger - // door turns a failed run into a refusal, so a dying sweep fails HERE. - const result: Rec = await verify.flows.run('case_sla_monitor', {}, { as: admin }); + // door turned a failed run into a refusal, as `runScheduledFlow` does, so a + // dying sweep fails HERE. + const result: Rec = await runScheduledFlow(verify, 'case_sla_monitor'); const byId: Record = {}; for (const [key, id] of Object.entries(ids)) byId[key] = (await verify.rows('crm_case', { id }))[0]!; const nodeOf = (id: string) => (result.summary?.nodes ?? []).find((n: Rec) => n.nodeId === id); @@ -229,12 +229,18 @@ describe('pool NON-EMPTY: the ownerless breach is assigned, then that owner is a // `mgr_busy`'s existing workload — what makes the two holders // distinguishable. busy = await caseIn('Already on the busy manager', { status: 'in_progress', priority: 'low', sla_due_date: iso(+30), owner_id: mgrBusy.id }); + // Each holding carries the install's organization. Since 18.0.0-next.0 + // every case does (the Default Organization, ADR-0131 D3), the escalation + // hook keeps a case inside its own organization's pool (AGENTS.md rule + // 10), and an assignment made through the platform's data door stamps the + // assigning session's organization, where a system seed stamps none. + const organization_id = String((await verify.contextFor(mgrBusy.token)).tenantId); await verify.seed('sys_user_position', [ - { user_id: mgrBusy.id, position: 'service_manager' }, - { user_id: mgrFree.id, position: 'service_manager' }, + { user_id: mgrBusy.id, position: 'service_manager', organization_id }, + { user_id: mgrFree.id, position: 'service_manager', organization_id }, // A service AGENT in the same table: the pool read must filter by // position, not take every row it finds. - { user_id: agent.id, position: 'service_agent' }, + { user_id: agent.id, position: 'service_agent', organization_id }, ]); }); }, 120_000); diff --git a/test/flow-sla-ownerless-case.test.ts b/test/flow-sla-ownerless-case.test.ts index 167eec37c..5de4dcd4e 100644 --- a/test/flow-sla-ownerless-case.test.ts +++ b/test/flow-sla-ownerless-case.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { - hotcrmStack, hotcrmMemoryStack, signUpPerson, guestInsert, systemUpdate, type Person, + hotcrmStack, signUpPerson, guestInsert, systemUpdate, runScheduledFlow, type Person, } from './helpers/verify-stack'; type Rec = Record; @@ -39,11 +39,12 @@ type Rec = Record; * can be done about ownership — and it is the reason the two files do not * overlap. * - * It runs on the shipped app booted by `@objectstack/verify`, once per - * datasource: on SQL an ownerless row holds a NULL `owner_id`, and on the - * sparse datasource (`driver-memory`, like `driver-mongodb`) a column a row was - * never written with is ABSENT. The sweep is started through the trigger door - * as the admin (`flows.run`), and it processes EVERY breached case in the + * It runs on the shipped app booted by `@objectstack/verify`, on the SQL + * datasource, where an ownerless row holds a NULL `owner_id`. (Until + * 18.0.0-next.0 it also ran on `driver-memory`, where such a column is ABSENT; + * that driver now refuses every tenant-scoped call, and the #2031 ruling + * retired the leg: 「就应该在 sql 上测试,作废内存驱动的测试」.) The sweep is started the way its schedule + * trigger starts it (`runScheduledFlow`), and it processes EVERY breached case in the * database — the boot's seed cases included — so it is run once first to * SETTLE them (flagged, they no longer match), and the fixture's own sweep is * the second. @@ -66,11 +67,7 @@ const iso = (daysFromNow: number): string => { * reachable in production: * * - NEVER WRITTEN — a web-to-case submission (a guest insert) carries no - * owner, and the rota is empty. On SQL the column reads NULL; on the - * sparse datasource the key is absent, and an unguarded - * `vars.currentCase.owner_id != null` aborts there with `No such key` and - * takes the run down exactly the way the empty recipient slate did. Only - * `has()` is total. + * owner, and the rota is empty. On SQL the column reads NULL. * - BLANK — ' ', left by a system write (a backfill, an integration; a * person's write is held to a real user). The notify node's own * `toStringList` trims and drops falsy entries, so it reaches the node as @@ -81,7 +78,6 @@ const OWNERLESS = ['c_unwritten', 'c_blank'] as const; const DATASOURCES = [ ['SQL datasource (NULL owner)', hotcrmStack], - ['sparse datasource (absent owner)', hotcrmMemoryStack], ] as const; describe.each(DATASOURCES)('case_sla_monitor — an ownerless breached case (#1405), %s', (_label, boot) => { @@ -108,11 +104,11 @@ describe.each(DATASOURCES)('case_sla_monitor — an ownerless breached case (#14 beforeAll(async () => { verify = await boot(); - const admin = await verify.signIn(); + await verify.signIn(); // the harness admin, as before: the sweep no longer runs as anyone rep1 = await signUpPerson(verify, `rep1.${_label.length}@flow-sla-ownerless-case.test`, { name: 'Rep One' }); rep2 = await signUpPerson(verify, `rep2.${_label.length}@flow-sla-ownerless-case.test`, { name: 'Rep Two' }); const rep3 = await signUpPerson(verify, `rep3.${_label.length}@flow-sla-ownerless-case.test`, { name: 'Rep Three' }); - await verify.flows.run('case_sla_monitor', {}, { as: admin }); + await runScheduledFlow(verify, 'case_sla_monitor'); ids = { c_first: await breach('First breach', { status: 'in_progress', sla_due_date: iso(-3), owner_id: rep1.id }), c_unwritten: await breach('Owner never written', { sla_due_date: iso(-4) }), @@ -126,8 +122,9 @@ describe.each(DATASOURCES)('case_sla_monitor — an ownerless breached case (#14 c_future: await breach('Not due yet', { status: 'in_progress', sla_due_date: iso(+5), owner_id: rep3.id }), }; // A terminal failure is recorded, not thrown by the run — and the trigger - // door turns a failed run into a refusal, so a dying sweep fails HERE. - result = await verify.flows.run('case_sla_monitor', {}, { as: admin }); + // door turned a failed run into a refusal, as `runScheduledFlow` does, so + // a dying sweep fails HERE. + result = await runScheduledFlow(verify, 'case_sla_monitor'); byId = {}; for (const [key, id] of Object.entries(ids)) byId[key] = (await verify.rows('crm_case', { id }))[0]!; }, 120_000); diff --git a/test/flow-variable-conditions.test.ts b/test/flow-variable-conditions.test.ts index 5ebc5d4f7..8e819099d 100644 --- a/test/flow-variable-conditions.test.ts +++ b/test/flow-variable-conditions.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { cel } from '@objectstack/spec'; import { ObjectQL } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; @@ -650,7 +651,7 @@ describe('every variable a flow condition reads is BOUND on every path to it', ( field.defaultValue, 'the screen field restates the default instead of deriving it — that is the ' + 'duplication #1173 removed, moved one level over', - ).toBe('{memberSource}'); + ).toEqual(cel`memberSource`); const literals = (flow.variables as AnyRec[]) .filter((v) => v.name === 'memberSource' && v.defaultValue !== undefined).length + (field.defaultValue === 'leads' ? 1 : 0); @@ -658,10 +659,11 @@ describe('every variable a flow condition reads is BOUND on every path to it', ( // `leadStatus` / `contactDepartment` are the DELIBERATE asymmetry, pinned // so a later reader does not "finish the job" by seeding them too. No - // condition reads either one — they are interpolated into a `get_record` - // filter, where an unresolved interpolation makes the node refuse to run - // and name the culprit. A declared default would replace that loud refusal - // with a silent enrolment of a segment nobody chose. + // condition reads either one — each is a `get_record` filter operand (a + // CEL envelope since 18.0.0-next.0), where an unbound variable fails the + // run before the query and names the culprit. A declared default would + // replace that loud failure with a silent enrolment of a segment nobody + // chose. for (const name of ['leadStatus', 'contactDepartment']) { const v = (flow.variables as AnyRec[]).find((x) => x.name === name)!; expect(v.defaultValue, `${name} gained a defaultValue — no condition reads it, so this buys nothing`).toBeUndefined(); @@ -681,7 +683,7 @@ describe('every variable a flow condition reads is BOUND on every path to it', ( field.defaultValue, 'the screen field restates the default instead of deriving it — that is the ' + 'duplication #1155 removed, moved one level over', - ).toBe('{createOpportunity}'); + ).toEqual(cel`createOpportunity`); // …and the literal really is written once. Restating `false` on the field // would put a second copy in this file with nothing reconciling them. @@ -1115,8 +1117,8 @@ describe('the two defects, reproduced end-to-end', () => { const plain = await prefillOf({}); expect(plain, 'the enrolment screen no longer offers memberSource').toBeDefined(); - // Interpolated to the raw value — NOT the literal '{memberSource}', which - // a `select` would match against no option and render as nothing chosen. + // Evaluated to the raw value — NOT the envelope, which a `select` would + // match against no option and render as nothing chosen. expect(plain!.defaultValue).toBe('leads'); expect(typeof plain!.defaultValue).toBe('string'); expect(plain!.required, 'memberSource stopped being required — the refusal test above no longer measures this flow').toBe(true); diff --git a/test/forecast-manual-override.test.ts b/test/forecast-manual-override.test.ts index dfdc7d19c..e60f596bb 100644 --- a/test/forecast-manual-override.test.ts +++ b/test/forecast-manual-override.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { ForecastSnapshotFlow } from '../src/sales/flows/forecast-snapshot.flow'; import { nodesUnder, edgesUnder } from './helpers/flow-regions'; -import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, runScheduledFlow, type Person } from './helpers/verify-stack'; type Rec = Record; @@ -59,7 +59,7 @@ type Rec = Record; * Inspecting the filter object would pass on a flow whose edges never reach the * write, and fail on a correct one authored differently. These drive * `forecast_snapshot` on the shipped app booted by `@objectstack/verify` — - * started through the trigger door as the admin, with the real + * started the way its schedule trigger starts it, with the real * `forecast_derive_period` hook in the write path — and read the rows back off * the engine. Every case writes its own people, deals and forecast rows (as * the system), so the sweep's other work — the boot's seed owners — is not @@ -155,7 +155,7 @@ const write = async (rows: Rec[]): Promise => { return stored; }; -const sweep = () => verify.flows.run('forecast_snapshot', {}, { as: admin }); +const sweep = () => runScheduledFlow(verify, 'forecast_snapshot'); /** * The window as the CONSUMERS see it: `period_start` matched by equality, the diff --git a/test/forecast-period-boundary.test.ts b/test/forecast-period-boundary.test.ts index 4db65884e..7c19c9ec4 100644 --- a/test/forecast-period-boundary.test.ts +++ b/test/forecast-period-boundary.test.ts @@ -5,7 +5,7 @@ import type { VerifyStack } from '@objectstack/verify'; import stack from './helpers/composed-stack'; import forecastHook from '../src/sales/objects/forecast.hook'; import { forecasts } from '../src/sales/data/forecast.seed'; -import { hotcrmStack, hotcrmMemoryStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; /** * A forecast may only start on a calendar-period boundary (#1008). @@ -59,10 +59,14 @@ import { hotcrmStack, hotcrmMemoryStack, signUpPerson, type Person } from './hel * * Every write here is a sales manager's — the persona who keys a forecast in — * through the engine's write door (`hooks.run`) on the app booted by - * `@objectstack/verify`, on the sparse datasource and on SQL. So + * `@objectstack/verify`, on the SQL datasource. So * `forecast_derive_period` runs where production runs it (before the engine's * required-field check and `validations[]`), and what is asserted is the row * the engine stored or the refusal it raised. + * + * (Until 18.0.0-next.0 the refusal matrix ran on `driver-memory`, which now + * refuses every tenant-scoped call; the #2031 ruling moved it to SQL: + * 「就应该在 sql 上测试,作废内存驱动的测试」.) */ type AnyRec = Record; @@ -188,8 +192,8 @@ describe('the boundary rule is declared where the platform can act on it', () => // ──────────────────────────────────── the refusal, on the real engine ── -describe('the write is REFUSED, not warned about (in-memory driver)', () => { - const desk = deskOn(hotcrmMemoryStack, 'memory'); +describe('the write is REFUSED, not warned about', () => { + const desk = deskOn(hotcrmStack, 'rule'); // The three rows from the issue body, and their month twin. const REJECTED = [ @@ -305,11 +309,8 @@ describe('the write is REFUSED, not warned about (in-memory driver)', () => { // ─────────────────────── the same contract on a real SQL database ────────── describe('the write is REFUSED on a real SQLite database too', () => { - // The in-memory driver hands back sparse records; a SQL driver hands back a - // full row with NULLs. Those are different inputs to the same predicate, and - // a marketplace app does not choose the datasource its host runs on. Both - // drivers were measured to hand `period_start` back as a `YYYY-MM-DD` string, - // which is the shape `string()` renders for the regex. + // A SQL driver hands back a full row with NULLs, and `period_start` as a + // `YYYY-MM-DD` string, which is the shape `string()` renders for the regex. const desk = deskOn(hotcrmStack, 'sqlite'); it('refuses the mid-quarter start and admits the boundary one', async () => { @@ -327,7 +328,7 @@ describe('the write is REFUSED on a real SQLite database too', () => { it('reads a column-complete row back — the precondition for the update path', async () => { const row = await viaHook(desk, 'insert', { ...BASE, period: 'month', period_start: '2026-05-01' }); const stored = await storedForecast(desk, row.id); - // Opposite precondition to the in-memory suite: the key IS present here. + // The precondition the update path reads: the key IS present. expect('period_label' in (stored ?? {})).toBe(true); const env = await refusal(() => viaHook(desk, 'update', { id: row.id, period_start: '2026-05-15' })); expect(env.code).toBe('VALIDATION_FAILED'); diff --git a/test/forecast-period-end-boundary.test.ts b/test/forecast-period-end-boundary.test.ts index 6a754f667..8943ab5bb 100644 --- a/test/forecast-period-end-boundary.test.ts +++ b/test/forecast-period-end-boundary.test.ts @@ -11,7 +11,7 @@ import artifact from '../objectstack.config'; import stack from './helpers/composed-stack'; import forecastHook from '../src/sales/objects/forecast.hook'; import { forecasts } from '../src/sales/data/forecast.seed'; -import { bootOptions, hotcrmStack, hotcrmMemoryStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { bootOptions, buildArtifactAgain, hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; /** * A forecast's window must be the calendar period it is labelled with — at the @@ -169,8 +169,8 @@ const expectCalendarRefusal = (env: Awaited>) => { * hook and flow by reference — for the blocks whose claim is what the app does * WITHOUT a rule. Booted through the platform's own `bootStack`, like the app. */ -const artifactWithout = (...names: string[]) => { - const a = artifact as unknown as AnyRec; +const artifactWithout = (base: typeof artifact, ...names: string[]) => { + const a = base as unknown as AnyRec; return { ...a, packages: (a.packages as AnyRec[]).map((p) => ({ @@ -188,7 +188,10 @@ const artifactWithout = (...names: string[]) => { /** The app booted with `names` removed from `crm_forecast`, and its manager. */ const deskWithout = async (label: string, names: string[], over: Parameters[0] = {}): Promise => { - const verify = await bootStack(artifactWithout(...names), bootOptions(over)); + // From a build of its own: one configuration object holds one live kernel + // since 18.0.0-next.0 (objectstack#22301), and a copy of a live one is not + // a configuration of its own. + const verify = await bootStack(artifactWithout(await buildArtifactAgain(), ...names), bootOptions(over)); const carried = ((verify.metadata.object('crm_forecast') as AnyRec).validations as AnyRec[]).map((r) => r.name); for (const name of names) expect(carried, `the variant boot still carries ${name}`).not.toContain(name); return { verify, manager: await managerOn(verify, label) }; @@ -256,10 +259,13 @@ describe('the window rule is declared where the platform can act on it', () => { }); }); -// ────────────────────────────── the refusal, on the real engine (memory) ── +// ──────────────────────────────────── the refusal, on the real engine ── -describe('the hand-typed window is REFUSED, not warned about (in-memory driver)', () => { - const desk = deskOn(hotcrmMemoryStack, 'memory'); +// (Until 18.0.0-next.0 this matrix ran on `driver-memory`, which now refuses +// every tenant-scoped call; the #2031 ruling moved it to SQL: +// 「就应该在 sql 上测试,作废内存驱动的测试」.) +describe('the hand-typed window is REFUSED, not warned about', () => { + const desk = deskOn(hotcrmStack, 'rule'); it("refuses the card's own row — Q3 2026 spanning ten months", async () => { const env = await refusal(() => viaHook(desk, 'insert', { ...BASE, ...TEN_MONTH_WINDOW })); @@ -533,9 +539,8 @@ describe('the != null guards are load-bearing, not decoration', () => { // ─────────────────────── the same contract on a real SQL database ─────────── describe('the write is REFUSED on a real SQLite database too', () => { - // The in-memory driver hands back sparse rows; a SQL driver hands back - // column-complete ones with NULLs. Those are different inputs to the same - // predicate, and a marketplace app does not choose its host's datasource. + // A SQL driver hands back column-complete rows with NULLs, which is the + // input the update path below reads. const desk = deskOn(hotcrmStack, 'sqlite'); it('refuses the stretched window and admits the calendar-true one', async () => { @@ -554,7 +559,7 @@ describe('the write is REFUSED on a real SQLite database too', () => { ...BASE, period: 'month', period_start: '2026-05-01', period_end: '2026-05-31', }); const stored = await storedForecast(desk, row.id); - // Opposite precondition to the in-memory suite: the key IS present here. + // The precondition the update path reads: the key IS present. expect('period_label' in (stored ?? {})).toBe(true); const env = await refusal(() => viaHook(desk, 'update', { id: row.id, period_end: '2026-12-31' })); expectCalendarRefusal(env); @@ -591,7 +596,7 @@ describe('the window rule is an INVARIANT — it reaches a row already stored wr rederived = await legacy('stored before the rule existed, edited without its window'); await before.verify.stop(); - const verify = await bootStack(artifact, bootOptions({ databaseFile })); + const verify = await bootStack(await buildArtifactAgain(), bootOptions({ databaseFile })); desk = { verify, manager: await managerOn(verify, 'after-upgrade') }; for (const id of [frozen, rederived]) { expect((await storedForecast(desk, id))?.period_end, 'the legacy row did not survive the restart').toBe('2027-05-15'); diff --git a/test/forecast-seeds.test.ts b/test/forecast-seeds.test.ts index 7d2b5cc80..c56d89d9f 100644 --- a/test/forecast-seeds.test.ts +++ b/test/forecast-seeds.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { cel } from '@objectstack/spec'; import { CrmSeedData } from '../objectstack.composition'; import { Forecast } from '../src/sales/objects/forecast.object'; import { ForecastSnapshotFlow } from '../src/sales/flows/forecast-snapshot.flow'; @@ -194,16 +195,18 @@ describe('the seeds stay out of the window forecast_snapshot owns (#702)', () => // guard must be re-derived rather than left passing for a stale reason. expect(findNode('find_forecast'), 'forecast_snapshot has no find_forecast node').toBeDefined(); expect(sweptPeriod, 'the sweep writes no fixed period any more').toMatch(/^(month|quarter)$/); - expect(String(sweepLookup.owner_id), 'the sweep lookup is no longer owner-scoped') - .toMatch(/\{currentOwner\.\w+\}/); + expect(String((sweepLookup.owner_id as { source?: unknown })?.source), 'the sweep lookup is no longer owner-scoped') + .toMatch(/^currentOwner\.\w+$/); // ⚠️ These two are SPELLING guards, and only that. They pin the window - // against a widening edit; they say nothing about whether `{TODAY()}` still + // against a widening edit; they say nothing about whether "today" still // MEANS anything, because they never execute the filter — which is how the - // rc.5 → rc.6 change in the token's treatment passed under them unnoticed - // (#1107). The execution guard lives in `test/flow-filter-today-token.test.ts` - // and must stay green beside these. - expect(sweepLookup.period_start).toEqual({ $lte: '{TODAY()}' }); - expect(sweepLookup.period_end).toEqual({ $gte: '{TODAY()}' }); + // rc.5 → rc.6 change in the `{TODAY()}` token's treatment passed under them + // unnoticed (#1107). The execution guard lives in + // `test/flow-filter-today-token.test.ts` and must stay green beside these. + // Since 18.0.0-next.0 the operand is the CEL value envelope the platform's + // migration entry prescribes for `{TODAY()}`. + expect(sweepLookup.period_start).toEqual({ $lte: cel`isoDate(today())` }); + expect(sweepLookup.period_end).toEqual({ $gte: cel`isoDate(today())` }); }); it('no seeded forecast row lands inside that window', () => { diff --git a/test/forecast-snapshot-amounts.test.ts b/test/forecast-snapshot-amounts.test.ts index 984dca511..c0e3379ee 100644 --- a/test/forecast-snapshot-amounts.test.ts +++ b/test/forecast-snapshot-amounts.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; -import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, runScheduledFlow, type Person } from './helpers/verify-stack'; type Rec = Record; /** * `forecast_snapshot`'s four bucket accumulators are CEL value envelopes * (#1984), run here on the shipped app booted by `@objectstack/verify` (the - * sweep started through the trigger door as the admin): each one is an + * sweep started the way its schedule trigger starts it): each one is an * `assignment` inside a `loop` body inside the owner loop's `try_catch`, so * this is also the proof that a value envelope in that position is evaluated * at all rather than written into the variable verbatim. @@ -41,11 +41,10 @@ const inPeriod = isoUtc(qStart); const STALE = 9_999_999; let verify: VerifyStack; -let admin: string; let k = 0; beforeAll(async () => { verify = await hotcrmStack(); - admin = await verify.signIn(); + await verify.signIn(); // the harness admin, as before: the sweeps no longer run as anyone }, 120_000); /** A fresh owner with a stale current-quarter snapshot row, and their account. */ @@ -67,7 +66,7 @@ const opp = (who: Person, accountId: string, over: Rec): Rec => ({ }); const sweep = async (who: Person) => { - await verify.flows.run('forecast_snapshot', {}, { as: admin }); + await runScheduledFlow(verify, 'forecast_snapshot'); const rows = await verify.rows('crm_forecast', { owner_id: who.id }); expect(rows, 'the sweep opened a second row').toHaveLength(1); return rows[0]!; diff --git a/test/freeze-guard-reference-cleanup.test.ts b/test/freeze-guard-reference-cleanup.test.ts index f2f996a8e..7db16a14b 100644 --- a/test/freeze-guard-reference-cleanup.test.ts +++ b/test/freeze-guard-reference-cleanup.test.ts @@ -12,7 +12,7 @@ import { REFUSAL_CODES } from '../src/sales/objects/_refusal'; import oppHooks from '../src/sales/objects/opportunity.hook'; import quoteHooks from '../src/revenue/objects/quote.hook'; import type { VerifyStack } from '@objectstack/verify'; -import { hotcrmStack, hotcrmMemoryStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; type Rec = Record; const hookNamed = (hooks: unknown, name: string): AnyRec => { @@ -21,8 +21,7 @@ const hookNamed = (hooks: unknown, name: string): AnyRec => { return hook; }; import { extractHookBody } from '@objectstack/cli/hook-body'; -import { SysUser, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; -import { identityObjects } from './helpers/identity-objects'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; /** * A frozen record must not make the people it references undeletable (#720). @@ -272,18 +271,30 @@ const uniq = (): string => `${++seq}`; beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } } as never); - await kernel.use(new DefaultDatasourcePlugin({ driver: 'memory', config: {} } as never)); + // The SQL in-memory driver: `driver-memory` refuses every tenant-scoped call + // on 18.0.0-next.0, and a system write here now carries the install's one + // organization (below), so this kernel left it (`@objectstack/verify` 34dba5a + // names the same move for its own boots). + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } } as never)); await kernel.use(new MetadataPlugin({ watch: false, artifactWatch: false, environmentId: 'proj_720' } as never)); await kernel.use(new ObjectQLPlugin({ environmentId: 'proj_720' } as never)); // 17.7.0 refuses an object name the registry does not hold (objectstack#21545): - // register the identity objects `plugin-auth` would (`test/helpers/identity-objects.ts`). - await kernel.use(identityObjects(SysUser, SysMember, SysOrganization) as never); + // register the identity objects `plugin-auth` would, through its own preset + // (`createIdentityObjectsPlugin`, objectstack#22074). + await kernel.use(createIdentityObjectsPlugin() as never); // The app's own metadata is the subject — objects and hooks exactly as // `objectstack.config.ts` declares them (the rig `cascade-guard-messages` // uses, which is the rig #720 was measured with). await kernel.use(new AppPlugin(artifact as never, undefined as never, { skipSeedData: true } as never)); await kernel.bootstrap(); ql = kernel.getService('objectql'); + // The install's one organization. Since 18.0.0-next.0 a system write on a + // tenant-scoped object takes its organization from the install's ONE + // organization and is refused when there is none (ADR-0131 D3/D9, + // `SystemWriteOrganizationRequiredError`). `plugin-auth` creates it at boot + // under the `single` posture; this kernel mounts no `plugin-auth`, so it + // writes the row itself. + await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); const user = await insert('sys_user', { name: 'Erasure Rep', email: 'rep@freeze-cleanup.test' }); userCtx = { userId: user.id, isSystem: true } as AnyRec; @@ -634,16 +645,12 @@ const expectRefusal = (err: unknown, wording: RegExp): void => { interface Desk { verify: VerifyStack; rep: Person } /** The SQL datasource (every declared column materialised) — where every case below runs. */ const sql = {} as Desk; -/** The sparse datasource (`driver-memory`; `driver-mongodb` stores the same shape) — see the last case. */ -const sparse = {} as Desk; let n = 0; beforeAll(async () => { - for (const [desk, boot] of [[sql, hotcrmStack], [sparse, hotcrmMemoryStack]] as const) { - desk.verify = await boot(); - desk.rep = await signUpPerson(desk.verify, 'rep@freeze-guard-units.test', { - name: 'Freeze Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], - }); - } + sql.verify = await hotcrmStack(); + sql.rep = await signUpPerson(sql.verify, 'rep@freeze-guard-units.test', { + name: 'Freeze Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); }, 180_000); /** The records a settled one links to — an account, two contacts, two deals — written as the system. */ @@ -786,19 +793,13 @@ describe.each(GUARDS)('$label yields to the cleanup shape and nothing else', (gu expectRefusal(await refusalOf(object, id, { [businessField]: null }), refusal); }); - it('does not yield when the link was already empty', async () => { - // Nothing to clean up: the engine only clears links that point somewhere, - // so a null over an absent value is a hand edit that invented one. That is - // the SPARSE datasource's shape — a link the row was never written with is - // ABSENT there — and the guard refuses it. - const { id } = await settled(false, sparse); - expectRefusal(await refusalOf(object, id, { [link]: null }, sparse), refusal); - }); - - it('on SQL the same write is no change at all, and lands as one', async () => { - // Measured, and recorded because the two datasources part here: SQL - // materialises the empty link as NULL, so `null` over it changes nothing, - // the guard finds nothing violating, and the link stays empty. + it('a null over a link that was already empty is no change at all, and lands as one', async () => { + // Measured: SQL materialises the empty link as NULL, so `null` over it + // changes nothing, the guard finds nothing violating, and the link stays + // empty. (Until 18.0.0-next.0 a twin case ran on `driver-memory`, where a + // link never written is ABSENT and the guard refused the same write; that + // driver now refuses every tenant-scoped call, and the #2031 ruling + // retired the case: 「就应该在 sql 上测试,作废内存驱动的测试」.) const { id } = await settled(false); expect((await repEdit(object, id, { [link]: null }))[link] ?? null).toBeNull(); }); diff --git a/test/guest-submission-sanitisation.test.ts b/test/guest-submission-sanitisation.test.ts index 18f3f05b7..37962ba16 100644 --- a/test/guest-submission-sanitisation.test.ts +++ b/test/guest-submission-sanitisation.test.ts @@ -110,9 +110,11 @@ const rowById = async (object: string, rowId: string): Promise => beforeAll(async () => { // The platform's boot — `@objectstack/verify`'s `bootStack` over the shipped // artifact (objectql, auth and its `tenancy` service, security with the app's - // own default profile, sharing) — on the in-memory datasource this file has - // always measured on. - verify = await bootStack(artifact, { databaseDriver: 'memory' }); + // own default profile, sharing) — on the SQL in-memory datasource. Through 17.7.0 this file measured on + // `driver-memory`; on 18.0.0-next.0 that driver refuses every tenant-scoped + // call, and the boot now creates the Default Organization (ADR-0131 D3), so + // `@objectstack/verify` 34dba5a moves such a leg to the SQL driver. + verify = await bootStack(artifact); // A fresh sign-up is an ordinary member (the stack's first user is its own // platform admin), so the agent's writes go through the same enforcement a diff --git a/test/helpers/identity-objects.ts b/test/helpers/identity-objects.ts deleted file mode 100644 index 397279caf..000000000 --- a/test/helpers/identity-objects.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The platform identity objects a reduced test stack has to register for itself - * (ObjectStack 17.7.0). - * - * ### What this exists for - * - * Several files in this suite boot ObjectQL over `objectstack.config.ts` and - * insert `sys_user` fixtures (a real user id is what their hooks and sharing - * rules key on). None mounts `plugin-auth`, because nothing they measure needs - * sign-in — and `plugin-auth` is the plugin that registers `sys_user`, - * `sys_organization`, `sys_member` and their siblings at boot (its manifest's - * `objects`). - * - * Through 17.6.0 the omission was invisible: the engine handed a name its - * registry did not hold to the driver as a table, and the memory driver created - * one on the first write. From 17.7.0 the engine's verbs refuse an unregistered - * object name with `404 OBJECT_NOT_FOUND` before any hook or driver runs - * (objectstack#21545), so the first `insert('sys_user', …)` failed the whole - * file. The release notes' prescription is "register the object first". - * - * This registers the platform's own definitions, imported rather than - * hand-mirrored, through the `manifest` service under the package id - * `plugin-auth` uses — the call `plugin-auth`'s own `init()` makes. - */ -export function identityObjects(...objects: unknown[]) { - return { - name: 'test-identity-objects', - version: '1.0.0', - dependencies: ['com.objectstack.engine.objectql'], - init(ctx: { getService: (name: string) => { register: (manifest: unknown) => void } }): void { - ctx.getService('manifest').register({ - id: 'com.objectstack.plugin-auth', - namespace: 'sys', - version: '1.0.0', - type: 'plugin', - scope: 'system', - name: 'Identity objects (test stand-in for plugin-auth)', - objects, - }); - }, - }; -} diff --git a/test/helpers/verify-stack.ts b/test/helpers/verify-stack.ts index 29b38ddad..53ece834c 100644 --- a/test/helpers/verify-stack.ts +++ b/test/helpers/verify-stack.ts @@ -91,13 +91,23 @@ export const HOTCRM_BOOT: BootOptions = bootOptions(); export const hotcrmStack = (): Promise => bootStackOnce(artifact, HOTCRM_BOOT); /** - * The app on the SPARSE datasource (`driver-memory`): a column a row was never - * written with comes back ABSENT rather than NULL — the shape `driver-mongodb` - * also produces, and the one AGENTS.md's predicate-totality rule is about. Only - * for a suite whose claim is about that shape. + * A second build of the app's configuration, from FRESH module instances — for + * a suite that keeps a second stack live beside {@link hotcrmStack}. + * + * Since 18.0.0-next.0 `bootStack` refuses a second live kernel of ONE + * configuration object (`RESOURCE_CONFLICT`, objectstack#22301): a live boot + * holds references into the configuration's nested definitions and plugin + * instances, so a second boot over the same object would share them. The + * remedy the refusal names is to build the configuration again from fresh + * module instances — never a `{ ...artifact }` copy — which is what + * `vi.resetModules()` and a dynamic import do. ⚠️ The reset reaches every + * module imported after it, so a test file that imports a local module + * dynamically after calling this gets a fresh instance of it. */ -export const HOTCRM_MEMORY_BOOT: BootOptions = bootOptions({ databaseDriver: 'memory' }); -export const hotcrmMemoryStack = (): Promise => bootStackOnce(artifact, HOTCRM_MEMORY_BOOT); +export const buildArtifactAgain = async (): Promise => { + vi.resetModules(); + return (await import('../../objectstack.config')).default; +}; /** * The names of every object the booted runtime registers that this app does @@ -123,12 +133,26 @@ export interface Person { * Sign up a person and give them the standing a fixture names — position rows * (`sys_user_position.position` holds the position NAME, which is what * `expandPositionUsers` filters on) and permission-set grants - * (`sys_user_permission_set.permission_set_id`, the set's row id). + * (`sys_user_permission_set.permission_set`, the set's NAME). + * + * The grant names its set since 18.0.0-next.0 (ADR-0131 D4): no seeder writes + * a `sys_permission_set` row for a declared set any more (upstream c8a9783, + * #15204 stage 6b-2), so there is no row id to look up. The name is checked + * against the sets the booted registry serves, so a misspelt fixture still + * fails here, by name, rather than granting nothing. * * Fixture SETUP, written as the system through `seed`, the way the platform's * own bootstrap writes RBAC rows: the person is not the surface under test, and * routing their provisioning through a caller's data door would make the proof * depend on that caller's grants. A fresh sign-up is a plain member until then. + * + * A position row carries the person's organization, as an administrator's + * assignment in Setup does (a non-system insert is stamped with the writer's + * active organization). Since 18.0.0-next.0 every boot has one — the Default + * Organization (ADR-0131 D3) — and every request carries it, and the approval + * service's position expansion reads holders of the REQUEST's organization + * only, so an organization-less row routes no approval. Permission-set grants + * stay organization-less, a grant the platform applies in every organization. */ export async function signUpPerson( stack: VerifyStack, @@ -136,14 +160,16 @@ export async function signUpPerson( opts: { name?: string; positions?: string[]; permissionSets?: string[] } = {}, ): Promise { const token = await stack.signUp(email, undefined, opts.name); - const id = String((await stack.contextFor(token)).userId); + const context = await stack.contextFor(token); + const id = String(context.userId); + const organization = context.tenantId ? { organization_id: String(context.tenantId) } : {}; for (const position of opts.positions ?? []) { - await stack.seed('sys_user_position', [{ user_id: id, position }]); + await stack.seed('sys_user_position', [{ user_id: id, position, ...organization }]); } for (const name of opts.permissionSets ?? []) { - const [set] = await stack.rows('sys_permission_set', { name }); - if (!set) throw new Error(`no permission set named "${name}" is registered on this stack`); - await stack.seed('sys_user_permission_set', [{ user_id: id, permission_set_id: set.id }]); + const served = stack.metadata.items<{ name?: string }>('permission').some((set) => set.name === name); + if (!served) throw new Error(`no permission set named "${name}" is registered on this stack`); + await stack.seed('sys_user_permission_set', [{ user_id: id, permission_set: name }]); } return { token, id }; } @@ -257,6 +283,40 @@ export const runRecordFlow = (stack: VerifyStack, flowName: string, object: stri record, object, event: 'record_change', params: { ...record }, }); +/** + * Start a scheduled flow the way its schedule trigger does. + * + * Since 18.0.0-next.0 the trigger door refuses a `runAs: 'system'` flow of type + * `schedule`, `autolaunched` or `record_change` to every caller but the system + * principal, an admin's session included (objectstack ca135dc, #22310: "This + * caller may not start this flow"). The handle's `flows.run` answers through + * that door and takes a person only, so it no longer starts a sweep at all. The + * one starter left is the one production has: a tick of + * `@objectstack/trigger-schedule`, which hands the booted `automation` service + * `{ event: 'schedule', params, callerParamKeys: [] }` with no user, and no + * `tenantId` because no flow here declares an organization (the `single` + * posture resolves the install's one organization beneath the run). This hands + * the service that context (the tick's own `jobId` / `schedule` seeds are left + * out; no flow here reads them) and answers as the door did: the run's result + * with `flowName`, or a rejection when the engine reports the run unsuccessful, + * so a sweep that dies still fails where it is started. It rejects on the + * engine's own `success: false` (a paused run excepted), a superset of what the + * door refused. The missing door is raised upstream from #2031. + */ +export async function runScheduledFlow(stack: VerifyStack, flowName: string): Promise { + const result: Row = await stack.kernel.getService('automation').execute(flowName, { + event: 'schedule', params: {}, callerParamKeys: [], + }); + if (result?.success === false && result.status !== 'paused') { + throw Object.assign(new Error(String(result.error || `flow '${flowName}' run failed`)), { + name: 'VerifyRefusal', code: String(result.code ?? 'FLOW_FAILED'), + ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), + ...(result.summary !== undefined ? { summary: result.summary } : {}), + }); + } + return { ...result, flowName }; +} + /** One write the engine received while a recorder was attached. */ export interface EngineWrite { op: 'insert' | 'update' | 'delete'; diff --git a/test/hierarchy-read-depth.test.ts b/test/hierarchy-read-depth.test.ts index b7bb2070c..bb10ad72f 100644 --- a/test/hierarchy-read-depth.test.ts +++ b/test/hierarchy-read-depth.test.ts @@ -272,13 +272,15 @@ describe('the spec gate accepts a hierarchy readScope only when the capability i beforeAll(async () => { // The platform's boot — `@objectstack/verify`'s `bootStack` over the shipped // artifact (objectql, auth and its `tenancy` service, security with the app's - // own default profile, sharing) — on the in-memory datasource this file has - // always measured on. + // own default profile, sharing) — on the SQL in-memory datasource. Through 17.7.0 this file measured on + // `driver-memory`; on 18.0.0-next.0 that driver refuses every tenant-scoped + // call, and the boot now creates the Default Organization (ADR-0131 D3), so + // `@objectstack/verify` 34dba5a moves such a leg to the SQL driver. // // NB: no `hierarchy-scope-resolver` is registered — that service ships in // `@objectstack/security-enterprise`, which this repo does not depend on. // That absence is the whole subject of this file. - verify = await bootStack(artifact, { databaseDriver: 'memory' }); + verify = await bootStack(artifact); // The throwaway permission sets, created as DATA. ⛔ Nothing here is authored // in `src/profiles/`; #1378 rules that no profile may gain a hierarchy diff --git a/test/hooks-runtime-sales.test.ts b/test/hooks-runtime-sales.test.ts index c9d7399d1..9ece7dcdd 100644 --- a/test/hooks-runtime-sales.test.ts +++ b/test/hooks-runtime-sales.test.ts @@ -816,23 +816,24 @@ describe('account_protection', () => { }); /** - * ⚠️ MEASURED DEFECT — reported as a finding on this card, pinned here so the - * fix is noticed. The guard lets drafts, expired and terminated contracts - * through, and the comment above says they go with the account. Measured on - * 17.7.0, the delete is refused all the same — by the engine, on the - * account's CONTACTS: the cascade reaches a contact while the contracts - * naming it as their required primary contact still exist. (The fixture - * carries no deal on the account: a closed one keeps the account by design - * since #2019, and would answer before the cascade this case measures.) + * The cascade the comment above promises, pinned. The guard lets drafts, + * expired and terminated contracts through, and they go with the account. + * + * Measured on 17.7.0 as a DEFECT: the delete was refused all the same, by the + * engine, on the account's CONTACTS — the cascade reached a contact while the + * contracts naming it as their required primary contact still existed. On + * 18.0.0-next.0 the same fixture deletes, as this case's own failure message + * asked to be told, so it now pins the cascade: the account and every one of + * its settled contracts are gone. (The fixture carries no deal on the + * account: a closed one keeps the account by design since #2019, and would + * answer before the cascade this case measures.) */ - it('⚠️ an account whose contracts are all draft, expired or terminated still cannot be deleted (measured defect)', async () => { - const err = await refuseDeleteForContracts(['draft', 'in_approval', 'expired', 'terminated']) - .then(() => null, (e: Error & Rec) => e); - expect( - err, - 'the account was deleted with its settled contracts — the defect is fixed: rewrite this case to pin the cascade', - ).toBeTruthy(); - await refusedByTheEngineNotTheGuard('crm_contract')(err!); + it('an account whose contracts are all draft, expired or terminated is deleted, contracts and all', async () => { + const acct = await customerWith([], ['draft', 'in_approval', 'expired', 'terminated']); + expect(await verify.rows('crm_contract', { crm_account: acct.id }), 'the fixture wrote no contract').toHaveLength(4); + await expect(verify.hooks.run('crm_account', 'delete', { id: acct.id }, as(admin))).resolves.toBeDefined(); + expect(await verify.rows('crm_account', { id: acct.id })).toEqual([]); + expect(await verify.rows('crm_contract', { crm_account: acct.id }), 'a settled contract outlived its account').toEqual([]); }); it('opportunities are reported before activated contracts', async () => { diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts index 5e5f86602..7a4b501c8 100644 --- a/test/hooks-runtime-service.test.ts +++ b/test/hooks-runtime-service.test.ts @@ -5,7 +5,7 @@ import type { VerifyStack } from '@objectstack/verify'; import campaignHooks, { CAMPAIGN_METRIC_WRITE_KEYS } from '../src/marketing/objects/campaign.hook'; import leadHooks from '../src/sales/objects/lead.hook'; import { - hotcrmStack, hotcrmMemoryStack, signUpPerson, guestInsert, systemUpdate, recordEngineWrites, today, daysFromNow, + hotcrmStack, signUpPerson, guestInsert, systemUpdate, recordEngineWrites, today, daysFromNow, type Person, } from './helpers/verify-stack'; @@ -571,24 +571,18 @@ describe('campaign_lead_conversion_refresh', () => { * one, sent in the other), and another lead L2 is in the first. The flip of * `is_converted` is the admin's (who may write members and campaigns). * - * On the SPARSE datasource, and the last case once more on SQL, the default. - * The promotion reads the lead's memberships with a field projection, then - * updates each by its `id`: `driver-memory` (like `driver-mongodb`) hands - * `id` back whether or not the projection names it, the SQL driver hands back - * exactly the projected columns — so the read names `id`, or on SQL every - * membership is skipped (#2018). + * On the SQL datasource, the file's shared boot and its people. The promotion + * reads the lead's memberships with a field projection, then updates each by + * its `id`: the SQL driver hands back exactly the projected columns, so the + * read names `id`, or every membership is skipped (#2018). (Until + * 18.0.0-next.0 this desk ran on `driver-memory`, which now refuses every + * tenant-scoped call; the #2031 ruling moved it to SQL: + * 「就应该在 sql 上测试,作废内存驱动的测试」.) */ const desk = {} as { verify: VerifyStack; admin: string; rep: Person; marketer: Person }; - beforeAll(async () => { - desk.verify = await hotcrmMemoryStack(); - desk.admin = await desk.verify.signIn(); - desk.rep = await signUpPerson(desk.verify, 'rep@hooks-runtime-service.test', { - name: 'Sales Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], - }); - desk.marketer = await signUpPerson(desk.verify, 'marketer@hooks-runtime-service.test', { - name: 'Marketing User', positions: ['marketing_user'], permissionSets: ['marketing_user'], - }); - }, 120_000); + beforeAll(() => { + Object.assign(desk, { verify, admin, rep, marketer }); + }); const store = async (m2Status = 'sent', on: VerifyStack = desk.verify) => { const owner = on === desk.verify ? desk.marketer.id : marketer.id; diff --git a/test/hooks-runtime.test.ts b/test/hooks-runtime.test.ts index f96f74415..9e616b016 100644 --- a/test/hooks-runtime.test.ts +++ b/test/hooks-runtime.test.ts @@ -1,9 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll, vi } from 'vitest'; -import type { VerifyStack } from '@objectstack/verify'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; import { - hotcrmStack, hotcrmMemoryStack, signUpPerson, guestInsert, systemUpdate, recordEngineWrites, today, type Person, + hotcrmStack, bootOptions, buildArtifactAgain, signUpPerson, guestInsert, systemUpdate, recordEngineWrites, today, type Person, } from './helpers/verify-stack'; /** @@ -45,7 +45,9 @@ beforeAll(async () => { }); }, 120_000); -const repWrites = (object: string, op: 'insert' | 'update' | 'delete', doc: Rec): Promise => +// Generic over the operation, as `hooks.run` is (@objectstack/verify 18.0.0-next.0): an insert or +// an update answers the row, a delete answers the engine's own `boolean | number`. +const repWrites = (object: string, op: Op, doc: Rec) => verify.hooks.run(object, op, doc, { as: rep.token }); const stored = async (object: string, id: string): Promise => (await verify.rows(object, { id }))[0]!; @@ -295,8 +297,11 @@ describe('lead_auto_assign', () => { }); it('is a no-op when there is no rep pool (never blocks intake)', async () => { - // The app on its own datasource, where nobody holds `sales_rep` yet. - const empty = await hotcrmMemoryStack(); + // The app on its own datasource, where nobody holds `sales_rep` yet. The + // SQL in-memory one: `driver-memory` refuses every tenant-scoped call since + // 18.0.0-next.0 (verify 34dba5a, until ADR-0131 C8), and a build of its own, + // as one configuration holds one live kernel (objectstack#22301). + const empty = await bootStack(await buildArtifactAgain(), bootOptions()); expect(await empty.rows('sys_user_position', { position: 'sales_rep' }), 'the empty boot has a rep pool').toEqual([]); const [written] = await empty.seed('crm_lead', [lead()]); expect((await empty.rows('crm_lead', { id: written!.id }))[0]!.owner_id ?? null).toBeNull(); diff --git a/test/lead-duplicate-link-cleanup.test.ts b/test/lead-duplicate-link-cleanup.test.ts index b31e1ab40..8381d9943 100644 --- a/test/lead-duplicate-link-cleanup.test.ts +++ b/test/lead-duplicate-link-cleanup.test.ts @@ -6,8 +6,7 @@ import { DefaultDatasourcePlugin, AppPlugin } from '@objectstack/runtime'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { MetadataPlugin } from '@objectstack/metadata'; import stack from '../objectstack.config'; -import { SysUser, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; -import { identityObjects } from './helpers/identity-objects'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; /** * A lead flagged as a duplicate must not make the record it duplicates @@ -177,12 +176,17 @@ const uniq = (): string => `${++seq}`; beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } } as never); - await kernel.use(new DefaultDatasourcePlugin({ driver: 'memory', config: {} } as never)); + // The SQL in-memory driver: `driver-memory` refuses every tenant-scoped call + // on 18.0.0-next.0, and a system write here now carries the install's one + // organization (below), so this kernel left it (`@objectstack/verify` 34dba5a + // names the same move for its own boots). + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } } as never)); await kernel.use(new MetadataPlugin({ watch: false, artifactWatch: false, environmentId: 'proj_1072' } as never)); await kernel.use(new ObjectQLPlugin({ environmentId: 'proj_1072' } as never)); // 17.7.0 refuses an object name the registry does not hold (objectstack#21545): - // register the identity objects `plugin-auth` would (`test/helpers/identity-objects.ts`). - await kernel.use(identityObjects(SysUser, SysMember, SysOrganization) as never); + // register the identity objects `plugin-auth` would, through its own preset + // (`createIdentityObjectsPlugin`, objectstack#22074). + await kernel.use(createIdentityObjectsPlugin() as never); // The app's own metadata is the subject — objects and hooks exactly as // `objectstack.config.ts` declares them. A unit test of the handler cannot // show any of this: only the engine decides that `set_null` reaches the lead @@ -191,6 +195,13 @@ beforeAll(async () => { await kernel.use(new AppPlugin(stack as never, undefined as never, { skipSeedData: true } as never)); await kernel.bootstrap(); ql = kernel.getService('objectql'); + // The install's one organization. Since 18.0.0-next.0 a system write on a + // tenant-scoped object takes its organization from the install's ONE + // organization and is refused when there is none (ADR-0131 D3/D9, + // `SystemWriteOrganizationRequiredError`). `plugin-auth` creates it at boot + // under the `single` posture; this kernel mounts no `plugin-auth`, so it + // writes the row itself. + await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); const user = await insert('sys_user', { name: 'Erasure Rep', email: 'rep@dupe-cleanup.test' }); userCtx = { userId: user.id, isSystem: true } as AnyRec; diff --git a/test/line-item-line-number.test.ts b/test/line-item-line-number.test.ts index 3dadcca1f..23966fe85 100644 --- a/test/line-item-line-number.test.ts +++ b/test/line-item-line-number.test.ts @@ -48,6 +48,22 @@ const seed = { isSystem: true }; let ql: AnyRec; +/** + * The two headers every case files its lines under, written as the seed loader + * would. Since 18.0.0-next.0 a non-system write of a detail must name a header + * the caller can read (`master_detail`, objectstack#22519), so the headers are + * real rows of the shipped parent objects rather than bare ids. + */ +const HEADERS = ['p1', 'p2']; +const header: Record AnyRec> = { + crm_opportunity: (id) => ({ + id, name: `Header ${id}`, crm_account: 'acc_1', amount: 100, stage: 'prospecting', close_date: '2026-12-31', + }), + crm_quote: (id) => ({ + id, name: `Header ${id}`, crm_account: 'acc_1', status: 'draft', quote_date: '2026-10-01', expiration_date: '2026-12-31', + }), +}; + /** * Boot on the shipped schemas. Only the numbering hooks are bound: the price * fill and the async rollups are other hooks' behaviour, and a line here carries @@ -57,10 +73,15 @@ async function boot(withNumbering: boolean) { ql = (await ObjectQL.create({ datasources: { default: new InMemoryDriver({ persistence: false }) }, objects: { + crm_opportunity: byName('crm_opportunity'), + crm_quote: byName('crm_quote'), crm_opportunity_line_item: byName('crm_opportunity_line_item'), crm_quote_line_item: byName('crm_quote_line_item'), } as never, })) as never; + for (const parent of PAIRS.map((p) => p.parentKey)) { + for (const id of HEADERS) await ql.insert(parent, header[parent](id), { context: seed }); + } if (withNumbering) { bindHooksToEngine( ql as never, diff --git a/test/opportunity-status-change-approval-gate.test.ts b/test/opportunity-status-change-approval-gate.test.ts index fe1658a2b..9e9615432 100644 --- a/test/opportunity-status-change-approval-gate.test.ts +++ b/test/opportunity-status-change-approval-gate.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll } from 'vitest'; +import { cel } from '@objectstack/spec'; import type { VerifyStack } from '@objectstack/verify'; import stack from './helpers/composed-stack'; import { OpportunityApprovalFlow } from '../src/sales/flows/opportunity-approval.flow'; @@ -193,7 +194,7 @@ describe('the approval branches leave the gate unable to re-enter', () => { it('approve applies the REQUEST to the stage and stamps `approved` in the same write', () => { expect(edge('approve')?.target).toBe('apply_status'); const fields = node('apply_status')?.config?.fields as AnyRec; - expect(fields.stage).toBe('{oppRecord.requested_status}'); + expect(fields.stage).toEqual(cel`oppRecord.requested_status`); expect(fields.status_change_approval_status).toBe('approved'); }); diff --git a/test/parent-derived-reach.test.ts b/test/parent-derived-reach.test.ts index 24c0027e8..82eafa97e 100644 --- a/test/parent-derived-reach.test.ts +++ b/test/parent-derived-reach.test.ts @@ -119,8 +119,11 @@ beforeAll(async () => { // artifact: the app's objects, profiles, positions and sharing rules exactly // as `objectstack.config.ts` declares them, with objectql, auth (and its // `tenancy` service), security with the app's own default profile, and - // sharing — on the in-memory datasource this file has always measured on. - verify = await bootStack(artifact, { databaseDriver: 'memory' }); + // sharing — on the SQL in-memory datasource. Through 17.7.0 this file measured on + // `driver-memory`, which on 18.0.0-next.0 refuses every tenant-scoped call once + // the boot creates the Default Organization (ADR-0131 D3); `@objectstack/verify` + // 34dba5a moves such a leg to the SQL driver. + verify = await bootStack(artifact); // ── principals ──────────────────────────────────────────────────────── // Fresh sign-ups are ordinary members (the stack's first user is its own diff --git a/test/readonly-write-semantics.test.ts b/test/readonly-write-semantics.test.ts index 329253e6d..79c17b6c9 100644 --- a/test/readonly-write-semantics.test.ts +++ b/test/readonly-write-semantics.test.ts @@ -5,7 +5,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import type * as Automation from '@objectstack/spec/automation'; -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; /** The engine's logger, silenced: these suites read results, not log lines. */ const silentLogger: any = { @@ -154,7 +154,7 @@ const writeNode = { id: 'write', type: 'update_record', label: 'Write Probe', config: { objectName: PROBE, - filter: { id: '{rowId}' }, + filter: { id: cel`rowId` }, fields: { locked_flag: true, open_flag: true }, }, }; @@ -558,7 +558,7 @@ const callerFlow = (name: string, childName: string): Flow => }, { id: 'stamp', type: 'subflow', label: 'Stamp', - config: { flowName: childName, input: { rowId: '{rowId}' } }, + config: { flowName: childName, input: { rowId: cel`rowId` } }, }, { // The parent's OWN write, after the child returned. Same object, same @@ -567,7 +567,7 @@ const callerFlow = (name: string, childName: string): Flow => id: 'parent_write', type: 'update_record', label: 'Parent Write', config: { objectName: PROBE, - filter: { id: '{rowId}' }, + filter: { id: cel`rowId` }, fields: { locked_after: true }, }, }, @@ -671,7 +671,7 @@ const orderedStamp = (name: string): Flow => { id: 'start', type: 'start', label: 'Start', config: {} }, { id: 'write', type: 'update_record', label: 'Stamp', - config: { objectName: ORDERED, filter: { id: '{rowId}' }, fields: { locked_flag: true } }, + config: { objectName: ORDERED, filter: { id: cel`rowId` }, fields: { locked_flag: true } }, }, { id: 'end', type: 'end', label: 'End' }, ], @@ -688,11 +688,11 @@ const orderedStamp = (name: string): Flow => const orderedCaller = (name: string, childName: string, reasonFirst: boolean): Flow => { const reasonNode = { id: 'write_reason', type: 'update_record', label: 'Write Reason', - config: { objectName: ORDERED, filter: { id: '{rowId}' }, fields: { reason: '{reason}' } }, + config: { objectName: ORDERED, filter: { id: cel`rowId` }, fields: { reason: cel`reason` } }, }; const stampNode = { id: 'stamp', type: 'subflow', label: 'Stamp', - config: { flowName: childName, input: { rowId: '{rowId}' } }, + config: { flowName: childName, input: { rowId: cel`rowId` } }, }; const [first, second] = reasonFirst ? [reasonNode, stampNode] : [stampNode, reasonNode]; return { diff --git a/test/saas-composition.test.ts b/test/saas-composition.test.ts index ef54c0f57..2078fd81e 100644 --- a/test/saas-composition.test.ts +++ b/test/saas-composition.test.ts @@ -224,15 +224,23 @@ describe('tenant_admin is an ORG admin, judged by the platform capability regist ).toBe('org'); }); - it('holds the org-scoped presentation-authoring capability (#1369)', () => { - // The org-bounded subset of metadata authoring. Its scope is read off the - // installed registry, never copied here: if the platform ever widened it, - // this and the platform-scoped pin below would both go red. - expect(TenantAdminProfile.systemPermissions).toContain('manage_org_presentation'); + it('holds no presentation-authoring grant: 18.0.0-next.0 retired the org-scoped one (#1369)', () => { + // #1369 granted `manage_org_presentation`, the org-bounded subset of + // metadata authoring. 18.0.0-next.0 retires it with the per-organization + // overlay axis (ADR-0131 D6; the spec's `manage-org-presentation-retired` + // entry): it leaves the registry, nothing consults it, and the remedy is to + // delete it from every set. Read off the installed registry, so the day the + // platform declares an org-scoped authoring capability again this goes red + // and #1369 is re-measured rather than forgotten. expect( - scopeOf.get('manage_org_presentation'), - 'manage_org_presentation is not org-scoped on the installed platform line — a tenant profile must not hold it', - ).toBe('org'); + scopeOf.has('manage_org_presentation'), + 'the installed platform declares manage_org_presentation again — re-measure #1369 before granting it', + ).toBe(false); + expect(TenantAdminProfile.systemPermissions).not.toContain('manage_org_presentation'); + // The one key that still admits those writes lands them environment-wide, + // so a tenant profile may not hold it. + expect(scopeOf.get('manage_metadata')).toBe('platform'); + expect(TenantAdminProfile.systemPermissions).not.toContain('manage_metadata'); }); it('grants NO platform-scoped capability', () => { diff --git a/test/sla-at-risk-live-work.test.ts b/test/sla-at-risk-live-work.test.ts index 695b19bc8..4490d198f 100644 --- a/test/sla-at-risk-live-work.test.ts +++ b/test/sla-at-risk-live-work.test.ts @@ -5,7 +5,7 @@ import type { VerifyStack } from '@objectstack/verify'; import { parseFilterAST } from '@objectstack/spec/data'; import stack from './helpers/composed-stack'; import { CLOSED_CASE_STATUSES } from '../src/service/objects/_case-assignment'; -import { hotcrmStack, hotcrmMemoryStack, signUpPerson } from './helpers/verify-stack'; +import { hotcrmStack, signUpPerson } from './helpers/verify-stack'; /** * A RESOLVED case is not at risk — the ⏰ SLA at Risk tab and the SLA sweep @@ -36,13 +36,14 @@ import { hotcrmStack, hotcrmMemoryStack, signUpPerson } from './helpers/verify-s * `not_in` ever stopped excluding, and this file would stay green if a sixth * spelling grew somewhere the view does not reach. * - * ### Both datasources, and the fixture is written rather than declared + * ### The SQL datasource, and the fixture is written rather than declared * - * The shipped app is booted through `@objectstack/verify`'s handle twice: on - * the SQL datasource (every declared column materialised) and on the sparse one - * (`driver-memory`, which stores only the columns a row was written with — the - * shape `driver-mongodb` also produces). `is_closed` is exactly the kind of - * boolean an absent-vs-NULL difference gets wrong, so the matrix runs twice. + * The shipped app is booted through `@objectstack/verify`'s handle on the SQL + * datasource (every declared column materialised). Until 18.0.0-next.0 the + * block also ran on `driver-memory`, whose rows hold only the columns they were + * written with; that driver now refuses every tenant-scoped call, and the + * maintainer's ruling on #2031 retired its leg: + * 「就应该在 sql 上测试,作废内存驱动的测试」. * * ⚠️ The fixture does not ASSERT `is_closed: false` on the resolved case — a * service agent writes each case through the engine's write door @@ -87,11 +88,8 @@ describe('the ⏰ SLA at Risk view still selects on the live-work predicate', () }); }); -/** - * Both row shapes. `memory` is the sparse one (`driver-mongodb` produces it - * too); `sqlite` lowers the filter to real SQL over materialised columns. - */ -for (const [driverName, boot] of [['memory', hotcrmMemoryStack], ['sqlite', hotcrmStack]] as const) { +/** `sqlite` lowers the filter to real SQL over materialised columns. */ +for (const [driverName, boot] of [['sqlite', hotcrmStack]] as const) { describe(`a resolved case is not at risk — over ${driverName}`, () => { let verify: VerifyStack; /** The subjects THIS block wrote — the boot replays the app's seed cases too. */ diff --git a/test/source-token-ratchet.test.ts b/test/source-token-ratchet.test.ts index ceceb0ac8..420e8cfef 100644 --- a/test/source-token-ratchet.test.ts +++ b/test/source-token-ratchet.test.ts @@ -193,6 +193,49 @@ function ceilingOf(module: string, label: string): number { /** The verdict line's label, exactly as the gate spells it. */ const verdictLabel = (module: string, label: string) => `${module} ${label}`; +/** + * Re-declare the SANDBOX gate's committed rows in the ANCHORED kind, for the + * cases about the anchored advisory, and return the sandbox's ceilings by + * verdict label. + * + * Since #2031 every committed ceiling is RULED (the ×1.5 grant), and a RULED + * row is exempt from the advisory by design (#1607), so the real table no + * longer holds a row that exercises it. These cases exercise it against a + * sandbox gate that declares it: the technique the RULED branch below used + * while the committed table held no ruled row, and strictly stronger than + * pinning whatever the committed table happens to hold. An anchored ceiling is + * `anchor()` of some reading, a multiple of 1,000, and a grant such as 88,500 + * is not one, so each RULED row becomes the largest multiple of 1,000 at or + * under its grant; a row already ANCHORED keeps its committed constant. The + * package, the label, the order and every other line are the gate's own, and a + * row this helper cannot find fails here rather than leaving the case to run + * against the ruled table. + */ +function anchoredSandbox(): Map { + const gatePath = join(root, GATE); + let text = readFileSync(gatePath, 'utf8'); + const ceilings = new Map(); + for (const row of COMMITTED_CEILINGS as { module: string; label: string; ceiling: number; kind: string }[]) { + if (row.kind === 'anchored') { + ceilings.set(verdictLabel(row.module, row.label), row.ceiling); + continue; + } + const anchored = Math.floor(row.ceiling / 1000) * 1000; + const committed = `{ module: '${row.module}', label: '${row.label}', ceiling: ${row.ceiling}, kind: CEILING_KIND.RULED }`; + expect( + text.split(committed).length - 1, + `the sandbox gate carries no row '${committed}' — teach anchoredSandbox() the new shape`, + ).toBe(1); + text = text.replace( + committed, + `{ module: '${row.module}', label: '${row.label}', ceiling: ${anchored}, kind: CEILING_KIND.ANCHORED }`, + ); + ceilings.set(verdictLabel(row.module, row.label), anchored); + } + writeFileSync(gatePath, text); + return ceilings; +} + let root: string; beforeEach(() => { @@ -603,7 +646,10 @@ describe('source token ratchet — the ratchet itself', () => { // Under 「给 5% 缓冲」 a healthy scope carries thousands of tokens of // headroom by design. An advisory that fired on that would instruct every // author to undo the ruling, so it triggers on relative drift instead — and - // when it does, it names the ceiling `anchor()` would commit today. + // when it does, it names the ceiling `anchor()` would commit today. On a + // sandbox gate whose rows are ANCHORED (`anchoredSandbox`): every committed + // row is RULED since #2031, and the advisory is the anchored kind's. + const sandbox = anchoredSandbox(); write(biz('crm_thing.object.ts'), 'export const a = 1;\n'); write(ux('thing.view.ts'), 'export const c = 2;\n'); @@ -613,8 +659,8 @@ describe('source token ratchet — the ratchet itself', () => { expect(tiny.output).toContain('re-anchor this ceiling to ~1,000'); // A scope sitting inside the buffer says nothing at all: it draws a clean ✓ - // and no advisory under it. Size that scope from the COMMITTED ceiling - // rather than from a literal — the largest reading whose `anchor()` is + // and no advisory under it. Size that scope from the sandbox's anchored + // ceiling rather than from a literal — the largest reading whose `anchor()` is // exactly this ceiling is the freshly-anchored one, the canonical "inside // the buffer", and that is what this case is about. It stays true at every // ceiling: the headroom it leaves is BUFFER/(1 + BUFFER) of the reading, @@ -627,7 +673,7 @@ describe('source token ratchet — the ratchet itself', () => { // only re-anchoring exposes it: at a 39,000 or 41,000 ceiling // `Math.round` is off by one token and `anchor()` is off by a thousand. // `expect(anchor(...))` below is the assertion that catches it. - const ceiling = ceilingOf(SCOPE, 'interaction layer'); + const ceiling = sandbox.get(verdictLabel(SCOPE, 'interaction layer'))!; const target = Math.floor(ceiling / (1 + BUFFER)); // Fill to exactly `target` tokens. `~tokens` is stripped chars / 4, so @@ -660,8 +706,8 @@ describe('source token ratchet — the ratchet itself', () => { /** * The second half of that advisory, and #1928 is what made it necessary. * - * `anchor()` rounds up to the next 1,000. On `src/sales`'s 59,000 that step is - * noise; on `src/marketing`'s interaction ceiling of 1,000 it IS the ceiling — + * `anchor()` rounds up to the next 1,000. On a ceiling the size of `src/sales`'s + * that step is noise; on an interaction ceiling of 1,000 it IS the ceiling — * every reading from 1 to 952 anchors to exactly 1,000. So a freshly anchored * small module sits permanently past the relative trigger (headroom is 14% of * a reading of 876) while `anchor()` has nothing lower to offer. Before this, @@ -671,6 +717,9 @@ describe('source token ratchet — the ratchet itself', () => { * reader to skip the line on the run where it means something. */ it('offers a re-anchor only when `anchor()` would commit a LOWER number', () => { + // Against the sandbox's ANCHORED rows: the committed ones are RULED since + // #2031, and a ruled row is never offered a re-anchor at all. + const sandbox = anchoredSandbox(); write(biz('crm_thing.object.ts'), 'export const a = 1;\n'); write(ux('thing.view.ts'), 'export const c = 2;\n'); @@ -680,7 +729,8 @@ describe('source token ratchet — the ratchet itself', () => { let offered = 0; let suppressed = 0; - for (const row of COMMITTED_CEILINGS as { module: string; label: string; ceiling: number }[]) { + for (const committed of COMMITTED_CEILINGS as { module: string; label: string }[]) { + const row = { ...committed, ceiling: sandbox.get(verdictLabel(committed.module, committed.label))! }; const tokens = measureIn(row.module)[row.label].tokens; const at = lines.findIndex((l) => l.includes(`✓ ${verdictLabel(row.module, row.label)} ~`)); expect(at, `no ✓ row for '${verdictLabel(row.module, row.label)}'`).toBeGreaterThan(-1); @@ -721,23 +771,26 @@ describe('source token ratchet — the ratchet itself', () => { // ruled row that is an instruction, printed as this gate's own // recommendation, to hand back the headroom a maintainer had just granted. // - // ⚠️ No committed ceiling is RULED today. #1905 re-anchored onto the - // `src/sales/` reading, retiring the #1601 grant along with the surface it - // was about, and #1928 anchored all twelve. The kind is still the one the - // next grant declares, so the branch is exercised against a sandbox gate - // that declares it, rather than against whatever the committed table - // happens to hold. That is strictly stronger than pinning a real ruled row: - // it no longer passes by accident the day the table has one, and no longer - // goes vacuous the day it does not. + // ⚠️ Both branches run against a sandbox gate rather than against whatever + // the committed table happens to hold. Until #2031 no committed ceiling was + // RULED, so the ruled branch was the constructed one; since #2031's ×1.5 + // grant every committed ceiling is RULED, so the ANCHORED control is + // constructed too (`anchoredSandbox`), and the ruled branch below patches + // one of its rows back. That is strictly stronger than pinning a real row + // of either kind: it no longer passes by accident the day the table holds + // one, and no longer goes vacuous the day it does not. The committed RULED + // rows themselves are the next case's. // // Which rows nag is still DERIVED from the kinds the gate under test // declares, never listed here: the exemption keys on the ruled/anchored // distinction rather than on a label, so a case naming a layer would pass // for the wrong reason. + const sandbox = anchoredSandbox(); + const sandboxCeiling = (label: string) => sandbox.get(verdictLabel(SCOPE, label))!; write(biz('crm_thing.object.ts'), 'export const a = 1;\n'); write(ux('thing.view.ts'), 'export const c = 2;\n'); - // The ANCHORED control, against the real gate: the app package's rows nag. + // The ANCHORED control, against the sandbox gate: the app package's rows nag. const { status, output } = run(root); expect(status).toBe(0); const scopes = measure(); @@ -751,10 +804,11 @@ describe('source token ratchet — the ratchet itself', () => { // Non-vacuity: the row really is past the trigger AND `anchor()` really // has a lower number to offer, so it WOULD nag if the kind were not // consulted. Both halves, since #1928 added the second one. - expect(ceilingOf(SCOPE, row.label) - tokens, row.label).toBeGreaterThan(tokens * 2 * BUFFER); - expect(anchor(tokens), row.label).toBeLessThan(ceilingOf(SCOPE, row.label)); + expect(sandboxCeiling(row.label) - tokens, row.label).toBeGreaterThan(tokens * 2 * BUFFER); + expect(anchor(tokens), row.label).toBeLessThan(sandboxCeiling(row.label)); - expect(row.kind, `${row.label} — this control assumes every committed ceiling is anchored`) + // Read off the gate's own report: the control's rows really are anchored. + expect(scopes[row.label].kind, `${row.label} — this control runs on anchored rows`) .toBe('anchored'); const lines = output.split('\n'); const at = lines.findIndex((l) => l.includes(`✓ ${verdictLabel(SCOPE, row.label)} ~`)); @@ -765,9 +819,9 @@ describe('source token ratchet — the ratchet itself', () => { // would pass every other assertion in this case. expect(under, row.label).toContain('over twice the 5% buffer'); expect(under, row.label).toContain(`re-anchor this ceiling to ~${fmt(anchor(tokens))}`); - expect(lines[at], row.label).toContain(`ceiling ~${fmt(ceilingOf(SCOPE, row.label))}`); + expect(lines[at], row.label).toContain(`ceiling ~${fmt(sandboxCeiling(row.label))}`); expect(lines[at], row.label).toContain( - `headroom ~${fmt(ceilingOf(SCOPE, row.label) - tokens)}`, + `headroom ~${fmt(sandboxCeiling(row.label) - tokens)}`, ); } @@ -779,7 +833,7 @@ describe('source token ratchet — the ratchet itself', () => { // a patch that matched nothing from passing silently. const gatePath = join(root, GATE); const target = new RegExp( - `(\\{ module: '${SCOPE}', label: '${RULED_LABEL}', ceiling: ${ceilingOf(SCOPE, RULED_LABEL)}, kind: CEILING_KIND\\.)ANCHORED`, + `(\\{ module: '${SCOPE}', label: '${RULED_LABEL}', ceiling: ${sandboxCeiling(RULED_LABEL)}, kind: CEILING_KIND\\.)ANCHORED`, ); const before = readFileSync(gatePath, 'utf8'); const patched = before.replace(target, '$1RULED'); @@ -811,7 +865,7 @@ describe('source token ratchet — the ratchet itself', () => { expect(under).toContain('Lowering it needs a ruling'); // The ✓ row itself is untouched by the kind: the ceiling and the headroom // are still reported, so exempting a row is not hiding it. - const ceiling = ceilingOf(SCOPE, RULED_LABEL); + const ceiling = sandboxCeiling(RULED_LABEL); expect(ruledLines[at]).toContain(`ceiling ~${fmt(ceiling)}`); expect(ruledLines[at]).toContain(`headroom ~${fmt(ceiling - scopes[RULED_LABEL].tokens)}`); @@ -832,6 +886,38 @@ describe('source token ratchet — the ratchet itself', () => { expect(ruledLines[siblingAt + 1] ?? '').not.toContain('ruled ceiling'); }); + it('exempts every committed RULED ceiling, against the real table', () => { + // The committed half of #1607, run on the gate's own table (#2031's ×1.5 + // grant made every committed ceiling RULED). Tiny fixtures put every row far + // past the advisory's trigger with `anchor()` offering a lower number, so a + // ruled row that were read as anchored would print the instruction to hand + // the grant back. Which rows are ruled is read off the committed table, so + // a later re-anchoring of one row moves it out of this case on its own. + seedScope(); + const { status, output } = run(root); + expect(status).toBe(0); + const lines = output.split('\n'); + const ruled = (COMMITTED_CEILINGS as { module: string; label: string; ceiling: number; kind: string }[]).filter( + (row) => row.kind === 'ruled', + ); + expect(ruled.length, 'no committed ceiling is RULED — this case has nothing to run on').toBeGreaterThan(0); + for (const row of ruled) { + const label = verdictLabel(row.module, row.label); + const tokens = measureIn(row.module)[row.label].tokens; + // Non-vacuity: it WOULD nag, were the kind not consulted. + expect(row.ceiling - tokens, label).toBeGreaterThan(tokens * 2 * BUFFER); + expect(anchor(tokens), label).toBeLessThan(row.ceiling); + const at = lines.findIndex((l) => l.includes(`✓ ${label} ~`)); + expect(at, `no ✓ row for '${label}'`).toBeGreaterThan(-1); + expect(lines[at], label).toContain(`ceiling ~${fmt(row.ceiling)}`); + const under = lines[at + 1] ?? ''; + expect(under, label).not.toContain('re-anchor this ceiling to'); + expect(under, label).not.toContain('over twice the 5% buffer'); + expect(under, label).toContain('ruled ceiling'); + expect(under, label).toContain('Lowering it needs a ruling'); + } + }); + it('is red — not silently green — when a measured scope reads as empty', () => { write(residual('thing.report.ts'), 'export const d = 1;\n'); @@ -1303,20 +1389,24 @@ describe('source token ratchet — the header table is derived from the ceilings expect(ruledRows().map(key).sort()).toEqual(declared('ruled')); expect(rows().map(key).sort()).toEqual(declared('anchored')); - // Non-vacuity — one half measured, one half constructed. - // - // The anchored half is measured: at least one committed ceiling is - // anchored, so that comparison is never empty-to-empty. + // Non-vacuity — whichever half the committed table holds is measured, and + // BOTH parsers are proven live against a row in their shape, so an empty + // half never passes empty-to-empty on a parser that stopped reading. // - // The ruled half cannot be, any more. #1905 re-anchored onto the - // `src/sales/` reading, which retired the #1601 grant along with the - // whole-tree surface it was about, and #1928 anchored all twelve. That is - // the finished state of the table, not a disabled mechanism. What an - // empty-to-empty pass WOULD hide is a `RULED_ROW` that has quietly stopped - // parsing, so that is asserted directly instead: the parser is proven live - // against a row in the shape the header carried one, even while the header - // carries none. - expect(declared('anchored').length).toBeGreaterThan(0); + // Until #2031 the anchored half was the measured one and the ruled half + // was empty (#1905 retired the #1601 grant, #1928 anchored all twelve). + // #2031's ×1.5 grant turned that round: every committed ceiling is RULED, + // the ruled half is measured, and the anchored half is the empty one. + expect(declared('ruled').length + declared('anchored').length).toBe(COMMITTED_CEILINGS.length); + expect(Math.max(declared('ruled').length, declared('anchored').length)).toBeGreaterThan(0); + expect( + TABLE_ROW.exec( + ' * src/sales business semantics 55,986 × 1.05 = 58,785 -> ceil 1k -> 59,000 (headroom 3,014, 5.4%) 2026-09-18', + )?.groups, + 'TABLE_ROW no longer parses a worked row in the shape the header carries one. Teach it the ' + + 'new shape — leaving the anchored comparison above to pass on two empty sets is exactly the ' + + 'vacuity this block exists to prevent.', + ).toMatchObject({ module: 'src/sales', label: 'business semantics', reading: '55,986', ceiling: '59,000' }); expect( RULED_ROW.exec( ' * src/sales business semantics ruled 100,000 — a maintainer grant, no reading derives it 2026-09-05', @@ -1337,6 +1427,14 @@ describe('source token ratchet — the header table is derived from the ceilings // The number printed in the row IS the constant committed below it … expect(row.ceiling, row.line).toBe(ceilingOf(row.module, row.label)); + // … the row names the ruling that grants it, and the header carries that + // ruling's record — a grant's only record is its ruling (#2031) … + const ruling = /#(?\d+) ruling/.exec(row.why)?.groups; + expect(ruling, `${row.line}\n a ruled row must name the ruling that grants it`).toBeDefined(); + expect(source(), `${row.line}\n the header carries no record of the #${ruling?.card} ruling`).toContain( + `objectstack-ai/hotcrm#${ruling?.card} (comment `, + ); + // … and the row's own claim, that no reading derives this ceiling, is // checked rather than taken on trust. // @@ -1363,7 +1461,22 @@ describe('source token ratchet — the header table is derived from the ceilings }); it('commits exactly `anchor(reading)` on every row', () => { - expect(rows().length).toBeGreaterThan(0); + // Every committed ANCHORED ceiling has its worked row … + expect(rows().map(key).sort()).toEqual( + (COMMITTED_CEILINGS as { module: string; label: string; kind: string }[]) + .filter((row) => row.kind === 'anchored') + .map(key) + .sort(), + ); + // … and the arithmetic below is live even while that set is empty, as it + // is since #2031 made every committed ceiling RULED: proven on a worked row + // in the shape the header carries one, so an empty loop cannot hide a + // parser or an `anchor()` that stopped agreeing with the table. + const sample = TABLE_ROW.exec( + ' * src/sales business semantics 55,986 × 1.05 = 58,785 -> ceil 1k -> 59,000 (headroom 3,014, 5.4%) 2026-09-18', + )?.groups; + expect(sample, 'TABLE_ROW no longer parses a worked row').toBeDefined(); + expect(anchor(num(sample!.reading))).toBe(num(sample!.ceiling)); for (const row of rows()) { // The number printed in the row IS the constant committed below it … expect(row.ceiling, row.line).toBe(ceilingOf(row.module, row.label)); @@ -1465,6 +1578,9 @@ describe('source token ratchet — the header table is derived from the ceilings ).toContain('1928 门禁 改为 多 sales 模块的门禁'); expect(source()).toContain('给 5% 缓冲'); expect(source()).toContain('translations + seed 肯定是不需要算 token 的'); + // #2031's ×1.5 grant, which every committed ceiling now carries. + expect(source()).toContain('「hotcrm token 上限提升 50%」'); + expect(source()).toContain('「暂时只优先处理升级相关的任务」'); }); }); diff --git a/test/unassigned-case-triage-reach.test.ts b/test/unassigned-case-triage-reach.test.ts index 5c6ba9ad0..4a9df2b89 100644 --- a/test/unassigned-case-triage-reach.test.ts +++ b/test/unassigned-case-triage-reach.test.ts @@ -27,22 +27,17 @@ import { CLAIMABLE_TARGET_STATUSES } from '../src/service/objects/_case-assignme * plugins `objectstack serve` mounts — over the app's own `objectstack.config.ts` * and asks the engine, as a real `service_agent`, what comes back. * - * ### ⚠️ The hazard is the ROW SHAPE, and the two shapes are different inputs + * ### The row shape: an ownerless case holds `owner_id` NULL * - * "No owner" is not one thing in storage: + * A SQL driver materialises every declared column, so an ownerless case has + * `owner_id` **present and NULL**, and the first describe below asserts that + * shape, so the matrix is measured on the input it claims to be. * - * - `driver-memory` (and `driver-mongodb`) store only the columns a row was - * actually written with, so an ownerless case has **no `owner_id` key at - * all**; - * - a SQL driver materialises every declared column, so the same case has - * `owner_id` **present and NULL**. - * - * A marketplace app does not choose its host's datasource, so a predicate that - * answers on one and not the other is a rule that works on half its installs. - * The whole matrix below therefore runs TWICE, once per driver, off one shared - * fixture builder — and `the two drivers really do store different shapes` - * asserts the difference is real, so the two runs cannot be the same measurement - * twice. + * Until 18.0.0-next.0 the matrix also ran on `driver-memory`, whose rows hold + * only the columns they were written with (`owner_id` ABSENT). That driver now + * refuses every tenant-scoped call (`MemoryMultiTenantUnsupportedError`), and + * the maintainer's ruling on #2031 retired its legs: + * 「就应该在 sql 上测试,作废内存驱动的测试」. * * ### ⚠️ Why the rule carries NO `has()` guard * @@ -56,9 +51,11 @@ import { CLAIMABLE_TARGET_STATUSES } from '../src/service/objects/_case-assignme * conditions); it does not reach this one, and applying it here is actively * harmful. `test/sharing-seeding.test.ts` pins the compiler's verdict on * `has()`; what THIS file pins is that the unguarded form nevertheless answers - * correctly on the absent-key shape, which is the reason the guard is not - * needed: `record.owner_id == null` lowers to `{ owner_id: { $null: true } }`, - * and `$null` reads an absent key and a NULL column alike. + * correctly on the ownerless shape the app's datasource stores, which is the + * reason the guard is not needed: `record.owner_id == null` lowers to + * `{ owner_id: { $null: true } }`, a pushdown filter rather than a per-record + * read, and it selects the NULL column. (Until 18.0.0-next.0 this file also + * measured it on the absent-key shape; that leg went with `driver-memory`.) * * ### How to read a failure here * @@ -124,8 +121,8 @@ interface Fixture { /** * The stored `owner_id` as a PRIMITIVE, read fresh from the driver. * - * ⚠️ Deliberately not "the row before" and "the row after". The in-memory - * driver can hand back the very object it stores, so a "before" ROW captured + * ⚠️ Deliberately not "the row before" and "the row after". A driver can hand + * back the very object it stores (`driver-memory` did, #1132), so a "before" ROW captured * into a variable is the same reference the write then mutates — comparing it * afterwards compares a row against itself and passes on unfixed code. A * string (or null) copied out at read time cannot do that. @@ -155,7 +152,7 @@ interface Fixture { * measure. That is not a trick — it is the exact first-install state #596 * describes, where an empty pool is the norm rather than an edge case. */ -async function boot(driver: 'memory' | 'sqlite-wasm'): Promise { +async function boot(driver: 'sqlite-wasm'): Promise { const verify = await bootStack(artifact, { databaseDriver: driver }); const id: Record = {}; @@ -227,8 +224,8 @@ async function boot(driver: 'memory' | 'sqlite-wasm'): Promise { // empty-pool window, for the same reason as the three above — a row created // after the pool is staffed is round-robined onto an agent by // `case_auto_assign` and is not ownerless at all. That is also what gives - // them the driver's genuine ownerless SHAPE (key absent on memory, column - // NULL on SQL), which is the input `case_self_claim`'s guard 3 has to answer. + // them the driver's genuine ownerless SHAPE (the column NULL), which is the + // input `case_self_claim`'s guard 3 has to answer. id.claim_pickup = await insert('crm_case', caseDoc('Claimable — picked up')); id.claim_waiting = await insert('crm_case', caseDoc('Claimable — answered')); // Seeded already `waiting_customer` (an import/migration shape): the status @@ -248,13 +245,12 @@ async function boot(driver: 'memory' | 'sqlite-wasm'): Promise { for (const user of [id.agent, id.other]) { await insert('sys_user_position', { user_id: user, position: 'service_agent' }); } - const [agentSet] = await verify.rows('sys_permission_set', { name: 'service_agent' }); for (const user of [id.agent, id.other]) { - // `permission_set_id` ONLY. `sys_user_permission_set` declares no bare - // `permission_set` column, and writing both keys — which a schemaless - // in-memory driver accepts silently — is rejected outright by SQL: - // "table sys_user_permission_set has no column named permission_set". - await insert('sys_user_permission_set', { user_id: user, permission_set_id: agentSet?.id }); + // By NAME: since 18.0.0-next.0 a declared permission set has no + // `sys_permission_set` row to point an id at (objectstack c8a9783), and a + // grant names its set in `sys_user_permission_set.permission_set` + // (ADR-0131 D4, verify aa71c4d) — the way `signUpPerson` grants one. + await insert('sys_user_permission_set', { user_id: user, permission_set: 'service_agent' }); } // ── the owned population ────────────────────────────────────────────── @@ -328,16 +324,12 @@ async function boot(driver: 'memory' | 'sqlite-wasm'): Promise { } /** - * The two row shapes, as two real datasources. - * - * `memory` is the sparse one — the shape `driver-mongodb` also produces, and the - * only place in the stack that exercises "the key is absent". `sqlite-wasm` is - * the column-complete one, standing in for every SQL host: it goes through - * `@objectstack/driver-sql`'s knex path, so the filter is lowered to real SQL - * rather than evaluated in JS. + * The datasource the matrix runs on. `sqlite-wasm` is column-complete, + * standing in for every SQL host: it goes through `@objectstack/driver-sql`'s + * knex path, so the filter is lowered to real SQL rather than evaluated in JS. + * (The `driver-memory` leg is retired by the #2031 ruling — see the header.) */ -const DRIVERS: Array<{ label: string; driver: 'memory' | 'sqlite-wasm' }> = [ - { label: 'driver-memory (sparse rows — the key is ABSENT)', driver: 'memory' }, +const DRIVERS: Array<{ label: string; driver: 'sqlite-wasm' }> = [ { label: 'sqlite-wasm (column-complete rows — the column is NULL)', driver: 'sqlite-wasm' }, ]; @@ -353,24 +345,15 @@ afterAll(async () => { for (const fixture of fixtures.values()) await fixture.verify.stop(); }); -describe('the two drivers really do store different shapes', () => { - it('memory omits the owner_id key; SQL materialises it as NULL', async () => { - // Anti-vacuum for the whole file: if both drivers stored the same shape, - // running the matrix twice would be one measurement repeated, and the - // "totality" question this card turns on would go unasked. - const sparse = await fixtures.get('memory')!.raw('crm_case', fixtures.get('memory')!.id.unowned_open); +describe('the ownerless case is stored with owner_id NULL', () => { + it('SQL materialises owner_id as NULL', async () => { + // Anti-vacuum for the whole file: the matrix below is about the ownerless + // shape, so the fixture must really hold one. const complete = await fixtures .get('sqlite-wasm')! .raw('crm_case', fixtures.get('sqlite-wasm')!.id.unowned_open); - expect(Object.keys(sparse), 'the memory fixture stored no ownerless case at all').toContain('subject'); - expect( - 'owner_id' in sparse, - 'driver-memory started materialising absent columns — the sparse shape this file ' + - 'exists to cover is no longer being exercised anywhere in the suite', - ).toBe(false); - - expect(Object.keys(complete)).toContain('subject'); + expect(Object.keys(complete), 'the fixture stored no ownerless case at all').toContain('subject'); expect( 'owner_id' in complete, 'the SQL fixture did not materialise owner_id — it is no longer the column-complete shape', @@ -592,8 +575,8 @@ for (const { label, driver } of DRIVERS) { // an owner, and re-using it would be measuring a different question. // // ⚠️ How the "before" state is read matters here. `ownerOf()` copies a - // STRING (or null) out of a fresh read, never a row object: the in-memory - // driver can hand back the object it stores, so a row captured into a + // STRING (or null) out of a fresh read, never a row object: a driver can + // hand back the object it stores (`driver-memory` did), so a row captured into a // variable and re-inspected after the write is the same reference the write // mutated — a comparison against itself, green on unfixed code (#1132). describe('the write half — taking ownership', () => { @@ -601,16 +584,11 @@ for (const { label, driver } of DRIVERS) { const { id, writes, ownerOf, raw, sees } = F(); // The input shape this driver actually presents to the seam's guard 3. - // Asserted per driver, so a claim on the sparse (key-absent) shape and - // a claim on the column-complete (NULL) shape are two measurements and - // never one repeated — the same reason the whole matrix runs twice. const stored = await raw('crm_case', id.claim_pickup); expect( 'owner_id' in stored, - driver === 'memory' - ? 'the memory fixture materialised owner_id — the absent-key shape is no longer covered' - : 'the SQL fixture did not materialise owner_id — the NULL-column shape is no longer covered', - ).toBe(driver !== 'memory'); + 'the SQL fixture did not materialise owner_id — the NULL-column shape is no longer covered', + ).toBe(true); expect(await ownerOf(id.claim_pickup), 'the fixture was not ownerless to begin with').toBeNull(); expect(