diff --git a/.circleci/config.yml b/.circleci/config.yml index 0fb1630fb..57907bf5a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -44,9 +44,8 @@ commands: name: Build bootstrap languages command: pnpm run build-languages - jobs: - build-and-test: + build: machine: true resource_class: coasys/marvin steps: @@ -99,9 +98,16 @@ jobs: command: pnpm test no_output_timeout: 30m - integration-tests-js: + integration-tests-core: machine: true resource_class: coasys/marvin + environment: + # Unique port range for this job's setup executor so it never conflicts + # with integration-tests-multi-user running concurrently on the + # same self-hosted runner (both jobs run after build in parallel). + AD4M_SETUP_GQL_PORT: "15700" + AD4M_SETUP_HC_ADMIN_PORT: "15701" + AD4M_SETUP_HC_APP_PORT: "15702" steps: - setup_integration_test_environment - run: @@ -109,34 +115,42 @@ jobs: command: | # Self-hosted runners reuse workdirs; previous job may have left # an executor alive (exec() shell-wrap means kill() only kills - # the shell, not the executor grandchild). Clear ports before test. + # the shell, not the executor grandchild). Clear our port range. for port in 15700 15701 15702; do - lsof -ti:$port | xargs -r kill -9 2>/dev/null || true + lsof -ti:$port -s TCP:LISTEN | xargs -r kill -9 2>/dev/null || true done + sleep 1 + - run: + name: Prepare test environment + command: cd ./tests/js && pnpm run prepare-test + no_output_timeout: 20m - run: name: Run integration tests - command: cd ./tests/js && pnpm run test-main + command: cd ./tests/js && pnpm run test:ci no_output_timeout: 30m - integration-tests-multi-user-simple: + integration-tests-multi-user: machine: true resource_class: coasys/marvin + environment: + # Unique port range for this job's setup executor — offset by 10 from + # integration-tests-core to prevent conflicts when both jobs run in parallel. + AD4M_SETUP_GQL_PORT: "15710" + AD4M_SETUP_HC_ADMIN_PORT: "15711" + AD4M_SETUP_HC_APP_PORT: "15712" steps: - setup_integration_test_environment - run: - name: Run multi-user-simple integration tests - command: cd ./tests/js && ./test-multi-user-with-setup.sh - no_output_timeout: 30m - - integration-tests-email-verification: - machine: true - resource_class: coasys/marvin - steps: - - setup_integration_test_environment + name: Kill any orphaned executors from previous runs + command: | + for port in 15710 15711 15712; do + lsof -ti:$port -s TCP:LISTEN | xargs -r kill -9 2>/dev/null || true + done + sleep 1 - run: - name: Run email-verification integration tests - command: cd ./tests/js && ./email-verification-test-with-setup.sh - no_output_timeout: 30m + name: Run multi-user integration tests + command: cd ./tests/js && pnpm run test-multi-user + no_output_timeout: 60m integration-tests-cli: machine: true @@ -164,13 +178,10 @@ workflows: version: 2 build-and-test: jobs: - - build-and-test - - integration-tests-js: - requires: - - build-and-test - - integration-tests-multi-user-simple: + - build + - integration-tests-core: requires: - - build-and-test - - integration-tests-email-verification: + - build + - integration-tests-multi-user: requires: - - build-and-test + - build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ebf8a3f4..257621425 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -437,14 +437,6 @@ jobs: tag: ${{ env.NPM_TAG }} access: public - - name: Publish ad4m hook helpers - uses: JS-DevTools/npm-publish@v1 - with: - token: ${{ secrets.COASYS_NPM_TOKEN }} - package: ad4m-hooks/helpers/package.json - tag: ${{ env.NPM_TAG }} - access: public - - name: Publish ad4m react hooks uses: JS-DevTools/npm-publish@v1 with: diff --git a/.github/workflows/publish_staging.yml b/.github/workflows/publish_staging.yml index dd834bded..417d91d6b 100644 --- a/.github/workflows/publish_staging.yml +++ b/.github/workflows/publish_staging.yml @@ -592,7 +592,7 @@ jobs: TAG="${{ env.NPM_TAG }}" echo "Publishing with tag: $TAG" FAILED=0 - for PKG in core connect ad4m-hooks/helpers ad4m-hooks/react ad4m-hooks/vue executor test-runner; do + for PKG in core connect ad4m-hooks/react ad4m-hooks/vue test-runner; do echo "=== Publishing $PKG ===" set +e OUTPUT=$(cd "$PKG" && npm publish --access public --tag "$TAG" --registry https://registry.npmjs.org 2>&1) diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/PR.md b/PR.md new file mode 100644 index 000000000..0f9804e3b --- /dev/null +++ b/PR.md @@ -0,0 +1,267 @@ +# Ad4mModel Refactor — SHACL-native ORM, full test suite refactored + +## Summary + +This PR completes the full `Ad4mModel` refactor started in the `feat/shacl-sdna-migration` base. The legacy Prolog-based subject class system is removed from the TypeScript ORM layer. `Ad4mModel` is now a clean, SHACL-native ORM backed entirely by SurrealDB, with a Prisma-inspired query/decorator API, atomic transactions, reactive subscriptions, and eager-loading via `IncludeMap`. The monolithic 3,917-line `Ad4mModel.ts` has been decomposed into focused modules and is now 759 lines. The full ad4m test suite has been refactored. + +--- + +## What changed + +### 🏗️ Phase 1 — Prolog removal + +- **Delete `Subject.ts`** — inlined the necessary bits into `PerspectiveProxy` as a local class (Phase 1c) +- **Strip all Prolog query paths** from `Ad4mModel.ts` and `decorators.ts` (Phase 1b): + - Deleted `queryToProlog()`, `instancesFromPrologResult()`, `countQueryToProlog()`, and all `build*Query` helpers that only served them + - Deleted `@InstanceQuery` decorator and `InstanceQueryParams` interface + - Deleted `generateSDNA()` from `@ModelOptions` + - Removed `prologGetter` / `prologSetter` from `PropertyOptions`, `PropertyMetadata`, `Optional()`, `Property()`, `ReadOnly()` interfaces + - Removed `useSurrealDB` flag and `useSurrealDB()` method from `ModelQueryBuilder` + - Renamed `makeRandomPrologAtom` → `makeRandomId` + +--- + +### 🎨 Phase 2 — New decorator & relation API + +- Introduced **`@Model`, `@Field`, `@HasMany`, `@HasOne`, `@BelongsToOne`, `@BelongsToMany`** decorators +- **WeakMap metadata registry** keyed on constructor (not prototype) — eliminates a silent inheritance/data-corruption bug for subclassed models +- **`@BelongsToOne` / `@BelongsToMany`** decorators with `direction='reverse'`/`maxCount`; `HasMany`/`HasOne` accept an optional model factory argument +- **Renamed `setCollection*` → `set*`** throughout the public API; `PerspectiveProxy.isCollectionSetter()` now uses metadata instead of a string prefix check +- **Renamed `collection*` → `relation*`** everywhere in the stack, including Rust: `CollectionSetter` enum variant → `RelationSetter` (with `#[serde(rename)]`) +- **`@HasOne` hydration fix** — `getData()` and `instancesFromSurrealResult()` now apply a `maxCount === 1` guard so `@HasOne` fields resolve to a scalar string, not an array +- **`@Flag` SHACL wiring** — `generatePropertySetterAction()` throws if `metadata.flag` is set; `innerUpdate()` skips flag fields; flags are immutable after creation; `@Flag` now automatically sets `readOnly: true` +- **Renamed `writable` → `readOnly`** throughout the stack (breaking, inverted semantics: `readOnly: true` means no setter; default `undefined`/`false` means writable): `PropertyOptions.writable → readOnly`, `SHACLPropertyShape.writable → readOnly` in `toTurtle`/`toLinks`/`fromLinks`/`toJSON`/`fromJSON`, Rust `PropertyShape.writable → read_only` with `#[serde(rename = "readOnly")]`, `PerspectiveProxy.writablePredicates → readOnlyPredicates` with inverted guard logic for `isSubjectInstance` matching +- **`@Property` auto-derive initial** — `generateSHACL()` now auto-derives `effectiveInitial = "literal://string:"` for any writable non-flag property without an explicit `initial`; destructor actions always added for all writable non-flag properties; users only need `initial:` for semantic defaults (e.g. `"todo://ready"`) +- Updated `@we/models` and the test app to the new decorator names + +--- + +### 🔬 Phase 3 — Decompose `Ad4mModel.ts` into focused modules + +`Ad4mModel.ts` went from **3,917 → 759 lines** across the following extractions: + +| File | Contents | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `model/types.ts` | `WhereCondition`, `Query`, `IncludeMap`, `ModelMetadata`, subscription types | +| `model/schema/metadata.ts` | `getModelMetadata()`, `assignValuesToInstance()`, `ModelValueTuple` | +| `model/schema/fromJSONSchema.ts` | `createModelFromJSONSchema`, `determinePredicate`, `determineNamespace` | +| `model/query/SurrealQueryBuilder.ts` (→ `surrealCompiler.ts`) | Pure SurrealQL builder helpers | +| `model/query/operations.ts` | `queryToSurrealQL`, `instancesFromSurrealResult`, `findAll`, `findOne`, `findAllAndCount`, `paginate`, `count` | +| `model/query/hydration.ts` | `hydrateInstanceFromLinks()`, `evaluateCustomGetters()` — shared between single-instance and bulk paths | +| `model/query/fetchInstance.ts` | 6-stage single-instance hydration pipeline (`getData()` body) | +| `model/query/QueryBuilder.ts` (→ `ModelQueryBuilder`) | Fluent query builder class | +| `model/mutation.ts` | `MutationContext`, `setProperty`, `setRelation*`, `saveInstance`, `innerUpdate`, `cleanCopy` | +| `model/transaction.ts` | `runTransaction`, `TransactionContext` | +| `model/subscription.ts` | `createSubscription()`, shared registry, fingerprinting | + +**Unified hydration** — `getData()` (single-instance) and `instancesFromSurrealResult()` (bulk) previously had divergent hydration implementations (one using latest-wins, the other first-wins); both now share `hydrateInstanceFromLinks()` from `hydration.ts` with consistent latest-wins ordering. + +**Transaction API (Phase 3b)** + +```ts +// Before (fragile — leaked batch if any save threw): +const batchId = await perspective.createBatch(); +await a.save(batchId); +await b.save(batchId); +await perspective.commitBatch(batchId); + +// After: +await Ad4mModel.transaction(perspective, async (tx) => { + await a.save(tx.batchId); + await b.save(tx.batchId); +}); +``` + +**Include API / IncludeMap (Phase 3c → 3f)** + +```ts +// Prisma-style eager loading — relations stay as bare IDs unless include is set +Recipe.findAll(perspective, { + include: { + comments: true, + tags: { where: { active: true }, limit: 5 }, + }, +}); +``` + +Removed the old `relations` field and `.relations()` fluent method. Nested include maps are supported: setting a relation key to another `IncludeMap` object (rather than `true`) recursively hydrates that relation's own relations. + +**Link-listener subscription API (Phase 3d)** + +- New `createSubscription()` in `subscription.ts` — fires immediately then on every relevant `link-added`/`link-removed` +- Relevance check: only re-queries when a changed link's predicate belongs to the model (properties + relations); optional source filter +- **Shared registry**: identical `(model, query)` pairs on the same perspective share one `findAll()` execution and one set of link listeners via a `WeakMap`-keyed registry +- **Result fingerprinting** (`stableFingerprint`): callbacks only fire when the result set actually changed +- **Late subscriber fast-path**: second subscriber receives cached `lastResults` immediately via microtask +- **Last-subscriber teardown**: shared entry and listeners are fully cleaned up when listener count reaches zero +- 50 ms coalesce window (`SETTLE_MS`) to batch rapid link events from a single `save()` into one re-query +- `.live(callback)` fluent terminal on `ModelQueryBuilder` alongside `.get()` + +**Server-push SurrealDB subscription removal (Phase 3e)** + +Deleted the legacy server-side SurrealDB subscription system that was made redundant by Phase 3d's client-side subscription registry: + +_TypeScript (client):_ + +- Removed `isSurrealDB` field from `QuerySubscriptionProxy` and its `if/else` surreal branches in `subscribe()`, keepalive loop, and `dispose()` +- Deleted `subscribeSurrealDB()` from `PerspectiveProxy` +- Deleted `perspectiveSubscribeSurrealQuery()`, `perspectiveKeepAliveSurrealQuery()`, `perspectiveDisposeSurrealQuerySubscription()` from `PerspectiveClient` + +_Rust (server):_ + +- Deleted `SurrealSubscribedQuery` struct +- Dropped `trigger_surreal_subscription_check` and `surreal_subscribed_queries` fields + `Arc::new()` initialisers from `PerspectiveInstance` +- Removed `surreal_subscription_cleanup_loop()` from `start_background_tasks()` +- Deleted 5 functions: `subscribe_and_query_surreal`, `keepalive_surreal_query`, `dispose_surreal_query_subscription`, `surreal_subscription_cleanup_loop`, `check_surreal_subscribed_queries` +- Deleted 3 GraphQL mutation resolvers: `perspective_subscribe_surreal_query`, `perspective_keep_alive_surreal_query`, `perspective_dispose_surreal_query_subscription` +- Removed two `trigger_surreal_subscription_check` trigger lines from the link-added/link-removed update paths + +Net: –876 lines, +769 lines across 4 files (the `PerspectiveClient` additions are JSDoc and test-fixture refactoring). + +--- + +### 🚀 Phase 4 — Advanced features + +- **Model inheritance via SHACL `sh:node`** — `SHACLShape` gains `parentShapes`, `addParentShape()`, and emits `sh:node` in both `toTurtle()` and `toLinks()` serializers; child shape uses only own properties/relations and references the parent via `sh:node` instead of duplicating; runtime inheritance already worked via the WeakMap prototype-chain walk in `getPropertiesMetadata`/`getRelationsMetadata` +- **`Ad4mModel.create(perspective, data)`** — static factory that constructs, assigns, and saves in one call +- **`Ad4mModel.register(perspective)`** — thin wrapper around `ensureSDNASubjectClass()` for a consistent static API +- **Remove `has_child` and source-scoping** — removed source param from constructor, `has_child` link write on create path, `Query.source`, `ModelQueryBuilder.source()`, and the source-scoped relevance check in subscription; legacy perspectives had meaningless `source → ad4m://has_child → baseExpression` links +- **`baseExpression` → `id`** throughout the `Ad4mModel` layer (breaking): `get baseExpression()` public getter removed, private field renamed, `MutationContext.baseExpression` → `id`; `PerspectiveProxy` and the Rust executor retain `baseExpression` as the correct protocol-level vocabulary +- **Remove `isInstance`, `prologCondition`, `where.condition`** from the query API +- **Drop `run()` alias** on `ModelQueryBuilder` +- **Fix `stableFingerprint`** — was always `undefined` because it referenced the deleted `baseExpression` field; now uses `id` +- **Fix multi-field sort** dropping all but the first key in `operations.ts` +- **Fix `count()` N+1** — added `hasJsFilterConditions()` fast path; `count()` was hydrating all rows just to count them +- **`resolveLanguage: "literal"` is now the implicit default** — `decorators.ts` auto-injects it into the SHACL shape for all non-flag properties so the Rust executor always uses `fn::parse_literal`; `surrealCompiler.ts` uses `fn::parse_literal` for both `undefined` and `"literal"` in WHERE comparisons; all explicit `resolveLanguage: "literal"` annotations removed from decorators and `fromJSONSchema` calls +- **Nested include support** — `operations.ts` now follows `Query.include` maps recursively (was hard-coded to `false` for sub-queries); `TestReaction` fixture model added; 4 new integration tests covering nested include maps, multi-level depth guard, mixed flat/nested, and empty nested maps + +--- + +### 🔄 SHACL / SurrealDB migration + +- **Migrated all flow methods to SHACL** — rewrote `sdnaFlows`, `availableFlows`, `startFlow`, `expressionsInFlowState`, `flowState`, `flowActions`, `runFlowAction` in `PerspectiveProxy.ts` to use `SHACLFlow`/`getFlow()` instead of Prolog `infer()` calls +- **Removed `parse_prolog_sdna_to_shacl_links`** (328-line Rust function) and its backward-compat Prolog→SHACL code generation from `add_sdna`; SHACL→Prolog direction (`shacl_to_prolog.rs`) is kept for compatibility +- **Extracted `shacl_to_prolog.rs`** — SHACL→Prolog compat code moved into its own module with comprehensive unit tests +- **Removed `subjectClassesFromSHACL` GraphQL endpoint** — replaced with client-side SHACL link queries via `findClassByProperties()` +- **Replaced `buildQueryFromTemplate()` / Prolog-based `SubjectClassOption`** with client-side SHACL matching via `findClassByProperties()`; then replaced by a single SurrealDB query (two-pass client-side processing) to avoid N+1 round trips; `subjectClassesByTemplate` falls back to `findClassByProperties()` when class name lookup fails +- **Used `SHACLShape.toJSON()`** in `ensureSDNASubjectClass` instead of manual JSON; `getSubjectClassMetadataFromSDNA` now uses `getShacl()`/`SHACLShape.fromLinks()` +- **Batched SHACL/Flow link writes** — `addShacl()`/`addFlow()` use `addLinks()` batch API; `getShacl()`/`getFlow()` use a single `querySurrealDB()` call instead of individual `get()` calls +- **Converted `ends_with` filters** to SurrealDB `string::ends_with` queries (5 functions in `perspective_instance.rs` that previously fetched all and filtered in memory) +- Fixed SurrealQL function name: `string::starts_with` not `starts::with`; replaced `SQL LIKE` with `string::starts_with` in `getFlow()` +- Fixed `sdnaCode` nullable in GraphQL schema — was `String!`, must be `String` since Prolog is now optional +- **Executor commit ordering fix** — `update_prolog_engines()` (which spawns the pubsub `link-added` task) previously ran _before_ `persist_link_diff()` (the SurrealDB write); any subscriber calling `findAll()` immediately on `link-added` would read stale data; order swapped so SurrealDB is committed before pubsub fires + +--- + +### 🧪 Test suite refactor + +**Infrastructure:** + +- Added `helpers/` directory: `ports.ts` (dynamic port allocation), `executor.ts` (`AgentHandle`), `assertions.ts` (`waitUntil`), `index.ts` (barrel) +- Added `wipePerspective()` export to `utils/utils.ts` +- Centralized `global.fetch` polyfill into `tests/setup.ts` +- Migrated auth and multi-user tests to a shared `startAgent` helper +- Removed `findAndKillProcess` entirely — matched any process by name and could kill a live AD4M instance; teardown already uses `tree-kill` with the specific child PID + +**New model test suite** (`tests/model/`): + +- `models.ts` — `TestPost`, `TestComment`, `TestTag`, `TestBaseModel`, `TestDerivedModel` fixture models +- `model-core.test.ts` — 20 CRUD + decorator coverage tests +- `model-query.test.ts` — `where`/`order`/`limit`/`offset`/`count`/`paginate`/`findAllAndCount`/`include` sub-query tests +- `model-subscriptions.test.ts` — live subscription tests using `.live()` API +- `model-transactions.test.ts` — batch/transaction pattern tests including rollback (7 tests) +- `model-inheritance.test.ts` — metadata isolation, SHACL, polymorphic `findAll` tests +- `model-where-operators.test.ts` — 9 tests covering all `WhereCondition` operators (`IN`, `not`, `contains`, `gt`, `gte`, `lt`, `lte`, `between`) +- `model-prolog.test.ts` — 5 pure-function tests for `generatePrologFacts()` + 2 executor `infer()` tests + +**Key fixes found during test rebuild:** + +- `register()` calls added to `beforeEach` in all model test files — SHACL definitions are stored as links and therefore don't survive `wipePerspective()` +- `resolveLanguage: 'literal'` on properties so SurrealQL `WHERE` uses `fn::parse_literal(out.uri) = 'value'` (fixes `where:{title:...}` and `count()` with `where`) +- Transaction tests rewritten to avoid `create+delete` within the same batch (the runtime doesn't handle constructor+destructor for the same entity in one committed batch) +- Fixed same-batch double-save bug: `#savedOnce` flag on instances passes an `alreadyExists` hint to `saveInstance()` to skip the SurrealDB existence-check on subsequent saves in the same uncommitted batch +- Fixed `@BelongsToMany` `include` hydration + +**Reorganisation:** + +- Split monolithic `multi-user-simple.test.ts` into **8 focused test files** +- Reorganised tests into subfolders (`auth/`, `model/`, `sdna/`, `multi-user/`) +- Renamed `prolog-and-literals.test.ts` → `sdna.test.ts`; removed duplicate model tests from it +- Script renames across the monorepo: `test-main` → `test`, `test-all` → `test-run`, `test-run` → `test:ci`; removed legacy aliases; combined auth scripts; folded `test-from-json-schema` into `test-model` + +**Test runner fixes:** + +- Replaced stale `findAndKillProcess('holochain')` / `findAndKillProcess('lair-keystore')` with `findAndKillProcess('ad4m')` — everything is a single `ad4m-executor` process since the Rust refactor +- Switched from downloading stale pre-built language bundles (CJS, required ESM conversion) to the bootstrap seed (`tests/js/bootstrapSeed.json`); the language-language fetches other system languages by hash from the Cloudflare bootstrap store at runtime; removed `node-wget-js` and `unzipper` dependencies +- Shell `EXIT` trap added to the Mocha run script — guarantees executor cleanup after the last suite finishes or on unexpected failure +- `AgentHandle.stop()` now clears and unrefs the fallback `SIGKILL` timer after a clean exit so Node doesn't linger waiting for an unreferenced timer handle +- `injectPublishingAgent.js` retries with exponential backoff — prevents flaky failures when the executor is slow to become ready on a heavily loaded CI machine +- Added `TestReaction` model and 4 nested-include integration tests to `model-query.test.ts` +- **`@BelongsToMany` CI stabilisation** — wrapped `findOne` call in `waitUntil` (5 s timeout, 100 ms poll) so the test retries until SurrealDB propagates the relation links, eliminating a read-cache race that caused intermittent failures on slower CI machines +- **Triple-agent DHT gossip lag fix** (`triple-agent-test.suite.ts`) — the `expected 16 to equal 20` flake was caused by Holochain `HolochainRetreiver: Could not find entry` errors after Jim's node failed to read entries that existed in Alice and Bob's chains but hadn't propagated to Jim's routing table; fixed by: calling `makeAllThreeNodesKnown()` again mid-test immediately before Jim's poll to refresh all three nodes' routing tables; adding a 10 s pre-sleep before Jim's first query; increasing per-retry interval from 1 s → 3 s; increasing retry count from 20 → 40; bumping the integration suite `this.timeout` from 200 s → 420 s + +--- + +### 🔧 Executor — JS bundle Deno ESM compatibility + +- **`executor/esbuild.ts` post-build buffer patch** — `safe-buffer@5.2.1` (inside `@transmute/did-key-ed25519`) was bundled as a `__commonJS` wrapper that emitted `var buffer2 = __require("buffer")`. Deno's ESM runtime cannot execute `require()` calls, so `coreInit()` threw `Dynamic require of "buffer" is not supported`, the executor started its HTTP server but silently failed all GraphQL operations, and integration tests hung until timeout. Fixed by adding a post-build regex replacement in `esbuild.ts` that rewrites all `__require("buffer"|"node:buffer")` calls to reference the existing top-level `import buffer from "node:buffer"` already present in the bundle. +- Added `// @ts-nocheck` to `executor/esbuild.ts` — tsc never processes this Deno script; suppresses ~35 pre-existing URL-import and implicit-any errors. + +--- + +### ⚙️ CI — migrated to self-hosted runner + +- **Replaced Docker executor** (`coasys/ad4m-ci-linux`) **with self-hosted machine runner** `coasys/marvin` (AMD Ryzen 9 9950X, 60 GB RAM, Ubuntu 25.04; Rust 1.92, Node 18, Deno, Go, pnpm pre-installed) +- **Removed remote cache** — `restore/save_cache` was uploading/downloading the full Rust `target/` dir (~20 GB) causing 29-minute cache restore steps on every run; the persistent machine runner does incremental compilation instead (`cleanup_working_directory: false`) +- **Fixed nvm stdout corruption** in cargo build scripts — `nvm use 18` in `$BASH_ENV` printed `Now using node v18.20.8` to stdout which corrupted the `libffi` link step via a bash syntax error in a subshell; fixed by adding the Node 18 bin dir directly to `PATH` + +--- + +### 🪝 ad4m-hooks — slim to `useModel`-only + +- **Deleted `helpers/src/`** entirely — `SubjectRepository`, `SubjectFactory`, `cache.ts`, `getProfile.ts` and their index re-exports removed; these relied on the legacy Subject proxy API and are superseded by `Ad4mModel` +- **Deleted from both react and vue** — `useMe`, `usePerspectives`, `useAgent`, `usePerspective`, `register` — these are app-level concerns already implemented in consumer repos (Flux); keeping them in the hook library created a coupling to the old agent API +- **Rewrote `react/src/useModel.ts`** with the new `Ad4mModel` API: + - `.live()` / `.unsubscribe()` replaces the old `.subscribe()` / `.dispose()` pair + - Growing-window pagination via `live({ limit: pageSize * pageNumber })` + - Entries keyed on `entry.id` (was `entry.baseExpression`) + - Single `useEffect` with proper cleanup — eliminates the previous two-effect mount chain that could leave stale subscriptions +- **Rewrote `vue/src/useModel.ts`** with the same design: + - `isRef()` normalisation of the `perspective` prop so both raw values and refs are accepted + - Active subscription torn down on `perspective` / `query` / `page` change and on `onUnmounted` + - Same growing-window pagination strategy +- Net: –1,292 lines, +144 lines (21 files) + +--- + +### 🐛 Other bug fixes + +- **WebSocket 1006 `isSocketCloseError` guard** — added `isSocketCloseError(e)` to `core/src/utils.ts`; applied as an error-handler guard across all six Apollo/graphql-ws subscription clients (`PerspectiveClient`, `AgentClient`, `RuntimeClient`, `AIClient`, `NeighbourhoodClient`, `NeighbourhoodProxy` — 16 handlers total); the 1006 "abnormal closure" event fires as an unhandled Observable rejection on every executor teardown, flooding the test runner with red noise; errors are now silently swallowed only for the expected shutdown case (`e.code === 1006` or `message.startsWith("Socket closed with event 1006")`) +- Fixed neighbourhood signal routing: `send_signal()` and `send_broadcast()` in `perspective_instance.rs` only searched `list_user_emails()` to decide if a recipient is local; the main agent has no email so signals to it fell through to the link language (which doesn't loop back), causing silent delivery failure when a managed user and the main agent co-owned a neighbourhood; fixed to also check the main agent's DID +- Fixed broadcast loopback — broadcasts skip local delivery when `owners` is `None` or empty; `owners=None/[]` treated as implicit main-agent ownership for legacy perspectives +- Fixed `innerUpdate` emitting `Property X has no metadata, skipping` noise for generated relation methods and un-decorated fields; `setProperty` now throws for truly unknown direct calls +- Fixed `save()` create/update routing: checks SurrealDB for existing links before branching; `setProperty()` now encodes raw values as `literal://` URIs before passing to `executeAction`, mirroring Rust's `resolve_property_value` +- Fixed `queryToSurrealQL` SELECT — was `->link AS links` (returns target node records, `id`/`uri` only); fixed with a correlated subquery returning `predicate`, `out.uri as target`, `author`, `timestamp`; also removed phantom `$perspective` filter (no such field on the link table — it was a silent no-op) +- Fixed quote escape no-op in `fetchInstance.ts` (`.replace(/'/g, "'")` replaced with same character) +- Fixed `infer()` call in `perspective_instance.rs` not awaiting `findAndKillProcess` in error handlers +- Multi-user test suite fixes: auth flow, error strings, URI validation, bootstrap timing +- **Literal-guard fix (from `origin/dev` merge)** — dev commits `70d1d508` / `d7a2e708` added a `propMeta.resolveLanguage === 'literal'` guard before `literal://` URI parsing in the legacy monolithic `Ad4mModel.ts` to prevent crashing on non-literal string values; our decomposed `hydration.ts` (`resolveValue()`) already incorporates an equivalent and strictly-superior guard — `(resolveLanguage === "literal" || resolveLanguage === undefined)` — covering both explicit literal properties and plain undecorated string fields +- **`Literal.get()` strict boolean parsing** — rejects payloads that are not exactly `"true"` or `"false"` instead of silently coercing via `Boolean()`, preventing non-boolean literals from accidentally passing a boolean type check +- **`Literal.toUrl()` throw on unsupported type** — throws a descriptive error instead of returning a malformed URL for unsupported literal types +- **`RelationSetter` backwards-compat alias** — Rust `CollectionSetter` enum variant gains a `#[serde(alias = "collectionSetter")]` so existing serialised data written with the old name continues to deserialise correctly +- **`matchesCondition` fix for combined operators** — `not` and `between` folded into the `allMet` chain in `surrealCompiler.ts` so they compose correctly with other `WhereCondition` operators +- **`normalizeTimestamp` for numeric min/max** — `hydrateInstanceFromLinks` now normalises timestamps before numeric min/max comparison, fixing latest-wins ordering for links with sub-second timestamps +- **Relation decorator validation** — `@HasMany`, `@HasOne`, `@BelongsToMany`, and `@BelongsToOne` throw a clear error at class-definition time if the `through` predicate is missing, instead of silently producing broken SHACL shapes +- **Neighbourhood Apollo signal subscription disposal** — `NeighbourhoodClient` stores the `ZenObservable` subscription returned by `.subscribe()` in a `#signalSubscriptions` map; `removeSignalHandler()` calls `.unsubscribe()` and fully cleans up both maps when the last handler for a perspective is removed, preventing a leaked WebSocket stream +- **`rust-executor` agent data directory** — `init_global_instance()` now creates `{app_path}/ad4m/` before constructing `AgentService`; the nested subdirectory was never created so `std::fs::write()` panicked with `Failed to write agent file: Os { code: 2, kind: NotFound }` on fresh CI workdirs + +--- + +### 📄 Documentation + +- Added `AD4M-MODEL-REFACTOR.md` refactor plan (continuously updated through 2026-02-24); backlog items for future PRs consolidated into `core/src/model/IMPROVEMENTS.md` +- Added `core/src/model/README.md` — architecture overview, full Recipe example, decorator reference table, query API cheatsheet, transaction pattern, `fromJSONSchema` examples, inheritance notes; corrected two method name errors after initial authoring (`.run()` → `.get()`, `.subscribe()` → `.live()` on the fluent builder) +- Added Phase G deprecation plan — documents what can be removed from `PerspectiveProxy` / Rust once Flux and `ad4m-hooks` migrate off the `Subject` proxy API to `Ad4mModel` (`getSubjectData`, `getSubjectProxy`, Rust `get_subject_data()`) +- Added `SUBSCRIPTION_STRATEGY.md` — documents client-side subscription architecture, multi-user node compatibility, shared registry design +- Trimmed JSDoc verbosity by 24–37% across `Ad4mModel.ts`, `decorators.ts`, `transaction.ts`, `types.ts` +- Updated CHANGELOG with 21 unreleased entries since 0.11.1 (Fixed ×11, Added ×5, Changed ×5) diff --git a/ad4m-hooks/helpers/package.json b/ad4m-hooks/helpers/package.json deleted file mode 100644 index acd5bf5a4..000000000 --- a/ad4m-hooks/helpers/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@coasys/hooks-helpers", - "description": "", - "main": "./src/index.ts", - "module": "./src/index.ts", - "private": false, - "type": "module", - "files": [ - "src" - ], - "scripts": {}, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@coasys/ad4m": "workspace:*", - "uuid": "*" - }, - "devDependencies": { - "@types/uuid": "^9.0.0" - }, - "publishConfig": { - "dependencies": { - "@coasys/ad4m": "*" - } - }, - "version": "0.12.0-rc1-dev.2" -} diff --git a/ad4m-hooks/helpers/src/cache.ts b/ad4m-hooks/helpers/src/cache.ts deleted file mode 100644 index 10f6d9ee1..000000000 --- a/ad4m-hooks/helpers/src/cache.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { PerspectiveProxy } from "@coasys/ad4m"; - -const cache: Map = new Map(); -const subscribers: Map = new Map(); - -export function getCache(key: string) { - const match: T | undefined = cache.get(key); - return match; -} - -export function setCache(key: string, value: T) { - cache.set(key, value); - getSubscribers(key).forEach((cb) => cb()); -} - -export function subscribe(key: string, callback: Function) { - getSubscribers(key).push(callback); -} - -export function unsubscribe(key: string, callback: Function) { - const subs = getSubscribers(key); - const index = subs.indexOf(callback); - if (index >= 0) { - subs.splice(index, 1); - } -} - -export function getSubscribers(key: string) { - if (!subscribers.has(key)) subscribers.set(key, []); - return subscribers.get(key)!; -} - -export function subscribeToPerspective( - perspective: PerspectiveProxy, - added: Function, - removed: Function -) { - const addedKey = `perspective-${perspective.uuid}-added`; - const removedKey = `perspective-${perspective.uuid}-removed`; - - if (!subscribers.has(addedKey)) { - console.log("subscribing!"); - perspective.addListener("link-added", (link) => { - subscribers.get(addedKey)!.forEach((cb) => cb(link)); - return null; - }); - } - - if (!subscribers.has(removedKey)) { - perspective.addListener("link-removed", (link) => { - subscribers.get(removedKey)!.forEach((cb) => cb(link)); - return null; - }); - } - - subscribe(addedKey, added); - subscribe(removedKey, removed); -} - -export function unsubscribeFromPerspective( - perspective: PerspectiveProxy, - added: Function, - removed: Function -) { - const addedKey = `perspective-${perspective.uuid}-added`; - const removedKey = `perspective-${perspective.uuid}-removed`; - - unsubscribe(addedKey, added); - unsubscribe(removedKey, removed); -} diff --git a/ad4m-hooks/helpers/src/factory/SubjectRepository.ts b/ad4m-hooks/helpers/src/factory/SubjectRepository.ts deleted file mode 100644 index 13f083850..000000000 --- a/ad4m-hooks/helpers/src/factory/SubjectRepository.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { - PerspectiveProxy, - Link, - Subject, - Literal, - LinkQuery, -} from "@coasys/ad4m"; -import { setProperties } from "./model"; -import { v4 as uuidv4 } from "uuid"; - -export const SELF = "ad4m://self"; - -export type ModelProps = { - perspective: PerspectiveProxy; - source?: string; -}; - -export class SubjectRepository { - source = SELF; - subject: SubjectClass | string; - perspective: PerspectiveProxy; - tempSubject: any | string; - - constructor(subject: { new (): SubjectClass } | string, props: ModelProps) { - this.perspective = props.perspective; - this.source = props.source || this.source; - this.subject = typeof subject === "string" ? subject : new subject(); - this.tempSubject = subject; - } - - get className(): string { - return typeof this.subject === "string" - ? this.subject - : this.subject.className; - } - - async ensureSubject() { - if (typeof this.tempSubject === "string") return; - await this.perspective.ensureSDNASubjectClass(this.tempSubject); - } - - async create( - data: SubjectClass, - id?: string, - source?: string - ): Promise { - await this.ensureSubject(); - const base = id || Literal.from(uuidv4()).toUrl(); - - let newInstance = await this.perspective.createSubject(this.subject, base); - - if (!newInstance) { - throw "Failed to create new instance of " + this.subject; - } - - // Connect new instance to source - await this.perspective.add( - new Link({ - source: source || this.source, - predicate: "ad4m://has_child", - target: base, - }) - ); - - Object.keys(data).forEach((key) => - data[key] === undefined || data[key] === null ? delete data[key] : {} - ); - - setProperties(newInstance, data); - - // @ts-ignore - return this.getSubjectData(newInstance); - } - - async update(id: string, data: QueryPartialEntity) { - const instance = await this.get(id); - - if (!instance) { - throw "Failed to find instance of " + this.subject + " with id " + id; - } - - Object.keys(data).forEach((key) => - data[key] === undefined ? delete data[key] : {} - ); - - // @ts-ignore - setProperties(instance, data); - - return this.getSubjectData(instance); - } - - async remove(id: string) { - if (this.perspective) { - const linksTo = await this.perspective.get(new LinkQuery({ target: id })); - const linksFrom = await this.perspective.get( - new LinkQuery({ source: id }) - ); - this.perspective.removeLinks([...linksFrom, ...linksTo]); - } - } - - async get(id: string): Promise { - if (id) { - await this.ensureSubject(); - const subjectProxy = await this.perspective.getSubjectProxy( - id, - this.subject - ); - - // @ts-ignore - return subjectProxy || null; - } else { - const all = await this.getAll(); - return all[0] || null; - } - } - - async getData(id: string): Promise { - const entry = await this.get(id); - if (entry) { - // @ts-ignore - return await this.getSubjectData(entry); - } - - return null; - } - - private async getSubjectData(entry: any) { - let links = await this.perspective.get( - new LinkQuery({ source: entry.baseExpression }) - ); - - let data: any = await this.perspective.getSubjectData(this.subject, entry.baseExpression) - - for (const key in data) { - if (this.tempSubject.prototype?.__properties[key]?.transform) { - data[key] = - this.tempSubject.prototype.__properties[key].transform(data[key]); - } - } - - return { - id: entry.baseExpression, - timestamp: links[0].timestamp, - author: links[0].author, - ...data, - } - } - - async getAll(source?: string, query?: QueryOptions): Promise { - await this.ensureSubject(); - - const tempSource = source || this.source; - - let res = []; - - if (query) { - try { - const queryResponse = ( - await this.perspective.infer( - `findall([Timestamp, Base], (subject_class("${this.className}", C), instance(C, Base), link("${tempSource}", Predicate, Base, Timestamp, Author)), AllData), length(AllData, DataLength), sort(AllData, SortedData).` - ) - )[0]; - - if (queryResponse.SortedData >= query.size) { - const isOutofBound = - query.size * query.page > queryResponse.DataLength; - - const newPageSize = isOutofBound - ? queryResponse.DataLength - query.size * (query.page - 1) - : query.size; - - const mainQuery = `findall([Timestamp, Base], (subject_class("${this.className}", C), instance(C, Base), link("${tempSource}", Predicate, Base, Timestamp, Author)), AllData), sort(AllData, SortedData), reverse(SortedData, ReverseSortedData), paginate(ReverseSortedData, ${query.page}, ${newPageSize}, PageData).`; - res = await this.perspective.infer(mainQuery); - - //@ts-ignore - res = res[0].PageData.map((r) => ({ - Base: r[1], - Timestamp: r[0], - })); - } else { - res = await this.perspective.infer( - `subject_class("${this.className}", C), instance(C, Base), triple("${tempSource}", Predicate, Base).` - ); - } - } catch (e) { - console.log("Query failed", e); - } - } else { - res = await this.perspective.infer( - `subject_class("${this.className}", C), instance(C, Base), triple("${tempSource}", Predicate, Base).` - ); - } - - const results = - res && - res.filter( - //@ts-ignore - (obj, index, self) => index === self.findIndex((t) => t.Base === obj.Base) - ); - - if (!res) return []; - - const data = await Promise.all( - results.map(async (result: any) => { - let subject = new Subject( - this.perspective!, - //@ts-ignore - result.Base, - this.className - ); - - await subject.init(); - - return subject; - }) - ); - - // @ts-ignore - return data; - } - - async getAllData( - source?: string, - query?: QueryOptions - ): Promise { - const subjects = await this.getAll(source, query); - - const entries = await Promise.all( - subjects.map((e) => this.getSubjectData(e)) - ); - - // @ts-ignore - return entries; - } -} - -export type QueryPartialEntity = { - [P in keyof T]?: T[P] | (() => string); -}; - -export type QueryOptions = { - page: number; - size: number; - infinite: boolean; - uniqueKey: string; -}; diff --git a/ad4m-hooks/helpers/src/factory/index.ts b/ad4m-hooks/helpers/src/factory/index.ts deleted file mode 100644 index 3f58419a0..000000000 --- a/ad4m-hooks/helpers/src/factory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./SubjectRepository"; diff --git a/ad4m-hooks/helpers/src/factory/model.ts b/ad4m-hooks/helpers/src/factory/model.ts deleted file mode 100644 index 2bae2833d..000000000 --- a/ad4m-hooks/helpers/src/factory/model.ts +++ /dev/null @@ -1,57 +0,0 @@ -type Target = String; - -export type PropertyValueMap = { - [property: string]: Target | Target[]; -}; - -export function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -// e.g. "name" -> "setName" -export function propertyNameToSetterName(property: string): string { - return `set${capitalize(property)}`; -} - -export function pluralToSingular(plural: string): string { - if (plural.endsWith("ies")) { - return plural.slice(0, -3) + "y"; - } else if (plural.endsWith("s")) { - return plural.slice(0, -1); - } else { - return plural; - } -} - -// e.g. "comments" -> "addComment" -export function collectionToAdderName(collection: string): string { - return `add${capitalize(collection)}`; -} - -export function collectionToSetterName(collection: string): string { - return `setCollection${capitalize(collection)}`; -} - -export function setProperties(subject: any, properties: PropertyValueMap) { - Object.keys(properties).forEach((key) => { - if (Array.isArray(properties[key])) { - // it's a collection - const adderName = collectionToAdderName(key); - const adderFunction = subject[adderName]; - if (adderFunction) { - adderFunction(properties[key]); - } else { - throw "No adder function found for collection: " + key; - } - } else { - // it's a property - const setterName = propertyNameToSetterName(key); - const setterFunction = subject[setterName]; - if (setterFunction) { - setterFunction(properties[key]); - } else { - throw "No setter function found for property: " + key; - } - } - }); -} diff --git a/ad4m-hooks/helpers/src/getProfile.ts b/ad4m-hooks/helpers/src/getProfile.ts deleted file mode 100644 index 36c023969..000000000 --- a/ad4m-hooks/helpers/src/getProfile.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AgentClient } from "@coasys/ad4m"; -import { LinkExpression } from "@coasys/ad4m"; - -export async function getProfile(agent: AgentClient, did: string, formatter?: (links: LinkExpression[]) => T): Promise { - const cleanedDid = did.replace("did://", ""); - - const agentPerspective = await agent.byDID(cleanedDid); - - if (agentPerspective) { - const links = agentPerspective!.perspective!.links; - - if (formatter) { - return formatter(links); - } - - return agentPerspective - } -} diff --git a/ad4m-hooks/helpers/src/index.ts b/ad4m-hooks/helpers/src/index.ts deleted file mode 100644 index b832fa981..000000000 --- a/ad4m-hooks/helpers/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './cache' -export * from './getProfile' -export * from './factory' \ No newline at end of file diff --git a/ad4m-hooks/react/package.json b/ad4m-hooks/react/package.json index 3ac385869..f63fe2913 100644 --- a/ad4m-hooks/react/package.json +++ b/ad4m-hooks/react/package.json @@ -13,8 +13,7 @@ "author": "", "license": "ISC", "dependencies": { - "@coasys/ad4m": "workspace:0.12.0-rc1-dev.2", - "@coasys/hooks-helpers": "workspace:*", + "@coasys/ad4m": "workspace:*", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19" }, @@ -24,8 +23,7 @@ }, "publishConfig": { "dependencies": { - "@coasys/ad4m": "*", - "@coasys/hooks-helpers": "*" + "@coasys/ad4m": "*" } }, "version": "0.12.0-rc1-dev.2" diff --git a/ad4m-hooks/react/src/index.ts b/ad4m-hooks/react/src/index.ts index be5c14373..5f27dac8f 100644 --- a/ad4m-hooks/react/src/index.ts +++ b/ad4m-hooks/react/src/index.ts @@ -1,15 +1,2 @@ -import { useAgent } from "./useAgent"; -import { useMe } from "./useMe"; -import { toCustomElement } from "./register"; -import { usePerspective } from "./usePerspective"; -import { usePerspectives } from "./usePerspectives"; -import { useModel } from "./useModel"; - -export { - toCustomElement, - useAgent, - useMe, - usePerspective, - usePerspectives, - useModel, -}; +export { useLiveQuery } from "./useLiveQuery"; +export type { LiveCollectionResult, LiveInstanceResult } from "./useLiveQuery"; diff --git a/ad4m-hooks/react/src/register.js b/ad4m-hooks/react/src/register.js deleted file mode 100644 index 0f6a1022d..000000000 --- a/ad4m-hooks/react/src/register.js +++ /dev/null @@ -1,177 +0,0 @@ -import { createElement as h, cloneElement, render, hydrate } from "preact"; - -export function toCustomElement(Component, propNames, options) { - function PreactElement() { - const inst = Reflect.construct(HTMLElement, [], PreactElement); - inst._vdomComponent = Component; - inst._root = - options && options.shadow ? inst.attachShadow({ mode: "open" }) : inst; - return inst; - } - PreactElement.prototype = Object.create(HTMLElement.prototype); - PreactElement.prototype.constructor = PreactElement; - PreactElement.prototype.connectedCallback = connectedCallback; - PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; - PreactElement.prototype.disconnectedCallback = disconnectedCallback; - - propNames = - propNames || - Component.observedAttributes || - Object.keys(Component.propTypes || {}); - PreactElement.observedAttributes = propNames; - - // Keep DOM properties and Preact props in sync - propNames.forEach((name) => { - Object.defineProperty(PreactElement.prototype, name, { - get() { - return this._vdom.props[name]; - }, - set(v) { - if (this._vdom) { - if (!this._props) this._props = {}; - this._props[name] = v; - this.attributeChangedCallback(name, null, v); - } else { - if (!this._props) this._props = {}; - this._props[name] = v; - this.connectedCallback(); - } - - // Reflect property changes to attributes if the value is a primitive - const type = typeof v; - if ( - v == null || - type === "string" || - type === "boolean" || - type === "number" - ) { - this.setAttribute(name, v); - } - }, - }); - }); - - return PreactElement; -} - -export default function register(Component, tagName, propNames, options) { - const PreactElement = toCustomElement(Component, propNames, options); - - return customElements.define( - tagName || Component.tagName || Component.displayName || Component.name, - PreactElement - ); -} - -register.toCustomElement = toCustomElement; - -function ContextProvider(props) { - this.getChildContext = () => props.context; - // eslint-disable-next-line no-unused-vars - const { context, children, ...rest } = props; - return cloneElement(children, rest); -} - -function connectedCallback() { - // Obtain a reference to the previous context by pinging the nearest - // higher up node that was rendered with Preact. If one Preact component - // higher up receives our ping, it will set the `detail` property of - // our custom event. This works because events are dispatched - // synchronously. - const event = new CustomEvent("_preact", { - detail: {}, - bubbles: true, - cancelable: true, - }); - this.dispatchEvent(event); - const context = event.detail.context; - - this._vdom = h( - ContextProvider, - { ...this._props, context }, - toVdom(this, this._vdomComponent) - ); - (this.hasAttribute("hydrate") ? hydrate : render)(this._vdom, this._root); -} - -function toCamelCase(str) { - return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : "")); -} - -function attributeChangedCallback(name, oldValue, newValue) { - if (!this._vdom) return; - // Attributes use `null` as an empty value whereas `undefined` is more - // common in pure JS components, especially with default parameters. - // When calling `node.removeAttribute()` we'll receive `null` as the new - // value. See issue #50. - newValue = newValue == null ? undefined : newValue; - const props = {}; - props[name] = newValue; - props[toCamelCase(name)] = newValue; - this._vdom = cloneElement(this._vdom, props); - render(this._vdom, this._root); -} - -function disconnectedCallback() { - render((this._vdom = null), this._root); -} - -/** - * Pass an event listener to each `` that "forwards" the current - * context value to the rendered child. The child will trigger a custom - * event, where will add the context value to. Because events work - * synchronously, the child can immediately pull of the value right - * after having fired the event. - */ -function Slot(props, context) { - const ref = (r) => { - if (!r) { - this.ref.removeEventListener("_preact", this._listener); - } else { - this.ref = r; - if (!this._listener) { - this._listener = (event) => { - event.stopPropagation(); - event.detail.context = context; - }; - r.addEventListener("_preact", this._listener); - } - } - }; - return h("slot", { ...props, ref }); -} - -function toVdom(element, nodeName) { - if (element.nodeType === 3) return element.data; - if (element.nodeType !== 1) return null; - let children = [], - props = {}, - i = 0, - a = element.attributes, - cn = element.childNodes; - for (i = a.length; i--; ) { - if (a[i].name !== "slot") { - props[a[i].name] = a[i].value; - props[toCamelCase(a[i].name)] = a[i].value; - } - } - - for (i = cn.length; i--; ) { - const vnode = toVdom(cn[i], null); - // Move slots correctly - const name = cn[i].slot; - if (name) { - props[name] = h(Slot, { name }, vnode); - } else { - children[i] = vnode; - } - } - - // Only wrap the topmost node with a slot - const wrappedChildren = nodeName ? h(Slot, null, children) : children; - return h( - nodeName || element.nodeName.toLowerCase(), - { ...props, element }, - wrappedChildren - ); -} diff --git a/ad4m-hooks/react/src/useAgent.ts b/ad4m-hooks/react/src/useAgent.ts deleted file mode 100644 index 91fdfda03..000000000 --- a/ad4m-hooks/react/src/useAgent.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { getCache, setCache, subscribe, unsubscribe, getProfile } from "@coasys/hooks-helpers"; -import { AgentClient, LinkExpression, Agent } from "@coasys/ad4m"; - -type Props = { - client: AgentClient; - did: string | (() => string); - formatter: (links: LinkExpression[]) => T; -}; - -export function useAgent(props: Props) { - const forceUpdate = useForceUpdate(); - const [error, setError] = useState(undefined); - const [profile, setProfile] = useState(null); - const didRef = typeof props.did === "function" ? props.did() : props.did; - - // Create cache key for entry - const cacheKey = `agents/${didRef}`; - - // Mutate shared/cached data for all subscribers - const mutate = useCallback( - (agent: Agent | null) => setCache(cacheKey, agent), - [cacheKey] - ); - - // Fetch data from AD4M and save to cache - const getData = useCallback(() => { - if (didRef) { - if (props.formatter) { - getProfile(props.client, didRef).then(profile => setProfile(props.formatter(profile.perspective.links))) - } - - props.client - .byDID(didRef) - .then(async (agent) => { - setError(undefined); - mutate(agent); - }) - .catch((error) => setError(error.toString())); - } - }, [cacheKey]); - - // Trigger initial fetch - useEffect(getData, [getData]); - - // Subscribe to changes (re-render on data change) - useEffect(() => { - subscribe(cacheKey, forceUpdate); - return () => unsubscribe(cacheKey, forceUpdate); - }, [cacheKey, forceUpdate]); - - const agent = getCache(cacheKey); - - return { agent, profile, error, mutate, reload: getData }; -} - -function useForceUpdate() { - const [, setState] = useState([]); - return useCallback(() => setState([]), [setState]); -} diff --git a/ad4m-hooks/react/src/useLiveQuery.ts b/ad4m-hooks/react/src/useLiveQuery.ts new file mode 100644 index 000000000..81e05614a --- /dev/null +++ b/ad4m-hooks/react/src/useLiveQuery.ts @@ -0,0 +1,283 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { + PerspectiveProxy, + Ad4mModel, + Query, + Subscription, + resolveParentPredicate, +} from "@coasys/ad4m"; + +type ModelCtor = (new (...args: any[]) => T) & + typeof Ad4mModel; + +/** + * Scope a reactive query to a specific parent node via a `@HasMany` relation. + * + * The hook reads the `through` predicate from the parent model's decorator + * metadata automatically — you never need to reference the predicate string + * at the call site. + * + * @example + * ```ts + * // field inferred — only one @HasMany on Channel points to Message + * const { data: messages } = useLiveQuery(Message, perspective, { + * parent: { model: Channel, id: channelId }, + * }); + * + * // field explicit — needed when multiple @HasMany point to the same type + * const { data: messages } = useLiveQuery(Message, perspective, { + * parent: { model: Channel, id: channelId, field: 'messages' }, + * }); + * ``` + */ +type ParentScope = + | { + /** Raw predicate form — use when the parent model type is unknown or + * the relationship is not declared via `@HasMany`. */ + id: string; + predicate: string; + } + | { + model: ModelCtor; + id: string; + /** + * The `@HasMany` field on the parent model with the linking predicate. + * Optional when exactly one `@HasMany` on the parent points to this child type. + */ + field?: string; + }; + +type LiveOptions = { + /** + * When provided, restricts results to children of this parent node via the + * declared `@HasMany` relation. The subscription also watches the relation + * predicate so additions/removals trigger a live re-query. + */ + parent?: ParentScope; + query?: Query; + /** When set, enables load-more / infinite-scroll mode. */ + pageSize?: number; + preserveReferences?: boolean; +}; + +export type LiveCollectionResult = { + data: T[]; + loading: boolean; + error: string; + totalCount: number; + loadMore: () => void; +}; + +export type LiveInstanceResult = { + data: T | null; + loading: boolean; + error: string; +}; + +// ── Overload signatures ──────────────────────────────────────────────────── + +/** + * Reactive single-instance query (supply `id` to select one node). + * Returns `{ data: T | null, loading, error }`. + */ +export function useLiveQuery( + model: ModelCtor, + perspective: PerspectiveProxy, + options: LiveOptions & { id: string }, +): LiveInstanceResult; + +/** + * Reactive collection query. + * Returns `{ data: T[], loading, error, totalCount, loadMore }`. + */ +export function useLiveQuery( + model: ModelCtor, + perspective: PerspectiveProxy, + options?: LiveOptions, +): LiveCollectionResult; + +/** + * Dynamic string-model collection query (e.g. for generic class browsers). + * When `model` is an empty string, returns empty data immediately. + */ +export function useLiveQuery( + model: string, + perspective: PerspectiveProxy, + options?: LiveOptions, +): LiveCollectionResult; + +// ── Implementation ───────────────────────────────────────────────────────── + +export function useLiveQuery( + model: ModelCtor | string, + perspective: PerspectiveProxy, + options: LiveOptions & { id?: string } = {}, +): LiveCollectionResult | LiveInstanceResult { + const { + parent, + query: userQuery = {}, + preserveReferences = false, + pageSize, + id, + } = options; + + const isInstance = id !== undefined; + + const [loading, setLoading] = useState(true); + const [collectionData, setCollectionData] = useState([]); + const [instanceData, setInstanceData] = useState(null); + const [error, setError] = useState(""); + const [pageNumber, setPageNumber] = useState(1); + const [totalCount, setTotalCount] = useState(0); + + const subRef = useRef(null); + + /** Resolve the `parent` query from the top-level `parent` scope option. */ + function resolveParentQuery(): { id: string; predicate: string } | undefined { + if (!parent) return undefined; + // Raw predicate form — pass through directly + if ("predicate" in parent) return { id: parent.id, predicate: parent.predicate }; + try { + const predicate = resolveParentPredicate( + parent.model.getModelMetadata(), + typeof model !== "string" ? model : undefined, + parent.field, + ); + return { id: parent.id, predicate }; + } catch (err) { + console.warn(`useLiveQuery: ${err instanceof Error ? err.message : err}`); + return undefined; + } + } + + /** Build the query builder for either a class constructor or a string class name. */ + function makeQueryBuilder(q: Query) { + if (typeof model === "string") { + return Ad4mModel.query(perspective, q).overrideModelClassName(model); + } + return (model as ModelCtor).query(perspective, q); + } + + function buildLiveQuery(): Query { + const base: Query = { + ...userQuery, + ...(pageSize ? { limit: pageSize * pageNumber } : {}), + }; + + // Parent scope takes precedence; only set parent if not already in userQuery + if (!base.parent) { + const resolvedParent = resolveParentQuery(); + if (resolvedParent) base.parent = resolvedParent; + } + + // For single-instance mode, filter to the specific node URI + if (id !== undefined) { + base.where = { ...base.where, base: id }; + } + + return base; + } + + function mergeEntries(oldEntries: T[], newEntries: T[]): T[] { + if (!preserveReferences) return newEntries; + const existingMap = new Map(oldEntries.map((e) => [e.id, e])); + return newEntries.map((n) => existingMap.get(n.id) ?? n); + } + + const subscribe = useCallback(() => { + if (!perspective) return; + + // If a parent scope is declared but its id is not yet resolved (e.g. the + // web-component prop hasn't been set yet), hold off. Running the query + // with an undefined id causes escapeSurrealString(undefined) to throw. + if (parent && !parent.id) { + setCollectionData([]); + setInstanceData(null); + setLoading(false); + return; + } + + subRef.current?.unsubscribe(); + subRef.current = null; + setLoading(true); + + const q = buildLiveQuery(); + + // For string models, skip subscription when model name is empty + if (typeof model === "string" && !model) { + setCollectionData([]); + setLoading(false); + return; + } + + try { + if (isInstance) { + subRef.current = makeQueryBuilder(q).live( + (results) => { + setInstanceData((results[0] as T) ?? null); + setLoading(false); + }, + { + onError: (err) => { + setError(err.message); + setLoading(false); + }, + }, + ); + } else { + subRef.current = makeQueryBuilder(q).live( + (results) => { + setCollectionData((old) => mergeEntries(old, results as T[])); + setLoading(false); + }, + { + onError: (err) => { + setError(err.message); + setLoading(false); + }, + }, + ); + + // Total count for pagination UI — only needed in load-more mode + if (pageSize) { + // Count query: same filters but without the page limit + const countQ: Query = { ...buildLiveQuery() }; + delete countQ.limit; + makeQueryBuilder(countQ) + .count() + .then(setTotalCount) + .catch(console.error); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + perspective?.uuid, + typeof model === "string" ? model : (model as ModelCtor).name, + pageNumber, + JSON.stringify(userQuery), + parent?.id, + parent && "field" in parent ? parent.field : undefined, + id, + ]); + + useEffect(() => { + subscribe(); + return () => { + subRef.current?.unsubscribe(); + subRef.current = null; + }; + }, [subscribe]); + + const loadMore = useCallback(() => { + if (pageSize) setPageNumber((p) => p + 1); + }, [pageSize]); + + if (isInstance) { + return { data: instanceData, loading, error }; + } + return { data: collectionData, loading, error, totalCount, loadMore }; +} diff --git a/ad4m-hooks/react/src/useMe.ts b/ad4m-hooks/react/src/useMe.ts deleted file mode 100644 index 7c89f5d66..000000000 --- a/ad4m-hooks/react/src/useMe.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { getCache, setCache, subscribe, unsubscribe } from "@coasys/hooks-helpers"; -import { Agent, AgentStatus, LinkExpression, AgentClient } from "@coasys/ad4m"; - -type MeData = { - agent?: Agent; - status?: AgentStatus; -}; - -type MyInfo = { - me?: Agent; - status?: AgentStatus; - profile: T | null; - error: string | undefined; - mutate: Function; - reload: Function; -}; - -export function useMe(agent: AgentClient | undefined, formatter: (links: LinkExpression[]) => T): MyInfo { - const forceUpdate = useForceUpdate(); - const [error, setError] = useState(undefined); - - // Create cache key for entry - const cacheKey = `agents/me`; - - // Mutate shared/cached data for all subscribers - const mutate = useCallback( - (data: MeData | null) => setCache(cacheKey, data), - [cacheKey] - ); - - // Fetch data from AD4M and save to cache - const getData = useCallback(() => { - if (!agent) { - return; - } - - const promises = Promise.all([agent.status(), agent.me()]); - - promises - .then(async ([status, agent]) => { - setError(undefined); - mutate({ agent, status }); - }) - .catch((error) => setError(error.toString())); - }, [agent, mutate]); - - // Trigger initial fetch - useEffect(getData, [getData]); - - // Subscribe to changes (re-render on data change) - useEffect(() => { - subscribe(cacheKey, forceUpdate); - return () => unsubscribe(cacheKey, forceUpdate); - }, [cacheKey, forceUpdate]); - - // Listen to remote changes - useEffect(() => { - const changed = (status: AgentStatus) => { - const newMeData = { agent: data?.agent, status }; - mutate(newMeData); - return null; - }; - - const updated = (agent: Agent) => { - const newMeData = { agent, status: data?.status }; - mutate(newMeData); - return null; - }; - - if (agent) { - agent.addAgentStatusChangedListener(changed); - agent.addUpdatedListener(updated); - - // TODO need a way to remove listeners - } - }, [agent]); - - const data = getCache(cacheKey); - let profile = null as T | null; - const perspective = data?.agent?.perspective; - - if (perspective) { - if (formatter) { - profile = formatter(perspective.links) - } - - } - - return { - status: data?.status, - me: data?.agent, - profile, - error, - mutate, - reload: getData, - }; -} - -function useForceUpdate() { - const [, setState] = useState([]); - return useCallback(() => setState([]), [setState]); -} diff --git a/ad4m-hooks/react/src/useModel.ts b/ad4m-hooks/react/src/useModel.ts deleted file mode 100644 index f1faee54c..000000000 --- a/ad4m-hooks/react/src/useModel.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useState, useEffect, useMemo, useRef } from "react"; -import { PerspectiveProxy, Ad4mModel, Query, PaginationResult, ModelQueryBuilder } from "@coasys/ad4m"; - -type Props = { - perspective: PerspectiveProxy; - model: string | ((new (...args: any[]) => T) & typeof Ad4mModel); - query?: Query; - pageSize?: number; - preserveReferences?: boolean; -}; - -type Result = { - entries: T[]; - loading: boolean; - error: string; - totalCount: number; - loadMore: () => void; -}; - -export function useModel(props: Props): Result { - const { perspective, model, query = {}, preserveReferences = false, pageSize } = props; - const [subjectEnsured, setSubjectEnsured] = useState(false); - const [loading, setLoading] = useState(true); - const [entries, setEntries] = useState([]); - const [error, setError] = useState(""); - const [pageNumber, setPageNumber] = useState(1); - const [totalCount, setTotalCount] = useState(0); - const modelQueryRef = useRef | null>(null); - - async function ensureSubject() { - if (typeof model !== "string") await perspective.ensureSDNASubjectClass(model); - setSubjectEnsured(true); - } - - function preserveEntryReferences(oldEntries: T[], newEntries: T[]): T[] { - // Merge new results into old results, preserving references for optimized rendering - const existingMap = new Map(oldEntries.map((entry) => [entry.baseExpression, entry])); - return newEntries.map((newEntry) => existingMap.get(newEntry.baseExpression) || newEntry); - } - - function handleNewEntires(newEntries: T[]) { - setEntries((oldEntries) => (preserveReferences ? preserveEntryReferences(oldEntries, newEntries) : newEntries)); - } - - function paginateSubscribeCallback({ results, totalCount: count }: PaginationResult) { - handleNewEntires(results); - setTotalCount(count as number); - } - - async function subscribeToCollection() { - try { - if (modelQueryRef.current) modelQueryRef.current.dispose(); - - modelQueryRef.current = - typeof model === "string" - ? Ad4mModel.query(perspective, query).overrideModelClassName(model) - : model.query(perspective, query); - - if (pageSize) { - // Handle paginated results - const totalPageSize = pageSize * pageNumber; - const { results, totalCount: count } = await modelQueryRef.current.paginateSubscribe( - totalPageSize, - 1, - paginateSubscribeCallback as (results: PaginationResult) => void - ); - setEntries(results as T[]); - setTotalCount(count as number); - } else { - // Handle non-paginated results - const results = await modelQueryRef.current.subscribe(handleNewEntires as (results: Ad4mModel[]) => void); - setEntries(results as T[]); - } - } catch (err) { - console.log("useAd4mModel error", err); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - } - - function loadMore() { - if (pageSize) { - setLoading(true); - setPageNumber((prevPage) => prevPage + 1); - } - } - - useEffect(() => { - ensureSubject(); - }, []); - - useEffect(() => { - if (subjectEnsured) subscribeToCollection(); - }, [subjectEnsured, model, JSON.stringify(query), pageNumber]); - - return useMemo( - () => ({ entries, loading, error, totalCount, loadMore }), - [entries, loading, error, totalCount, loadMore] - ); -} diff --git a/ad4m-hooks/react/src/usePerspective.ts b/ad4m-hooks/react/src/usePerspective.ts deleted file mode 100644 index 34dc5d4ef..000000000 --- a/ad4m-hooks/react/src/usePerspective.ts +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { usePerspectives } from './usePerspectives'; -import { Ad4mClient, PerspectiveProxy } from '@coasys/ad4m'; - -export function usePerspective(client: Ad4mClient, uuid: string | Function) { - const [uuidState, setUuidState] = useState(typeof uuid === 'function' ? uuid() : uuid); - - const { perspectives } = usePerspectives(client); - - const [data, setData] = useState<{ perspective: PerspectiveProxy | null, synced: boolean }>({ - perspective: null, - synced: false, - }); - - useEffect(() => { - const pers = perspectives[uuidState]; - setData(prevData => ({ ...prevData, perspective: pers })); - }, [perspectives, uuidState]); - - useEffect(() => { - if (typeof uuid === 'function') { - setUuidState(uuid()); - } else { - setUuidState(uuid); - } - }, [uuid]); - - return { data }; -} \ No newline at end of file diff --git a/ad4m-hooks/react/src/usePerspectives.ts b/ad4m-hooks/react/src/usePerspectives.ts deleted file mode 100644 index 15e64e6a3..000000000 --- a/ad4m-hooks/react/src/usePerspectives.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { Ad4mClient } from "@coasys/ad4m"; - -type UUID = string; - -interface PerspectiveProxy { - uuid: UUID; - sharedUrl: string; - addListener(event: string, callback: Function): void; - removeListener(event: string, callback: Function): void; -} - -export function usePerspectives(client: Ad4mClient) { - const [perspectives, setPerspectives] = useState<{ [x: UUID]: PerspectiveProxy }>({}); - const [neighbourhoods, setNeighbourhoods] = useState<{ [x: UUID]: PerspectiveProxy }>({}); - const onAddedLinkCbs = useRef([]); - const onRemovedLinkCbs = useRef([]); - const hasFetched = useRef(false); - - useEffect(() => { - const fetchPerspectives = async () => { - if (hasFetched.current) return; - hasFetched.current = true; - - const allPerspectives = await client.perspective.all(); - const newPerspectives: { [x: UUID]: PerspectiveProxy } = {}; - - allPerspectives.forEach((p) => { - newPerspectives[p.uuid] = p; - addListeners(p); - }); - - setPerspectives(newPerspectives); - }; - - const addListeners = (p: PerspectiveProxy) => { - p.addListener("link-added", (link: any) => { - onAddedLinkCbs.current.forEach((cb) => { - cb(p, link); - }); - }); - - p.addListener("link-removed", (link: any) => { - onRemovedLinkCbs.current.forEach((cb) => { - cb(p, link); - }); - }); - }; - - const perspectiveUpdatedListener = async (handle: any) => { - const perspective = await client.perspective.byUUID(handle.uuid); - if (perspective) { - setPerspectives((prevPerspectives) => ({ - ...prevPerspectives, - [handle.uuid]: perspective, - })); - } - }; - - const perspectiveAddedListener = async (handle: any) => { - const perspective = await client.perspective.byUUID(handle.uuid); - if (perspective) { - setPerspectives((prevPerspectives) => ({ - ...prevPerspectives, - [handle.uuid]: perspective, - })); - addListeners(perspective); - } - }; - - const perspectiveRemovedListener = (uuid: UUID) => { - setPerspectives((prevPerspectives) => { - const newPerspectives = { ...prevPerspectives }; - delete newPerspectives[uuid]; - return newPerspectives; - }); - }; - - fetchPerspectives(); - - // @ts-ignore - client.perspective.addPerspectiveUpdatedListener(perspectiveUpdatedListener); - // @ts-ignore - client.perspective.addPerspectiveAddedListener(perspectiveAddedListener); - // @ts-ignore - client.perspective.addPerspectiveRemovedListener(perspectiveRemovedListener); - - return () => { - // @ts-ignore - client.perspective.removePerspectiveUpdatedListener(perspectiveUpdatedListener); - // @ts-ignore - client.perspective.removePerspectiveAddedListener(perspectiveAddedListener); - // @ts-ignore - client.perspective.removePerspectiveRemovedListener(perspectiveRemovedListener); - }; - }, []); - - useEffect(() => { - const newNeighbourhoods = Object.keys(perspectives).reduce((acc, key) => { - if (perspectives[key]?.sharedUrl) { - return { - ...acc, - [key]: perspectives[key], - }; - } else { - return acc; - } - }, {}); - - setNeighbourhoods(newNeighbourhoods); - }, [perspectives]); - - function onLinkAdded(cb: Function) { - onAddedLinkCbs.current.push(cb); - } - - function onLinkRemoved(cb: Function) { - onRemovedLinkCbs.current.push(cb); - } - - return { perspectives, neighbourhoods, onLinkAdded, onLinkRemoved }; -} diff --git a/ad4m-hooks/vue/package.json b/ad4m-hooks/vue/package.json index 2c5f315fc..bd5003b9c 100644 --- a/ad4m-hooks/vue/package.json +++ b/ad4m-hooks/vue/package.json @@ -12,16 +12,14 @@ "author": "", "license": "ISC", "dependencies": { - "@coasys/ad4m": "workspace:0.12.0-rc1-dev.2", - "@coasys/hooks-helpers": "workspace:*" + "@coasys/ad4m": "workspace:*" }, "peerDependencies": { "vue": "^3.2.47" }, "publishConfig": { "dependencies": { - "@coasys/ad4m": "*", - "@coasys/hooks-helpers": "*" + "@coasys/ad4m": "*" } }, "version": "0.12.0-rc1-dev.2" diff --git a/ad4m-hooks/vue/src/index.ts b/ad4m-hooks/vue/src/index.ts index ac14bb56b..5f27dac8f 100644 --- a/ad4m-hooks/vue/src/index.ts +++ b/ad4m-hooks/vue/src/index.ts @@ -1,5 +1,2 @@ -export * from './useAgent' -export * from './useMe' -export * from './usePerspective' -export * from './usePerspectives' -export * from './useModel'; \ No newline at end of file +export { useLiveQuery } from "./useLiveQuery"; +export type { LiveCollectionResult, LiveInstanceResult } from "./useLiveQuery"; diff --git a/ad4m-hooks/vue/src/useAgent.ts b/ad4m-hooks/vue/src/useAgent.ts deleted file mode 100644 index c348612ff..000000000 --- a/ad4m-hooks/vue/src/useAgent.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { computed, ref, shallowRef, watch } from "vue"; -import { Agent, LinkExpression, AgentClient } from "@coasys/ad4m"; - - - -export function useAgent(client: AgentClient, did: string | Function, formatter: (links: LinkExpression[]) => T) { - const agent = shallowRef(null); - const profile = shallowRef(null); - const didRef = typeof did === "function" ? (did as any) : ref(did); - - watch( - [client, didRef], - async ([c, d]) => { - if (d) { - agent.value = await client.byDID(d); - if (agent.value?.perspective) { - const perspective = agent.value.perspective; - - const prof = formatter(perspective.links); - - profile.value = { ...prof, did: d} as T; - } else { - profile.value = null; - } - } - }, - { immediate: true } - ); - - return { agent, profile }; -} diff --git a/ad4m-hooks/vue/src/useLiveQuery.ts b/ad4m-hooks/vue/src/useLiveQuery.ts new file mode 100644 index 000000000..1d6502bce --- /dev/null +++ b/ad4m-hooks/vue/src/useLiveQuery.ts @@ -0,0 +1,324 @@ +import { + Ad4mModel, + PerspectiveProxy, + Query, + Subscription, + resolveParentPredicate, +} from "@coasys/ad4m"; +import { + ComputedRef, + isRef, + onUnmounted, + ref, + Ref, + shallowRef, + watch, +} from "vue"; + +type ModelCtor = (new (...args: any[]) => T) & + typeof Ad4mModel; + +/** + * Scope a reactive query to a specific parent node via a `@HasMany` relation. + * The hook reads the `through` predicate from the parent model's decorator + * metadata automatically. + * + * @example + * ```ts + * // field inferred — only one @HasMany on Channel points to Message + * const { data: messages } = useLiveQuery(Message, perspective, { + * parent: { model: Channel, id: channelId }, + * }); + * + * // field explicit — needed when multiple @HasMany point to the same type + * const { data: messages } = useLiveQuery(Message, perspective, { + * parent: { model: Channel, id: channelId, field: 'messages' }, + * }); + * ``` + */ +type ParentScope = + | { + /** Raw predicate form — use when the parent model type is unknown or + * the relationship is not declared via `@HasMany`. */ + id: string; + predicate: string; + } + | { + model: ModelCtor; + id: string; + /** + * The `@HasMany` field on the parent model with the linking predicate. + * Optional when exactly one `@HasMany` on the parent points to this child type. + */ + field?: string; + }; + +type LiveOptions = { + /** + * When provided, restricts results to children of this parent node via the + * declared `@HasMany` relation. The subscription also watches the relation + * predicate so additions/removals trigger a live re-query. + */ + parent?: ParentScope; + query?: Query; + /** When set, enables load-more / infinite-scroll mode. */ + pageSize?: number; + preserveReferences?: boolean; +}; + +export type LiveCollectionResult = { + data: Ref; + loading: Ref; + error: Ref; + totalCount: Ref; + loadMore: () => void; +}; + +export type LiveInstanceResult = { + data: Ref; + loading: Ref; + error: Ref; +}; + +// ── Overload signatures ──────────────────────────────────────────────────── + +/** + * Reactive single-instance query (supply `id` to select one node). + * Returns `{ data: Ref, loading, error }`. + */ +export function useLiveQuery( + model: ModelCtor, + perspective: PerspectiveProxy | ComputedRef, + options: LiveOptions & { id: string }, +): LiveInstanceResult; + +/** + * Reactive collection query. + * Returns `{ data: Ref, loading, error, totalCount, loadMore }`. + */ +export function useLiveQuery( + model: ModelCtor, + perspective: PerspectiveProxy | ComputedRef, + options?: LiveOptions, +): LiveCollectionResult; + +// ── Implementation ───────────────────────────────────────────────────────── + +export function useLiveQuery( + model: ModelCtor, + perspective: PerspectiveProxy | ComputedRef, + options: LiveOptions & { id: string }, +): LiveInstanceResult; + +/** + * Reactive collection query. + * Returns `{ data: Ref, loading, error, totalCount, loadMore }`. + */ +export function useLiveQuery( + model: ModelCtor, + perspective: PerspectiveProxy | ComputedRef, + options?: LiveOptions, +): LiveCollectionResult; + +/** + * Dynamic string-model collection query (e.g. for generic class browsers). + * When `model` is an empty string, returns empty data immediately. + */ +export function useLiveQuery( + model: string, + perspective: PerspectiveProxy | ComputedRef, + options?: LiveOptions, +): LiveCollectionResult; + +// ── Implementation ───────────────────────────────────────────────────────── + +export function useLiveQuery( + model: ModelCtor | string, + perspective: PerspectiveProxy | ComputedRef, + options: LiveOptions & { id?: string } = {}, +): LiveCollectionResult | LiveInstanceResult { + const { + parent, + query: userQuery = {}, + preserveReferences = false, + pageSize, + id, + } = options; + + const isInstance = id !== undefined; + + const collectionData = ref([]) as Ref; + const instanceData = ref(null) as Ref; + const loading = ref(true); + const error = ref(""); + const pageNumber = ref(1); + const totalCount = ref(0); + + // Normalise: accept either a raw PerspectiveProxy or a ref/computed wrapping one + const perspectiveRef = shallowRef( + isRef(perspective) + ? (perspective as ComputedRef).value + : perspective, + ); + + if (isRef(perspective)) { + watch(perspective as ComputedRef, (val) => { + perspectiveRef.value = val; + }); + } + + let activeSub: Subscription | null = null; + + /** Resolve the `parent` query from the top-level `parent` scope option. */ + function resolveParentQuery(): { id: string; predicate: string } | undefined { + if (!parent) return undefined; + // Raw predicate form — pass through directly + if ("predicate" in parent) return { id: parent.id, predicate: parent.predicate }; + try { + const predicate = resolveParentPredicate( + parent.model.getModelMetadata(), + typeof model !== "string" ? model : undefined, + parent.field, + ); + return { id: parent.id, predicate }; + } catch (err) { + console.warn(`useLiveQuery: ${err instanceof Error ? err.message : err}`); + return undefined; + } + } + + /** Build the query builder for either a class constructor or a string class name. */ + function makeQueryBuilder(q: Query, p: PerspectiveProxy) { + if (typeof model === "string") { + return Ad4mModel.query(p, q).overrideModelClassName(model); + } + return (model as ModelCtor).query(p, q); + } + + function buildLiveQuery(): Query { + const base: Query = { + ...userQuery, + ...(pageSize ? { limit: pageSize * pageNumber.value } : {}), + }; + + // Parent scope takes precedence; only set parent if not already in userQuery + if (!base.parent) { + const resolvedParent = resolveParentQuery(); + if (resolvedParent) base.parent = resolvedParent; + } + + if (id !== undefined) { + base.where = { ...base.where, base: id }; + } + + return base; + } + + function mergeEntries(oldEntries: T[], newEntries: T[]): T[] { + if (!preserveReferences) return newEntries; + const existingMap = new Map(oldEntries.map((e) => [e.id, e])); + return newEntries.map((n) => existingMap.get(n.id) ?? n); + } + + function subscribe(p: PerspectiveProxy) { + activeSub?.unsubscribe(); + activeSub = null; + loading.value = true; + + const q = buildLiveQuery(); + + // For string models, skip subscription when model name is empty + if (typeof model === "string" && !model) { + collectionData.value = []; + loading.value = false; + return; + } + + try { + if (isInstance) { + activeSub = makeQueryBuilder(q, p).live( + (results) => { + instanceData.value = (results[0] as T) ?? null; + loading.value = false; + }, + { + onError: (err) => { + error.value = err.message; + loading.value = false; + }, + }, + ); + } else { + activeSub = makeQueryBuilder(q, p).live( + (results) => { + collectionData.value = mergeEntries( + collectionData.value, + results as T[], + ); + loading.value = false; + }, + { + onError: (err) => { + error.value = err.message; + loading.value = false; + }, + }, + ); + + if (pageSize) { + const countQ: Query = { ...buildLiveQuery() }; + delete countQ.limit; + makeQueryBuilder(countQ, p) + .count() + .then((n) => { + totalCount.value = n; + }) + .catch(console.error); + } + } + } catch (err) { + error.value = err instanceof Error ? err.message : String(err); + loading.value = false; + } + } + + // Re-subscribe when perspective identity changes + watch( + perspectiveRef, + (newP, oldP) => { + if (!newP) { + activeSub?.unsubscribe(); + activeSub = null; + loading.value = false; + collectionData.value = []; + instanceData.value = null; + return; + } + if (newP.uuid !== oldP?.uuid) { + collectionData.value = []; + instanceData.value = null; + } + subscribe(newP); + }, + { immediate: true }, + ); + + // Re-subscribe when query or page changes + watch([() => JSON.stringify(userQuery), pageNumber], () => { + if (perspectiveRef.value) subscribe(perspectiveRef.value); + }); + + onUnmounted(() => { + activeSub?.unsubscribe(); + activeSub = null; + }); + + const loadMore = () => { + if (pageSize) pageNumber.value += 1; + }; + + if (isInstance) { + return { data: instanceData, loading, error }; + } + return { data: collectionData, loading, error, totalCount, loadMore }; +} diff --git a/ad4m-hooks/vue/src/useMe.ts b/ad4m-hooks/vue/src/useMe.ts deleted file mode 100644 index 7f29c13a5..000000000 --- a/ad4m-hooks/vue/src/useMe.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { computed, effect, ref, shallowRef, watch } from "vue"; -import { Agent, AgentStatus, LinkExpression, AgentClient } from "@coasys/ad4m"; - -const status = shallowRef({ isInitialized: false, isUnlocked: false }); -const agent = shallowRef(); -const isListening = shallowRef(false); -const profile = shallowRef(null); - -export function useMe(client: AgentClient, formatter: (links: LinkExpression[]) => T) { - effect(async () => { - if (isListening.value) return; - - status.value = await client.status(); - agent.value = await client.me(); - - isListening.value = true; - - client.addAgentStatusChangedListener(async (s: AgentStatus) => { - status.value = s; - }); - - client.addUpdatedListener(async (a: Agent) => { - agent.value = a; - }); - }, {}); - - watch( - () => agent.value, - (newAgent) => { - if (agent.value?.perspective) { - const perspective = newAgent?.perspective; - if (!perspective) return; - profile.value = formatter(perspective.links); - } else { - profile.value = null; - } - }, - { immediate: true } - ) - - - return { status, me: agent, profile }; -} diff --git a/ad4m-hooks/vue/src/useModel.ts b/ad4m-hooks/vue/src/useModel.ts deleted file mode 100644 index d8c523e67..000000000 --- a/ad4m-hooks/vue/src/useModel.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Ad4mModel, ModelQueryBuilder, PaginationResult, PerspectiveProxy, Query } from "@coasys/ad4m"; -import { ComputedRef, ref, Ref, shallowRef, watch } from "vue"; - -type Props = { - perspective: PerspectiveProxy | ComputedRef; - model: string | ((new (...args: any[]) => T) & typeof Ad4mModel); - query?: Query; - pageSize?: number; - preserveReferences?: boolean; -}; - -type Result = { - entries: Ref; - loading: Ref; - error: Ref; - totalCount: Ref; - loadMore: () => void; -}; - -export function useModel(props: Props): Result { - const { perspective, model, query = {}, preserveReferences = false, pageSize } = props; - const entries = ref([]) as Ref; - const loading = ref(true); - const error = ref(""); - const pageNumber = ref(1); - const totalCount = ref(0); - let modelQuery: ModelQueryBuilder | null = null; - - // Handle perspective as a ref/computed or direct value - const isPerspectiveRef = perspective && typeof perspective === "object" && "value" in perspective; - const perspectiveValue = isPerspectiveRef ? (perspective as Ref).value : perspective; - const perspectiveRef = shallowRef(perspectiveValue); - - // Set up a watch if perspective is a ref/computed - if (isPerspectiveRef) { - watch( - () => (perspective as Ref).value, - (newVal) => { - perspectiveRef.value = newVal; - }, - { immediate: true } - ); - } - - function includeBaseExpressions(entries: T[]): T[] { - // Makes the baseExpression on each entry enumerable (while preserving the original instance) - return entries.map((entry) => { - if (entry.baseExpression !== undefined) { - Object.defineProperty(entry, "baseExpression", { - value: entry.baseExpression, - enumerable: true, - }); - } - return entry; - }); - } - - function preserveEntryReferences(oldEntries: T[], newEntries: T[]): T[] { - const existingMap = new Map(oldEntries.map((entry) => [entry.baseExpression, entry])); - return newEntries.map((newEntry) => existingMap.get(newEntry.baseExpression) || newEntry); - } - - function handleNewEntires(newEntries: T[]) { - entries.value = includeBaseExpressions( - preserveReferences ? preserveEntryReferences(entries.value, newEntries) : newEntries - ); - } - - function paginateSubscribeCallback(result: PaginationResult) { - handleNewEntires(result.results as T[]); - totalCount.value = result.totalCount as number; - } - - async function subscribeToCollection() { - try { - // Return early if no perspective - if (!perspectiveRef.value) { - loading.value = false; - return; - } - - if (modelQuery) modelQuery.dispose(); - - modelQuery = - typeof model === "string" - ? Ad4mModel.query(perspectiveRef.value, query).overrideModelClassName(model) - : model.query(perspectiveRef.value, query); - - if (pageSize) { - // Handle paginated results - const totalPageSize = pageSize * pageNumber.value; - const { results, totalCount: count } = await modelQuery.paginateSubscribe( - totalPageSize, - 1, - paginateSubscribeCallback - ); - entries.value = includeBaseExpressions(results as T[]); - totalCount.value = count as number; - } else { - // Handle non-paginated results - const results = await modelQuery.subscribe((results: Ad4mModel[]) => handleNewEntires(results as T[])); - entries.value = includeBaseExpressions(results as T[]); - } - } catch (err) { - console.error("Error in subscribeToCollection:", err); - error.value = err instanceof Error ? err.message : String(err); - } finally { - loading.value = false; - } - } - - function loadMore() { - if (pageSize) { - loading.value = true; - pageNumber.value += 1; - } - } - - // Watch for perspective changes - watch( - perspectiveRef, - async (newPerspective, oldPerspective) => { - if (!oldPerspective || newPerspective?.uuid !== oldPerspective?.uuid) { - loading.value = true; - entries.value = []; - if (newPerspective) { - try { - if (typeof model !== "string") { - await newPerspective.ensureSDNASubjectClass(model); - } - await subscribeToCollection(); - } finally { - loading.value = false; - } - } else { - loading.value = false; - } - } - }, - { immediate: true } - ); - - // Watch for query/page changes - watch([() => JSON.stringify(query), pageNumber], async () => { - if (perspectiveRef.value) { - await subscribeToCollection(); - } - }); - - return { entries, loading, error, totalCount, loadMore }; -} diff --git a/ad4m-hooks/vue/src/usePerspective.ts b/ad4m-hooks/vue/src/usePerspective.ts deleted file mode 100644 index 5f46919bc..000000000 --- a/ad4m-hooks/vue/src/usePerspective.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ref, watch, shallowRef } from "vue"; -import { usePerspectives } from "./usePerspectives"; -import { Ad4mClient, PerspectiveProxy } from "@coasys/ad4m"; - -export function usePerspective(client: Ad4mClient, uuid: string | Function) { - const uuidRef = typeof uuid === "function" ? ref(uuid()) : ref(uuid); - - const { perspectives } = usePerspectives(client); - - const data = shallowRef<{ - perspective: PerspectiveProxy | null; - synced: boolean; - }>({ - perspective: null, - synced: false, - }); - - watch( - [perspectives, uuidRef], - ([perspectives, id]) => { - const pers = perspectives[id]; - data.value = { ...data.value, perspective: pers }; - }, - { immediate: true } - ); - - watch( - // @ts-ignore - uuid, - (id) => { - uuidRef.value = id as string; - }, - { immediate: true } - ); - - return { data }; -} diff --git a/ad4m-hooks/vue/src/usePerspectives.ts b/ad4m-hooks/vue/src/usePerspectives.ts deleted file mode 100644 index 60efc06d5..000000000 --- a/ad4m-hooks/vue/src/usePerspectives.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { ref, effect, shallowRef, watch } from "vue"; -import { Ad4mClient, PerspectiveProxy } from "@coasys/ad4m"; - -type UUID = string; - -const perspectives = shallowRef<{ [x: UUID]: PerspectiveProxy }>({}); -const neighbourhoods = shallowRef<{ [x: UUID]: PerspectiveProxy }>({}); -const onAddedLinkCbs = ref([]); -const onRemovedLinkCbs = ref([]); -const hasFetched = ref(false); - -watch( - () => perspectives.value, - (newPers) => { - neighbourhoods.value = Object.keys(newPers).reduce((acc, key) => { - if (newPers[key]?.sharedUrl) { - return { - ...acc, - [key]: newPers[key], - }; - } else { - return acc; - } - }, {}); - }, - { immediate: true } -); - -function addListeners(p: PerspectiveProxy) { - p.addListener("link-added", (link) => { - onAddedLinkCbs.value.forEach((cb) => { - cb(p, link); - }); - return null; - }); - - p.removeListener("link-removed", (link) => { - onAddedLinkCbs.value.forEach((cb) => { - cb(p, link); - }); - return null; - }); -} - -export function usePerspectives(client: Ad4mClient) { - effect(async () => { - if (hasFetched.value) return; - // First component that uses this hook will set this to true, - // so the next components will not fetch and add listeners - hasFetched.value = true; - - // Get all perspectives - const allPerspectives = await client.perspective.all(); - - perspectives.value = allPerspectives.reduce((acc, p) => { - return { ...acc, [p.uuid]: p }; - }, {}); - - // Add each perspective to our state - allPerspectives.forEach((p) => { - addListeners(p); - }); - - // @ts-ignore - client.perspective.addPerspectiveUpdatedListener(async (handle) => { - const perspective = await client.perspective.byUUID(handle.uuid); - - if (perspective) { - perspectives.value = { - ...perspectives.value, - [handle.uuid]: perspective, - }; - } - return null; - }); - - // Add new incoming perspectives - // @ts-ignore - client.perspective.addPerspectiveAddedListener(async (handle) => { - const perspective = await client.perspective.byUUID(handle.uuid); - - if (perspective) { - perspectives.value = { - ...perspectives.value, - [handle.uuid]: perspective, - }; - addListeners(perspective); - } - }); - - // Remove new deleted perspectives - client.perspective.addPerspectiveRemovedListener((uuid) => { - perspectives.value = Object.keys(perspectives.value).reduce( - (acc, key) => { - const p = perspectives.value[key]; - return key === uuid ? acc : { ...acc, [key]: p }; - }, - {} - ); - return null; - }); - }, {}); - - function fetchPerspectives() {} - - function onLinkAdded(cb: Function) { - onAddedLinkCbs.value.push(cb); - } - - function onLinkRemoved(cb: Function) { - onRemovedLinkCbs.value.push(cb); - } - - return { perspectives, neighbourhoods, onLinkAdded, onLinkRemoved }; -} diff --git a/connect/package.json b/connect/package.json index a4bbc1859..2c023af62 100644 --- a/connect/package.json +++ b/connect/package.json @@ -48,7 +48,7 @@ "email": "nicolas@coasys.org" }, "devDependencies": { - "@coasys/ad4m": "workspace:0.12.0-rc1-dev.2", + "@coasys/ad4m": "workspace:*", "@apollo/client": "3.7.10", "@types/node": "^16.11.11", "esbuild": "^0.15.5", @@ -59,6 +59,9 @@ "typescript": "^4.6.2", "vite": "^4.1.1" }, + "peerDependencies": { + "@coasys/ad4m": "*" + }, "dependencies": { "@undecaf/barcode-detector-polyfill": "^0.9.15", "@undecaf/zbar-wasm": "^0.9.12", @@ -68,7 +71,7 @@ "lit": "^2.3.1" }, "publishConfig": { - "devDependencies": { + "peerDependencies": { "@coasys/ad4m": "*" } }, diff --git a/connect/scripts/esbuild.js b/connect/scripts/esbuild.js index 5c20ebbfe..3911e9a22 100644 --- a/connect/scripts/esbuild.js +++ b/connect/scripts/esbuild.js @@ -9,5 +9,6 @@ esbuild sourcemap: false, outfile: "dist/core.js", watch: process.env.NODE_ENV === "dev" ? true : false, + external: ["@coasys/ad4m"], }) .catch(() => process.exit(1)); diff --git a/connect/scripts/esbuild_index.js b/connect/scripts/esbuild_index.js index 73cdf2a23..9c1835fc8 100644 --- a/connect/scripts/esbuild_index.js +++ b/connect/scripts/esbuild_index.js @@ -7,6 +7,7 @@ const buildOptions = { minify: true, sourcemap: false, outfile: "dist/index.js", + external: ["@coasys/ad4m"], }; (async () => { diff --git a/connect/scripts/esbuild_utils.js b/connect/scripts/esbuild_utils.js index 95020451e..e94485b1f 100644 --- a/connect/scripts/esbuild_utils.js +++ b/connect/scripts/esbuild_utils.js @@ -9,5 +9,6 @@ esbuild sourcemap: false, outfile: "dist/utils.js", watch: process.env.NODE_ENV === "dev" ? true : false, + external: ["@coasys/ad4m"], }) .catch(() => process.exit(1)); diff --git a/connect/scripts/esbuild_web.js b/connect/scripts/esbuild_web.js index 4e3c7d843..05ac638b8 100644 --- a/connect/scripts/esbuild_web.js +++ b/connect/scripts/esbuild_web.js @@ -11,5 +11,6 @@ esbuild outfile: "dist/web.js", watch: process.env.NODE_ENV === "dev" ? true : false, plugins: [litPlugin()], + external: ["@coasys/ad4m"], }) .catch(() => process.exit(1)); diff --git a/core/jest.config.js b/core/jest.config.js index 9e9331001..0d72579fe 100644 --- a/core/jest.config.js +++ b/core/jest.config.js @@ -1,6 +1,6 @@ export default { - preset: 'ts-jest', - rootDir: 'src', + preset: "ts-jest", + rootDir: "src", testTimeout: 200000, - setupFiles: ["../jest-setup.ts"] -}; \ No newline at end of file + setupFiles: ["../jest-setup.ts"], +}; diff --git a/core/package.json b/core/package.json index f0261732f..55446b92d 100644 --- a/core/package.json +++ b/core/package.json @@ -9,7 +9,7 @@ "build": "patch-package && tsc && pnpm run buildSchema && pnpm run bundle", "buildSchema": "node --es-module-specifier-resolution=node lib/src/buildSchema.js", "bundle": "rollup -c rollup.config.js", - "test": "jest --forceExit" + "test": "jest" }, "repository": { "type": "git", diff --git a/core/src/Ad4mClient.test.ts b/core/src/Ad4mClient.test.ts index 3fae10a41..414f73d52 100644 --- a/core/src/Ad4mClient.test.ts +++ b/core/src/Ad4mClient.test.ts @@ -1,1344 +1,1593 @@ -import { buildSchema } from "type-graphql" +import { buildSchema } from "type-graphql"; +import { EventEmitter } from "events"; -import { createServer } from 'http'; -import { ApolloServer } from '@apollo/server'; -import { expressMiddleware } from '@apollo/server/express4'; -import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer' +import { createServer } from "http"; +import { ApolloServer } from "@apollo/server"; +import { expressMiddleware } from "@apollo/server/express4"; +import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; -import { WebSocketServer } from 'ws'; +import { WebSocketServer } from "ws"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; -import { useServer } from 'graphql-ws/lib/use/ws'; +import { useServer } from "graphql-ws/lib/use/ws"; import { ApolloClient, InMemoryCache } from "@apollo/client/core"; -import { createClient } from 'graphql-ws'; +import { createClient } from "graphql-ws"; import Websocket from "ws"; -import express from 'express'; +import express from "express"; -import AgentResolver from "./agent/AgentResolver" +import AgentResolver from "./agent/AgentResolver"; import { Ad4mClient } from "./Ad4mClient"; -import { Perspective, PerspectiveUnsignedInput } from "./perspectives/Perspective"; -import { Link, LinkExpression, LinkExpressionInput, LinkInput, LinkMutations } from "./links/Links"; +import { + Perspective, + PerspectiveUnsignedInput, +} from "./perspectives/Perspective"; +import { + Link, + LinkExpression, + LinkExpressionInput, + LinkInput, + LinkMutations, +} from "./links/Links"; import LanguageResolver from "./language/LanguageResolver"; import NeighbourhoodResolver from "./neighbourhood/NeighbourhoodResolver"; import PerspectiveResolver from "./perspectives/PerspectiveResolver"; import RuntimeResolver from "./runtime/RuntimeResolver"; import ExpressionResolver from "./expression/ExpressionResolver"; -import AIResolver from './ai/AIResolver' -import { AuthInfoInput, EntanglementProofInput, CapabilityInput, ResourceInput } from "./agent/Agent"; +import AIResolver from "./ai/AIResolver"; +import { + AuthInfoInput, + EntanglementProofInput, + CapabilityInput, + ResourceInput, +} from "./agent/Agent"; import { LanguageMetaInput } from "./language/LanguageMeta"; import { InteractionCall } from "./language/Language"; import { PerspectiveState } from "./perspectives/PerspectiveHandle"; -jest.setTimeout(15000) +jest.setTimeout(15000); async function createGqlServer(port: number) { - const schema = await buildSchema({ - resolvers: [ - AgentResolver, - ExpressionResolver, - LanguageResolver, - NeighbourhoodResolver, - PerspectiveResolver, - RuntimeResolver, - AIResolver - ] - }) - - const app = express(); - const httpServer = createServer(app); - - let serverCleanup: any; - const server = new ApolloServer({ - schema, - plugins: [ - ApolloServerPluginDrainHttpServer({ httpServer }), - { - async serverWillStart() { - return { - async drainServer() { - await serverCleanup.dispose(); - }, - }; - }, + const schema = await buildSchema({ + resolvers: [ + AgentResolver, + ExpressionResolver, + LanguageResolver, + NeighbourhoodResolver, + PerspectiveResolver, + RuntimeResolver, + AIResolver, + ], + }); + + const app = express(); + const httpServer = createServer(app); + + let serverCleanup: any; + const server = new ApolloServer({ + schema, + plugins: [ + ApolloServerPluginDrainHttpServer({ httpServer }), + { + async serverWillStart() { + return { + async drainServer() { + await serverCleanup.dispose(); }, + }; + }, + }, + ], + }); + + // Creating the WebSocket server + const wsServer = new WebSocketServer({ + // This is the `httpServer` we created in a previous step. + server: httpServer, + // Pass a different path here if your ApolloServer serves at + // a different path. + path: "/subscriptions", + }); + + // Hand in the schema we just created and have the + // WebSocketServer start listening. + serverCleanup = useServer({ schema }, wsServer); + + await server.start(); + app.use("/graphql", expressMiddleware(server)); + httpServer.listen({ port }); + return { port, stop: () => server.stop() }; +} + +describe("Ad4mClient", () => { + let ad4mClient; + let apolloClient; + let stopServer: () => Promise; + + beforeAll(async () => { + // Each subscription test adds a listener to the shared PubSub EventEmitter; + // raise the limit so Node doesn't emit a false-positive leak warning. + EventEmitter.defaultMaxListeners = 30; + + const { port, stop } = await createGqlServer(4000); + stopServer = stop; + + const wsLink = new GraphQLWsLink( + createClient({ + url: `ws://localhost:${port}/subscriptions`, + webSocketImpl: Websocket, + }), + ); + + apolloClient = new ApolloClient({ + link: wsLink, + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + }, + }, + }); + + ad4mClient = new Ad4mClient(apolloClient); + }); + + afterAll(async () => { + apolloClient.stop(); + await stopServer(); + }); + + describe(".agent", () => { + it("me() smoke test", async () => { + const agent = await ad4mClient.agent.me(); + expect(agent.did).toBe("did:ad4m:test"); + }); + + it("status() smoke test", async () => { + const agentStatus = await ad4mClient.agent.status(); + expect(agentStatus.did).toBe("did:ad4m:test"); + expect(agentStatus.isUnlocked).toBe(false); + }); + + it("import() smoke test", async () => { + const did = "did:test:test"; + const didDocument = "did document test"; + const keystore = "test"; + const passphrase = "secret"; + + const agentStatus = await ad4mClient.agent.import({ + did, + didDocument, + keystore, + passphrase, + }); + + expect(agentStatus.did).toBe(did); + expect(agentStatus.didDocument).toBe(didDocument); + expect(agentStatus.isInitialized).toBe(true); + expect(agentStatus.isUnlocked).toBe(true); + }); + + it("generate() smoke test", async () => { + const agentStatus = await ad4mClient.agent.generate("passphrase"); + expect(agentStatus.did).toBeDefined(); + expect(agentStatus.isInitialized).toBeTruthy(); + }); + + it("lock() smoke test", async () => { + const agentStatus = await ad4mClient.agent.lock("secret"); + expect(agentStatus.did).toBe("did:ad4m:test"); + expect(agentStatus.isUnlocked).toBe(false); + }); + + it("unlock() smoke test", async () => { + const agentStatus = await ad4mClient.agent.unlock("secret", false); + expect(agentStatus.did).toBe("did:ad4m:test"); + expect(agentStatus.isUnlocked).toBe(true); + }); + + it("byDID() smoke test", async () => { + const agent = await ad4mClient.agent.byDID("did:method:12345"); + expect(agent.did).toBe("did:method:12345"); + }); + + it("updatePublicPerspective() smoke test", async () => { + const perspective = new Perspective(); + const link = new LinkExpression(); + link.author = "did:method:12345"; + link.timestamp = new Date().toString(); + link.data = new Link({ + source: "root", + target: "perspective://Qm34589a3ccc0", + }); + link.proof = { signature: "asdfasdf", key: "asdfasdf" }; + perspective.links.push(link); + + const agent = await ad4mClient.agent.updatePublicPerspective(perspective); + expect(agent.did).toBe("did:ad4m:test"); + expect(agent.perspective.links.length).toBe(1); + expect(agent.perspective.links[0].data.source).toBe("root"); + expect(agent.perspective.links[0].data.target).toBe( + "perspective://Qm34589a3ccc0", + ); + }); + + it("mutatePublicPerspective() smoke test", async () => { + let additionLink = new Link({ + source: "root", + target: "perspective://Qm34589a3ccc0", + }); + const removalLink = new LinkExpression(); + removalLink.author = "did:ad4m:test"; + removalLink.timestamp = Date.now().toString(); + removalLink.data = { + source: "root2", + target: "perspective://Qm34589a3ccc0", + }; + removalLink.proof = { + signature: "", + key: "", + valid: true, + }; + + //Note; here we dont get the links above since mutatePublicPerspective relies on a snapshot which returns the default test link for perspectives + const agent = await ad4mClient.agent.mutatePublicPerspective({ + additions: [additionLink], + removals: [removalLink], + } as LinkMutations); + expect(agent.did).toBe("did:ad4m:test"); + expect(agent.perspective.links.length).toBe(1); + expect(agent.perspective.links[0].data.source).toBe("root"); + expect(agent.perspective.links[0].data.target).toBe( + "neighbourhood://Qm12345", + ); + }); + + it("updateDirectMessageLanguage() smoke test", async () => { + const agent = await ad4mClient.agent.updateDirectMessageLanguage("abcd"); + expect(agent.directMessageLanguage).toBe("abcd"); + }); + + it("entanglementProof() smoke tests", async () => { + const addProof = await ad4mClient.agent.addEntanglementProofs([ + new EntanglementProofInput( + "did:key:hash", + "did-key-id", + "ethereum", + "ethAddr", + "sig", + "sig2", + ), + ]); + expect(addProof[0].did).toBe("did:key:hash"); + expect(addProof[0].didSigningKeyId).toBe("did-key-id"); + expect(addProof[0].deviceKeyType).toBe("ethereum"); + expect(addProof[0].deviceKey).toBe("ethAddr"); + expect(addProof[0].deviceKeySignedByDid).toBe("sig"); + expect(addProof[0].didSignedByDeviceKey).toBe("sig2"); + + const getProofs = await ad4mClient.agent.getEntanglementProofs(); + expect(getProofs[0].did).toBe("did:key:hash"); + expect(getProofs[0].didSigningKeyId).toBe("did-key-id"); + expect(getProofs[0].deviceKeyType).toBe("ethereum"); + expect(getProofs[0].deviceKey).toBe("ethAddr"); + expect(getProofs[0].deviceKeySignedByDid).toBe("sig"); + expect(getProofs[0].didSignedByDeviceKey).toBe("sig2"); + + const deleteProofs = await ad4mClient.agent.deleteEntanglementProofs([ + new EntanglementProofInput( + "did:key:hash", + "did-key-id", + "ethereum", + "ethAddr", + "sig", + "sig2", + ), + ]); + expect(deleteProofs[0].did).toBe("did:key:hash"); + expect(deleteProofs[0].didSigningKeyId).toBe("did-key-id"); + expect(deleteProofs[0].deviceKeyType).toBe("ethereum"); + expect(deleteProofs[0].deviceKey).toBe("ethAddr"); + expect(deleteProofs[0].deviceKeySignedByDid).toBe("sig"); + expect(deleteProofs[0].didSignedByDeviceKey).toBe("sig2"); + + const preflight = await ad4mClient.agent.entanglementProofPreFlight( + "ethAddr", + "ethereum", + ); + expect(preflight.did).toBe("did:key:hash"); + expect(preflight.didSigningKeyId).toBe("did-key-id"); + expect(preflight.deviceKeyType).toBe("ethereum"); + expect(preflight.deviceKey).toBe("ethAddr"); + expect(preflight.deviceKeySignedByDid).toBe("sig"); + expect(preflight.didSignedByDeviceKey).toBe(null); + }); + + it("requestCapability() smoke tests", async () => { + const requestId = await ad4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "demo.test.org", + appUrl: "https://demo-link", + appIconPath: "/some/image/path", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + } as ResourceInput, + can: ["QUERY"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + expect(requestId).toBe("test-request-id"); + }); + + it("agentGetApps() smoke tests", async () => { + const apps = await ad4mClient.agent.getApps(); + expect(apps.length).toBe(0); + }); + + it("agentPermitCapability() smoke tests", async () => { + const rand = await ad4mClient.agent.permitCapability( + '{"requestId":"4f30e2e2-d307-4f2b-b0a0-6dac4ca4af26","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["QUERY"]}]}}', + ); + expect(rand).toBe("123"); + }); + + it("agentGenerateJwt() smoke tests", async () => { + const jwt = await ad4mClient.agent.generateJwt("test-request-id", "123"); + expect(jwt).toBe("test-jwt"); + }); + + it("agentRevokeToken() smoke tests", async () => { + const newApps = await ad4mClient.agent.revokeToken("test-request-id"); + expect(newApps.length).toBe(1); + expect(newApps[0].revoked).toBe(true); + }); + + it("agentRemoveToken() smoke tests", async () => { + const newApps = await ad4mClient.agent.removeApp("test-request-id"); + expect(newApps.length).toBe(0); + }); + + it("agentIsLocked() smoke tests", async () => { + const status = await ad4mClient.agent.isLocked(); + expect(status).toBe(false); + }); + + it("agentSignMessage() smoke tests", async () => { + const sig = await ad4mClient.agent.signMessage("test-message"); + expect(sig.signature).toBe("test-message-signature"); + expect(sig.publicKey).toBe("test-public-key"); + }); + }); + + describe(".expression", () => { + it("get() smoke test", async () => { + const nonExisting = await ad4mClient.expression.get("wrong address"); + expect(nonExisting).toBeNull(); + + const expression = await ad4mClient.expression.get( + "neighbourhood://Qm123", + ); + expect(expression).toBeDefined(); + expect(expression.author).toBe("did:ad4m:test"); + expect(expression.data).toBe( + '{"type":"test expression","content":"test"}', + ); + }); + + it("getMany() smoke test", async () => { + const getMany = await ad4mClient.expression.getMany(["hash1", "hash2"]); + expect(getMany.length).toBe(2); + expect(getMany[0].author).toBe("did:ad4m:test"); + expect(getMany[0].data).toBe( + '{"type":"test expression","content":"test"}', + ); + expect(getMany[1]).toBeNull(); + }); + + it("getRaw() smoke test", async () => { + const nonExisting = await ad4mClient.expression.getRaw("wrong address"); + expect(nonExisting).toBeNull(); + + const expressionRaw = await ad4mClient.expression.getRaw( + "neighbourhood://Qm123", + ); + expect(expressionRaw).toBeDefined(); + const expression = JSON.parse(expressionRaw); + expect(expression.author).toBe("did:ad4m:test"); + expect(expression.data).toBe( + '{"type":"test expression","content":"test"}', + ); + }); + + it("create() smoke test", async () => { + const address = await ad4mClient.expression.create("content", "Qmabcdf"); + expect(address.toString()).toBe("Qm1234"); + + const address2 = await ad4mClient.expression.create( + { content: "json" }, + "Qmabcdf", + ); + expect(address2.toString()).toBe("Qm1234"); + }); + + it("interactions() smoke test", async () => { + const interactions = await ad4mClient.expression.interactions("Qmabcdf"); + expect(interactions.length).toBe(1); + const i = interactions[0]; + expect(i.label).toBe("Add a comment"); + expect(i.name).toBe("add_comment"); + expect(i.parameters.length).toBe(1); + const param = i.parameters[0]; + expect(param.name).toBe("comment"); + expect(param.type).toBe("string"); + }); + + it("interact() smoke test", async () => { + const call = new InteractionCall("add_comment", { content: "test" }); + const result = await ad4mClient.expression.interact("Qmabcdf", call); + expect(result.toString()).toBe("test result"); + }); + }); + + describe(".langauges", () => { + it("byAddress() smoke test", async () => { + const language = await ad4mClient.languages.byAddress( + "test-language-address", + ); + expect(language.address).toBe("test-language-address"); + }); + + it("byFilter() smoke test", async () => { + const languages = await ad4mClient.languages.byFilter("linksAdapter"); + expect(languages.length).toBe(1); + expect(languages[0].name).toBe("test-links-language"); + }); + + it("all() smoke test", async () => { + const languages = await ad4mClient.languages.all(); + expect(languages.length).toBe(1); + expect(languages[0].name).toBe("test-links-language"); + }); + + it("writeSettings() smoke test", async () => { + const result = await ad4mClient.languages.writeSettings( + "test-language-address", + JSON.stringify({ testSetting: true }), + ); + expect(result).toBe(true); + }); + + it("applyTemplateAndPublish() smoke test", async () => { + const language = await ad4mClient.languages.applyTemplateAndPublish( + "languageHash", + '{"name": "test-templating"}', + ); + expect(language.name).toBe("languageHash-clone"); + }); + + it("publish() smoke test", async () => { + let input = new LanguageMetaInput(); + input.name = "test language 1"; + input.description = "Language for smoke testing"; + input.possibleTemplateParams = ["uuid", "name", "membrane"]; + input.sourceCodeLink = "https://github.com/perspect3vism/test-language"; + + const languageMeta = await ad4mClient.languages.publish( + "/some/language/path/", + input, + ); + expect(languageMeta.name).toBe(input.name); + expect(languageMeta.description).toBe(input.description); + expect(languageMeta.possibleTemplateParams).toStrictEqual( + input.possibleTemplateParams, + ); + expect(languageMeta.sourceCodeLink).toBe(input.sourceCodeLink); + expect(languageMeta.address).toBe("Qm12345"); + expect(languageMeta.author).toBe("did:test:me"); + expect(languageMeta.templateSourceLanguageAddress).toBe("Qm12345"); + expect(languageMeta.templateAppliedParams).toBe( + JSON.stringify({ uuid: "asdfsdaf", name: "test template" }), + ); + }); + + it("meta() smoke test", async () => { + let input = new LanguageMetaInput(); + input.name = "test language 1"; + input.description = "Language for smoke testing"; + input.possibleTemplateParams = ["uuid", "name", "membrane"]; + input.sourceCodeLink = "https://github.com/perspect3vism/test-language"; + + const languageMeta = await ad4mClient.languages.meta("Qm12345"); + + expect(languageMeta.name).toBe("test-language"); + expect(languageMeta.address).toBe("Qm12345"); + expect(languageMeta.description).toBe("Language meta for testing"); + expect(languageMeta.author).toBe("did:test:me"); + expect(languageMeta.templated).toBe(true); + expect(languageMeta.templateSourceLanguageAddress).toBe("Qm12345"); + expect(languageMeta.templateAppliedParams).toBe( + JSON.stringify({ uuid: "asdfsdaf", name: "test template" }), + ); + expect(languageMeta.possibleTemplateParams).toStrictEqual([ + "uuid", + "name", + ]); + expect(languageMeta.sourceCodeLink).toBe( + "https://github.com/perspect3vism/ad4m", + ); + }); + + it("source() smoke test", async () => { + const source = await ad4mClient.languages.source("Qm12345"); + expect(source).toBe("var test = 'language source code'"); + }); + + it("remove() smoke test", async () => { + const result = await ad4mClient.languages.remove("Qm12345"); + expect(result).toBe(true); + }); + }); + + describe(".neighbourhood", () => { + const testPerspective = new Perspective(); + const linkExpr = new LinkExpression(); + linkExpr.author = "did:method:12345"; + linkExpr.timestamp = new Date().toString(); + linkExpr.data = new Link({ + source: "root", + target: "perspective://Qm34589a3ccc0", + }); + linkExpr.proof = { signature: "asdfasdf", key: "asdfasdf" }; + testPerspective.links.push(linkExpr); + + const testUnsignedPerspective = new PerspectiveUnsignedInput(); + const link = new Link({ + source: "root", + target: "perspective://Qm34589a3ccc0", + }); + testUnsignedPerspective.links.push(link); + + it("publishFromPerspective() smoke test", async () => { + const expressionRef = + await ad4mClient.neighbourhood.publishFromPerspective( + "UUID", + "test-link-lang", + new Perspective(), + ); + expect(expressionRef).toBe("neighbourhood://neighbourhoodAddress"); + }); + + it("joinFromUrl() smoke test", async () => { + const perspective = await ad4mClient.neighbourhood.joinFromUrl( + "neighbourhood://Qm3sdf3dfwhsafd", + ); + expect(perspective.sharedUrl).toBe("neighbourhood://Qm3sdf3dfwhsafd"); + expect(perspective.uuid).toBeTruthy(); + expect(perspective.name).toBeTruthy(); + }); + + it("hasTelepresenceAdapter() smoke test", async () => { + const result = + await ad4mClient.neighbourhood.hasTelepresenceAdapter("01234"); + expect(result).toBe(true); + }); + + it("otherAgents() smoke test", async () => { + const agents = await ad4mClient.neighbourhood.otherAgents("01234"); + expect(agents.length).toBe(1); + expect(agents[0]).toBe("did:test:other"); + }); + + it("onlineAgents() smoke test", async () => { + const agents = await ad4mClient.neighbourhood.onlineAgents("01234"); + expect(agents.length).toBe(1); + expect(agents[0].did).toBe("did:test:online"); + const status = agents[0].status; + expect(status.author).toBe("did:ad4m:test"); + expect(status.data.links.length).toBe(1); + const link = status.data.links[0]; + expect(link.author).toBe("did:ad4m:test"); + expect(link.data.source).toBe("root"); + }); + + it("setOnlineStatus() smoke test", async () => { + const result = await ad4mClient.neighbourhood.setOnlineStatus( + "01234", + testPerspective, + ); + expect(result).toBe(true); + }); + + it("setOnlineStatusU() smoke test", async () => { + const result = await ad4mClient.neighbourhood.setOnlineStatusU( + "01234", + testUnsignedPerspective, + ); + expect(result).toBe(true); + }); + + it("sendSignal() smoke test", async () => { + const result = await ad4mClient.neighbourhood.sendSignal( + "01234", + "did:test:recipient", + testPerspective, + ); + expect(result).toBe(true); + }); + + it("sendSignaU() smoke test", async () => { + const result = await ad4mClient.neighbourhood.sendSignalU( + "01234", + "did:test:recipient", + testUnsignedPerspective, + ); + expect(result).toBe(true); + }); + + it("sendBroadcast() smoke test", async () => { + const result = await ad4mClient.neighbourhood.sendBroadcast( + "01234", + testPerspective, + ); + expect(result).toBe(true); + }); + + it("sendBroadcastU() smoke test", async () => { + const result = await ad4mClient.neighbourhood.sendBroadcastU( + "01234", + testUnsignedPerspective, + ); + expect(result).toBe(true); + }); + + it("can be accessed via NeighbourhoodProxy", async () => { + const perspective = await ad4mClient.perspective.byUUID("00001"); + const nh = await perspective.getNeighbourhoodProxy(); + + expect(await nh.hasTelepresenceAdapter()).toBe(true); + expect(await nh.otherAgents()).toStrictEqual(["did:test:other"]); + expect((await nh.onlineAgents())[0].did).toStrictEqual("did:test:online"); + expect(await nh.setOnlineStatus(testPerspective)).toBe(true); + expect(await nh.sendSignal("did:test:recipient", testPerspective)).toBe( + true, + ); + expect(await nh.sendBroadcast(testPerspective)).toBe(true); + + nh.addSignalHandler((perspective) => { + //.. + }); + }); + }); + + describe(".perspective", () => { + it("all() smoke test", async () => { + const perspectives = await ad4mClient.perspective.all(); + expect(perspectives.length).toBe(2); + const p1 = perspectives[0]; + const p2 = perspectives[1]; + expect(p1.name).toBe("test-perspective-1"); + expect(p2.name).toBe("test-perspective-2"); + expect(p1.uuid).toBe("00001"); + expect(p2.uuid).toBe("00002"); + expect(p2.sharedUrl).toBe("neighbourhood://Qm12345"); + expect(p2.neighbourhood.data.linkLanguage).toBe("language://Qm12345"); + }); + + it("byUUID() smoke test", async () => { + const p = await ad4mClient.perspective.byUUID("00004"); + expect(p.uuid).toBe("00004"); + expect(p.name).toBe("test-perspective-1"); + }); + + it("snapshotByUUID() smoke test", async () => { + const ps = await ad4mClient.perspective.snapshotByUUID("00004"); + expect(ps.links.length).toBe(1); + expect(ps.links[0].author).toBe("did:ad4m:test"); + expect(ps.links[0].data.source).toBe("root"); + expect(ps.links[0].data.target).toBe("neighbourhood://Qm12345"); + }); + + it("publishSnapshotByUUID() smoke test", async () => { + const snapshotUrl = + await ad4mClient.perspective.publishSnapshotByUUID("00004"); + expect(snapshotUrl).toBe("perspective://Qm12345"); + }); + + it("queryLinks() smoke test", async () => { + const links = await ad4mClient.perspective.queryLinks("000001", { + source: "root", + }); + expect(links.length).toBe(1); + expect(links[0].data.source).toBe("root"); + expect(links[0].data.target).toBe("neighbourhood://Qm12345"); + }); + + it("queryProlog() smoke test", async () => { + let result = await ad4mClient.perspective.queryProlog( + "000001", + "link(X, 2).", + ); + expect(result.length).toBe(1); + expect(result[0].X).toBe(1); + + const proxy = await ad4mClient.perspective.byUUID("000001"); + result = await proxy.infer("link(X, 2)."); + expect(result.length).toBe(1); + expect(result[0].X).toBe(1); + }); + + it("add() smoke test", async () => { + const p = await ad4mClient.perspective.add("p-name"); + expect(p.uuid).toBe("00006"); + expect(p.name).toBe("p-name"); + }); + + it("update() smoke test", async () => { + const p = await ad4mClient.perspective.update("00001", "new-name"); + expect(p.uuid).toBe("00001"); + expect(p.name).toBe("new-name"); + }); + + it("remove() smoke test", async () => { + const r = await ad4mClient.perspective.remove("000001"); + expect(r).toBeTruthy(); + }); + + it("addLink() smoke test", async () => { + const link = await ad4mClient.perspective.addLink("00001", { + source: "root", + target: "lang://Qm123", + predicate: "p", + }); + expect(link.author).toBe("did:ad4m:test"); + expect(link.data.source).toBe("root"); + expect(link.data.predicate).toBe("p"); + expect(link.data.target).toBe("lang://Qm123"); + expect(link.status).toBe("shared"); + }); + + it("addLocalLink() smoke test", async () => { + const link = await ad4mClient.perspective.addLink( + "00001", + { source: "root", target: "lang://Qm123", predicate: "p" }, + "local", + ); + expect(link.author).toBe("did:ad4m:test"); + expect(link.data.source).toBe("root"); + expect(link.data.predicate).toBe("p"); + expect(link.data.target).toBe("lang://Qm123"); + expect(link.status).toBe("local"); + }); + + it("addLinks() smoke test", async () => { + const links = await ad4mClient.perspective.addLinks("00001", [ + { source: "root", target: "lang://Qm123", predicate: "p" }, + { source: "root", target: "lang://Qm123", predicate: "p" }, + ]); + expect(links.length).toBe(2); + expect(links[0].author).toBe("did:ad4m:test"); + expect(links[0].data.source).toBe("root"); + expect(links[0].data.predicate).toBe("p"); + expect(links[0].data.target); + }); + + it("removeLinks() smoke test", async () => { + const links = await ad4mClient.perspective.removeLinks("00001", [ + { + author: "", + timestamp: "", + proof: { signature: "", key: "" }, + data: { source: "root", target: "lang://Qm123", predicate: "p" }, + }, + { + author: "", + timestamp: "", + proof: { signature: "", key: "" }, + data: { source: "root", target: "lang://Qm123", predicate: "p" }, + }, + ]); + expect(links.length).toBe(2); + expect(links[0].author).toBe("did:ad4m:test"); + expect(links[0].data.source).toBe("root"); + expect(links[0].data.predicate).toBe("p"); + expect(links[0].data.target); + }); + + it("linkMutations() smoke test", async () => { + const mutations = await ad4mClient.perspective.linkMutations("00001", { + additions: [ + { source: "root", target: "lang://Qm123", predicate: "p" }, + { source: "root", target: "lang://Qm123", predicate: "p" }, + ], + removals: [ + { + author: "", + timestamp: "", + proof: { signature: "", key: "" }, + data: { source: "root", target: "lang://Qm123", predicate: "p" }, + }, + { + author: "", + timestamp: "", + proof: { signature: "", key: "" }, + data: { source: "root", target: "lang://Qm123", predicate: "p" }, + }, ], + }); + expect(mutations.additions.length).toBe(2); + expect(mutations.removals.length).toBe(2); + + expect(mutations.additions[0].author).toBe("did:ad4m:test"); + expect(mutations.additions[0].data.source).toBe("root"); + expect(mutations.additions[0].data.predicate).toBe("p"); + expect(mutations.additions[0].data.target).toBe("lang://Qm123"); + + expect(mutations.removals[0].author).toBe("did:ad4m:test"); + expect(mutations.removals[0].data.source).toBe("root"); + expect(mutations.removals[0].data.predicate).toBe("p"); + expect(mutations.removals[0].data.target).toBe("lang://Qm123"); }); + it("addLinkExpression() smoke test", async () => { + const testLink = new LinkExpression(); + testLink.author = "did:ad4m:test"; + testLink.timestamp = Date.now().toString(); + testLink.data = { + source: "root", + target: "lang://Qm123", + predicate: "p", + }; + testLink.proof = { + signature: "", + key: "", + valid: true, + }; + const link = await ad4mClient.perspective.addLinkExpression( + "00001", + testLink, + ); + expect(link.author).toBe("did:ad4m:test"); + expect(link.data.source).toBe("root"); + expect(link.data.predicate).toBe("p"); + expect(link.data.target).toBe("lang://Qm123"); + }); - // Creating the WebSocket server - const wsServer = new WebSocketServer({ - // This is the `httpServer` we created in a previous step. - server: httpServer, - // Pass a different path here if your ApolloServer serves at - // a different path. - path: '/subscriptions', + it("addListener() smoke test", async () => { + let perspective = await ad4mClient.perspective.byUUID("00004"); + + const testLink = new LinkExpression(); + testLink.author = "did:ad4m:test"; + testLink.timestamp = Date.now().toString(); + testLink.data = { + source: "root", + target: "neighbourhood://Qm12345", + }; + testLink.proof = { + signature: "", + key: "", + valid: true, + }; + + const linkAdded = jest.fn(); + const linkRemoved = jest.fn(); + + await perspective.addListener("link-added", linkAdded); + const link = new LinkExpressionInput(); + link.source = "root"; + link.target = "perspective://Qm34589a3ccc0"; + await perspective.add(link); + + expect(linkAdded).toBeCalledTimes(1); + expect(linkRemoved).toBeCalledTimes(0); + + perspective = await ad4mClient.perspective.byUUID("00004"); + + await perspective.addListener("link-removed", linkRemoved); + await perspective.remove(testLink); + + expect(linkAdded).toBeCalledTimes(1); + expect(linkRemoved).toBeCalledTimes(1); }); - // Hand in the schema we just created and have the - // WebSocketServer start listening. - serverCleanup = useServer({ schema }, wsServer); + it("removeListener() smoke test", async () => { + let perspective = await ad4mClient.perspective.byUUID("00004"); - await server.start() - app.use('/graphql', expressMiddleware(server)); - // server.applyMiddleware({ app }); - httpServer.listen({ port }) - return port -} + const linkAdded = jest.fn(); + + await perspective.addListener("link-added", linkAdded); + await perspective.add({ + source: "root", + target: "neighbourhood://Qm12345", + }); + + expect(linkAdded).toBeCalledTimes(1); + + linkAdded.mockClear(); + + perspective = await ad4mClient.perspective.byUUID("00004"); + + await perspective.removeListener("link-added", linkAdded); + await perspective.add({ + source: "root", + target: "neighbourhood://Qm123456", + }); + + expect(linkAdded).toBeCalledTimes(1); + }); + + it("addSyncStateChangeListener() smoke test", async () => { + let perspective = await ad4mClient.perspective.byUUID("00004"); + + const syncState = jest.fn(); + + await perspective.addSyncStateChangeListener(syncState); + await perspective.add({ + source: "root", + target: "neighbourhood://Qm12345", + }); + + expect(syncState).toBeCalledTimes(1); + expect(syncState).toBeCalledWith(PerspectiveState.Synced); + }); + + it("updateLink() smoke test", async () => { + const link = await ad4mClient.perspective.updateLink( + "00001", + { + author: "", + timestamp: "", + proof: { signature: "", key: "" }, + data: { source: "root", target: "none" }, + }, + { source: "root", target: "lang://Qm123", predicate: "p" }, + ); + expect(link.author).toBe("did:ad4m:test"); + expect(link.data.source).toBe("root"); + expect(link.data.predicate).toBe("p"); + expect(link.data.target).toBe("lang://Qm123"); + }); + + it("removeLink() smoke test", async () => { + const r = await ad4mClient.perspective.removeLink("00001", { + author: "", + timestamp: "", + proof: { signature: "", key: "" }, + data: { source: "root", target: "none" }, + }); + expect(r).toBeTruthy(); + }); + + it("addSdna() smoke test", async () => { + const r = await ad4mClient.perspective.addSdna( + "00001", + "Test", + 'subject_class("Test", test)', + "subject_class", + ); + expect(r).toBeTruthy(); + }); + + it("executeCommands() smoke test", async () => { + const result = await ad4mClient.perspective.executeCommands( + "00001", + "command1; command2", + "expression1", + "param1, param2", + ); + expect(result).toBeTruthy(); + }); + + it("getSubjectData() smoke test", async () => { + const result = await ad4mClient.perspective.getSubjectData( + "00001", + "Test", + "test", + ); + expect(result).toBe(""); + }); + + it("createSubject() smoke test", async () => { + const result = await ad4mClient.perspective.createSubject( + "00001", + "command1; command2", + "expression1", + ); + expect(result).toBeTruthy(); + }); + }); + + describe(".runtime", () => { + it("quit() smoke test", async () => { + const r = await ad4mClient.runtime.quit(); + expect(r).toBeTruthy(); + }); + + it("openLink() smoke test", async () => { + const r = await ad4mClient.runtime.openLink("https://ad4m.dev"); + expect(r).toBeTruthy(); + }); + + it("addTrustedAgents() smoke test", async () => { + const r = await ad4mClient.runtime.addTrustedAgents(["agentPubKey"]); + expect(r).toStrictEqual(["agentPubKey"]); + }); + + it("deleteTrustedAgents() smoke test", async () => { + const r = await ad4mClient.runtime.deleteTrustedAgents(["agentPubKey"]); + expect(r).toStrictEqual([]); + }); + + it("getTrustedAgents() smoke test", async () => { + const r = await ad4mClient.runtime.getTrustedAgents(); + expect(r).toStrictEqual(["agentPubKey"]); + }); + + it("addKnownLinkLanguageTemplates() smoke test", async () => { + const r = await ad4mClient.runtime.addKnownLinkLanguageTemplates([ + "Qm1337", + ]); + expect(r).toStrictEqual(["Qm1337"]); + }); + + it("removeKnownLinkLanguageTemplates() smoke test", async () => { + const r = await ad4mClient.runtime.removeKnownLinkLanguageTemplates([ + "Qm12345abcdef", + ]); + expect(r).toStrictEqual([]); + }); + + it("knownLinkLanguageTemplates() smoke test", async () => { + const r = await ad4mClient.runtime.knownLinkLanguageTemplates(); + expect(r).toStrictEqual(["Qm12345abcdef"]); + }); + + it("addFriends() smoke test", async () => { + const r = await ad4mClient.runtime.addFriends([ + "did:test:another_friend", + ]); + expect(r).toStrictEqual(["did:test:another_friend"]); + }); + + it("removeFriends() smoke test", async () => { + const r = await ad4mClient.runtime.removeFriends(["did:test:friend"]); + expect(r).toStrictEqual([]); + }); + + it("friends() smoke test", async () => { + const r = await ad4mClient.runtime.friends(); + expect(r).toStrictEqual(["did:test:friend"]); + }); + + it("hcAgentInfos smoke test", async () => { + const agentInfos = JSON.parse(await ad4mClient.runtime.hcAgentInfos()); + expect(agentInfos.length).toBe(4); + expect(agentInfos[0].agent).toBeDefined(); + expect(agentInfos[0].signature).toBeDefined(); + expect(agentInfos[0].agent_info).toBeDefined(); + }); -describe('Ad4mClient', () => { - let ad4mClient - let apolloClient - - beforeAll(async () => { - let port = await createGqlServer(4000); - - console.log(`GraphQL server listening at: http://localhost:${port}/graphql`) - - const wsLink = new GraphQLWsLink(createClient({ - url: `ws://localhost:${port}/subscriptions`, - webSocketImpl: Websocket - })); - - - - apolloClient = new ApolloClient({ - link: wsLink, - cache: new InMemoryCache(), - defaultOptions: { - watchQuery: { - fetchPolicy: 'network-only', - nextFetchPolicy: 'network-only' - }, - } - }); - - console.log("GraphQL client connected") - - ad4mClient = new Ad4mClient(apolloClient) - - console.log("GraphQL client connected") - }) - - describe('.agent', () => { - it('me() smoke test', async () => { - const agent = await ad4mClient.agent.me() - expect(agent.did).toBe('did:ad4m:test') - }) - - it('status() smoke test', async () => { - const agentStatus = await ad4mClient.agent.status() - expect(agentStatus.did).toBe('did:ad4m:test') - expect(agentStatus.isUnlocked).toBe(false) - }) - - it('import() smoke test', async () => { - const did = "did:test:test" - const didDocument = "did document test" - const keystore = "test" - const passphrase = "secret" - - const agentStatus = await ad4mClient.agent.import({ - did, didDocument, keystore, passphrase - }) - - expect(agentStatus.did).toBe(did) - expect(agentStatus.didDocument).toBe(didDocument) - expect(agentStatus.isInitialized).toBe(true) - expect(agentStatus.isUnlocked).toBe(true) - }) - - it('generate() smoke test', async () => { - const agentStatus = await ad4mClient.agent.generate("passphrase") - expect(agentStatus.did).toBeDefined() - expect(agentStatus.isInitialized).toBeTruthy() - }) - - it('lock() smoke test', async () => { - const agentStatus = await ad4mClient.agent.lock('secret') - expect(agentStatus.did).toBe("did:ad4m:test") - expect(agentStatus.isUnlocked).toBe(false) - }) - - it('unlock() smoke test', async () => { - const agentStatus = await ad4mClient.agent.unlock('secret', false) - expect(agentStatus.did).toBe("did:ad4m:test") - expect(agentStatus.isUnlocked).toBe(true) - }) - - it('byDID() smoke test', async () => { - const agent = await ad4mClient.agent.byDID('did:method:12345') - expect(agent.did).toBe('did:method:12345') - }) - - it('updatePublicPerspective() smoke test', async () => { - const perspective = new Perspective() - const link = new LinkExpression() - link.author = 'did:method:12345' - link.timestamp = new Date().toString() - link.data = new Link({source: 'root', target: 'perspective://Qm34589a3ccc0'}) - link.proof = { signature: 'asdfasdf', key: 'asdfasdf' } - perspective.links.push(link) - - const agent = await ad4mClient.agent.updatePublicPerspective(perspective) - expect(agent.did).toBe('did:ad4m:test') - expect(agent.perspective.links.length).toBe(1) - expect(agent.perspective.links[0].data.source).toBe('root') - expect(agent.perspective.links[0].data.target).toBe('perspective://Qm34589a3ccc0') - }) - - it('mutatePublicPerspective() smoke test', async () => { - let additionLink = new Link({source: 'root', target: 'perspective://Qm34589a3ccc0'}) - const removalLink = new LinkExpression() - removalLink.author = "did:ad4m:test" - removalLink.timestamp = Date.now().toString() - removalLink.data = { - source: 'root2', - target: 'perspective://Qm34589a3ccc0' - } - removalLink.proof = { - signature: '', - key: '', - valid: true - } - - - //Note; here we dont get the links above since mutatePublicPerspective relies on a snapshot which returns the default test link for perspectives - const agent = await ad4mClient.agent.mutatePublicPerspective({additions: [additionLink], removals: [removalLink]} as LinkMutations) - expect(agent.did).toBe('did:ad4m:test') - expect(agent.perspective.links.length).toBe(1) - expect(agent.perspective.links[0].data.source).toBe('root') - expect(agent.perspective.links[0].data.target).toBe('neighbourhood://Qm12345') - }) - - it('updateDirectMessageLanguage() smoke test', async () => { - const agent = await ad4mClient.agent.updateDirectMessageLanguage("abcd") - expect(agent.directMessageLanguage).toBe('abcd') - }) - - it('entanglementProof() smoke tests', async () => { - const addProof = await ad4mClient.agent.addEntanglementProofs([new EntanglementProofInput("did:key:hash", "did-key-id", "ethereum", "ethAddr", "sig", "sig2")]); - expect(addProof[0].did).toBe("did:key:hash") - expect(addProof[0].didSigningKeyId).toBe("did-key-id") - expect(addProof[0].deviceKeyType).toBe("ethereum") - expect(addProof[0].deviceKey).toBe("ethAddr") - expect(addProof[0].deviceKeySignedByDid).toBe("sig") - expect(addProof[0].didSignedByDeviceKey).toBe("sig2") - - const getProofs = await ad4mClient.agent.getEntanglementProofs(); - expect(getProofs[0].did).toBe("did:key:hash") - expect(getProofs[0].didSigningKeyId).toBe("did-key-id") - expect(getProofs[0].deviceKeyType).toBe("ethereum") - expect(getProofs[0].deviceKey).toBe("ethAddr") - expect(getProofs[0].deviceKeySignedByDid).toBe("sig") - expect(getProofs[0].didSignedByDeviceKey).toBe("sig2") - - const deleteProofs = await ad4mClient.agent.deleteEntanglementProofs([new EntanglementProofInput("did:key:hash", "did-key-id", "ethereum", "ethAddr", "sig", "sig2")]); - expect(deleteProofs[0].did).toBe("did:key:hash") - expect(deleteProofs[0].didSigningKeyId).toBe("did-key-id") - expect(deleteProofs[0].deviceKeyType).toBe("ethereum") - expect(deleteProofs[0].deviceKey).toBe("ethAddr") - expect(deleteProofs[0].deviceKeySignedByDid).toBe("sig") - expect(deleteProofs[0].didSignedByDeviceKey).toBe("sig2") - - const preflight = await ad4mClient.agent.entanglementProofPreFlight("ethAddr", "ethereum"); - expect(preflight.did).toBe("did:key:hash") - expect(preflight.didSigningKeyId).toBe("did-key-id") - expect(preflight.deviceKeyType).toBe("ethereum") - expect(preflight.deviceKey).toBe("ethAddr") - expect(preflight.deviceKeySignedByDid).toBe("sig") - expect(preflight.didSignedByDeviceKey).toBe(null) - }) - - it('requestCapability() smoke tests', async () => { - const requestId = await ad4mClient.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "demo.test.org", - appUrl: "https://demo-link", - appIconPath: "/some/image/path", - capabilities: [ - { - with: { - "domain":"agent", - "pointers":["*"] - } as ResourceInput, - can: ["QUERY"] - }] as CapabilityInput[] - } as AuthInfoInput) - expect(requestId).toBe("test-request-id") - }) - - - it('agentGetApps() smoke tests', async () => { - const apps = await ad4mClient.agent.getApps() - expect(apps.length).toBe(0) - }) - - it('agentPermitCapability() smoke tests', async () => { - const rand = await ad4mClient.agent.permitCapability('{"requestId":"4f30e2e2-d307-4f2b-b0a0-6dac4ca4af26","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["QUERY"]}]}}') - expect(rand).toBe("123") - }) - - it('agentGenerateJwt() smoke tests', async () => { - const jwt = await ad4mClient.agent.generateJwt("test-request-id", "123") - expect(jwt).toBe("test-jwt") - }) - - it('agentRevokeToken() smoke tests', async () => { - const newApps = await ad4mClient.agent.revokeToken('test-request-id') - expect(newApps.length).toBe(1) - expect(newApps[0].revoked).toBe(true) - }) - - it('agentRemoveToken() smoke tests', async () => { - const newApps = await ad4mClient.agent.removeApp('test-request-id') - expect(newApps.length).toBe(0) - }) - - it('agentIsLocked() smoke tests', async () => { - const status = await ad4mClient.agent.isLocked() - expect(status).toBe(false) - }) - - it('agentSignMessage() smoke tests', async () => { - const sig = await ad4mClient.agent.signMessage("test-message") - expect(sig.signature).toBe("test-message-signature") - expect(sig.publicKey).toBe("test-public-key") - }) - }) - - describe('.expression', () => { - it('get() smoke test', async () => { - const nonExisting = await ad4mClient.expression.get("wrong address") - expect(nonExisting).toBeNull() - - const expression = await ad4mClient.expression.get("neighbourhood://Qm123") - expect(expression).toBeDefined() - expect(expression.author).toBe('did:ad4m:test') - expect(expression.data).toBe("{\"type\":\"test expression\",\"content\":\"test\"}") - }) - - it('getMany() smoke test', async () => { - const getMany = await ad4mClient.expression.getMany(["hash1", "hash2"]); - expect(getMany.length).toBe(2); - expect(getMany[0].author).toBe('did:ad4m:test'); - expect(getMany[0].data).toBe("{\"type\":\"test expression\",\"content\":\"test\"}"); - expect(getMany[1]).toBeNull(); - }) - - it('getRaw() smoke test', async () => { - const nonExisting = await ad4mClient.expression.getRaw("wrong address") - expect(nonExisting).toBeNull() - - const expressionRaw = await ad4mClient.expression.getRaw("neighbourhood://Qm123") - expect(expressionRaw).toBeDefined() - const expression = JSON.parse(expressionRaw) - expect(expression.author).toBe('did:ad4m:test') - expect(expression.data).toBe("{\"type\":\"test expression\",\"content\":\"test\"}") - }) - - it('create() smoke test', async () => { - const address = await ad4mClient.expression.create('content', 'Qmabcdf') - expect(address.toString()).toBe("Qm1234") - - const address2 = await ad4mClient.expression.create({content: 'json'}, 'Qmabcdf') - expect(address2.toString()).toBe("Qm1234") - }) - - it('interactions() smoke test', async () => { - const interactions = await ad4mClient.expression.interactions('Qmabcdf') - expect(interactions.length).toBe(1) - const i = interactions[0] - expect(i.label).toBe("Add a comment") - expect(i.name).toBe("add_comment") - expect(i.parameters.length).toBe(1) - const param = i.parameters[0] - expect(param.name).toBe('comment') - expect(param.type).toBe('string') - }) - - it('interact() smoke test', async () => { - const call = new InteractionCall('add_comment', { content: 'test'}) - const result = await ad4mClient.expression.interact('Qmabcdf', call) - expect(result.toString()).toBe("test result") - }) - }) - - describe('.langauges', () => { - it('byAddress() smoke test', async () => { - const language = await ad4mClient.languages.byAddress('test-language-address') - expect(language.address).toBe('test-language-address') - }) - - it('byFilter() smoke test', async () => { - const languages = await ad4mClient.languages.byFilter('linksAdapter') - expect(languages.length).toBe(1) - expect(languages[0].name).toBe('test-links-language') - }) - - it('all() smoke test', async () => { - const languages = await ad4mClient.languages.all() - expect(languages.length).toBe(1) - expect(languages[0].name).toBe('test-links-language') - }) - - it('writeSettings() smoke test', async () => { - const result = await ad4mClient.languages.writeSettings( - 'test-language-address', - JSON.stringify({testSetting: true}) - ) - expect(result).toBe(true) - }) - - it('applyTemplateAndPublish() smoke test', async () => { - const language = await ad4mClient.languages.applyTemplateAndPublish( - 'languageHash', - '{"name": "test-templating"}', - ) - expect(language.name).toBe('languageHash-clone') - }) - - it('publish() smoke test', async () => { - let input = new LanguageMetaInput() - input.name = "test language 1" - input.description = "Language for smoke testing" - input.possibleTemplateParams = ['uuid', 'name', 'membrane'] - input.sourceCodeLink = "https://github.com/perspect3vism/test-language" - - const languageMeta = await ad4mClient.languages.publish( - '/some/language/path/', - input, - ) - expect(languageMeta.name).toBe(input.name) - expect(languageMeta.description).toBe(input.description) - expect(languageMeta.possibleTemplateParams).toStrictEqual(input.possibleTemplateParams) - expect(languageMeta.sourceCodeLink).toBe(input.sourceCodeLink) - expect(languageMeta.address).toBe("Qm12345") - expect(languageMeta.author).toBe("did:test:me") - expect(languageMeta.templateSourceLanguageAddress).toBe("Qm12345") - expect(languageMeta.templateAppliedParams).toBe(JSON.stringify({uuid: 'asdfsdaf', name: 'test template'})) - }) - - it('meta() smoke test', async () => { - let input = new LanguageMetaInput() - input.name = "test language 1" - input.description = "Language for smoke testing" - input.possibleTemplateParams = ['uuid', 'name', 'membrane'] - input.sourceCodeLink = "https://github.com/perspect3vism/test-language" - - const languageMeta = await ad4mClient.languages.meta("Qm12345") - - expect(languageMeta.name).toBe("test-language") - expect(languageMeta.address).toBe("Qm12345") - expect(languageMeta.description).toBe("Language meta for testing") - expect(languageMeta.author).toBe("did:test:me") - expect(languageMeta.templated).toBe(true) - expect(languageMeta.templateSourceLanguageAddress).toBe("Qm12345") - expect(languageMeta.templateAppliedParams).toBe(JSON.stringify({uuid: 'asdfsdaf', name: 'test template'})) - expect(languageMeta.possibleTemplateParams).toStrictEqual(['uuid', 'name']) - expect(languageMeta.sourceCodeLink).toBe("https://github.com/perspect3vism/ad4m") - }) - - it('source() smoke test', async () => { - const source = await ad4mClient.languages.source("Qm12345") - expect(source).toBe("var test = 'language source code'") - }) - - it('remove() smoke test', async () => { - const result = await ad4mClient.languages.remove("Qm12345"); - expect(result).toBe(true); - }) - }) - - describe('.neighbourhood', () => { - const testPerspective = new Perspective() - const linkExpr = new LinkExpression() - linkExpr.author = 'did:method:12345' - linkExpr.timestamp = new Date().toString() - linkExpr.data = new Link({source: 'root', target: 'perspective://Qm34589a3ccc0'}) - linkExpr.proof = { signature: 'asdfasdf', key: 'asdfasdf' } - testPerspective.links.push(linkExpr) - - const testUnsignedPerspective = new PerspectiveUnsignedInput() - const link = new Link({ - source: 'root', target: 'perspective://Qm34589a3ccc0' - }) - testUnsignedPerspective.links.push(link) - - it('publishFromPerspective() smoke test', async () => { - const expressionRef = await ad4mClient.neighbourhood.publishFromPerspective('UUID', 'test-link-lang', new Perspective()) - expect(expressionRef).toBe('neighbourhood://neighbourhoodAddress') - }) - - it('joinFromUrl() smoke test', async () => { - const perspective = await ad4mClient.neighbourhood.joinFromUrl('neighbourhood://Qm3sdf3dfwhsafd') - expect(perspective.sharedUrl).toBe('neighbourhood://Qm3sdf3dfwhsafd') - expect(perspective.uuid).toBeTruthy() - expect(perspective.name).toBeTruthy() - }) - - it('hasTelepresenceAdapter() smoke test', async () => { - const result = await ad4mClient.neighbourhood.hasTelepresenceAdapter('01234') - expect(result).toBe(true) - }) - - it('otherAgents() smoke test', async () => { - const agents = await ad4mClient.neighbourhood.otherAgents('01234') - expect(agents.length).toBe(1) - expect(agents[0]).toBe('did:test:other') - }) - - it('onlineAgents() smoke test', async () => { - const agents = await ad4mClient.neighbourhood.onlineAgents('01234') - expect(agents.length).toBe(1) - expect(agents[0].did).toBe('did:test:online') - const status = agents[0].status - expect(status.author).toBe('did:ad4m:test') - expect(status.data.links.length).toBe(1) - const link = status.data.links[0] - expect(link.author).toBe('did:ad4m:test') - expect(link.data.source).toBe('root') - }) - - it('setOnlineStatus() smoke test', async () => { - const result = await ad4mClient.neighbourhood.setOnlineStatus('01234', testPerspective) - expect(result).toBe(true) - }) - - it('setOnlineStatusU() smoke test', async () => { - const result = await ad4mClient.neighbourhood.setOnlineStatusU('01234', testUnsignedPerspective) - expect(result).toBe(true) - }) - - it('sendSignal() smoke test', async () => { - const result = await ad4mClient.neighbourhood.sendSignal('01234', "did:test:recipient", testPerspective) - expect(result).toBe(true) - }) - - it('sendSignaU() smoke test', async () => { - const result = await ad4mClient.neighbourhood.sendSignalU('01234', "did:test:recipient", testUnsignedPerspective) - expect(result).toBe(true) - }) - - it('sendBroadcast() smoke test', async () => { - const result = await ad4mClient.neighbourhood.sendBroadcast('01234', testPerspective) - expect(result).toBe(true) - }) - - it('sendBroadcastU() smoke test', async () => { - const result = await ad4mClient.neighbourhood.sendBroadcastU('01234', testUnsignedPerspective) - expect(result).toBe(true) - }) - - it('can be accessed via NeighbourhoodProxy', async () => { - const perspective = await ad4mClient.perspective.byUUID('00001') - const nh = await perspective.getNeighbourhoodProxy() - - expect(await nh.hasTelepresenceAdapter()).toBe(true) - expect(await nh.otherAgents()).toStrictEqual(['did:test:other']) - expect((await nh.onlineAgents())[0].did).toStrictEqual('did:test:online') - expect(await nh.setOnlineStatus(testPerspective)).toBe(true) - expect(await nh.sendSignal('did:test:recipient', testPerspective)).toBe(true) - expect(await nh.sendBroadcast(testPerspective)).toBe(true) - - nh.addSignalHandler((perspective) => { - //.. - }) - }) - }) - - describe('.perspective', () => { - it('all() smoke test',async () => { - const perspectives = await ad4mClient.perspective.all() - expect(perspectives.length).toBe(2) - const p1 = perspectives[0] - const p2 = perspectives[1] - expect(p1.name).toBe('test-perspective-1') - expect(p2.name).toBe('test-perspective-2') - expect(p1.uuid).toBe('00001') - expect(p2.uuid).toBe('00002') - expect(p2.sharedUrl).toBe('neighbourhood://Qm12345') - expect(p2.neighbourhood.data.linkLanguage).toBe("language://Qm12345") - }) - - it('byUUID() smoke test', async () => { - const p = await ad4mClient.perspective.byUUID('00004') - expect(p.uuid).toBe('00004') - expect(p.name).toBe('test-perspective-1') - }) - - it('snapshotByUUID() smoke test', async () => { - const ps = await ad4mClient.perspective.snapshotByUUID('00004') - expect(ps.links.length).toBe(1) - expect(ps.links[0].author).toBe('did:ad4m:test') - expect(ps.links[0].data.source).toBe('root') - expect(ps.links[0].data.target).toBe('neighbourhood://Qm12345') - }) - - it('publishSnapshotByUUID() smoke test', async () => { - const snapshotUrl = await ad4mClient.perspective.publishSnapshotByUUID('00004') - expect(snapshotUrl).toBe('perspective://Qm12345') - - }) - - it('queryLinks() smoke test', async () => { - const links = await ad4mClient.perspective.queryLinks('000001', {source: 'root'}) - expect(links.length).toBe(1) - expect(links[0].data.source).toBe('root') - expect(links[0].data.target).toBe('neighbourhood://Qm12345') - }) - - it('queryProlog() smoke test', async () => { - let result = await ad4mClient.perspective.queryProlog('000001', "link(X, 2).") - expect(result.length).toBe(1) - expect(result[0].X).toBe(1) - - const proxy = await ad4mClient.perspective.byUUID('000001') - result = await proxy.infer("link(X, 2).") - expect(result.length).toBe(1) - expect(result[0].X).toBe(1) - }) - - it('add() smoke test', async () => { - const p = await ad4mClient.perspective.add('p-name') - expect(p.uuid).toBe('00006') - expect(p.name).toBe('p-name') - }) - - it('update() smoke test', async () => { - const p = await ad4mClient.perspective.update('00001', 'new-name') - expect(p.uuid).toBe('00001') - expect(p.name).toBe('new-name') - }) - - it('remove() smoke test', async () => { - const r = await ad4mClient.perspective.remove('000001') - expect(r).toBeTruthy() - }) - - it('addLink() smoke test', async () => { - const link = await ad4mClient.perspective.addLink('00001', {source: 'root', target: 'lang://Qm123', predicate: 'p'}) - expect(link.author).toBe('did:ad4m:test') - expect(link.data.source).toBe('root') - expect(link.data.predicate).toBe('p') - expect(link.data.target).toBe('lang://Qm123') - expect(link.status).toBe('shared') - }) - - it('addLocalLink() smoke test', async () => { - const link = await ad4mClient.perspective.addLink('00001', {source: 'root', target: 'lang://Qm123', predicate: 'p'}, 'local') - expect(link.author).toBe('did:ad4m:test') - expect(link.data.source).toBe('root') - expect(link.data.predicate).toBe('p') - expect(link.data.target).toBe('lang://Qm123') - expect(link.status).toBe('local') - }) - - it('addLinks() smoke test', async () => { - const links = await ad4mClient.perspective.addLinks('00001', [ - {source: 'root', target: 'lang://Qm123', predicate: 'p'}, - {source: 'root', target: 'lang://Qm123', predicate: 'p'} - ]) - expect(links.length).toBe(2) - expect(links[0].author).toBe('did:ad4m:test') - expect(links[0].data.source).toBe('root') - expect(links[0].data.predicate).toBe('p') - expect(links[0].data.target) - }) - - it('removeLinks() smoke test', async () => { - const links = await ad4mClient.perspective.removeLinks('00001', [ - {author: '', timestamp: '', proof: {signature: '', key: ''}, data: {source: 'root', target: 'lang://Qm123', predicate: 'p'}}, - {author: '', timestamp: '', proof: {signature: '', key: ''}, data: {source: 'root', target: 'lang://Qm123', predicate: 'p'}} - ]) - expect(links.length).toBe(2) - expect(links[0].author).toBe('did:ad4m:test') - expect(links[0].data.source).toBe('root') - expect(links[0].data.predicate).toBe('p') - expect(links[0].data.target) - }) - - it('linkMutations() smoke test', async () => { - const mutations = await ad4mClient.perspective.linkMutations('00001', { - additions: [ - {source: 'root', target: 'lang://Qm123', predicate: 'p'}, - {source: 'root', target: 'lang://Qm123', predicate: 'p'} - ], - removals: [ - {author: '', timestamp: '', proof: {signature: '', key: ''}, data: {source: 'root', target: 'lang://Qm123', predicate: 'p'}}, - {author: '', timestamp: '', proof: {signature: '', key: ''}, data: {source: 'root', target: 'lang://Qm123', predicate: 'p'}} - ] - }); - expect(mutations.additions.length).toBe(2) - expect(mutations.removals.length).toBe(2) - - expect(mutations.additions[0].author).toBe('did:ad4m:test') - expect(mutations.additions[0].data.source).toBe('root') - expect(mutations.additions[0].data.predicate).toBe('p') - expect(mutations.additions[0].data.target).toBe('lang://Qm123') - - expect(mutations.removals[0].author).toBe('did:ad4m:test') - expect(mutations.removals[0].data.source).toBe('root') - expect(mutations.removals[0].data.predicate).toBe('p') - expect(mutations.removals[0].data.target).toBe('lang://Qm123') - }) - - it('addLinkExpression() smoke test', async () => { - const testLink = new LinkExpression() - testLink.author = "did:ad4m:test" - testLink.timestamp = Date.now().toString() - testLink.data = { - source: 'root', - target: 'lang://Qm123', - predicate: 'p' - } - testLink.proof = { - signature: '', - key: '', - valid: true - } - const link = await ad4mClient.perspective.addLinkExpression('00001', testLink); - expect(link.author).toBe('did:ad4m:test') - expect(link.data.source).toBe('root') - expect(link.data.predicate).toBe('p') - expect(link.data.target).toBe('lang://Qm123') - }) - - it('addListener() smoke test', async () => { - let perspective = await ad4mClient.perspective.byUUID('00004') - - const testLink = new LinkExpression() - testLink.author = "did:ad4m:test" - testLink.timestamp = Date.now().toString() - testLink.data = { - source: 'root', - target: 'neighbourhood://Qm12345' - } - testLink.proof = { - signature: '', - key: '', - valid: true - } - - const linkAdded = jest.fn() - const linkRemoved = jest.fn() - - await perspective.addListener('link-added', linkAdded) - const link = new LinkExpressionInput() - link.source = 'root' - link.target = 'perspective://Qm34589a3ccc0' - await perspective.add(link) - - expect(linkAdded).toBeCalledTimes(1) - expect(linkRemoved).toBeCalledTimes(0) - - perspective = await ad4mClient.perspective.byUUID('00004') - - await perspective.addListener('link-removed', linkRemoved) - await perspective.remove(testLink) - - expect(linkAdded).toBeCalledTimes(1) - expect(linkRemoved).toBeCalledTimes(1) - }) - - it('removeListener() smoke test', async () => { - let perspective = await ad4mClient.perspective.byUUID('00004') - - const linkAdded = jest.fn() - - await perspective.addListener('link-added', linkAdded) - await perspective.add({source: 'root', target: 'neighbourhood://Qm12345'}) - - expect(linkAdded).toBeCalledTimes(1) - - linkAdded.mockClear(); - - perspective = await ad4mClient.perspective.byUUID('00004') - - await perspective.removeListener('link-added', linkAdded) - await perspective.add({source: 'root', target: 'neighbourhood://Qm123456'}) - - expect(linkAdded).toBeCalledTimes(1) - }) - - it('addSyncStateChangeListener() smoke test', async () => { - let perspective = await ad4mClient.perspective.byUUID('00004') - - const syncState = jest.fn() - - await perspective.addSyncStateChangeListener(syncState) - await perspective.add({source: 'root', target: 'neighbourhood://Qm12345'}) - - expect(syncState).toBeCalledTimes(1) - expect(syncState).toBeCalledWith(PerspectiveState.Synced) - }) - - it('updateLink() smoke test', async () => { - const link = await ad4mClient.perspective.updateLink( - '00001', - {author: '', timestamp: '', proof: {signature: '', key: ''}, data:{source: 'root', target: 'none'}}, - {source: 'root', target: 'lang://Qm123', predicate: 'p'}) - expect(link.author).toBe('did:ad4m:test') - expect(link.data.source).toBe('root') - expect(link.data.predicate).toBe('p') - expect(link.data.target).toBe('lang://Qm123') - }) - - it('removeLink() smoke test', async () => { - const r = await ad4mClient.perspective.removeLink('00001', {author: '', timestamp: '', proof: {signature: '', key: ''}, data:{source: 'root', target: 'none'}}) - expect(r).toBeTruthy() - }) - - it('addSdna() smoke test', async () => { - const r = await ad4mClient.perspective.addSdna('00001', "Test", 'subject_class("Test", test)', 'subject_class'); - expect(r).toBeTruthy() - }) - - it('executeCommands() smoke test', async () => { - const result = await ad4mClient.perspective.executeCommands( - '00001', - 'command1; command2', - 'expression1', - 'param1, param2' - ); - expect(result).toBeTruthy(); - }) - - it('getSubjectData() smoke test', async () => { - const result = await ad4mClient.perspective.getSubjectData('00001', 'Test', 'test'); - expect(result).toBe(""); - }); - - it('createSubject() smoke test', async () => { - const result = await ad4mClient.perspective.createSubject( - '00001', - 'command1; command2', - 'expression1', - ); - expect(result).toBeTruthy(); - }) - }) - - describe('.runtime', () => { - it('quit() smoke test', async () => { - const r = await ad4mClient.runtime.quit() - expect(r).toBeTruthy() - }) - - it('openLink() smoke test', async () => { - const r = await ad4mClient.runtime.openLink('https://ad4m.dev') - expect(r).toBeTruthy() - }) - - it('addTrustedAgents() smoke test', async () => { - const r = await ad4mClient.runtime.addTrustedAgents(["agentPubKey"]); - expect(r).toStrictEqual([ 'agentPubKey' ]) - }) - - it('deleteTrustedAgents() smoke test', async () => { - const r = await ad4mClient.runtime.deleteTrustedAgents(["agentPubKey"]); - expect(r).toStrictEqual([]) - }) - - it('getTrustedAgents() smoke test', async () => { - const r = await ad4mClient.runtime.getTrustedAgents(); - expect(r).toStrictEqual([ 'agentPubKey' ]) - }) - - it('addKnownLinkLanguageTemplates() smoke test', async () => { - const r = await ad4mClient.runtime.addKnownLinkLanguageTemplates(["Qm1337"]); - expect(r).toStrictEqual([ 'Qm1337' ]) - }) - - it('removeKnownLinkLanguageTemplates() smoke test', async () => { - const r = await ad4mClient.runtime.removeKnownLinkLanguageTemplates(["Qm12345abcdef"]); - expect(r).toStrictEqual([]) - }) - - it('knownLinkLanguageTemplates() smoke test', async () => { - const r = await ad4mClient.runtime.knownLinkLanguageTemplates(); - expect(r).toStrictEqual([ 'Qm12345abcdef' ]) - }) - - it('addFriends() smoke test', async () => { - const r = await ad4mClient.runtime.addFriends(["did:test:another_friend"]); - expect(r).toStrictEqual([ 'did:test:another_friend' ]) - }) - - it('removeFriends() smoke test', async () => { - const r = await ad4mClient.runtime.removeFriends(["did:test:friend"]); - expect(r).toStrictEqual([]) - }) - - it('friends() smoke test', async () => { - const r = await ad4mClient.runtime.friends(); - expect(r).toStrictEqual([ 'did:test:friend' ]) - }) - - it('hcAgentInfos smoke test', async () => { - const agentInfos = JSON.parse(await ad4mClient.runtime.hcAgentInfos()) - expect(agentInfos.length).toBe(4) - expect(agentInfos[0].agent).toBeDefined() - expect(agentInfos[0].signature).toBeDefined() - expect(agentInfos[0].agent_info).toBeDefined() - }) - - it('hcAddAgentInfos smoke test', async () => { - await ad4mClient.runtime.hcAddAgentInfos("agent infos string") - }) - - it('ververifyStringSignedByDid() smoke test', async () => { - const verify = await ad4mClient.runtime.verifyStringSignedByDid("did", "didSigningKeyId", "data", "signedData") - expect(verify).toBe(true) - }) - - it('setStatus smoke test', async () => { - const link = new LinkExpression() - link.author = 'did:method:12345' - link.timestamp = new Date().toString() - link.data = new Link({source: 'root', target: 'perspective://Qm34589a3ccc0'}) - link.proof = { signature: 'asdfasdf', key: 'asdfasdf' } - await ad4mClient.runtime.setStatus(new Perspective([link])) - }) - - it('friendStatus smoke test', async () => { - const statusExpr = await ad4mClient.runtime.friendStatus("did:ad4m:test") - expect(statusExpr.author).toBe("did:ad4m:test") - const statusPersp = statusExpr.data - expect(statusPersp.links.length).toBe(1) - expect(statusPersp.links[0].data.source).toBe('root') - expect(statusPersp.links[0].data.target).toBe('neighbourhood://Qm12345') - }) - - it('friendSendMessage smoke test', async () => { - const link = new LinkExpression() - link.author = 'did:method:12345' - link.timestamp = new Date().toString() - link.data = new Link({source: 'root', target: 'perspective://Qm34589a3ccc0'}) - link.proof = { signature: 'asdfasdf', key: 'asdfasdf' } - await ad4mClient.runtime.friendSendMessage('did:ad4m:test', new Perspective([link])) - }) - - it('messageInbox smoke test', async () => { - const messages = await ad4mClient.runtime.messageInbox() - expect(messages.length).toBe(1) - const message = messages[0] - expect(message.author).toBe("did:ad4m:test") - const messagePersp = message.data - expect(messagePersp.links.length).toBe(1) - expect(messagePersp.links[0].data.source).toBe('root') - expect(messagePersp.links[0].data.target).toBe('neighbourhood://Qm12345') - }) - - it('messageOutbox smoke test', async () => { - const sentMessages = await ad4mClient.runtime.messageOutbox("did:ad4m:test") - expect(sentMessages.length).toBe(1) - const sentMessage = sentMessages[0] - expect(sentMessage.recipient).toBe("did:test:recipient") - const message = sentMessage.message - expect(message.author).toBe("did:ad4m:test") - const messagePersp = message.data - expect(messagePersp.links.length).toBe(1) - expect(messagePersp.links[0].data.source).toBe('root') - expect(messagePersp.links[0].data.target).toBe('neighbourhood://Qm12345') - }) - - it('runtimeInfo smoke test', async () => { - const runtimeInfo = await ad4mClient.runtime.info(); - expect(runtimeInfo.ad4mExecutorVersion).toBe("x.x.x"); - expect(runtimeInfo.isInitialized).toBe(true); - expect(runtimeInfo.isUnlocked).toBe(true); - }) - - it('requestInstallNotification smoke test', async () => { - await ad4mClient.runtime.requestInstallNotification({ - description: "Test description", - appName: "Test app name", - appUrl: "https://example.com", - appIconPath: "https://example.com/icon", - trigger: "triple(X, ad4m://has_type, flux://message)", - perspectiveIds: ["u983ud-jdhh38d"], - webhookUrl: "https://example.com/webhook", - webhookAuth: "test-auth", - }); - }) - - it('grantNotification smoke test', async () => { - await ad4mClient.runtime.grantNotification("test-notification"); - }) - - it('notifications smoke test', async () => { - const notifications = await ad4mClient.runtime.notifications(); - expect(notifications.length).toBe(1); - }) - - it('updateNotification smoke test', async () => { - await ad4mClient.runtime.updateNotification("test-notification", { - description: "Test description", - appName: "Test app name", - appUrl: "https://example.com", - appIconPath: "https://example.com/icon", - trigger: "triple(X, ad4m://has_type, flux://message)", - perspectiveIds: ["u983ud-jdhh38d"], - webhookUrl: "https://example.com/webhook", - webhookAuth: "test-auth", - }); - }) - - it('removeNotification smoke test', async () => { - await ad4mClient.runtime.removeNotification("test-notification"); - }) - }) - - describe('Ad4mClient subscriptions', () => { - describe('ad4mClient without subscription', () => { - let ad4mClientWithoutSubscription: Ad4mClient - - beforeEach(() => { - ad4mClientWithoutSubscription = new Ad4mClient(apolloClient, false) - }) - - it('agent subscribeAgentUpdated smoke test', async () => { - const agentUpdatedCallback = jest.fn() - ad4mClientWithoutSubscription.agent.addUpdatedListener(agentUpdatedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(agentUpdatedCallback).toBeCalledTimes(0) - - ad4mClientWithoutSubscription.agent.subscribeAgentUpdated() - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithoutSubscription.agent.updateDirectMessageLanguage("lang://test"); - expect(agentUpdatedCallback).toBeCalledTimes(1) - }) - - it('agent subscribeAgentStatusChanged smoke test', async () => { - const agentStatusChangedCallback = jest.fn() - ad4mClientWithoutSubscription.agent.addAgentStatusChangedListener(agentStatusChangedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(agentStatusChangedCallback).toBeCalledTimes(0) - - ad4mClientWithoutSubscription.agent.subscribeAgentStatusChanged() - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithoutSubscription.agent.unlock("test", false); - expect(agentStatusChangedCallback).toBeCalledTimes(1) - }) - - it('agent subscribeAppsChanged smoke test', async () => { - const appsChangedCallback = jest.fn() - ad4mClientWithoutSubscription.agent.addAppChangedListener(appsChangedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(appsChangedCallback).toBeCalledTimes(0) - - ad4mClientWithoutSubscription.agent.subscribeAppsChanged() - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithoutSubscription.agent.removeApp("test"); - expect(appsChangedCallback).toBeCalledTimes(1) - }) - - it('perspective subscribePerspectiveAdded smoke test', async () => { - const perspectiveAddedCallback = jest.fn() - ad4mClientWithoutSubscription.perspective.addPerspectiveAddedListener(perspectiveAddedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveAddedCallback).toBeCalledTimes(0) - - ad4mClientWithoutSubscription.perspective.subscribePerspectiveAdded() - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithoutSubscription.perspective.add('p-name-1'); - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveAddedCallback).toBeCalledTimes(1) - }) - - it('perspective subscribePerspectiveUpdated smoke test', async () => { - const perspectiveUpdatedCallback = jest.fn() - ad4mClientWithoutSubscription.perspective.addPerspectiveUpdatedListener(perspectiveUpdatedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveUpdatedCallback).toBeCalledTimes(0) - - ad4mClientWithoutSubscription.perspective.subscribePerspectiveUpdated() - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithoutSubscription.perspective.update('00006', 'p-test2'); - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveUpdatedCallback).toBeCalledTimes(1) - }) - - it('perspective subscribePerspectiveRemoved smoke test', async () => { - const perspectiveRemovedCallback = jest.fn() - ad4mClientWithoutSubscription.perspective.addPerspectiveRemovedListener(perspectiveRemovedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveRemovedCallback).toBeCalledTimes(0) - - ad4mClientWithoutSubscription.perspective.subscribePerspectiveRemoved() - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithoutSubscription.perspective.remove('00006'); - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveRemovedCallback).toBeCalledTimes(1) - }) - }) - - describe('ad4mClient with subscription', () => { - let ad4mClientWithSubscription - - beforeEach(() => { - ad4mClientWithSubscription = new Ad4mClient(apolloClient, true) - }) - - it('agent subscribeAgentUpdated smoke test', async () => { - const agentUpdatedCallback = jest.fn() - ad4mClientWithSubscription.agent.addUpdatedListener(agentUpdatedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(agentUpdatedCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithSubscription.agent.updateDirectMessageLanguage("lang://test"); - expect(agentUpdatedCallback).toBeCalledTimes(1) - }) - - it('agent subscribeAgentStatusChanged smoke test', async () => { - const agentStatusChangedCallback = jest.fn() - ad4mClientWithSubscription.agent.addAgentStatusChangedListener(agentStatusChangedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(agentStatusChangedCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithSubscription.agent.unlock("test", false); - expect(agentStatusChangedCallback).toBeCalledTimes(1) - }) - - it('agent subscribeAppsChanged smoke test', async () => { - const appsChangedCallback = jest.fn() - ad4mClientWithSubscription.agent.addAppChangedListener(appsChangedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(appsChangedCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithSubscription.agent.removeApp("test"); - expect(appsChangedCallback).toBeCalledTimes(1) - }) - - it('perspective subscribePerspectiveAdded smoke test', async () => { - const perspectiveAddedCallback = jest.fn() - ad4mClientWithSubscription.perspective.addPerspectiveAddedListener(perspectiveAddedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveAddedCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithSubscription.perspective.add('p-name-1'); - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveAddedCallback).toBeCalledTimes(1) - }) - - it('perspective subscribePerspectiveUpdated smoke test', async () => { - const perspectiveUpdatedCallback = jest.fn() - ad4mClientWithSubscription.perspective.addPerspectiveUpdatedListener(perspectiveUpdatedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveUpdatedCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithSubscription.perspective.update('00006', 'p-test2'); - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveUpdatedCallback).toBeCalledTimes(1) - }) - - it('perspective subscribePerspectiveRemoved smoke test', async () => { - const perspectiveRemovedCallback = jest.fn() - ad4mClientWithSubscription.perspective.addPerspectiveRemovedListener(perspectiveRemovedCallback) - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveRemovedCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - await ad4mClientWithSubscription.perspective.remove('00006'); - await new Promise(resolve => setTimeout(resolve, 100)) - expect(perspectiveRemovedCallback).toBeCalledTimes(1) - }) - }) - }) - describe('.ai', () => { - it('getModels smoke test', async () => { - const models = await ad4mClient.ai.getModels(); - expect(models).toBeDefined(); - expect(Array.isArray(models)).toBe(true); - if (models.length > 0) { - const model = models[0]; - expect(model).toHaveProperty('name'); - expect(model).toHaveProperty('id'); - expect(model).toHaveProperty('modelType'); - if (model.api) { - expect(model.api).toHaveProperty('baseUrl'); - expect(model.api).toHaveProperty('apiKey'); - expect(model.api).toHaveProperty('model'); - expect(model.api).toHaveProperty('apiType'); - } - if (model.local) { - expect(model.local).toHaveProperty('fileName'); - if (model.local.tokenizerSource) { - expect(model.local.tokenizerSource).toHaveProperty('repo'); - expect(model.local.tokenizerSource).toHaveProperty('revision'); - expect(model.local.tokenizerSource).toHaveProperty('fileName'); - } - expect(model.local).toHaveProperty('huggingfaceRepo'); - expect(model.local).toHaveProperty('revision'); - } - } - }) - - it('addModel smoke test', async () => { - const newModel = { - name: "New Test Model", - api: { - baseUrl: "https://api.newexample.com", - apiKey: "new-test-api-key", - apiType: "OpenAi", - model: "gpt-4o" - }, - local: { - fileName: "new-test-model.bin", - tokenizerSource: { - repo: "test-repo", - revision: "main", - fileName: "tokenizer.json" - }, - huggingfaceRepo: "test-repo", - revision: "main" - }, - modelType: "LLM" - }; - const result = await ad4mClient.ai.addModel(newModel); - expect(result).toBe("new-model-id"); - }) - - it('updateModel smoke test', async () => { - const modelId = "test-model-id"; - const updatedModel = { - name: "Updated Test Model", - api: { - baseUrl: "https://api.updatedexample.com", - apiKey: "updated-test-api-key", - apiType: "OpenAi", - model: "gpt-4o" - }, - local: { - fileName: "updated-test-model.bin", - tokenizerSource: { - repo: "test-repo", - revision: "main", - fileName: "tokenizer.json" - }, - huggingfaceRepo: "test-repo", - revision: "main" - }, - modelType: "LLM" - }; - const result = await ad4mClient.ai.updateModel(modelId, updatedModel); - expect(result).toBe(true); - }) - - it('removeModel smoke test', async () => { - const modelName = "Test Model to Remove"; - const result = await ad4mClient.ai.removeModel(modelName); - expect(result).toBe(true); - }) - - // skip this because ModelType is a string enum that can't be annotated for - // TypeGraphQL without changing it to a real enum. - // This all works with the Rust resolvers in AD4M, - // as the intergration tests demonstrate. - it.skip('setDefaultModel and getDefaultModel smoke test', async () => { - const modelName = "Test Model"; - const modelType = "LLM"; - - const setResult = await ad4mClient.ai.setDefaultModel(modelType, modelName); - expect(setResult).toBe(true); - - const defaultModel = await ad4mClient.ai.getDefaultModel(modelType); - expect(defaultModel).toBeDefined(); - expect(defaultModel.name).toBe("Default Test Model"); - expect(defaultModel.api).toBeDefined(); - expect(defaultModel.api.baseUrl).toBe("https://api.example.com"); - expect(defaultModel.api.apiKey).toBe("test-api-key"); - expect(defaultModel.api.apiType).toBe("OpenAi"); - expect(defaultModel.local).toBeDefined(); - expect(defaultModel.local.fileName).toBe("test-model.bin"); - if (defaultModel.local.tokenizerSource) { - expect(defaultModel.local.tokenizerSource.repo).toBe("test-repo"); - expect(defaultModel.local.tokenizerSource.revision).toBe("main"); - expect(defaultModel.local.tokenizerSource.fileName).toBe("tokenizer.json"); - } - expect(defaultModel.local.huggingfaceRepo).toBe("test-repo"); - expect(defaultModel.local.revision).toBe("main"); - expect(defaultModel.modelType).toBe(modelType); - }) - - it('embed()', async () => { - const vector = await ad4mClient.ai.embed("model", "test ets") - expect(vector[0]).toEqual(0) - expect(vector[1]).toEqual(10) - expect(vector[2]).toEqual(20) - expect(vector[3]).toEqual(30) - }) - - it('tasks()', async () => { - const tasks = await ad4mClient.ai.tasks() - expect(tasks.length).toBe(2) - expect(tasks[0].taskId).toBe("task_id") - expect(tasks[0].modelId).toBe("modelId") - }) - - it('addTask()', async () => { - const task = await ad4mClient.ai.addTask("task_name", "model_id", "system prompt", []); - expect(task.name).toBe("task_name") - expect(task.taskId).toBe("task_id") - expect(task.modelId).toBe("model_id") - expect(task.systemPrompt).toBe("system prompt") - }); - - it('removeTask()', async () => { - const task = await ad4mClient.ai.removeTask("task_id", "system prompt", []); - expect(task.taskId).toBe("task_id") - expect(task.modelId).toBe("model_id") - expect(task.systemPrompt).toBe("system prompt") - }); - - it('updateTask()', async () => { - const task = await ad4mClient.ai.updateTask("task_id", { - name: "task_name", - modelId: "model_id", - systemPrompt: "system prompt", - promptExamples: [] - }); - expect(task.name).toBe("task_name") - expect(task.taskId).toBe("task_id") - expect(task.modelId).toBe("model_id") - expect(task.systemPrompt).toBe("system prompt") - }); - - it('modelLoadingStatus()', async () => { - const status = await ad4mClient.ai.modelLoadingStatus("model_id"); - expect(status.status).toBe("loaded") - }); - - it('prompt()', async () => { - const prompt = await ad4mClient.ai.prompt("task_id", "Do something"); - console.log(prompt) - expect(prompt).toBe("output") - }) - - it('openTranscriptionStream(), closeTranscriptionStream(), feedTranscriptionStream() & aiTranscriptionText subscription', async () => { - const streamCallback = jest.fn() - const streamId = await ad4mClient.ai.openTranscriptionStream("model_id", streamCallback); - expect(streamId).toBeTruthy() - expect(streamId).toBe("streamId") - expect(streamCallback).toBeCalledTimes(0) - - await new Promise(resolve => setTimeout(resolve, 100)) - - await ad4mClient.ai.feedTranscriptionStream(streamId, [0, 10, 20, 30]); - - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(streamCallback).toBeCalledTimes(1) - - const stream = await ad4mClient.ai.closeTranscriptionStream(streamId) - expect(stream).toBeTruthy() - }) - }) -}) \ No newline at end of file + it("hcAddAgentInfos smoke test", async () => { + await ad4mClient.runtime.hcAddAgentInfos("agent infos string"); + }); + + it("ververifyStringSignedByDid() smoke test", async () => { + const verify = await ad4mClient.runtime.verifyStringSignedByDid( + "did", + "didSigningKeyId", + "data", + "signedData", + ); + expect(verify).toBe(true); + }); + + it("setStatus smoke test", async () => { + const link = new LinkExpression(); + link.author = "did:method:12345"; + link.timestamp = new Date().toString(); + link.data = new Link({ + source: "root", + target: "perspective://Qm34589a3ccc0", + }); + link.proof = { signature: "asdfasdf", key: "asdfasdf" }; + await ad4mClient.runtime.setStatus(new Perspective([link])); + }); + + it("friendStatus smoke test", async () => { + const statusExpr = await ad4mClient.runtime.friendStatus("did:ad4m:test"); + expect(statusExpr.author).toBe("did:ad4m:test"); + const statusPersp = statusExpr.data; + expect(statusPersp.links.length).toBe(1); + expect(statusPersp.links[0].data.source).toBe("root"); + expect(statusPersp.links[0].data.target).toBe("neighbourhood://Qm12345"); + }); + + it("friendSendMessage smoke test", async () => { + const link = new LinkExpression(); + link.author = "did:method:12345"; + link.timestamp = new Date().toString(); + link.data = new Link({ + source: "root", + target: "perspective://Qm34589a3ccc0", + }); + link.proof = { signature: "asdfasdf", key: "asdfasdf" }; + await ad4mClient.runtime.friendSendMessage( + "did:ad4m:test", + new Perspective([link]), + ); + }); + + it("messageInbox smoke test", async () => { + const messages = await ad4mClient.runtime.messageInbox(); + expect(messages.length).toBe(1); + const message = messages[0]; + expect(message.author).toBe("did:ad4m:test"); + const messagePersp = message.data; + expect(messagePersp.links.length).toBe(1); + expect(messagePersp.links[0].data.source).toBe("root"); + expect(messagePersp.links[0].data.target).toBe("neighbourhood://Qm12345"); + }); + + it("messageOutbox smoke test", async () => { + const sentMessages = + await ad4mClient.runtime.messageOutbox("did:ad4m:test"); + expect(sentMessages.length).toBe(1); + const sentMessage = sentMessages[0]; + expect(sentMessage.recipient).toBe("did:test:recipient"); + const message = sentMessage.message; + expect(message.author).toBe("did:ad4m:test"); + const messagePersp = message.data; + expect(messagePersp.links.length).toBe(1); + expect(messagePersp.links[0].data.source).toBe("root"); + expect(messagePersp.links[0].data.target).toBe("neighbourhood://Qm12345"); + }); + + it("runtimeInfo smoke test", async () => { + const runtimeInfo = await ad4mClient.runtime.info(); + expect(runtimeInfo.ad4mExecutorVersion).toBe("x.x.x"); + expect(runtimeInfo.isInitialized).toBe(true); + expect(runtimeInfo.isUnlocked).toBe(true); + }); + + it("requestInstallNotification smoke test", async () => { + await ad4mClient.runtime.requestInstallNotification({ + description: "Test description", + appName: "Test app name", + appUrl: "https://example.com", + appIconPath: "https://example.com/icon", + trigger: "triple(X, ad4m://has_type, flux://message)", + perspectiveIds: ["u983ud-jdhh38d"], + webhookUrl: "https://example.com/webhook", + webhookAuth: "test-auth", + }); + }); + + it("grantNotification smoke test", async () => { + await ad4mClient.runtime.grantNotification("test-notification"); + }); + + it("notifications smoke test", async () => { + const notifications = await ad4mClient.runtime.notifications(); + expect(notifications.length).toBe(1); + }); + + it("updateNotification smoke test", async () => { + await ad4mClient.runtime.updateNotification("test-notification", { + description: "Test description", + appName: "Test app name", + appUrl: "https://example.com", + appIconPath: "https://example.com/icon", + trigger: "triple(X, ad4m://has_type, flux://message)", + perspectiveIds: ["u983ud-jdhh38d"], + webhookUrl: "https://example.com/webhook", + webhookAuth: "test-auth", + }); + }); + + it("removeNotification smoke test", async () => { + await ad4mClient.runtime.removeNotification("test-notification"); + }); + }); + + describe("Ad4mClient subscriptions", () => { + describe("ad4mClient without subscription", () => { + let ad4mClientWithoutSubscription: Ad4mClient; + + beforeEach(() => { + ad4mClientWithoutSubscription = new Ad4mClient(apolloClient, false); + }); + + it("agent subscribeAgentUpdated smoke test", async () => { + const agentUpdatedCallback = jest.fn(); + ad4mClientWithoutSubscription.agent.addUpdatedListener( + agentUpdatedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(agentUpdatedCallback).toBeCalledTimes(0); + + ad4mClientWithoutSubscription.agent.subscribeAgentUpdated(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithoutSubscription.agent.updateDirectMessageLanguage( + "lang://test", + ); + expect(agentUpdatedCallback).toBeCalledTimes(1); + }); + + it("agent subscribeAgentStatusChanged smoke test", async () => { + const agentStatusChangedCallback = jest.fn(); + ad4mClientWithoutSubscription.agent.addAgentStatusChangedListener( + agentStatusChangedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(agentStatusChangedCallback).toBeCalledTimes(0); + + ad4mClientWithoutSubscription.agent.subscribeAgentStatusChanged(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithoutSubscription.agent.unlock("test", false); + expect(agentStatusChangedCallback).toBeCalledTimes(1); + }); + + it("agent subscribeAppsChanged smoke test", async () => { + const appsChangedCallback = jest.fn(); + ad4mClientWithoutSubscription.agent.addAppChangedListener( + appsChangedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(appsChangedCallback).toBeCalledTimes(0); + + ad4mClientWithoutSubscription.agent.subscribeAppsChanged(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithoutSubscription.agent.removeApp("test"); + expect(appsChangedCallback).toBeCalledTimes(1); + }); + + it("perspective subscribePerspectiveAdded smoke test", async () => { + const perspectiveAddedCallback = jest.fn(); + ad4mClientWithoutSubscription.perspective.addPerspectiveAddedListener( + perspectiveAddedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveAddedCallback).toBeCalledTimes(0); + + ad4mClientWithoutSubscription.perspective.subscribePerspectiveAdded(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithoutSubscription.perspective.add("p-name-1"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveAddedCallback).toBeCalledTimes(1); + }); + + it("perspective subscribePerspectiveUpdated smoke test", async () => { + const perspectiveUpdatedCallback = jest.fn(); + ad4mClientWithoutSubscription.perspective.addPerspectiveUpdatedListener( + perspectiveUpdatedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveUpdatedCallback).toBeCalledTimes(0); + + ad4mClientWithoutSubscription.perspective.subscribePerspectiveUpdated(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithoutSubscription.perspective.update( + "00006", + "p-test2", + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveUpdatedCallback).toBeCalledTimes(1); + }); + + it("perspective subscribePerspectiveRemoved smoke test", async () => { + const perspectiveRemovedCallback = jest.fn(); + ad4mClientWithoutSubscription.perspective.addPerspectiveRemovedListener( + perspectiveRemovedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveRemovedCallback).toBeCalledTimes(0); + + ad4mClientWithoutSubscription.perspective.subscribePerspectiveRemoved(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithoutSubscription.perspective.remove("00006"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveRemovedCallback).toBeCalledTimes(1); + }); + }); + + describe("ad4mClient with subscription", () => { + let ad4mClientWithSubscription; + + beforeEach(() => { + ad4mClientWithSubscription = new Ad4mClient(apolloClient, true); + }); + + it("agent subscribeAgentUpdated smoke test", async () => { + const agentUpdatedCallback = jest.fn(); + ad4mClientWithSubscription.agent.addUpdatedListener( + agentUpdatedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(agentUpdatedCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithSubscription.agent.updateDirectMessageLanguage( + "lang://test", + ); + expect(agentUpdatedCallback).toBeCalledTimes(1); + }); + + it("agent subscribeAgentStatusChanged smoke test", async () => { + const agentStatusChangedCallback = jest.fn(); + ad4mClientWithSubscription.agent.addAgentStatusChangedListener( + agentStatusChangedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(agentStatusChangedCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithSubscription.agent.unlock("test", false); + expect(agentStatusChangedCallback).toBeCalledTimes(1); + }); + + it("agent subscribeAppsChanged smoke test", async () => { + const appsChangedCallback = jest.fn(); + ad4mClientWithSubscription.agent.addAppChangedListener( + appsChangedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(appsChangedCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithSubscription.agent.removeApp("test"); + expect(appsChangedCallback).toBeCalledTimes(1); + }); + + it("perspective subscribePerspectiveAdded smoke test", async () => { + const perspectiveAddedCallback = jest.fn(); + ad4mClientWithSubscription.perspective.addPerspectiveAddedListener( + perspectiveAddedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveAddedCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithSubscription.perspective.add("p-name-1"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveAddedCallback).toBeCalledTimes(1); + }); + + it("perspective subscribePerspectiveUpdated smoke test", async () => { + const perspectiveUpdatedCallback = jest.fn(); + ad4mClientWithSubscription.perspective.addPerspectiveUpdatedListener( + perspectiveUpdatedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveUpdatedCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithSubscription.perspective.update("00006", "p-test2"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveUpdatedCallback).toBeCalledTimes(1); + }); + + it("perspective subscribePerspectiveRemoved smoke test", async () => { + const perspectiveRemovedCallback = jest.fn(); + ad4mClientWithSubscription.perspective.addPerspectiveRemovedListener( + perspectiveRemovedCallback, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveRemovedCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await ad4mClientWithSubscription.perspective.remove("00006"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(perspectiveRemovedCallback).toBeCalledTimes(1); + }); + }); + }); + describe(".ai", () => { + it("getModels smoke test", async () => { + const models = await ad4mClient.ai.getModels(); + expect(models).toBeDefined(); + expect(Array.isArray(models)).toBe(true); + if (models.length > 0) { + const model = models[0]; + expect(model).toHaveProperty("name"); + expect(model).toHaveProperty("id"); + expect(model).toHaveProperty("modelType"); + if (model.api) { + expect(model.api).toHaveProperty("baseUrl"); + expect(model.api).toHaveProperty("apiKey"); + expect(model.api).toHaveProperty("model"); + expect(model.api).toHaveProperty("apiType"); + } + if (model.local) { + expect(model.local).toHaveProperty("fileName"); + if (model.local.tokenizerSource) { + expect(model.local.tokenizerSource).toHaveProperty("repo"); + expect(model.local.tokenizerSource).toHaveProperty("revision"); + expect(model.local.tokenizerSource).toHaveProperty("fileName"); + } + expect(model.local).toHaveProperty("huggingfaceRepo"); + expect(model.local).toHaveProperty("revision"); + } + } + }); + + it("addModel smoke test", async () => { + const newModel = { + name: "New Test Model", + api: { + baseUrl: "https://api.newexample.com", + apiKey: "new-test-api-key", + apiType: "OpenAi", + model: "gpt-4o", + }, + local: { + fileName: "new-test-model.bin", + tokenizerSource: { + repo: "test-repo", + revision: "main", + fileName: "tokenizer.json", + }, + huggingfaceRepo: "test-repo", + revision: "main", + }, + modelType: "LLM", + }; + const result = await ad4mClient.ai.addModel(newModel); + expect(result).toBe("new-model-id"); + }); + + it("updateModel smoke test", async () => { + const modelId = "test-model-id"; + const updatedModel = { + name: "Updated Test Model", + api: { + baseUrl: "https://api.updatedexample.com", + apiKey: "updated-test-api-key", + apiType: "OpenAi", + model: "gpt-4o", + }, + local: { + fileName: "updated-test-model.bin", + tokenizerSource: { + repo: "test-repo", + revision: "main", + fileName: "tokenizer.json", + }, + huggingfaceRepo: "test-repo", + revision: "main", + }, + modelType: "LLM", + }; + const result = await ad4mClient.ai.updateModel(modelId, updatedModel); + expect(result).toBe(true); + }); + + it("removeModel smoke test", async () => { + const modelName = "Test Model to Remove"; + const result = await ad4mClient.ai.removeModel(modelName); + expect(result).toBe(true); + }); + + // skip this because ModelType is a string enum that can't be annotated for + // TypeGraphQL without changing it to a real enum. + // This all works with the Rust resolvers in AD4M, + // as the intergration tests demonstrate. + it.skip("setDefaultModel and getDefaultModel smoke test", async () => { + const modelName = "Test Model"; + const modelType = "LLM"; + + const setResult = await ad4mClient.ai.setDefaultModel( + modelType, + modelName, + ); + expect(setResult).toBe(true); + + const defaultModel = await ad4mClient.ai.getDefaultModel(modelType); + expect(defaultModel).toBeDefined(); + expect(defaultModel.name).toBe("Default Test Model"); + expect(defaultModel.api).toBeDefined(); + expect(defaultModel.api.baseUrl).toBe("https://api.example.com"); + expect(defaultModel.api.apiKey).toBe("test-api-key"); + expect(defaultModel.api.apiType).toBe("OpenAi"); + expect(defaultModel.local).toBeDefined(); + expect(defaultModel.local.fileName).toBe("test-model.bin"); + if (defaultModel.local.tokenizerSource) { + expect(defaultModel.local.tokenizerSource.repo).toBe("test-repo"); + expect(defaultModel.local.tokenizerSource.revision).toBe("main"); + expect(defaultModel.local.tokenizerSource.fileName).toBe( + "tokenizer.json", + ); + } + expect(defaultModel.local.huggingfaceRepo).toBe("test-repo"); + expect(defaultModel.local.revision).toBe("main"); + expect(defaultModel.modelType).toBe(modelType); + }); + + it("embed()", async () => { + const vector = await ad4mClient.ai.embed("model", "test ets"); + expect(vector[0]).toEqual(0); + expect(vector[1]).toEqual(10); + expect(vector[2]).toEqual(20); + expect(vector[3]).toEqual(30); + }); + + it("tasks()", async () => { + const tasks = await ad4mClient.ai.tasks(); + expect(tasks.length).toBe(2); + expect(tasks[0].taskId).toBe("task_id"); + expect(tasks[0].modelId).toBe("modelId"); + }); + + it("addTask()", async () => { + const task = await ad4mClient.ai.addTask( + "task_name", + "model_id", + "system prompt", + [], + ); + expect(task.name).toBe("task_name"); + expect(task.taskId).toBe("task_id"); + expect(task.modelId).toBe("model_id"); + expect(task.systemPrompt).toBe("system prompt"); + }); + + it("removeTask()", async () => { + const task = await ad4mClient.ai.removeTask( + "task_id", + "system prompt", + [], + ); + expect(task.taskId).toBe("task_id"); + expect(task.modelId).toBe("model_id"); + expect(task.systemPrompt).toBe("system prompt"); + }); + + it("updateTask()", async () => { + const task = await ad4mClient.ai.updateTask("task_id", { + name: "task_name", + modelId: "model_id", + systemPrompt: "system prompt", + promptExamples: [], + }); + expect(task.name).toBe("task_name"); + expect(task.taskId).toBe("task_id"); + expect(task.modelId).toBe("model_id"); + expect(task.systemPrompt).toBe("system prompt"); + }); + + it("modelLoadingStatus()", async () => { + const status = await ad4mClient.ai.modelLoadingStatus("model_id"); + expect(status.status).toBe("loaded"); + }); + + it("prompt()", async () => { + const prompt = await ad4mClient.ai.prompt("task_id", "Do something"); + expect(prompt).toBe("output"); + }); + + it("openTranscriptionStream(), closeTranscriptionStream(), feedTranscriptionStream() & aiTranscriptionText subscription", async () => { + const streamCallback = jest.fn(); + const streamId = await ad4mClient.ai.openTranscriptionStream( + "model_id", + streamCallback, + ); + expect(streamId).toBeTruthy(); + expect(streamId).toBe("streamId"); + expect(streamCallback).toBeCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await ad4mClient.ai.feedTranscriptionStream(streamId, [0, 10, 20, 30]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(streamCallback).toBeCalledTimes(1); + + const stream = await ad4mClient.ai.closeTranscriptionStream(streamId); + expect(stream).toBeTruthy(); + }); + }); +}); diff --git a/core/src/Ad4mClient.ts b/core/src/Ad4mClient.ts index 0874ad52a..991515629 100644 --- a/core/src/Ad4mClient.ts +++ b/core/src/Ad4mClient.ts @@ -1,72 +1,73 @@ -import { ApolloClient } from '@apollo/client/core' -import { AgentClient } from './agent/AgentClient' -import { LanguageClient } from './language/LanguageClient' -import { NeighbourhoodClient } from './neighbourhood/NeighbourhoodClient' -import { PerspectiveClient } from './perspectives/PerspectiveClient' -import { RuntimeClient } from './runtime/RuntimeClient' -import { ExpressionClient } from './expression/ExpressionClient' -import { AIClient } from './ai/AIClient' +import { ApolloClient } from "@apollo/client/core"; +import { AgentClient } from "./agent/AgentClient"; +import { LanguageClient } from "./language/LanguageClient"; +import { NeighbourhoodClient } from "./neighbourhood/NeighbourhoodClient"; +import { PerspectiveClient } from "./perspectives/PerspectiveClient"; +import { RuntimeClient } from "./runtime/RuntimeClient"; +import { ExpressionClient } from "./expression/ExpressionClient"; +import { AIClient } from "./ai/AIClient"; /** * Client for the Ad4m interface wrapping GraphQL queryies * for convenient use in user facing code. - * + * * Aggregates the six sub-clients: * AgentClient, ExpressionClient, LanguageClient, * NeighbourhoodClient, PerspectiveClient and RuntimeClient * for the respective functionality. */ export class Ad4mClient { - #apolloClient: ApolloClient - #agentClient: AgentClient - #expressionClient: ExpressionClient - #languageClient: LanguageClient - #neighbourhoodClient: NeighbourhoodClient - #perspectiveClient: PerspectiveClient - #runtimeClient: RuntimeClient - #aiClient: AIClient + private _apolloClient: ApolloClient; + private _agentClient: AgentClient; + private _expressionClient: ExpressionClient; + private _languageClient: LanguageClient; + private _neighbourhoodClient: NeighbourhoodClient; + private _perspectiveClient: PerspectiveClient; + private _runtimeClient: RuntimeClient; + private _aiClient: AIClient; + constructor(client: ApolloClient, subscribe: boolean = true) { + this._apolloClient = client; + this._agentClient = new AgentClient(this._apolloClient, subscribe); + this._expressionClient = new ExpressionClient(this._apolloClient); + this._languageClient = new LanguageClient(this._apolloClient); + this._neighbourhoodClient = new NeighbourhoodClient(this._apolloClient); + this._aiClient = new AIClient(this._apolloClient, subscribe); + this._perspectiveClient = new PerspectiveClient( + this._apolloClient, + subscribe, + ); + this._perspectiveClient.setExpressionClient(this._expressionClient); + this._perspectiveClient.setNeighbourhoodClient(this._neighbourhoodClient); + this._perspectiveClient.setAIClient(this._aiClient); + this._runtimeClient = new RuntimeClient(this._apolloClient, subscribe); + } - constructor(client: ApolloClient, subscribe: boolean = true) { - this.#apolloClient = client - this.#agentClient = new AgentClient(this.#apolloClient, subscribe) - this.#expressionClient = new ExpressionClient(this.#apolloClient) - this.#languageClient = new LanguageClient(this.#apolloClient) - this.#neighbourhoodClient = new NeighbourhoodClient(this.#apolloClient) - this.#aiClient = new AIClient(this.#apolloClient, subscribe) - this.#perspectiveClient = new PerspectiveClient(this.#apolloClient, subscribe) - this.#perspectiveClient.setExpressionClient(this.#expressionClient) - this.#perspectiveClient.setNeighbourhoodClient(this.#neighbourhoodClient) - this.#perspectiveClient.setAIClient(this.#aiClient) - this.#runtimeClient = new RuntimeClient(this.#apolloClient, subscribe) - - } + get agent(): AgentClient { + return this._agentClient; + } - get agent(): AgentClient { - return this.#agentClient - } + get expression(): ExpressionClient { + return this._expressionClient; + } - get expression(): ExpressionClient { - return this.#expressionClient - } + get languages(): LanguageClient { + return this._languageClient; + } - get languages(): LanguageClient { - return this.#languageClient - } + get neighbourhood(): NeighbourhoodClient { + return this._neighbourhoodClient; + } - get neighbourhood(): NeighbourhoodClient { - return this.#neighbourhoodClient - } + get perspective(): PerspectiveClient { + return this._perspectiveClient; + } - get perspective(): PerspectiveClient { - return this.#perspectiveClient - } + get runtime(): RuntimeClient { + return this._runtimeClient; + } - get runtime(): RuntimeClient { - return this.#runtimeClient - } - - get ai(): AIClient { - return this.#aiClient - } -} \ No newline at end of file + get ai(): AIClient { + return this._aiClient; + } +} diff --git a/core/src/Literal.test.ts b/core/src/Literal.test.ts index c8d910484..7a433f9fd 100644 --- a/core/src/Literal.test.ts +++ b/core/src/Literal.test.ts @@ -1,31 +1,50 @@ -import { Literal } from './Literal' +import { Literal } from "./Literal"; describe("Literal", () => { - it("can handle strings", () => { - const testString = "test string" - const testUrl = "literal://string:test%20string" - expect(Literal.from(testString).toUrl()).toBe(testUrl) - expect(Literal.fromUrl(testUrl).get()).toBe(testString) - }) + it("can handle strings", () => { + const testString = "test string"; + const testUrl = "literal://string:test%20string"; + expect(Literal.from(testString).toUrl()).toBe(testUrl); + expect(Literal.fromUrl(testUrl).get()).toBe(testString); + }); - it("can handle numbers", () => { - const testNumber = 3.1415 - const testUrl = "literal://number:3.1415" - expect(Literal.from(testNumber).toUrl()).toBe(testUrl) - expect(Literal.fromUrl(testUrl).get()).toBe(testNumber) - }) + it("can handle numbers", () => { + const testNumber = 3.1415; + const testUrl = "literal://number:3.1415"; + expect(Literal.from(testNumber).toUrl()).toBe(testUrl); + expect(Literal.fromUrl(testUrl).get()).toBe(testNumber); + }); - it("can handle objects", () => { - const testObject = {testNumber: "1337", testString: "test"} - const testUrl = "literal://json:%7B%22testNumber%22%3A%221337%22%2C%22testString%22%3A%22test%22%7D" - expect(Literal.from(testObject).toUrl()).toBe(testUrl) - expect(Literal.fromUrl(testUrl).get()).toStrictEqual(testObject) - }) + it("can handle objects", () => { + const testObject = { testNumber: "1337", testString: "test" }; + const testUrl = + "literal://json:%7B%22testNumber%22%3A%221337%22%2C%22testString%22%3A%22test%22%7D"; + expect(Literal.from(testObject).toUrl()).toBe(testUrl); + expect(Literal.fromUrl(testUrl).get()).toStrictEqual(testObject); + }); - it("can handle special characters", () => { - const testString = "message(X) :- triple('ad4m://self', _, X)." - const testUrl = "literal://string:message%28X%29%20%3A-%20triple%28%27ad4m%3A%2F%2Fself%27%2C%20_%2C%20X%29." - expect(Literal.from(testString).toUrl()).toBe(testUrl) - expect(Literal.fromUrl(testUrl).get()).toBe(testString) - }) -}) \ No newline at end of file + it("can handle special characters", () => { + const testString = "message(X) :- triple('ad4m://self', _, X)."; + const testUrl = + "literal://string:message%28X%29%20%3A-%20triple%28%27ad4m%3A%2F%2Fself%27%2C%20_%2C%20X%29."; + expect(Literal.from(testString).toUrl()).toBe(testUrl); + expect(Literal.fromUrl(testUrl).get()).toBe(testString); + }); + + it("can handle boolean true", () => { + const testUrl = "literal://boolean:true"; + expect(Literal.from(true).toUrl()).toBe(testUrl); + expect(Literal.fromUrl(testUrl).get()).toBe(true); + }); + + it("can handle boolean false", () => { + const testUrl = "literal://boolean:false"; + expect(Literal.from(false).toUrl()).toBe(testUrl); + expect(Literal.fromUrl(testUrl).get()).toBe(false); + }); + + it("can handle zero", () => { + expect(Literal.from(0).toUrl()).toBe("literal://number:0"); + expect(Literal.fromUrl("literal://number:0").get()).toBe(0); + }); +}); diff --git a/core/src/Literal.ts b/core/src/Literal.ts index c597a22b4..fe2823a73 100644 --- a/core/src/Literal.ts +++ b/core/src/Literal.ts @@ -1,84 +1,100 @@ function encodeRFC3986URIComponent(str) { - return encodeURIComponent(str) - .replace( - /[!'()*]/g, - (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` - ); + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); } export class Literal { - #literal?: any - #url?: string - - public static fromUrl(url: string) { - if(!url || !url.startsWith("literal://")) - throw new Error("Can't create Literal from non-literal URL") - const l = new Literal() - l.#url = url - return l - } + private _literal?: any; + private _url?: string; - public static from(literal: any) { - const l = new Literal() - l.#literal = literal - return l - } + public static fromUrl(url: string) { + if (!url || !url.startsWith("literal://")) + throw new Error("Can't create Literal from non-literal URL"); + const l = new Literal(); + l._url = url; + return l; + } - toUrl(): string { - if(this.#url && !this.#literal) - return this.#url - if(!this.#url && (this.#literal === undefined || this.#literal === "" || this.#literal === null)) - throw new Error("Can't turn empty Literal into URL") + public static from(literal: any) { + const l = new Literal(); + l._literal = literal; + return l; + } - let encoded - switch(typeof this.#literal) { - case 'string': - encoded = `string:${encodeRFC3986URIComponent(this.#literal)}` - break; - case 'number': - encoded = `number:${encodeRFC3986URIComponent(this.#literal)}` - break; - case 'boolean': - encoded = `boolean:${encodeRFC3986URIComponent(this.#literal)}` - break; - case 'object': - encoded = `json:${encodeRFC3986URIComponent(JSON.stringify(this.#literal))}` - break; - } + toUrl(): string { + if (this._url && !this._literal) return this._url; + if ( + !this._url && + (this._literal === undefined || + this._literal === "" || + this._literal === null) + ) + throw new Error("Can't turn empty Literal into URL"); - return `literal://${encoded}` + let encoded; + switch (typeof this._literal) { + case "string": + encoded = `string:${encodeRFC3986URIComponent(this._literal)}`; + break; + case "number": + encoded = `number:${encodeRFC3986URIComponent(this._literal)}`; + break; + case "boolean": + encoded = `boolean:${encodeRFC3986URIComponent(this._literal)}`; + break; + case "object": + encoded = `json:${encodeRFC3986URIComponent(JSON.stringify(this._literal))}`; + break; + default: + throw new Error( + `Literal.toUrl(): unsupported type "${typeof this._literal}" (value: ${String(this._literal)})`, + ); } - get(): any { - if(this.#literal) - return this.#literal - - if(!this.#url) - throw new Error("Can't render empty Literal") + return `literal://${encoded}`; + } - if(!this.#url.startsWith("literal://")) - throw new Error("Can't render Literal from non-literal URL") - - // get rid of "literal://" - const body = this.#url.substring(10) - + get(): any { + if ( + this._literal !== undefined && + this._literal !== null && + this._literal !== "" + ) + return this._literal; - if(body.startsWith("string:")) { - return decodeURIComponent(body.substring(7)) - } + if (!this._url) throw new Error("Can't render empty Literal"); - if(body.startsWith("number:")) { - const numberString = body.substring(7) - return parseFloat(numberString) - } + if (!this._url.startsWith("literal://")) + throw new Error("Can't render Literal from non-literal URL"); - if(body.startsWith("json:")) { - const json = body.substring(5) - return JSON.parse(decodeURIComponent(json)) - } + // get rid of "literal://" + const body = this._url.substring(10); - throw new Error(`Can't parse unknown literal: ${body}`) + if (body.startsWith("string:")) { + return decodeURIComponent(body.substring(7)); } + if (body.startsWith("number:")) { + const numberString = body.substring(7); + return parseFloat(numberString); + } -} \ No newline at end of file + if (body.startsWith("boolean:")) { + const boolStr = body.substring(8).trim(); + if (boolStr === "true") return true; + if (boolStr === "false") return false; + throw new Error( + `Literal.get(): malformed boolean payload "${boolStr}" — expected "true" or "false"`, + ); + } + + if (body.startsWith("json:")) { + const json = body.substring(5); + return JSON.parse(decodeURIComponent(json)); + } + + throw new Error(`Can't parse unknown literal: ${body}`); + } +} diff --git a/core/src/SmartLiteral.ts b/core/src/SmartLiteral.ts index 62ba1f88c..05f050542 100644 --- a/core/src/SmartLiteral.ts +++ b/core/src/SmartLiteral.ts @@ -3,73 +3,89 @@ import { Literal } from "./Literal"; import { LinkQuery } from "./perspectives/LinkQuery"; import { PerspectiveProxy } from "./perspectives/PerspectiveProxy"; -export const SMART_LITERAL_CONTENT_PREDICATE = "smart_literal://content" - +export const SMART_LITERAL_CONTENT_PREDICATE = "smart_literal://content"; function makeRandomStringID(length: number): string { - let result = ''; - let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let charactersLength = characters.length; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; - } + let result = ""; + let characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} export class SmartLiteral { - #perspective: PerspectiveProxy - #base: string + private _perspective: PerspectiveProxy; + private _base: string; - constructor(perspective: PerspectiveProxy, base: string) { - this.#perspective = perspective - this.#base = base - } + constructor(perspective: PerspectiveProxy, base: string) { + this._perspective = perspective; + this._base = base; + } - get base() { - return this.#base - } + get base() { + return this._base; + } - public static async create(perspective: PerspectiveProxy, literal: any): Promise { - const base = Literal.from(makeRandomStringID(10)).toUrl() - const smartLiteral = new SmartLiteral(perspective, base) - await smartLiteral.set(literal) - return smartLiteral - } + public static async create( + perspective: PerspectiveProxy, + literal: any, + ): Promise { + const base = Literal.from(makeRandomStringID(10)).toUrl(); + const smartLiteral = new SmartLiteral(perspective, base); + await smartLiteral.set(literal); + return smartLiteral; + } - public static async isSmartLiteralBase(perspective: PerspectiveProxy, base: string): Promise { - let links = await perspective.get(new LinkQuery({ - source: base, - predicate: SMART_LITERAL_CONTENT_PREDICATE - })) - return links.length > 0 - } + public static async isSmartLiteralBase( + perspective: PerspectiveProxy, + base: string, + ): Promise { + let links = await perspective.get( + new LinkQuery({ + source: base, + predicate: SMART_LITERAL_CONTENT_PREDICATE, + }), + ); + return links.length > 0; + } - public static async getAllSmartLiterals(perspective: PerspectiveProxy): Promise { - let links = await perspective.get(new LinkQuery({ - predicate: SMART_LITERAL_CONTENT_PREDICATE - })) - return links.map(link => new SmartLiteral(perspective, link.data.source)) - } - - async get(): Promise { - let link = await this.#perspective.getSingleTarget(new LinkQuery({ - source: this.#base, - predicate: SMART_LITERAL_CONTENT_PREDICATE - })) + public static async getAllSmartLiterals( + perspective: PerspectiveProxy, + ): Promise { + let links = await perspective.get( + new LinkQuery({ + predicate: SMART_LITERAL_CONTENT_PREDICATE, + }), + ); + return links.map((link) => new SmartLiteral(perspective, link.data.source)); + } - if(!link) { - throw `No content for smart literal ${this.#base}` - } + async get(): Promise { + let link = await this._perspective.getSingleTarget( + new LinkQuery({ + source: this._base, + predicate: SMART_LITERAL_CONTENT_PREDICATE, + }), + ); - return Literal.fromUrl(link).get() - } - - async set(content: any) { - let literal = Literal.from(content) - await this.#perspective.setSingleTarget(new Link({ - source: this.#base, - predicate: SMART_LITERAL_CONTENT_PREDICATE, - target: literal.toUrl() - })) + if (!link) { + throw `No content for smart literal ${this._base}`; } -} \ No newline at end of file + + return Literal.fromUrl(link).get(); + } + + async set(content: any) { + let literal = Literal.from(content); + await this._perspective.setSingleTarget( + new Link({ + source: this._base, + predicate: SMART_LITERAL_CONTENT_PREDICATE, + target: literal.toUrl(), + }), + ); + } +} diff --git a/core/src/agent/AgentClient.ts b/core/src/agent/AgentClient.ts index 71f33d16a..afe8aee49 100644 --- a/core/src/agent/AgentClient.ts +++ b/core/src/agent/AgentClient.ts @@ -1,6 +1,7 @@ import { ApolloClient, gql } from "@apollo/client/core"; import { PerspectiveInput } from "../perspectives/Perspective"; import unwrapApolloResult from "../unwrapApolloResult"; +import { isSocketCloseError } from "../utils"; import { Agent, Apps, @@ -87,16 +88,16 @@ export type AgentAppsUpdatedCallback = () => null; * as well as updating the publicly shared Agent expression. */ export class AgentClient { - #apolloClient: ApolloClient; - #appsChangedCallback: AgentAppsUpdatedCallback[]; - #updatedCallbacks: AgentUpdatedCallback[]; - #agentStatusChangedCallbacks: AgentStatusChangedCallback[]; + private _apolloClient: ApolloClient; + private _appsChangedCallback: AgentAppsUpdatedCallback[]; + private _updatedCallbacks: AgentUpdatedCallback[]; + private _agentStatusChangedCallbacks: AgentStatusChangedCallback[]; constructor(client: ApolloClient, subscribe: boolean = true) { - this.#apolloClient = client; - this.#updatedCallbacks = []; - this.#agentStatusChangedCallbacks = []; - this.#appsChangedCallback = []; + this._apolloClient = client; + this._updatedCallbacks = []; + this._agentStatusChangedCallbacks = []; + this._appsChangedCallback = []; if (subscribe) { this.subscribeAgentUpdated(); @@ -113,9 +114,9 @@ export class AgentClient { */ async me(): Promise { const { agent } = unwrapApolloResult( - await this.#apolloClient.query({ + await this._apolloClient.query({ query: gql`query agent { agent { ${AGENT_SUBITEMS} } }`, - }) + }), ); let agentObject = new Agent(agent.did, agent.perspective); agentObject.directMessageLanguage = agent.directMessageLanguage; @@ -124,20 +125,20 @@ export class AgentClient { async status(): Promise { const { agentStatus } = unwrapApolloResult( - await this.#apolloClient.query({ + await this._apolloClient.query({ query: gql`query agentStatus { agentStatus { ${AGENT_STATUS_FIELDS} } }`, - }) + }), ); return new AgentStatus(agentStatus); } async generate(passphrase: string): Promise { const { agentGenerate } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentGenerate( $passphrase: String! ) { @@ -146,7 +147,7 @@ export class AgentClient { } }`, variables: { passphrase }, - }) + }), ); return new AgentStatus(agentGenerate); } @@ -154,7 +155,7 @@ export class AgentClient { async import(args: InitializeArgs): Promise { let { did, didDocument, keystore, passphrase } = args; const { agentImport } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentImport( $did: String!, $didDocument: String!, @@ -166,49 +167,49 @@ export class AgentClient { } }`, variables: { did, didDocument, keystore, passphrase }, - }) + }), ); return new AgentStatus(agentImport); } async lock(passphrase: string): Promise { const { agentLock } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentLock($passphrase: String!) { agentLock(passphrase: $passphrase) { ${AGENT_STATUS_FIELDS} } }`, variables: { passphrase }, - }) + }), ); return new AgentStatus(agentLock); } async unlock(passphrase: string, holochain = true): Promise { const { agentUnlock } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentUnlock($passphrase: String!, $holochain: Boolean!) { agentUnlock(passphrase: $passphrase, holochain: $holochain) { ${AGENT_STATUS_FIELDS} } }`, variables: { passphrase, holochain }, - }) + }), ); return new AgentStatus(agentUnlock); } async byDID(did: string): Promise { const { agentByDID } = unwrapApolloResult( - await this.#apolloClient.query({ + await this._apolloClient.query({ query: gql`query agentByDID($did: String!) { agentByDID(did: $did) { ${AGENT_SUBITEMS} } }`, variables: { did }, - }) + }), ); return agentByDID as Agent; } @@ -224,14 +225,14 @@ export class AgentClient { }); const { agentUpdatePublicPerspective } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentUpdatePublicPerspective($perspective: PerspectiveInput!) { agentUpdatePublicPerspective(perspective: $perspective) { ${AGENT_SUBITEMS} } }`, variables: { perspective: cleanedPerspective }, - }) + }), ); const a = agentUpdatePublicPerspective; const agent = new Agent(a.did, a.perspective); @@ -240,12 +241,12 @@ export class AgentClient { } async mutatePublicPerspective(mutations: LinkMutations): Promise { - const perspectiveClient = new PerspectiveClient(this.#apolloClient); - const agentClient = new AgentClient(this.#apolloClient); + const perspectiveClient = new PerspectiveClient(this._apolloClient); + const agentClient = new AgentClient(this._apolloClient); //Create the proxy perspective and load existing links const proxyPerspective = await perspectiveClient.add( - "Agent Perspective Proxy" + "Agent Perspective Proxy", ); const agentMe = await agentClient.me(); @@ -271,17 +272,17 @@ export class AgentClient { } async updateDirectMessageLanguage( - directMessageLanguage: string + directMessageLanguage: string, ): Promise { const { agentUpdateDirectMessageLanguage } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentUpdateDirectMessageLanguage($directMessageLanguage: String!) { agentUpdateDirectMessageLanguage(directMessageLanguage: $directMessageLanguage) { ${AGENT_SUBITEMS} } }`, variables: { directMessageLanguage }, - }) + }), ); const a = agentUpdateDirectMessageLanguage; const agent = new Agent(a.did, a.perspective); @@ -290,77 +291,77 @@ export class AgentClient { } async addEntanglementProofs( - proofs: EntanglementProofInput[] + proofs: EntanglementProofInput[], ): Promise { const { agentAddEntanglementProofs } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentAddEntanglementProofs($proofs: [EntanglementProofInput!]!) { agentAddEntanglementProofs(proofs: $proofs) { ${ENTANGLEMENT_PROOF_FIELDS} } }`, variables: { proofs }, - }) + }), ); return agentAddEntanglementProofs; } async deleteEntanglementProofs( - proofs: EntanglementProofInput[] + proofs: EntanglementProofInput[], ): Promise { const { agentDeleteEntanglementProofs } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentDeleteEntanglementProofs($proofs: [EntanglementProofInput!]!) { agentDeleteEntanglementProofs(proofs: $proofs) { ${ENTANGLEMENT_PROOF_FIELDS} } }`, variables: { proofs }, - }) + }), ); return agentDeleteEntanglementProofs; } async getEntanglementProofs(): Promise { const { agentGetEntanglementProofs } = unwrapApolloResult( - await this.#apolloClient.query({ + await this._apolloClient.query({ query: gql`query agentGetEntanglementProofs { agentGetEntanglementProofs { ${ENTANGLEMENT_PROOF_FIELDS} } }`, - }) + }), ); return agentGetEntanglementProofs; } async entanglementProofPreFlight( deviceKey: string, - deviceKeyType: string + deviceKeyType: string, ): Promise { const { agentEntanglementProofPreFlight } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentEntanglementProofPreFlight($deviceKey: String!, $deviceKeyType: String!) { agentEntanglementProofPreFlight(deviceKey: $deviceKey, deviceKeyType: $deviceKeyType) { ${ENTANGLEMENT_PROOF_FIELDS} } }`, variables: { deviceKey, deviceKeyType }, - }) + }), ); return agentEntanglementProofPreFlight; } addUpdatedListener(listener) { - this.#updatedCallbacks.push(listener); + this._updatedCallbacks.push(listener); } addAppChangedListener(listener) { - this.#appsChangedCallback.push(listener); + this._appsChangedCallback.push(listener); } subscribeAgentUpdated() { - this.#apolloClient + this._apolloClient .subscribe({ query: gql` subscription { agentUpdated { ${AGENT_SUBITEMS} } @@ -370,16 +371,18 @@ export class AgentClient { .subscribe({ next: (result) => { const agent = result.data.agentUpdated; - this.#updatedCallbacks.forEach((cb) => { + this._updatedCallbacks.forEach((cb) => { cb(agent); }); }, - error: (e) => console.error(e), + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, }); } subscribeAppsChanged() { - this.#apolloClient + this._apolloClient .subscribe({ query: gql` subscription { agentAppsChanged { @@ -390,20 +393,22 @@ export class AgentClient { }) .subscribe({ next: (result) => { - this.#appsChangedCallback.forEach((cb) => { + this._appsChangedCallback.forEach((cb) => { cb(); }); }, - error: (e) => console.error(e), + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, }); } addAgentStatusChangedListener(listener) { - this.#agentStatusChangedCallbacks.push(listener); + this._agentStatusChangedCallbacks.push(listener); } subscribeAgentStatusChanged() { - this.#apolloClient + this._apolloClient .subscribe({ query: gql` subscription { agentStatusChanged { ${AGENT_STATUS_FIELDS} } @@ -413,178 +418,218 @@ export class AgentClient { .subscribe({ next: (result) => { const agent = result.data.agentStatusChanged; - this.#agentStatusChangedCallbacks.forEach((cb) => { + this._agentStatusChangedCallbacks.forEach((cb) => { cb(agent); }); }, - error: (e) => console.error(e), + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, }); } async requestCapability(authInfo: AuthInfoInput): Promise { const { agentRequestCapability } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql` mutation agentRequestCapability($authInfo: AuthInfoInput!) { agentRequestCapability(authInfo: $authInfo) } `, variables: { authInfo }, - }) + }), ); return agentRequestCapability; } async permitCapability(auth: string): Promise { const { agentPermitCapability } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql` mutation agentPermitCapability($auth: String!) { agentPermitCapability(auth: $auth) } `, variables: { auth }, - }) + }), ); return agentPermitCapability; } async generateJwt(requestId: string, rand: string): Promise { const { agentGenerateJwt } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql` mutation agentGenerateJwt($requestId: String!, $rand: String!) { agentGenerateJwt(requestId: $requestId, rand: $rand) } `, variables: { requestId, rand }, - }) + }), ); return agentGenerateJwt; } async getApps(): Promise { const { agentGetApps } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`query agentGetApps { agentGetApps { ${Apps_FIELDS} } }`, - }) + }), ); return agentGetApps; } async removeApp(requestId: string): Promise { const { agentRemoveApp } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentRemoveApp($requestId: String!) { agentRemoveApp(requestId: $requestId) { ${Apps_FIELDS} } }`, variables: { requestId }, - }) + }), ); return agentRemoveApp; } async revokeToken(requestId: string): Promise { const { agentRevokeToken } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentRevokeToken($requestId: String!) { agentRevokeToken(requestId: $requestId) { ${Apps_FIELDS} } }`, variables: { requestId }, - }) + }), ); return agentRevokeToken; } async isLocked(): Promise { const { agentIsLocked } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql` query agentIsLocked { agentIsLocked } `, - }) + }), ); return agentIsLocked; } async signMessage(message: string): Promise { const { agentSignMessage } = unwrapApolloResult( - await this.#apolloClient.mutate({ + await this._apolloClient.mutate({ mutation: gql`mutation agentSignMessage($message: String!) { agentSignMessage(message: $message) { ${AGENT_SIGNATURE_FIELDS} } }`, variables: { message }, - }) + }), ); return agentSignMessage; } // Multi-user methods - async createUser(email: string, password: string, appInfo?: AuthInfoInput): Promise { + async createUser( + email: string, + password: string, + appInfo?: AuthInfoInput, + ): Promise { const { runtimeCreateUser } = unwrapApolloResult( - await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeCreateUser($email: String!, $password: String!, $appInfo: AuthInfoInput) { - runtimeCreateUser(email: $email, password: $password, appInfo: $appInfo) { - did - success - error + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeCreateUser( + $email: String! + $password: String! + $appInfo: AuthInfoInput + ) { + runtimeCreateUser( + email: $email + password: $password + appInfo: $appInfo + ) { + did + success + error + } } - }`, + `, variables: { email, password, appInfo }, - }) + }), ); return runtimeCreateUser; } async loginUser(email: string, password: string): Promise { const { runtimeLoginUser } = unwrapApolloResult( - await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeLoginUser($email: String!, $password: String!) { - runtimeLoginUser(email: $email, password: $password) - }`, + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeLoginUser($email: String!, $password: String!) { + runtimeLoginUser(email: $email, password: $password) + } + `, variables: { email, password }, - }) + }), ); return runtimeLoginUser; } - async requestLoginVerification(email: string, appInfo?: AuthInfoInput): Promise { + async requestLoginVerification( + email: string, + appInfo?: AuthInfoInput, + ): Promise { const { runtimeRequestLoginVerification } = unwrapApolloResult( - await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeRequestLoginVerification($email: String!, $appInfo: AuthInfoInput) { - runtimeRequestLoginVerification(email: $email, appInfo: $appInfo) { - success - message - requiresPassword - isExistingUser + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeRequestLoginVerification( + $email: String! + $appInfo: AuthInfoInput + ) { + runtimeRequestLoginVerification(email: $email, appInfo: $appInfo) { + success + message + requiresPassword + isExistingUser + } } - }`, + `, variables: { email, appInfo }, - }) + }), ); return runtimeRequestLoginVerification; } - async verifyEmailCode(email: string, code: string, verificationType: string): Promise { + async verifyEmailCode( + email: string, + code: string, + verificationType: string, + ): Promise { const { runtimeVerifyEmailCode } = unwrapApolloResult( - await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeVerifyEmailCode($email: String!, $code: String!, $verificationType: String!) { - runtimeVerifyEmailCode(email: $email, code: $code, verificationType: $verificationType) - }`, + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeVerifyEmailCode( + $email: String! + $code: String! + $verificationType: String! + ) { + runtimeVerifyEmailCode( + email: $email + code: $code + verificationType: $verificationType + ) + } + `, variables: { email, code, verificationType }, - }) + }), ); return runtimeVerifyEmailCode; } diff --git a/core/src/ai/AIClient.ts b/core/src/ai/AIClient.ts index b3fd89590..c4e68b2ba 100644 --- a/core/src/ai/AIClient.ts +++ b/core/src/ai/AIClient.ts @@ -1,375 +1,425 @@ import { ApolloClient, gql } from "@apollo/client"; import unwrapApolloResult from "../unwrapApolloResult"; -import base64js from 'base64-js'; -import pako from 'pako' +import { isSocketCloseError } from "../utils"; +import base64js from "base64-js"; +import pako from "pako"; import { AIModelLoadingStatus, AITask, AITaskInput } from "./Tasks"; -import { ModelInput, Model, ModelType } from "./AIResolver" +import { ModelInput, Model, ModelType } from "./AIResolver"; export class AIClient { - #apolloClient: ApolloClient; - #transcriptionSubscriptions: Map = new Map(); - - constructor(apolloClient: ApolloClient, subscribe: boolean = true) { - this.#apolloClient = apolloClient; - } - - async getModels(): Promise { - const result = await this.#apolloClient.query({ - query: gql` - query { - aiGetModels { - id - name - api { - baseUrl - apiKey - model - apiType - } - local { - fileName - tokenizerSource { - repo - revision - fileName - } - huggingfaceRepo - revision - } - modelType - } - } - ` - }); - return unwrapApolloResult(result).aiGetModels; - } - - async addModel(model: ModelInput): Promise { - const result = await this.#apolloClient.mutate({ - mutation: gql` - mutation($model: ModelInput!) { - aiAddModel(model: $model) - } - `, - variables: { model } - }); - return unwrapApolloResult(result).aiAddModel; - } - - async updateModel(modelId: string, model: ModelInput): Promise { - const result = await this.#apolloClient.mutate({ - mutation: gql` - mutation($modelId: String!, $model: ModelInput!) { - aiUpdateModel(modelId: $modelId, model: $model) - } - `, - variables: { modelId, model } - }); - return unwrapApolloResult(result).aiUpdateModel; - } - - async removeModel(modelId: string): Promise { - const result = await this.#apolloClient.mutate({ - mutation: gql` - mutation($modelId: String!) { - aiRemoveModel(modelId: $modelId) - } - `, - variables: { modelId } - }); - return unwrapApolloResult(result).aiRemoveModel; - } - - async setDefaultModel(modelType: ModelType, modelId: string): Promise { - const result = await this.#apolloClient.mutate({ - mutation: gql` - mutation($modelType: ModelType!, $modelId: String!) { - aiSetDefaultModel(modelType: $modelType modelId: $modelId) - } - `, - variables: { modelId, modelType } - }); - return unwrapApolloResult(result).aiSetDefaultModel; - } - - async getDefaultModel(modelType: ModelType): Promise { - const result = await this.#apolloClient.query({ - query: gql` - query($modelType: ModelType!) { - aiGetDefaultModel(modelType: $modelType) { - id - name - api { - baseUrl - apiKey - model - apiType - } - local { - fileName - tokenizerSource { - repo - revision - fileName - } - huggingfaceRepo - revision - } - modelType - } - } - `, - variables: { modelType } - }); - return unwrapApolloResult(result).aiGetDefaultModel; - } - - async tasks(): Promise { - const { aiTasks } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql` - query { - aiTasks { - name - modelId - taskId - systemPrompt - promptExamples { - input - output - } - metaData - createdAt - updatedAt - } - } - ` - })); - - return aiTasks; - } - - async addTask(name: string, modelId: string, systemPrompt: string, promptExamples: { input: string, output: string }[], metaData?: string): Promise { - const task = new AITaskInput(name, modelId, systemPrompt, promptExamples, metaData); - const { aiAddTask } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation AiAddTask($task: AITaskInput!) { - aiAddTask(task: $task) { - name - modelId - taskId - systemPrompt - promptExamples { - input - output - } - metaData - createdAt - updatedAt - } - } - `, - variables: { - task + private _apolloClient: ApolloClient; + private _transcriptionSubscriptions: Map = new Map(); + + constructor(apolloClient: ApolloClient, subscribe: boolean = true) { + this._apolloClient = apolloClient; + } + + async getModels(): Promise { + const result = await this._apolloClient.query({ + query: gql` + query { + aiGetModels { + id + name + api { + baseUrl + apiKey + model + apiType } - })); - - return aiAddTask; - } - - async removeTask(taskId: string): Promise { - const { aiRemoveTask } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation AiRemoveTask($taskId: String!) { - aiRemoveTask(taskId: $taskId) { - name - modelId - taskId - systemPrompt - promptExamples { - input - output - } - metaData - createdAt - updatedAt - } - } - `, - variables: { - taskId + local { + fileName + tokenizerSource { + repo + revision + fileName + } + huggingfaceRepo + revision } - })); - - return aiRemoveTask; - } - - async updateTask(taskId: string, task: AITask): Promise { - const { aiUpdateTask } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation AiUpdateTask($taskId: String!, $task: AITaskInput!) { - aiUpdateTask(taskId: $taskId, task: $task) { - name - modelId - taskId - systemPrompt - promptExamples { - input - output - } - metaData - createdAt - updatedAt - } - } - `, - variables: { - taskId, - task: { - name: task.name, - modelId: task.modelId, - systemPrompt: task.systemPrompt, - promptExamples: task.promptExamples - } + modelType + } + } + `, + }); + return unwrapApolloResult(result).aiGetModels; + } + + async addModel(model: ModelInput): Promise { + const result = await this._apolloClient.mutate({ + mutation: gql` + mutation ($model: ModelInput!) { + aiAddModel(model: $model) + } + `, + variables: { model }, + }); + return unwrapApolloResult(result).aiAddModel; + } + + async updateModel(modelId: string, model: ModelInput): Promise { + const result = await this._apolloClient.mutate({ + mutation: gql` + mutation ($modelId: String!, $model: ModelInput!) { + aiUpdateModel(modelId: $modelId, model: $model) + } + `, + variables: { modelId, model }, + }); + return unwrapApolloResult(result).aiUpdateModel; + } + + async removeModel(modelId: string): Promise { + const result = await this._apolloClient.mutate({ + mutation: gql` + mutation ($modelId: String!) { + aiRemoveModel(modelId: $modelId) + } + `, + variables: { modelId }, + }); + return unwrapApolloResult(result).aiRemoveModel; + } + + async setDefaultModel( + modelType: ModelType, + modelId: string, + ): Promise { + const result = await this._apolloClient.mutate({ + mutation: gql` + mutation ($modelType: ModelType!, $modelId: String!) { + aiSetDefaultModel(modelType: $modelType, modelId: $modelId) + } + `, + variables: { modelId, modelType }, + }); + return unwrapApolloResult(result).aiSetDefaultModel; + } + + async getDefaultModel(modelType: ModelType): Promise { + const result = await this._apolloClient.query({ + query: gql` + query ($modelType: ModelType!) { + aiGetDefaultModel(modelType: $modelType) { + id + name + api { + baseUrl + apiKey + model + apiType } - })); - - return aiUpdateTask; - } - - async modelLoadingStatus(model: string): Promise { - const { aiModelLoadingStatus } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql` - query AiModelLoadingStatus($model: String!) { - aiModelLoadingStatus(model: $model) { - model - status - progress - loaded - downloaded - } - } - `, - variables: { - model + local { + fileName + tokenizerSource { + repo + revision + fileName + } + huggingfaceRepo + revision } - })); - - return aiModelLoadingStatus - } - - async prompt(taskId: string, prompt: string): Promise { - const { aiPrompt } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation AiPrompt($taskId: String!, $prompt: String!) { - aiPrompt(taskId: $taskId, prompt: $prompt) - } - `, - variables: { - taskId, - prompt + modelType + } + } + `, + variables: { modelType }, + }); + return unwrapApolloResult(result).aiGetDefaultModel; + } + + async tasks(): Promise { + const { aiTasks } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query { + aiTasks { + name + modelId + taskId + systemPrompt + promptExamples { + input + output + } + metaData + createdAt + updatedAt } - })); - - return aiPrompt; - } - - async embed(modelId: string, text: string): Promise> { - const { aiEmbed } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation aiEmbed($modelId: String!, $text: String!) { - aiEmbed(modelId: $modelId, text: $text) - } - `, - variables: { - modelId, - text + } + `, + }), + ); + + return aiTasks; + } + + async addTask( + name: string, + modelId: string, + systemPrompt: string, + promptExamples: { input: string; output: string }[], + metaData?: string, + ): Promise { + const task = new AITaskInput( + name, + modelId, + systemPrompt, + promptExamples, + metaData, + ); + const { aiAddTask } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation AiAddTask($task: AITaskInput!) { + aiAddTask(task: $task) { + name + modelId + taskId + systemPrompt + promptExamples { + input + output + } + metaData + createdAt + updatedAt } - })); - - const compressed = base64js.toByteArray(aiEmbed); - - const decompressed = JSON.parse(pako.inflate(compressed, { to: 'string' })); - - return decompressed; - } - - async openTranscriptionStream( - modelId: string, - streamCallback: (text: string) => void, - params?: { - startThreshold?: number; - startWindow?: number; - endThreshold?: number; - endWindow?: number; - timeBeforeSpeech?: number; - } - ): Promise { - const { aiOpenTranscriptionStream } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation AiOpenTranscriptionStream($modelId: String!, $params: VoiceActivityParamsInput) { - aiOpenTranscriptionStream(modelId: $modelId, params: $params) - } - `, - variables: { - modelId, - params + } + `, + variables: { + task, + }, + }), + ); + + return aiAddTask; + } + + async removeTask(taskId: string): Promise { + const { aiRemoveTask } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation AiRemoveTask($taskId: String!) { + aiRemoveTask(taskId: $taskId) { + name + modelId + taskId + systemPrompt + promptExamples { + input + output + } + metaData + createdAt + updatedAt } - })); - - const subscription = this.#apolloClient.subscribe({ - query: gql` subscription { - aiTranscriptionText(streamId: "${aiOpenTranscriptionStream}") - }` - }).subscribe({ - next(data) { - streamCallback(data.data.aiTranscriptionText); - - return data.data.aiTranscriptionText; - }, - error(err) { - console.error(err); + } + `, + variables: { + taskId, + }, + }), + ); + + return aiRemoveTask; + } + + async updateTask(taskId: string, task: AITask): Promise { + const { aiUpdateTask } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation AiUpdateTask($taskId: String!, $task: AITaskInput!) { + aiUpdateTask(taskId: $taskId, task: $task) { + name + modelId + taskId + systemPrompt + promptExamples { + input + output + } + metaData + createdAt + updatedAt } - }); - - this.#transcriptionSubscriptions.set(aiOpenTranscriptionStream, subscription); - - return aiOpenTranscriptionStream; - } - - async closeTranscriptionStream(streamId: string): Promise { - const { aiCloseTranscriptionStream } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation aiCloseTranscriptionStream($streamId: String!) { - aiCloseTranscriptionStream(streamId: $streamId) - } - `, - variables: { - streamId + } + `, + variables: { + taskId, + task: { + name: task.name, + modelId: task.modelId, + systemPrompt: task.systemPrompt, + promptExamples: task.promptExamples, + }, + }, + }), + ); + + return aiUpdateTask; + } + + async modelLoadingStatus(model: string): Promise { + const { aiModelLoadingStatus } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query AiModelLoadingStatus($model: String!) { + aiModelLoadingStatus(model: $model) { + model + status + progress + loaded + downloaded } - })); - - const subscription = this.#transcriptionSubscriptions.get(streamId); - - if (!subscription.closed) { - subscription.unsubscribe(); - } - - return aiCloseTranscriptionStream; + } + `, + variables: { + model, + }, + }), + ); + + return aiModelLoadingStatus; + } + + async prompt(taskId: string, prompt: string): Promise { + const { aiPrompt } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation AiPrompt($taskId: String!, $prompt: String!) { + aiPrompt(taskId: $taskId, prompt: $prompt) + } + `, + variables: { + taskId, + prompt, + }, + }), + ); + + return aiPrompt; + } + + async embed(modelId: string, text: string): Promise> { + const { aiEmbed } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation aiEmbed($modelId: String!, $text: String!) { + aiEmbed(modelId: $modelId, text: $text) + } + `, + variables: { + modelId, + text, + }, + }), + ); + + const compressed = base64js.toByteArray(aiEmbed); + + const decompressed = JSON.parse(pako.inflate(compressed, { to: "string" })); + + return decompressed; + } + + async openTranscriptionStream( + modelId: string, + streamCallback: (text: string) => void, + params?: { + startThreshold?: number; + startWindow?: number; + endThreshold?: number; + endWindow?: number; + timeBeforeSpeech?: number; + }, + ): Promise { + const { aiOpenTranscriptionStream } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation AiOpenTranscriptionStream( + $modelId: String! + $params: VoiceActivityParamsInput + ) { + aiOpenTranscriptionStream(modelId: $modelId, params: $params) + } + `, + variables: { + modelId, + params, + }, + }), + ); + + const subscription = this._apolloClient + .subscribe({ + query: gql` subscription { + aiTranscriptionText(streamId: "${aiOpenTranscriptionStream}") + }`, + }) + .subscribe({ + next(data) { + streamCallback(data.data.aiTranscriptionText); + + return data.data.aiTranscriptionText; + }, + error(err) { + if (!isSocketCloseError(err)) console.error(err); + }, + }); + + this._transcriptionSubscriptions.set( + aiOpenTranscriptionStream, + subscription, + ); + + return aiOpenTranscriptionStream; + } + + async closeTranscriptionStream(streamId: string): Promise { + const { aiCloseTranscriptionStream } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation aiCloseTranscriptionStream($streamId: String!) { + aiCloseTranscriptionStream(streamId: $streamId) + } + `, + variables: { + streamId, + }, + }), + ); + + const subscription = this._transcriptionSubscriptions.get(streamId); + + if (!subscription.closed) { + subscription.unsubscribe(); } - async feedTranscriptionStream(streamIds: string | string[], audio: Float32Array): Promise { - const { aiFeedTranscriptionStream } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql` - mutation AiFeedTranscriptionStream($streamIds: [String!]!, $audio: [Float!]!) { - aiFeedTranscriptionStream(streamIds: $streamIds, audio: $audio) - } - `, - variables: { - streamIds: Array.isArray(streamIds) ? streamIds : [streamIds], - audio: audio - } - })); - - return aiFeedTranscriptionStream; - } -} \ No newline at end of file + return aiCloseTranscriptionStream; + } + + async feedTranscriptionStream( + streamIds: string | string[], + audio: Float32Array, + ): Promise { + const { aiFeedTranscriptionStream } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation AiFeedTranscriptionStream( + $streamIds: [String!]! + $audio: [Float!]! + ) { + aiFeedTranscriptionStream(streamIds: $streamIds, audio: $audio) + } + `, + variables: { + streamIds: Array.isArray(streamIds) ? streamIds : [streamIds], + audio: audio, + }, + }), + ); + + return aiFeedTranscriptionStream; + } +} diff --git a/core/src/expression/ExpressionClient.ts b/core/src/expression/ExpressionClient.ts index 4fb05a9ef..04ba13b98 100644 --- a/core/src/expression/ExpressionClient.ts +++ b/core/src/expression/ExpressionClient.ts @@ -5,108 +5,154 @@ import { ExpressionRendered } from "./Expression"; import { Literal } from "../Literal"; export class ExpressionClient { - #apolloClient: ApolloClient + private _apolloClient: ApolloClient; - constructor(client: ApolloClient) { - this.#apolloClient = client - } + constructor(client: ApolloClient) { + this._apolloClient = client; + } - async get(url: string, alwaysGet: boolean = false): Promise { - if(!alwaysGet){ - try { - let literalValue = Literal.fromUrl(url).get(); - if (typeof literalValue === 'object' && literalValue !== null) { - if ('author' in literalValue && 'timestamp' in literalValue && 'data' in literalValue && 'proof' in literalValue) { - return literalValue; - } - } - } catch(e) {} + async get( + url: string, + alwaysGet: boolean = false, + ): Promise { + if (!alwaysGet) { + try { + let literalValue = Literal.fromUrl(url).get(); + if (typeof literalValue === "object" && literalValue !== null) { + if ( + "author" in literalValue && + "timestamp" in literalValue && + "data" in literalValue && + "proof" in literalValue + ) { + return literalValue; + } } - - - const { expression } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query expression($url: String!) { - expression(url: $url) { - author - timestamp - data - language { - address - } - proof { - valid - invalid - } - } - }`, - variables: { url } - })) - return expression + } catch (e) {} } - async getMany(urls: string[]): Promise { - const { expressionMany } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query expressionMany($urls: [String!]!) { - expressionMany(urls: $urls) { - author - timestamp - data - language { - address - } - proof { - valid - invalid - } - } - }`, - variables: { urls } - })) - return expressionMany - } + const { expression } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query expression($url: String!) { + expression(url: $url) { + author + timestamp + data + language { + address + } + proof { + valid + invalid + } + } + } + `, + variables: { url }, + }), + ); + return expression; + } - async getRaw(url: string): Promise { - const { expressionRaw } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query expressionRaw($url: String!) { - expressionRaw(url: $url) - }`, - variables: { url } - })) - return expressionRaw - } + async getMany(urls: string[]): Promise { + const { expressionMany } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query expressionMany($urls: [String!]!) { + expressionMany(urls: $urls) { + author + timestamp + data + language { + address + } + proof { + valid + invalid + } + } + } + `, + variables: { urls }, + }), + ); + return expressionMany; + } - async create(content: any, languageAddress: string): Promise { - content = JSON.stringify(content) - const { expressionCreate } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation expressionCreate($content: String!, $languageAddress: String!){ - expressionCreate(content: $content, languageAddress: $languageAddress) - }`, - variables: { content, languageAddress } - })) - return expressionCreate - } + async getRaw(url: string): Promise { + const { expressionRaw } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query expressionRaw($url: String!) { + expressionRaw(url: $url) + } + `, + variables: { url }, + }), + ); + return expressionRaw; + } - async interactions(url: string): Promise { - const { expressionInteractions } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query expressionInteractions($url: String!) { - expressionInteractions(url: $url) { - label - name - parameters { name, type } - } - }`, - variables: { url } - })) - return expressionInteractions - } + async create(content: any, languageAddress: string): Promise { + content = JSON.stringify(content); + const { expressionCreate } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation expressionCreate( + $content: String! + $languageAddress: String! + ) { + expressionCreate( + content: $content + languageAddress: $languageAddress + ) + } + `, + variables: { content, languageAddress }, + }), + ); + return expressionCreate; + } - async interact(url: string, interactionCall: InteractionCall): Promise { - const { expressionInteract } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation expressionInteract($url: String!, $interactionCall: InteractionCall!){ - expressionInteract(url: $url, interactionCall: $interactionCall) - }`, - variables: { url, interactionCall } - })) - return expressionInteract - } -} \ No newline at end of file + async interactions(url: string): Promise { + const { expressionInteractions } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query expressionInteractions($url: String!) { + expressionInteractions(url: $url) { + label + name + parameters { + name + type + } + } + } + `, + variables: { url }, + }), + ); + return expressionInteractions; + } + + async interact( + url: string, + interactionCall: InteractionCall, + ): Promise { + const { expressionInteract } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation expressionInteract( + $url: String! + $interactionCall: InteractionCall! + ) { + expressionInteract(url: $url, interactionCall: $interactionCall) + } + `, + variables: { url, interactionCall }, + }), + ); + return expressionInteract; + } +} diff --git a/core/src/index.ts b/core/src/index.ts index 59d11ea07..e775f2e78 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -20,7 +20,7 @@ export * from "./perspectives/PerspectiveDiff"; export * from "./perspectives/LinkQuery"; export * from "./SmartLiteral"; export * from "./model/decorators"; -export * from "./model/Subject"; +export * from "./model/prolog"; export * from "./neighbourhood/Neighbourhood"; export * from "./neighbourhood/NeighbourhoodProxy"; export * from "./typeDefs"; diff --git a/core/src/language/LanguageClient.ts b/core/src/language/LanguageClient.ts index c4e250a1d..b87695fae 100644 --- a/core/src/language/LanguageClient.ts +++ b/core/src/language/LanguageClient.ts @@ -1,8 +1,8 @@ -import { ApolloClient, gql } from "@apollo/client/core" -import unwrapApolloResult from "../unwrapApolloResult" -import { LanguageHandle } from "./LanguageHandle" -import { LanguageMeta, LanguageMetaInput } from "./LanguageMeta" -import { LanguageRef } from "./LanguageRef" +import { ApolloClient, gql } from "@apollo/client/core"; +import unwrapApolloResult from "../unwrapApolloResult"; +import { LanguageHandle } from "./LanguageHandle"; +import { LanguageMeta, LanguageMetaInput } from "./LanguageMeta"; +import { LanguageRef } from "./LanguageRef"; const LANGUAGE_COMPLETE = ` name @@ -11,7 +11,7 @@ const LANGUAGE_COMPLETE = ` icon { code } constructorIcon { code } settingsIcon { code } -` +`; const LANGUAGE_META = ` name @@ -23,81 +23,104 @@ const LANGUAGE_META = ` templateAppliedParams possibleTemplateParams sourceCodeLink -` +`; export class LanguageClient { - #apolloClient: ApolloClient + private _apolloClient: ApolloClient; - constructor(apolloClient: ApolloClient) { - this.#apolloClient = apolloClient - } + constructor(apolloClient: ApolloClient) { + this._apolloClient = apolloClient; + } - async byAddress(address: string): Promise { - const { language } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query byAddress($address: String!) { + async byAddress(address: string): Promise { + const { language } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query byAddress($address: String!) { language(address: $address) { ${LANGUAGE_COMPLETE} } }`, - variables: { address } - })) - return language - } - - async byFilter(filter: string): Promise { - const { languages } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query byFilter($filter: String!) { + variables: { address }, + }), + ); + return language; + } + + async byFilter(filter: string): Promise { + const { languages } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query byFilter($filter: String!) { languages(filter: $filter) { ${LANGUAGE_COMPLETE} } }`, - variables: { filter } - })) - return languages - } - - async all(): Promise { - return this.byFilter('') - } - - async writeSettings( - languageAddress: string, - settings: string - ): Promise { - const { languageWriteSettings } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation writeSettings($languageAddress: String!, $settings: String!) { - languageWriteSettings(languageAddress: $languageAddress, settings: $settings) - }`, - variables: { languageAddress, settings } - })) - return languageWriteSettings - } - - async applyTemplateAndPublish( - sourceLanguageHash: string, - templateData: string - ): Promise { - const { languageApplyTemplateAndPublish } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation languageApplyTemplateAndPublish( - $sourceLanguageHash: String!, - $templateData: String!, + variables: { filter }, + }), + ); + return languages; + } + + async all(): Promise { + return this.byFilter(""); + } + + async writeSettings( + languageAddress: string, + settings: string, + ): Promise { + const { languageWriteSettings } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation writeSettings( + $languageAddress: String! + $settings: String! + ) { + languageWriteSettings( + languageAddress: $languageAddress + settings: $settings + ) + } + `, + variables: { languageAddress, settings }, + }), + ); + return languageWriteSettings; + } + + async applyTemplateAndPublish( + sourceLanguageHash: string, + templateData: string, + ): Promise { + const { languageApplyTemplateAndPublish } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation languageApplyTemplateAndPublish( + $sourceLanguageHash: String! + $templateData: String! + ) { + languageApplyTemplateAndPublish( + sourceLanguageHash: $sourceLanguageHash + templateData: $templateData ) { - languageApplyTemplateAndPublish(sourceLanguageHash: $sourceLanguageHash, templateData: $templateData) { - name, address - } - }`, - variables: { sourceLanguageHash, templateData } - })) - - return languageApplyTemplateAndPublish - } - - async publish( - languagePath: string, - languageMeta: LanguageMetaInput - ): Promise { - const { languagePublish } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation languagePublish( + name + address + } + } + `, + variables: { sourceLanguageHash, templateData }, + }), + ); + + return languageApplyTemplateAndPublish; + } + + async publish( + languagePath: string, + languageMeta: LanguageMetaInput, + ): Promise { + const { languagePublish } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation languagePublish( $languagePath: String!, $languageMeta: LanguageMetaInput!, ) { @@ -105,56 +128,57 @@ export class LanguageClient { ${LANGUAGE_META} } }`, - variables: { languagePath, languageMeta } - })) + variables: { languagePath, languageMeta }, + }), + ); - return languagePublish - } + return languagePublish; + } - async meta( - address: string, - ): Promise { - const { languageMeta } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query languageMeta( + async meta(address: string): Promise { + const { languageMeta } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query languageMeta( $address: String!, ) { languageMeta(address: $address) { ${LANGUAGE_META} } }`, - variables: { address } - })) - - return languageMeta - } - - async source( - address: string, - ): Promise { - const { languageSource } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query languageSource( - $address: String!, - ) { - languageSource(address: $address) - }`, - variables: { address } - })) - - return languageSource - } - - async remove( - address: string - ): Promise { - const { languageRemove } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation languageRemove( - $address: String!, - ) { - languageRemove(address: $address) - }`, - variables: { address } - })) - - return languageRemove - } -} \ No newline at end of file + variables: { address }, + }), + ); + + return languageMeta; + } + + async source(address: string): Promise { + const { languageSource } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query languageSource($address: String!) { + languageSource(address: $address) + } + `, + variables: { address }, + }), + ); + + return languageSource; + } + + async remove(address: string): Promise { + const { languageRemove } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation languageRemove($address: String!) { + languageRemove(address: $address) + } + `, + variables: { address }, + }), + ); + + return languageRemove; + } +} diff --git a/core/src/model/Ad4mModel.test.ts b/core/src/model/Ad4mModel.test.ts index b98cd76ff..6585475b1 100644 --- a/core/src/model/Ad4mModel.test.ts +++ b/core/src/model/Ad4mModel.test.ts @@ -1,203 +1,223 @@ import { Ad4mModel } from "./Ad4mModel"; -import { ModelOptions, Property, Optional, ReadOnly, Collection, Flag } from "./decorators"; +import { Model, Property, HasMany, Flag } from "./decorators"; +import { Literal } from "../Literal"; +import { resolveParentPredicate, normalizeParentQuery } from "./parentUtils"; + +// ── Shared test fixtures ────────────────────────────────────────────────────── +// Defined once at module level — used by queryToSurrealQL, +// instancesFromSurrealResult, and hydration describe blocks alike. + +@Model({ name: "Recipe" }) +class Recipe extends Ad4mModel { + @Property({ through: "recipe://name", required: true }) + name: string = ""; + + @Property({ through: "recipe://rating", required: true }) + rating: number = 0; + + @HasMany({ through: "recipe://ingredient" }) + ingredients: string[] = []; +} describe("Ad4mModel.getModelMetadata()", () => { it("should extract basic model metadata with className", () => { - @ModelOptions({ name: "SimpleModel" }) + @Model({ name: "SimpleModel" }) class SimpleModel extends Ad4mModel {} const metadata = SimpleModel.getModelMetadata(); - + expect(metadata.className).toBe("SimpleModel"); expect(metadata.properties).toEqual({}); - expect(metadata.collections).toEqual({}); + expect(metadata.relations).toEqual({}); }); it("should extract property metadata with all fields", () => { - @ModelOptions({ name: "PropertyModel" }) + @Model({ name: "PropertyModel" }) class PropertyModel extends Ad4mModel { - @Property({ through: "test://name", resolveLanguage: "literal" }) + @Property({ + through: "test://name", + resolveLanguage: "literal", + required: true, + }) name: string = ""; - - @Optional({ through: "test://optional", writable: true }) + + @Property({ through: "test://optional" }) optional: string = ""; - - @ReadOnly({ through: "test://readonly", prologGetter: "custom_getter" }) + + @Property({ through: "test://readonly", readOnly: true }) readonly: string = ""; - + @Flag({ through: "test://type", value: "test://flag" }) type: string = ""; } const metadata = PropertyModel.getModelMetadata(); - + // Should have 4 properties expect(Object.keys(metadata.properties)).toHaveLength(4); - + // Verify "name" property expect(metadata.properties.name.predicate).toBe("test://name"); expect(metadata.properties.name.required).toBe(true); - expect(metadata.properties.name.writable).toBe(true); + expect(metadata.properties.name.readOnly).toBeFalsy(); expect(metadata.properties.name.resolveLanguage).toBe("literal"); - + // Verify "optional" property expect(metadata.properties.optional.predicate).toBe("test://optional"); - expect(metadata.properties.optional.writable).toBe(true); - + expect(metadata.properties.optional.readOnly).toBeFalsy(); + // Verify "readonly" property expect(metadata.properties.readonly.predicate).toBe("test://readonly"); - expect(metadata.properties.readonly.writable).toBe(false); - expect(metadata.properties.readonly.prologGetter).toBe("custom_getter"); - + expect(metadata.properties.readonly.readOnly).toBe(true); + // Verify "type" property (flag) expect(metadata.properties.type.predicate).toBe("test://type"); expect(metadata.properties.type.flag).toBe(true); expect(metadata.properties.type.initial).toBe("test://flag"); }); - it("should extract collection metadata with where clauses", () => { - @ModelOptions({ name: "CollectionModel" }) + it("should extract collection metadata", () => { + @Model({ name: "CollectionModel" }) class CollectionModel extends Ad4mModel { - @Collection({ through: "test://items" }) + @HasMany({ through: "test://items" }) items: string[] = []; - - @Collection({ - through: "test://filtered", - where: { condition: "triple(Target, 'test://active', 'true')" } - }) - filtered: string[] = []; - - @Collection({ through: "test://local", local: true }) + + @HasMany({ through: "test://local", local: true }) local: string[] = []; } const metadata = CollectionModel.getModelMetadata(); - - // Should have 3 collections - expect(Object.keys(metadata.collections)).toHaveLength(3); - + + // Should have 2 collections + expect(Object.keys(metadata.relations)).toHaveLength(2); + // Verify "items" collection - expect(metadata.collections.items.predicate).toBe("test://items"); - expect(metadata.collections.items.where).toBeUndefined(); - - // Verify "filtered" collection - expect(metadata.collections.filtered.predicate).toBe("test://filtered"); - expect(metadata.collections.filtered.where?.condition).toBe("triple(Target, 'test://active', 'true')"); - + expect(metadata.relations.items.predicate).toBe("test://items"); + // Verify "local" collection - expect(metadata.collections.local.predicate).toBe("test://local"); - expect(metadata.collections.local.local).toBe(true); + expect(metadata.relations.local.predicate).toBe("test://local"); + expect(metadata.relations.local.local).toBe(true); }); it("should extract transform function from property metadata", () => { - @ModelOptions({ name: "TransformModel" }) + @Model({ name: "TransformModel" }) class TransformModel extends Ad4mModel { - @Optional({ + @Property({ through: "test://data", - transform: (value: string) => value.toUpperCase() + transform: (value: string) => value.toUpperCase(), }) data: string = ""; } const metadata = TransformModel.getModelMetadata(); - + // Assert transform is a function expect(typeof metadata.properties.data.transform).toBe("function"); - + // Test the transform function const transformed = metadata.properties.data.transform!("test"); expect(transformed).toBe("TEST"); }); - it("should extract custom getter and setter from property metadata", () => { - @ModelOptions({ name: "CustomModel" }) + it("should extract custom SurrealQL getter from property metadata", () => { + @Model({ name: "CustomModel" }) class CustomModel extends Ad4mModel { - @Optional({ + @Property({ through: "test://computed", - prologGetter: "triple(Base, 'test://value', V), Value is V * 2", - prologSetter: "Value is V / 2, Actions = [{action: 'setSingleTarget', source: 'this', predicate: 'test://value', target: Value}]" + getter: "(<-link[WHERE predicate = 'test://value'].in.uri)[0]", }) - computed: number = 0; + computed: string = ""; } const metadata = CustomModel.getModelMetadata(); - - // Assert prologGetter and prologSetter contain the custom code - expect(metadata.properties.computed.prologGetter).toContain("triple(Base, 'test://value', V), Value is V * 2"); - expect(metadata.properties.computed.prologSetter).toContain("Value is V / 2"); - expect(metadata.properties.computed.prologSetter).toContain("setSingleTarget"); + + // Assert getter contains the custom SurrealQL code + expect(metadata.properties.computed.getter).toContain("test://value"); }); - it("should handle collection with isInstance where clause", () => { - @ModelOptions({ name: "Comment" }) + it("should register relatedModel factory for typed @HasMany", () => { + @Model({ name: "Comment" }) class Comment extends Ad4mModel {} - - @ModelOptions({ name: "Post" }) + + @Model({ name: "Post" }) class Post extends Ad4mModel { - @Collection({ - through: "post://comment", - where: { isInstance: Comment } - }) - comments: string[] = []; + @HasMany(() => Comment, { through: "post://comment" }) + comments: Comment[] = []; } const metadata = Post.getModelMetadata(); - - // Assert isInstance is defined - expect(metadata.collections.comments.where?.isInstance).toBeDefined(); + + // relatedModel factory should be registered and return Comment + expect(metadata.relations.comments.relatedModel).toBeDefined(); + expect(metadata.relations.comments.relatedModel!()).toBe(Comment); }); it("should throw error for class without @ModelOptions decorator", () => { class NoDecoratorModel extends Ad4mModel {} // Assert that calling getModelMetadata throws an error - expect(() => NoDecoratorModel.getModelMetadata()).toThrow("Model class must be decorated with @ModelOptions"); + expect(() => NoDecoratorModel.getModelMetadata()).toThrow( + "Model class must be decorated with @Model", + ); }); it("should handle complex model with mixed property and collection types", () => { - @ModelOptions({ name: "Recipe" }) + @Model({ name: "Recipe" }) class Recipe extends Ad4mModel { - @Property({ through: "recipe://name", resolveLanguage: "literal" }) + @Property({ + through: "recipe://name", + required: true, + }) name: string = ""; - - @Optional({ through: "recipe://description" }) + + @Property({ through: "recipe://description" }) description: string = ""; - - @ReadOnly({ through: "recipe://rating", prologGetter: "avg_rating(Base, Value)" }) + + @Property({ + through: "recipe://rating", + getter: "avg_rating_surreal(Base, Value)", + readOnly: true, + }) rating: number = 0; - - @Collection({ through: "recipe://ingredient" }) + + @HasMany({ through: "recipe://ingredient" }) ingredients: string[] = []; - - @Collection({ through: "recipe://step", local: true }) + + @HasMany({ through: "recipe://step", local: true }) steps: string[] = []; } const metadata = Recipe.getModelMetadata(); - + // Assert className expect(metadata.className).toBe("Recipe"); - + // Assert properties has 3 entries expect(Object.keys(metadata.properties)).toHaveLength(3); expect(metadata.properties.name).toBeDefined(); expect(metadata.properties.description).toBeDefined(); expect(metadata.properties.rating).toBeDefined(); - + // Assert collections has 2 entries - expect(Object.keys(metadata.collections)).toHaveLength(2); - expect(metadata.collections.ingredients).toBeDefined(); - expect(metadata.collections.steps).toBeDefined(); - + expect(Object.keys(metadata.relations)).toHaveLength(2); + expect(metadata.relations.ingredients).toBeDefined(); + expect(metadata.relations.steps).toBeDefined(); + // Verify all metadata fields are correctly extracted expect(metadata.properties.name.predicate).toBe("recipe://name"); - expect(metadata.properties.name.resolveLanguage).toBe("literal"); - expect(metadata.properties.description.predicate).toBe("recipe://description"); + expect(metadata.properties.name.resolveLanguage).toBeUndefined(); // "literal" is now the implicit default — no need to store it + expect(metadata.properties.description.predicate).toBe( + "recipe://description", + ); expect(metadata.properties.rating.predicate).toBe("recipe://rating"); - expect(metadata.properties.rating.prologGetter).toBe("avg_rating(Base, Value)"); - expect(metadata.collections.ingredients.predicate).toBe("recipe://ingredient"); - expect(metadata.collections.steps.predicate).toBe("recipe://step"); - expect(metadata.collections.steps.local).toBe(true); + expect(metadata.properties.rating.getter).toBe( + "avg_rating_surreal(Base, Value)", + ); + expect(metadata.relations.ingredients.predicate).toBe( + "recipe://ingredient", + ); + expect(metadata.relations.steps.predicate).toBe("recipe://step"); + expect(metadata.relations.steps.local).toBe(true); }); }); @@ -209,15 +229,14 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { properties: { name: { type: "string" }, price: { type: "number" }, - description: { type: "string" } + description: { type: "string" }, }, - required: ["name", "price"] + required: ["name", "price"], }; const ProductClass = Ad4mModel.fromJSONSchema(schema, { name: "Product", namespace: "product://", - resolveLanguage: "literal" }); const metadata = ProductClass.getModelMetadata(); @@ -230,16 +249,18 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { expect(metadata.properties.name).toBeDefined(); expect(metadata.properties.name.predicate).toBe("product://name"); expect(metadata.properties.name.required).toBe(true); - expect(metadata.properties.name.writable).toBe(true); - expect(metadata.properties.name.resolveLanguage).toBe("literal"); + expect(metadata.properties.name.readOnly).toBeFalsy(); + expect(metadata.properties.name.resolveLanguage).toBeUndefined(); // implicit literal default expect(metadata.properties.price).toBeDefined(); expect(metadata.properties.price.predicate).toBe("product://price"); expect(metadata.properties.price.required).toBe(true); - expect(metadata.properties.price.resolveLanguage).toBe("literal"); + expect(metadata.properties.price.resolveLanguage).toBeUndefined(); // implicit literal default expect(metadata.properties.description).toBeDefined(); - expect(metadata.properties.description.predicate).toBe("product://description"); + expect(metadata.properties.description.predicate).toBe( + "product://description", + ); expect(metadata.properties.description.required).toBe(false); }); @@ -251,19 +272,19 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { title: { type: "string" }, tags: { type: "array", - items: { type: "string" } + items: { type: "string" }, }, comments: { type: "array", - items: { type: "string" } - } + items: { type: "string" }, + }, }, - required: ["title"] + required: ["title"], }; const PostClass = Ad4mModel.fromJSONSchema(schema, { name: "Post", - namespace: "post://" + namespace: "post://", }); const metadata = PostClass.getModelMetadata(); @@ -271,13 +292,13 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { // Verify className expect(metadata.className).toBe("Post"); - // Verify collections are extracted - expect(Object.keys(metadata.collections).length).toBeGreaterThan(0); - expect(metadata.collections.tags).toBeDefined(); - expect(metadata.collections.tags.predicate).toBe("post://tags"); + // Verify relations are extracted + expect(Object.keys(metadata.relations).length).toBeGreaterThan(0); + expect(metadata.relations.tags).toBeDefined(); + expect(metadata.relations.tags.predicate).toBe("post://tags"); - expect(metadata.collections.comments).toBeDefined(); - expect(metadata.collections.comments.predicate).toBe("post://comments"); + expect(metadata.relations.comments).toBeDefined(); + expect(metadata.relations.comments.predicate).toBe("post://comments"); // Verify properties (should include at least title) expect(metadata.properties.title).toBeDefined(); @@ -289,7 +310,7 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { const schema = { title: "Contact", "x-ad4m": { - namespace: "contact://" + namespace: "contact://", }, type: "object", properties: { @@ -297,31 +318,29 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { type: "string", "x-ad4m": { through: "foaf://name", - resolveLanguage: "literal", - writable: true - } + }, }, email: { type: "string", "x-ad4m": { through: "foaf://mbox", - local: true - } - } + local: true, + }, + }, }, - required: ["name"] + required: ["name"], }; const ContactClass = Ad4mModel.fromJSONSchema(schema, { - name: "Contact" + name: "Contact", }); const metadata = ContactClass.getModelMetadata(); // Verify x-ad4m metadata is respected expect(metadata.properties.name.predicate).toBe("foaf://name"); - expect(metadata.properties.name.resolveLanguage).toBe("literal"); - expect(metadata.properties.name.writable).toBe(true); + expect(metadata.properties.name.resolveLanguage).toBeUndefined(); // implicit literal default + expect(metadata.properties.name.readOnly).toBeFalsy(); expect(metadata.properties.name.required).toBe(true); expect(metadata.properties.email.predicate).toBe("foaf://mbox"); @@ -334,9 +353,9 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { type: "object", properties: { username: { type: "string" }, - fullName: { type: "string" } + fullName: { type: "string" }, }, - required: ["username"] + required: ["username"], }; const UserClass = Ad4mModel.fromJSONSchema(schema, { @@ -344,8 +363,8 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { namespace: "user://", propertyMapping: { username: "custom://identifier", - fullName: "custom://name" - } + fullName: "custom://name", + }, }); const metadata = UserClass.getModelMetadata(); @@ -365,23 +384,22 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { published: { type: "boolean" }, authors: { type: "array", - items: { type: "string" } + items: { type: "string" }, }, tags: { type: "array", items: { type: "string" }, "x-ad4m": { - local: true - } - } + local: true, + }, + }, }, - required: ["title", "published"] + required: ["title", "published"], }; const ArticleClass = Ad4mModel.fromJSONSchema(schema, { name: "Article", namespace: "article://", - resolveLanguage: "literal" }); const metadata = ArticleClass.getModelMetadata(); @@ -393,35 +411,37 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { expect(metadata.properties.title).toBeDefined(); expect(metadata.properties.title.predicate).toBe("article://title"); expect(metadata.properties.title.required).toBe(true); - expect(metadata.properties.title.resolveLanguage).toBe("literal"); + expect(metadata.properties.title.resolveLanguage).toBeUndefined(); // implicit literal default expect(metadata.properties.views).toBeDefined(); expect(metadata.properties.views.predicate).toBe("article://views"); - expect(metadata.properties.views.resolveLanguage).toBe("literal"); + expect(metadata.properties.views.resolveLanguage).toBeUndefined(); // implicit literal default expect(metadata.properties.published).toBeDefined(); expect(metadata.properties.published.predicate).toBe("article://published"); expect(metadata.properties.published.required).toBe(true); - // Verify collections - expect(metadata.collections.authors).toBeDefined(); - expect(metadata.collections.authors.predicate).toBe("article://authors"); + // Verify relations + expect(metadata.relations.authors).toBeDefined(); + expect(metadata.relations.authors.predicate).toBe("article://authors"); - expect(metadata.collections.tags).toBeDefined(); - expect(metadata.collections.tags.predicate).toBe("article://tags"); - expect(metadata.collections.tags.local).toBe(true); + expect(metadata.relations.tags).toBeDefined(); + expect(metadata.relations.tags.predicate).toBe("article://tags"); + expect(metadata.relations.tags.local).toBe(true); }); it("should handle models with only an auto-generated type flag", () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const schema = { title: "EmptyModel", type: "object", - properties: {} + properties: {}, }; const EmptyModelClass = Ad4mModel.fromJSONSchema(schema, { name: "EmptyModel", - namespace: "empty://" + namespace: "empty://", }); const metadata = EmptyModelClass.getModelMetadata(); @@ -434,6 +454,12 @@ describe("Ad4mModel.fromJSONSchema() with getModelMetadata()", () => { expect(metadata.properties.__ad4m_type.predicate).toBe("ad4m://type"); expect(metadata.properties.__ad4m_type.initial).toBe("empty://instance"); expect(metadata.properties.__ad4m_type.flag).toBe(true); + + // The warning is the expected fallback signal for empty schemas + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("No properties with initial values found"), + ); + warnSpy.mockRestore(); }); }); @@ -443,20 +469,7 @@ describe("Ad4mModel.queryToSurrealQL()", () => { // Helper function to normalize whitespace in queries for easier comparison function normalizeQuery(query: string): string { - return query.replace(/\s+/g, ' ').trim(); - } - - // Test Recipe model - @ModelOptions({ name: "Recipe" }) - class Recipe extends Ad4mModel { - @Property({ through: "recipe://name" }) - name: string = ""; - - @Property({ through: "recipe://rating" }) - rating: number = 0; - - @Collection({ through: "recipe://ingredient" }) - ingredients: string[] = []; + return query.replace(/\s+/g, " ").trim(); } it("should generate basic query with no filters", async () => { @@ -465,43 +478,63 @@ describe("Ad4mModel.queryToSurrealQL()", () => { expect(query).toContain("id AS source"); expect(query).toContain("uri AS source_uri"); expect(query).toContain("FROM node"); - expect(query).toContain("->link[WHERE perspective = $perspective] AS links"); + expect(query).toContain( + "(SELECT predicate, out.uri AS target, author, timestamp FROM link WHERE in = $parent.id ORDER BY timestamp ASC) AS links", + ); expect(query).toContain("WHERE"); // Should have graph traversal filters for required properties expect(query).toContain("count(->link[WHERE"); }); it("should generate query with simple property filter", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: "Pasta" } }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: "Pasta" }, + }); + // Should have graph traversal filters for required properties and user filter - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name']) > 0"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://rating']) > 0"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri = 'Pasta']) > 0"); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name']) > 0", + ); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://rating']) > 0", + ); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) = 'Pasta']) > 0", + ); }); it("should generate query with multiple property filters", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: "Pasta", rating: 5 } }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: "Pasta", rating: 5 }, + }); + expect(query).toContain("WHERE"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri = 'Pasta']) > 0"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://rating' AND out.uri = 5]) > 0"); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) = 'Pasta']) > 0", + ); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://rating' AND fn::parse_literal(out.uri) = 5]) > 0", + ); expect(query).toContain("AND"); }); it("should handle gt operator (filtered in JavaScript, not SQL)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { rating: { gt: 4 } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { rating: { gt: 4 } }, + }); // Comparison operators for regular properties are now filtered in JavaScript post-query // The SQL just filters on the predicate existing expect(query).toContain("FROM node"); - + // Should NOT contain comparison operators in SQL for regular properties expect(query).not.toContain("target > 4"); }); it("should handle lt operator (filtered in JavaScript, not SQL)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { rating: { lt: 3 } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { rating: { lt: 3 } }, + }); // Comparison operators are filtered in JavaScript post-query expect(query).toContain("FROM node"); @@ -509,7 +542,9 @@ describe("Ad4mModel.queryToSurrealQL()", () => { }); it("should handle gte and lte operators (filtered in JavaScript, not SQL)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { rating: { gte: 3, lte: 5 } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { rating: { gte: 3, lte: 5 } }, + }); // Comparison operators are filtered in JavaScript post-query expect(query).toContain("FROM node"); @@ -518,21 +553,31 @@ describe("Ad4mModel.queryToSurrealQL()", () => { }); it("should handle not operator with single value", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: { not: "Salad" } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: { not: "Salad" } }, + }); // Not operator uses graph traversal with count = 0 - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri = 'Salad']) = 0"); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) = 'Salad']) = 0", + ); }); it("should handle not operator with array (NOT IN)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: { not: ["Salad", "Soup"] } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: { not: ["Salad", "Soup"] } }, + }); // Not operator with array uses graph traversal with count = 0 - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri IN ['Salad', 'Soup']]) = 0"); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) IN ['Salad', 'Soup']]) = 0", + ); }); it("should handle between operator (filtered in JavaScript, not SQL)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { rating: { between: [3, 5] } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { rating: { between: [3, 5] } }, + }); // Between operator for regular properties is now filtered in JavaScript post-query expect(query).toContain("FROM node"); @@ -541,7 +586,9 @@ describe("Ad4mModel.queryToSurrealQL()", () => { }); it("should handle contains operator on string property (filtered in JavaScript, not SQL)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: { contains: "Past" } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: { contains: "Past" } }, + }); // Contains operator for regular properties is now filtered in JavaScript post-query expect(query).toContain("FROM node"); @@ -549,47 +596,34 @@ describe("Ad4mModel.queryToSurrealQL()", () => { }); it("should handle contains operator on regular property with substring (filtered in JavaScript, not SQL)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: { contains: "Salad" } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: { contains: "Salad" } }, + }); // Contains operator is filtered in JavaScript post-query expect(query).toContain("FROM node"); expect(query).not.toContain("target CONTAINS 'Salad'"); }); - it.skip("should handle contains operator on special field (author)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { author: { contains: "alice" } } }); - - expect(query).toContain("WHERE author CONTAINS 'alice'"); - // Should not use a subquery pattern - expect(query).not.toContain("SELECT source FROM node"); - }); - - it.skip("should handle contains operator on special field (base)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: { contains: "test" } } }); - - expect(query).toContain("WHERE source CONTAINS 'test'"); - }); - it("should handle array values (IN clause)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: ["Pasta", "Pizza"] } }); - - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri IN ['Pasta', 'Pizza']]) > 0"); - }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: ["Pasta", "Pizza"] }, + }); - it.skip("should handle special fields (author, timestamp) without subqueries", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { author: "did:key:alice" } }); - - expect(query).toContain("WHERE author = 'did:key:alice'"); - // Ensure it's NOT using a subquery pattern for author in WHERE clause - expect(normalizeQuery(query)).not.toMatch(/WHERE.*source IN.*SELECT source FROM node.*author/); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) IN ['Pasta', 'Pizza']]) > 0", + ); }); it("should not generate ORDER BY clause in SQL (handled in JavaScript)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { order: { timestamp: "DESC" } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + order: { timestamp: "DESC" }, + }); // ORDER BY is now handled in JavaScript post-query + // Note: the links correlated subquery uses ORDER BY timestamp ASC internally for link ordering expect(query).toContain("FROM node"); - expect(query).not.toContain("ORDER BY"); + expect(query).not.toContain("ORDER BY timestamp DESC"); }); it("should not generate LIMIT clause in SQL (handled in JavaScript)", async () => { @@ -601,7 +635,9 @@ describe("Ad4mModel.queryToSurrealQL()", () => { }); it("should not generate START clause in SQL (handled in JavaScript)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { offset: 20 }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + offset: 20, + }); // START/offset is now handled in JavaScript post-query expect(query).toContain("FROM node"); @@ -613,58 +649,72 @@ describe("Ad4mModel.queryToSurrealQL()", () => { where: { name: "Pasta", rating: { gt: 4 } }, order: { timestamp: "DESC" }, limit: 10, - offset: 20 + offset: 20, }); // WHERE clause uses graph traversal filters expect(query).toContain("WHERE"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri = 'Pasta']) > 0"); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) = 'Pasta']) > 0", + ); // Comparison operators (gt) are filtered in JavaScript, not SQL expect(query).not.toContain("out.uri > 4"); expect(query).not.toContain("target > 4"); // ORDER BY, LIMIT, START are now handled in JavaScript post-query - expect(query).not.toContain("ORDER BY"); + // Note: the links subquery uses ORDER BY timestamp ASC internally + expect(query).not.toContain("ORDER BY timestamp DESC"); expect(query).not.toContain("LIMIT"); expect(query).not.toContain("START"); }); - it("should only select requested properties", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { properties: ["name"] }); + it("should only select requested fields", async () => { + const query = await Recipe.queryToSurrealQL(mockPerspective, { + properties: ["name"], + }); // With array::group(), all link data is selected, filtering happens in instancesFromSurrealResult - expect(query).toContain("->link[WHERE perspective = $perspective] AS links"); - + expect(query).toContain( + "(SELECT predicate, out.uri AS target, author, timestamp FROM link WHERE in = $parent.id ORDER BY timestamp ASC) AS links", + ); }); - it("should only select requested collections", async () => { - @ModelOptions({ name: "MultiCollectionModel" }) + it("should only select requested relations", async () => { + @Model({ name: "MultiCollectionModel" }) class MultiCollectionModel extends Ad4mModel { - @Collection({ through: "test://coll1" }) + @HasMany({ through: "test://coll1" }) coll1: string[] = []; - @Collection({ through: "test://coll2" }) + @HasMany({ through: "test://coll2" }) coll2: string[] = []; } - const query = await MultiCollectionModel.queryToSurrealQL(mockPerspective, { collections: ["coll1"] }); + const query = await MultiCollectionModel.queryToSurrealQL( + mockPerspective, + {}, + ); // With array::group(), all link data is selected, filtering happens in instancesFromSurrealResult - expect(query).toContain("->link[WHERE perspective = $perspective] AS links"); - + expect(query).toContain( + "(SELECT predicate, out.uri AS target, author, timestamp FROM link WHERE in = $parent.id ORDER BY timestamp ASC) AS links", + ); }); it("should escape single quotes in string values", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { name: "O'Brien's Recipe" } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { name: "O'Brien's Recipe" }, + }); // Single quotes are escaped with backslash in SurrealDB expect(query).toContain("O\\'Brien\\'s Recipe"); }); it("should handle numeric values without quotes", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { rating: 5 } }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { rating: 5 }, + }); + // Numeric values should not have quotes around them - expect(query).toContain("out.uri = 5"); + expect(query).toContain("fn::parse_literal(out.uri) = 5"); expect(query).not.toContain("out.uri = '5'"); }); @@ -673,16 +723,18 @@ describe("Ad4mModel.queryToSurrealQL()", () => { where: { name: "Pasta", rating: { gte: 4, lte: 5 }, - author: "did:key:alice" + author: "did:key:alice", }, order: { rating: "DESC" }, - limit: 5 + limit: 5, }); const normalized = normalizeQuery(query); // Verify graph traversal filters are present - expect(normalized).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri = 'Pasta']) > 0"); + expect(normalized).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) = 'Pasta']) > 0", + ); // Comparison operators (gte, lte) are filtered in JavaScript, not SQL expect(normalized).not.toContain("out.uri >= 4"); expect(normalized).not.toContain("target >= 4"); @@ -692,7 +744,8 @@ describe("Ad4mModel.queryToSurrealQL()", () => { expect(normalized).not.toContain("author = 'did:key:alice'"); // ORDER BY and LIMIT are handled in JavaScript post-query - expect(normalized).not.toContain("ORDER BY"); + // Note: the links correlated subquery uses ORDER BY timestamp ASC internally + expect(normalized).not.toContain("ORDER BY rating"); expect(normalized).not.toContain("LIMIT"); // Verify query structure (FROM node, no GROUP BY) @@ -701,67 +754,91 @@ describe("Ad4mModel.queryToSurrealQL()", () => { it("should handle empty query object", async () => { const query = await Recipe.queryToSurrealQL(mockPerspective, {}); - + // Should generate valid query with WHERE clause for required properties (name and rating) expect(query).toContain("id AS source"); expect(query).toContain("uri AS source_uri"); expect(query).toContain("FROM node"); - + // Should have WHERE clause filtering for required properties using graph traversal expect(query).toContain("WHERE"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name']) > 0"); - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://rating']) > 0"); - expect(query).not.toContain("ORDER BY"); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://name']) > 0", + ); + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://rating']) > 0", + ); + // No user-requested ORDER BY or START should appear at the top level expect(query).not.toContain("START"); }); it("should handle base special field", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: "literal://test" } }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: "literal://test" }, + }); + expect(query).toContain("uri = 'literal://test'"); }); it("should handle base special field with array (IN clause)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: ["literal://test1", "literal://test2"] } }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: ["literal://test1", "literal://test2"] }, + }); + expect(query).toContain("uri IN ['literal://test1', 'literal://test2']"); }); it("should handle base special field with not operator", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: { not: "literal://test" } } }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: { not: "literal://test" } }, + }); + expect(query).toContain("uri != 'literal://test'"); }); it("should handle base special field with not operator and array (NOT IN)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: { not: ["literal://test1", "literal://test2"] } } }); - - expect(query).toContain("uri NOT IN ['literal://test1', 'literal://test2']"); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: { not: ["literal://test1", "literal://test2"] } }, + }); + + expect(query).toContain( + "uri NOT IN ['literal://test1', 'literal://test2']", + ); }); it("should handle base special field with between operator", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: { between: ["literal://a", "literal://z"] } } } as any); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: { between: ["literal://a", "literal://z"] } }, + } as any); + const normalized = normalizeQuery(query); - expect(normalized).toContain("uri >= 'literal://a' AND uri <= 'literal://z'"); + expect(normalized).toContain( + "uri >= 'literal://a' AND uri <= 'literal://z'", + ); }); it("should handle base special field with gt operator", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: { gt: "literal://m" } } } as any); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: { gt: "literal://m" } }, + } as any); + expect(query).toContain("uri > 'literal://m'"); }); it("should handle base special field with gte and lte operators", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { base: { gte: "literal://a", lte: "literal://z" } } } as any); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { base: { gte: "literal://a", lte: "literal://z" } }, + } as any); + const normalized = normalizeQuery(query); expect(normalized).toContain("uri >= 'literal://a'"); expect(normalized).toContain("uri <= 'literal://z'"); }); it("should handle timestamp special field with gt operator (filtered in JavaScript)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { timestamp: { gt: 1234567890 } } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { timestamp: { gt: 1234567890 } }, + }); // timestamp filtering is done in JavaScript post-query expect(query).toContain("FROM node"); @@ -772,8 +849,8 @@ describe("Ad4mModel.queryToSurrealQL()", () => { const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { author: "did:key:alice", - timestamp: { gt: 1000 } - } + timestamp: { gt: 1000 }, + }, }); // author and timestamp filtering is done in JavaScript post-query @@ -787,13 +864,15 @@ describe("Ad4mModel.queryToSurrealQL()", () => { where: { name: "Pasta", author: "did:key:alice", - rating: { gt: 4 } - } + rating: { gt: 4 }, + }, }); const normalized = normalizeQuery(query); // Regular properties use graph traversal filters - expect(normalized).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://name' AND out.uri = 'Pasta']) > 0"); + expect(normalized).toContain( + "count(->link[WHERE predicate = 'recipe://name' AND fn::parse_literal(out.uri) = 'Pasta']) > 0", + ); // Comparison operators (gt) are filtered in JavaScript, not SQL expect(normalized).not.toContain("out.uri > 4"); expect(normalized).not.toContain("target > 4"); @@ -802,87 +881,116 @@ describe("Ad4mModel.queryToSurrealQL()", () => { }); it("should handle boolean values", async () => { - @ModelOptions({ name: "Task" }) + @Model({ name: "Task" }) class Task extends Ad4mModel { - @Property({ through: "task://completed" }) + @Property({ + through: "task://completed", + required: true, + }) completed: boolean = false; } + const query = await Task.queryToSurrealQL(mockPerspective, { + where: { completed: true }, + }); - const query = await Task.queryToSurrealQL(mockPerspective, { where: { completed: true } }); - - expect(query).toContain("out.uri = true"); + expect(query).toContain("fn::parse_literal(out.uri) = true"); }); it("should handle array of numbers", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { rating: [4, 5] } }); - - expect(query).toContain("count(->link[WHERE perspective = $perspective AND predicate = 'recipe://rating' AND out.uri IN [4, 5]]) > 0"); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { rating: [4, 5] }, + }); + + expect(query).toContain( + "count(->link[WHERE predicate = 'recipe://rating' AND fn::parse_literal(out.uri) IN [4, 5]]) > 0", + ); }); it("should skip unknown properties in where clause", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { where: { unknownProp: "value" } as any }); - + const query = await Recipe.queryToSurrealQL(mockPerspective, { + where: { unknownProp: "value" } as any, + }); + // Should not throw error, just skip the unknown property expect(query).toContain("source"); - - // Should not contain any condition for unknownProp - expect(query).not.toContain("unknownProp"); - }); - it.skip("should skip unknown properties in select clause", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { properties: ["name", "unknownProp"] as any }); - - // Should include name using aggregation - expect(query).toContain("array::first(target[WHERE predicate = 'recipe://name']) AS name"); - // Should not error on unknownProp, just skip it + // Should not contain any condition for unknownProp expect(query).not.toContain("unknownProp"); }); it("should handle order by regular property (handled in JavaScript)", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { order: { name: "ASC" } }); + const query = await Recipe.queryToSurrealQL(mockPerspective, { + order: { name: "ASC" }, + }); // ORDER BY is now handled in JavaScript post-query + // Note: the links correlated subquery uses ORDER BY timestamp ASC internally expect(query).toContain("FROM node"); - expect(query).not.toContain("ORDER BY"); + expect(query).not.toContain("ORDER BY name"); }); - it("should generate query with only properties, no collections", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { properties: ["name", "rating"], collections: [] }); + it("should generate query with only fields, no relations", async () => { + const query = await Recipe.queryToSurrealQL(mockPerspective, { + properties: ["name", "rating"], + }); // With array::group(), all link data is selected, filtering happens in instancesFromSurrealResult - expect(query).toContain("->link[WHERE perspective = $perspective] AS links"); - + expect(query).toContain( + "(SELECT predicate, out.uri AS target, author, timestamp FROM link WHERE in = $parent.id ORDER BY timestamp ASC) AS links", + ); }); - it("should generate query with only collections, no properties", async () => { - const query = await Recipe.queryToSurrealQL(mockPerspective, { properties: [], collections: ["ingredients"] }); + it("should generate query with only relations, no fields", async () => { + const query = await Recipe.queryToSurrealQL(mockPerspective, { + properties: [], + }); // With array::group(), all link data is selected, filtering happens in instancesFromSurrealResult - expect(query).toContain("->link[WHERE perspective = $perspective] AS links"); - + expect(query).toContain( + "(SELECT predicate, out.uri AS target, author, timestamp FROM link WHERE in = $parent.id ORDER BY timestamp ASC) AS links", + ); }); -}); -describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () => { - // Test Recipe model - @ModelOptions({ name: "Recipe" }) - class Recipe extends Ad4mModel { - @Property({ through: "recipe://name" }) - name: string = ""; - - @Property({ through: "recipe://rating" }) - rating: number = 0; - - @Collection({ through: "recipe://ingredient" }) - ingredients: string[] = []; - } + it("should include backward link filter when parent is given as raw predicate form", async () => { + const query = await Recipe.queryToSurrealQL(mockPerspective, { + parent: { id: "some://parent-uri", predicate: "custom://has_recipe" }, + }); + + expect(query).toContain( + "count(<-link[WHERE predicate = 'custom://has_recipe' AND in.uri = 'some://parent-uri']) > 0", + ); + }); + + it("should resolve model-backed parent form and include correct backward link filter", async () => { + @Model({ name: "MenuForQueryTest" }) + class MenuForQueryTest extends Ad4mModel { + @HasMany(() => Recipe, { through: "menu://has_recipe" }) + recipes: Recipe[] = []; + } + + const query = await Recipe.queryToSurrealQL(mockPerspective, { + parent: { + id: "some://menu-uri", + model: MenuForQueryTest, + field: "recipes", + }, + }); + + expect(query).toContain( + "count(<-link[WHERE predicate = 'menu://has_recipe' AND in.uri = 'some://menu-uri']) > 0", + ); + }); +}); +describe("Ad4mModel.instancesFromSurrealResult() — hydration and query dispatch", () => { // Mock perspective with both querySurrealDB and infer methods const mockPerspective = { querySurrealDB: jest.fn(), infer: jest.fn(), - uuid: 'test-perspective-uuid', - stringOrTemplateObjectToSubjectClassName: jest.fn().mockResolvedValue('Recipe') + uuid: "test-perspective-uuid", + stringOrTemplateObjectToSubjectClassName: jest + .fn() + .mockResolvedValue("Recipe"), } as any; beforeEach(() => { @@ -890,8 +998,12 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () }); it("should convert empty SurrealDB results correctly", async () => { - const result = await Recipe.instancesFromSurrealResult(mockPerspective, {}, []); - + const result = await Recipe.instancesFromSurrealResult( + mockPerspective, + {}, + [], + ); + expect(result.results).toEqual([]); expect(result.totalCount).toBe(0); }); @@ -902,27 +1014,81 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "tomato", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "cheese", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] + { + predicate: "recipe://name", + target: Literal.from("Pasta").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: Literal.from(5).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "tomato", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "cheese", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], }, { source: "node:def456", source_uri: "literal://recipe2", links: [ - { predicate: "recipe://name", target: "Pizza", author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" }, - { predicate: "recipe://rating", target: "4", author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "dough", author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "cheese", author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "tomato", author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: Literal.from("Pizza").toUrl(), + author: "did:key:bob", + timestamp: "2023-01-02T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: Literal.from(4).toUrl(), + author: "did:key:bob", + timestamp: "2023-01-02T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "dough", + author: "did:key:bob", + timestamp: "2023-01-02T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "cheese", + author: "did:key:bob", + timestamp: "2023-01-02T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "tomato", + author: "did:key:bob", + timestamp: "2023-01-02T00:00:00Z", + }, + ], + }, ]; - const result = await Recipe.instancesFromSurrealResult(mockPerspective, {}, surrealResults); + const result = await Recipe.instancesFromSurrealResult( + mockPerspective, + {}, + surrealResults, + ); expect(result.results).toHaveLength(2); expect(result.totalCount).toBe(2); @@ -939,78 +1105,221 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () expect(recipe2.rating).toBe(4); }); - it("should filter properties when query specifies properties", async () => { - const surrealResults = [ - { - source: "node:abc123", - source_uri: "literal://recipe1", - links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "tomato", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } - ]; + it("should set instance id from source_uri", async () => { + const result = await Recipe.instancesFromSurrealResult( + mockPerspective, + {}, + [ + { + source: "node:abc123", + source_uri: "literal://recipe1", + links: [ + { + predicate: "recipe://name", + target: Literal.from("Pasta").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: Literal.from(5).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, + ], + ); + expect(result.results).toHaveLength(1); + // id is derived from source_uri (the node's URI in the graph) + expect(result.results[0].id).toBe("literal://recipe1"); + }); + + it("should use latest-wins semantics when multiple links share a predicate", async () => { const result = await Recipe.instancesFromSurrealResult( mockPerspective, - { properties: ["name"] }, - surrealResults + {}, + [ + { + source: "node:abc123", + source_uri: "literal://recipe1", + links: [ + { + predicate: "recipe://name", + target: Literal.from("Old Name").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + // later timestamp — this value should win + predicate: "recipe://name", + target: Literal.from("New Name").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-02T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: Literal.from(5).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, + ], ); expect(result.results).toHaveLength(1); - const recipe = result.results[0]; - expect(recipe.name).toBe("Pasta"); - // rating and ingredients should be removed since only "name" was requested - expect(recipe.rating).toBeUndefined(); - expect(recipe.ingredients).toBeUndefined(); - // author and timestamp should still be present - expect(recipe.author).toBe("did:key:alice"); - // Timestamp is converted to Unix epoch (milliseconds) - expect(recipe.timestamp).toBe(new Date("2023-01-01T00:00:00Z").getTime()); + expect(result.results[0].name).toBe("New Name"); + }); + + it("should apply transform function during hydration", async () => { + @Model({ name: "TransformRecipe" }) + class TransformRecipe extends Ad4mModel { + @Property({ + through: "recipe://name", + transform: (v: string) => v.toUpperCase(), + }) + name: string = ""; + } + + const result = await TransformRecipe.instancesFromSurrealResult( + mockPerspective, + {}, + [ + { + source: "node:abc123", + source_uri: "literal://tr1", + links: [ + { + predicate: "recipe://name", + target: Literal.from("pasta").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, + ], + ); + + expect(result.results).toHaveLength(1); + expect(result.results[0].name).toBe("PASTA"); + }); + + it("should apply JS-level operator filters — smoke test", async () => { + // Ratings 1–5 encoded as Literals so the JS filter receives actual numbers + const surrealResults = Array.from({ length: 5 }, (_, i) => ({ + source: `node:abc${i + 1}`, + source_uri: `literal://recipe${i + 1}`, + links: [ + { + predicate: "recipe://name", + target: Literal.from(`Recipe ${i + 1}`).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: Literal.from(i + 1).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + })); + mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); + + // Only ratings 4 and 5 pass the JS-level gt filter + const results = await Recipe.findAll(mockPerspective, { + where: { rating: { gt: 3 } }, + }); + expect(results).toHaveLength(2); + + // count() must be consistent with findAll() + const count = await Recipe.count(mockPerspective, { + where: { rating: { gt: 3 } }, + }); + expect(count).toBe(2); }); - it("should filter collections when query specifies collections", async () => { + it("should filter fields when query specifies fields", async () => { const surrealResults = [ { source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "tomato", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "tomato", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, ]; const result = await Recipe.instancesFromSurrealResult( mockPerspective, - { collections: ["ingredients"] }, - surrealResults + { properties: ["name"] }, + surrealResults, ); expect(result.results).toHaveLength(1); const recipe = result.results[0]; - expect(recipe.ingredients).toEqual(["pasta", "tomato"]); - // name and rating should be removed since only "ingredients" was requested - expect(recipe.name).toBeUndefined(); + expect(recipe.name).toBe("Pasta"); + // rating and ingredients should be removed since only "name" was requested expect(recipe.rating).toBeUndefined(); + expect(recipe.ingredients).toBeUndefined(); + // author and timestamp are metadata fields — they are also stripped unless + // explicitly listed in `properties`. Integration tests cover the + // `properties: ["author", "createdAt"]` case. + expect(recipe.author).toBeUndefined(); + expect(recipe.createdAt).toBeUndefined(); }); it("should handle results missing base field", async () => { const surrealResults = [ { links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], // Missing source field - } as any + } as any, ]; - const result = await Recipe.instancesFromSurrealResult(mockPerspective, {}, surrealResults); + const result = await Recipe.instancesFromSurrealResult( + mockPerspective, + {}, + surrealResults, + ); // Should filter out the invalid result (or handle gracefully) expect(result.results).toHaveLength(0); @@ -1023,11 +1332,26 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, ]; mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); @@ -1040,39 +1364,40 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () expect(results[0].name).toBe("Pasta"); }); - it("should use Prolog when useSurrealDB is false in findAll()", async () => { - const prologResults = [{ - AllInstances: [ - ["literal://recipe1", [["name", "Pasta"]], [["ingredients", ["pasta"]]], "2023-01-01T00:00:00Z", "did:key:alice"] - ], - TotalCount: 1 - }]; - - mockPerspective.infer.mockResolvedValue(prologResults); - - const results = await Recipe.findAll(mockPerspective, {}, false); - - expect(mockPerspective.infer).toHaveBeenCalledTimes(1); - expect(mockPerspective.querySurrealDB).not.toHaveBeenCalled(); - expect(results).toHaveLength(1); - }); - it("should use SurrealDB by default in findAllAndCount()", async () => { const surrealResults = [ { source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, ]; mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); - const { results, totalCount } = await Recipe.findAllAndCount(mockPerspective, {}); + const { results, totalCount } = await Recipe.findAllAndCount( + mockPerspective, + {}, + ); expect(mockPerspective.querySurrealDB).toHaveBeenCalledTimes(1); expect(mockPerspective.infer).not.toHaveBeenCalled(); @@ -1086,11 +1411,26 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, ]; mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); @@ -1108,12 +1448,22 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () // Since count() uses result.length and GROUP BY returns one row per source, // mock 5 recipe sources const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, + source: `node:abc${i + 1}`, + source_uri: `literal://recipe${i + 1}`, links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] + { + predicate: "recipe://name", + target: `Recipe ${i + 1}`, + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], })); mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); @@ -1125,28 +1475,32 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () expect(count).toBe(5); }); - it("should use Prolog when useSurrealDB is false in count()", async () => { - const prologResults = [{ TotalCount: 10 }]; - mockPerspective.infer.mockResolvedValue(prologResults); - - const count = await Recipe.count(mockPerspective, {}, false); - - expect(mockPerspective.infer).toHaveBeenCalledTimes(1); - expect(mockPerspective.querySurrealDB).not.toHaveBeenCalled(); - expect(count).toBe(10); - }); - it("should use SurrealDB by default in ModelQueryBuilder.get()", async () => { const surrealResults = [ { source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, ]; mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); @@ -1161,35 +1515,25 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () expect(results[0].name).toBe("Pasta"); }); - it("should use Prolog when useSurrealDB(false) in ModelQueryBuilder.get()", async () => { - const prologResults = [{ - AllInstances: [ - ["literal://recipe1", [["name", "Pasta"]], [["ingredients", ["pasta"]]], "2023-01-01T00:00:00Z", "did:key:alice"] - ], - TotalCount: 1 - }]; - - mockPerspective.infer.mockResolvedValue(prologResults); - - const results = await Recipe.query(mockPerspective) - .where({ name: "Pasta" }) - .useSurrealDB(false) - .get(); - - expect(mockPerspective.infer).toHaveBeenCalledTimes(1); - expect(mockPerspective.querySurrealDB).not.toHaveBeenCalled(); - expect(results).toHaveLength(1); - }); - it("should use SurrealDB by default in ModelQueryBuilder.count()", async () => { // count() counts the number of rows returned by the query (one row per source) const surrealResults = Array.from({ length: 3 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, + source: `node:abc${i + 1}`, + source_uri: `literal://recipe${i + 1}`, links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] + { + predicate: "recipe://name", + target: `Recipe ${i + 1}`, + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], })); mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); @@ -1208,11 +1552,26 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () source: "node:abc123", source_uri: "literal://recipe1", links: [ - { predicate: "recipe://name", target: "Pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://ingredient", target: "pasta", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - } + { + predicate: "recipe://name", + target: "Pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://rating", + target: "5", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "recipe://ingredient", + target: "pasta", + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, ]; mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); @@ -1229,248 +1588,370 @@ describe("Ad4mModel.instancesFromSurrealResult() and SurrealDB integration", () }); }); -describe("Ad4mModel.count() with advanced where conditions", () => { - // Test Recipe model - @ModelOptions({ name: "Recipe" }) - class Recipe extends Ad4mModel { - @Property({ through: "recipe://name" }) +describe("resolveLanguage implicit literal default", () => { + // A plain @Property with no resolveLanguage should behave identically to + // resolveLanguage: "literal" — scalar values encoded as literal:// URIs + // are unwrapped on read, and no createExpression call is made on write. + @Model({ name: "ImplicitLiteralModel" }) + class ImplicitLiteralModel extends Ad4mModel { + @Property({ through: "test://name" }) name: string = ""; - - @Property({ through: "recipe://rating" }) - rating: number = 0; - - @Collection({ through: "recipe://ingredient" }) - ingredients: string[] = []; + + @Property({ through: "test://count" }) + count: number = 0; + + @Property({ through: "test://active" }) + active: boolean = false; } - // Mock perspective const mockPerspective = { querySurrealDB: jest.fn(), infer: jest.fn(), - uuid: 'test-perspective-uuid', - stringOrTemplateObjectToSubjectClassName: jest.fn().mockResolvedValue('Recipe') + uuid: "test-perspective-uuid", + stringOrTemplateObjectToSubjectClassName: jest + .fn() + .mockResolvedValue("ImplicitLiteralModel"), } as any; - beforeEach(() => { - jest.clearAllMocks(); + beforeEach(() => jest.clearAllMocks()); + + it('should store undefined resolveLanguage in metadata (not "literal")', () => { + const metadata = ImplicitLiteralModel.getModelMetadata(); + expect(metadata.properties.name.resolveLanguage).toBeUndefined(); + expect(metadata.properties.count.resolveLanguage).toBeUndefined(); + expect(metadata.properties.active.resolveLanguage).toBeUndefined(); }); - it("should apply JS-level filtering for gt operator on properties in SurrealDB count()", async () => { - // Mock SurrealDB results: 5 recipes with ratings 1, 2, 3, 4, 5 - const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: `${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - })); - + it("should hydrate literal:// encoded targets for properties with no resolveLanguage", async () => { + const surrealResults = [ + { + source: "node:abc123", + source_uri: "literal://base", + links: [ + { + predicate: "test://name", + target: Literal.from("hello world").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "test://count", + target: Literal.from(42).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + { + predicate: "test://active", + target: Literal.from(true).toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, + ]; + mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); - // Count recipes with rating > 3 (should match 2 recipes: rating 4 and 5) - const count = await Recipe.count(mockPerspective, { where: { rating: { gt: 3 } } }); - - // Verify count matches the number of instances that would be returned by findAll - const findAllResults = await Recipe.findAll(mockPerspective, { where: { rating: { gt: 3 } } }); - - expect(count).toBe(2); - expect(count).toBe(findAllResults.length); + const result = await ImplicitLiteralModel.instancesFromSurrealResult( + mockPerspective, + {}, + surrealResults, + ); + + expect(result.results).toHaveLength(1); + const instance = result.results[0]; + expect(instance.name).toBe("hello world"); + expect(instance.count).toBe(42); + expect(instance.active).toBe(true); }); - it("should apply JS-level filtering for between operator on properties in SurrealDB count()", async () => { - // Mock SurrealDB results: 5 recipes with ratings 1, 2, 3, 4, 5 - const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: `${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - })); - + it("should NOT call perspective.getExpression for properties with no resolveLanguage", async () => { + const surrealResults = [ + { + source: "node:abc123", + source_uri: "literal://base", + links: [ + { + predicate: "test://name", + target: Literal.from("test").toUrl(), + author: "did:key:alice", + timestamp: "2023-01-01T00:00:00Z", + }, + ], + }, + ]; + mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); + const getExpression = jest.fn(); + mockPerspective.getExpression = getExpression; - // Count recipes with rating between 2 and 4 (should match 3 recipes: rating 2, 3, 4) - const count = await Recipe.count(mockPerspective, { where: { rating: { between: [2, 4] } } }); - - // Verify count matches the number of instances that would be returned by findAll - const findAllResults = await Recipe.findAll(mockPerspective, { where: { rating: { between: [2, 4] } } }); - - expect(count).toBe(3); - expect(count).toBe(findAllResults.length); + await ImplicitLiteralModel.instancesFromSurrealResult( + mockPerspective, + {}, + surrealResults, + ); + + expect(getExpression).not.toHaveBeenCalled(); }); +}); - it("should apply JS-level filtering for timestamp gt operator in SurrealDB count()", async () => { - // Mock SurrealDB results: 5 recipes with different timestamps - const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: `2023-01-0${i+1}T00:00:00Z` }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: `2023-01-0${i+1}T00:00:00Z` } - ] - })); - - mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); +describe("resolveParentPredicate()", () => { + @Model({ name: "Comment" }) + class Comment extends Ad4mModel {} - // Count recipes with timestamp > 2023-01-03 (should match 2 recipes: 2023-01-04 and 2023-01-05) - const targetTimestamp = new Date("2023-01-03T00:00:00Z").getTime(); - const count = await Recipe.count(mockPerspective, { where: { timestamp: { gt: targetTimestamp } } }); - - // Verify count matches the number of instances that would be returned by findAll - const findAllResults = await Recipe.findAll(mockPerspective, { where: { timestamp: { gt: targetTimestamp } } }); - - expect(count).toBe(2); - expect(count).toBe(findAllResults.length); + @Model({ name: "Tag" }) + class Tag extends Ad4mModel {} + + @Model({ name: "Channel" }) + class Channel extends Ad4mModel { + @HasMany(() => Comment, { through: "channel://has_comment" }) + comments: Comment[] = []; + + @HasMany(() => Tag, { through: "channel://has_tag" }) + tags: Tag[] = []; + } + + @Model({ name: "AmbiguousParent" }) + class AmbiguousParent extends Ad4mModel { + @HasMany(() => Comment, { through: "parent://primary_comments" }) + primaryComments: Comment[] = []; + + @HasMany(() => Comment, { through: "parent://secondary_comments" }) + secondaryComments: Comment[] = []; + } + + it("resolves predicate by explicit field name", () => { + const meta = Channel.getModelMetadata(); + expect(resolveParentPredicate(meta, Comment, "comments")).toBe( + "channel://has_comment", + ); }); - it("should apply JS-level filtering for timestamp between operator in SurrealDB count()", async () => { - // Mock SurrealDB results: 5 recipes with different timestamps - const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: `2023-01-0${i+1}T00:00:00Z` }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: `2023-01-0${i+1}T00:00:00Z` } - ] - })); - - mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); + it("infers predicate from child constructor when field is omitted", () => { + const meta = Channel.getModelMetadata(); + expect(resolveParentPredicate(meta, Comment)).toBe("channel://has_comment"); + }); + + it("throws when explicit field does not exist on parent", () => { + const meta = Channel.getModelMetadata(); + expect(() => resolveParentPredicate(meta, Comment, "nonExistent")).toThrow( + /field "nonExistent" not found/, + ); + }); + + it("throws when no relation on parent matches the child type", () => { + @Model({ name: "UnrelatedParent" }) + class UnrelatedParent extends Ad4mModel {} + const meta = UnrelatedParent.getModelMetadata(); + expect(() => resolveParentPredicate(meta, Comment)).toThrow( + /no forward relation pointing to "Comment"/, + ); + }); - // Count recipes with timestamp between 2023-01-02 and 2023-01-04 - const startTimestamp = new Date("2023-01-02T00:00:00Z").getTime(); - const endTimestamp = new Date("2023-01-04T00:00:00Z").getTime(); - const count = await Recipe.count(mockPerspective, { - where: { timestamp: { between: [startTimestamp, endTimestamp] } } + it("throws when neither field nor childCtor is provided", () => { + const meta = Channel.getModelMetadata(); + expect(() => resolveParentPredicate(meta, undefined)).toThrow( + /either "field" or a child model constructor must be provided/, + ); + }); + + it("throws with disambiguation hint when multiple relations point to the same type", () => { + const meta = AmbiguousParent.getModelMetadata(); + expect(() => resolveParentPredicate(meta, Comment)).toThrow( + /multiple relations.*provide "field" to disambiguate/i, + ); + }); +}); + +describe("normalizeParentQuery()", () => { + @Model({ name: "NpqTag" }) + class NpqTag extends Ad4mModel {} + + @Model({ name: "NpqComment" }) + class NpqComment extends Ad4mModel {} + + @Model({ name: "NpqBlogPost" }) + class NpqBlogPost extends Ad4mModel { + @HasMany(() => NpqTag, { through: "blog://has_tag" }) + tags: NpqTag[] = []; + + @HasMany(() => NpqComment, { through: "blog://has_comment" }) + comments: NpqComment[] = []; + + @HasMany(() => NpqComment, { through: "blog://has_pinned_comment" }) + pinnedComments: NpqComment[] = []; + } + + it("passes through raw { id, predicate } form unchanged", () => { + const raw = { id: "some://uri", predicate: "custom://predicate" }; + expect(normalizeParentQuery(raw)).toEqual(raw); + }); + + it("resolves model-backed form with explicit field", () => { + const result = normalizeParentQuery({ + id: "some://uri", + model: NpqBlogPost, + field: "tags", }); - - // Verify count matches the number of instances that would be returned by findAll - const findAllResults = await Recipe.findAll(mockPerspective, { - where: { timestamp: { between: [startTimestamp, endTimestamp] } } + expect(result).toEqual({ id: "some://uri", predicate: "blog://has_tag" }); + }); + + it("infers predicate from childCtor when field is omitted", () => { + const result = normalizeParentQuery( + { id: "some://uri", model: NpqBlogPost }, + NpqTag, + ); + expect(result).toEqual({ id: "some://uri", predicate: "blog://has_tag" }); + }); + + it("throws when model-backed form has no field and multiple relations point to the same child type", () => { + expect(() => + normalizeParentQuery( + { id: "some://uri", model: NpqBlogPost }, + NpqComment, + ), + ).toThrow(/multiple relations.*provide "field" to disambiguate/i); + }); +}); + +describe("Ad4mModel.update() — static convenience method", () => { + @Model({ name: "UpdatableModel" }) + class UpdatableModel extends Ad4mModel { + @Property({ through: "test://title" }) + title: string = ""; + @Property({ through: "test://body" }) + body: string = ""; + @Property({ through: "test://count" }) + count: number = 0; + } + + it("calls get() then save() after merging data", async () => { + const existingTitle = "Original Title"; + const existingBody = "Original Body"; + + const mockPerspective = { + querySurrealDB: jest.fn().mockResolvedValue([ + { + source: "literal://string:abc123", + source_uri: "literal://string:abc123", + links: [ + { predicate: "test://title", target: `literal://string:${existingTitle}`, author: "did:key:alice", timestamp: "2024-01-01T00:00:00Z" }, + { predicate: "test://body", target: `literal://string:${existingBody}`, author: "did:key:alice", timestamp: "2024-01-01T00:00:00Z" }, + { predicate: "test://count", target: "literal://number:5", author: "did:key:alice", timestamp: "2024-01-01T00:00:00Z" }, + ], + }, + ]), + infer: jest.fn(), + uuid: "test-uuid", + stringOrTemplateObjectToSubjectClassName: jest.fn().mockResolvedValue("UpdatableModel"), + add: jest.fn(), + remove: jest.fn(), + get: jest.fn().mockResolvedValue([]), + removeSubject: jest.fn(), + createSubject: jest.fn(), + subjectEntities: jest.fn().mockResolvedValue([]), + } as any; + + const saveSpy = jest.spyOn(UpdatableModel.prototype, "save").mockResolvedValue(); + const getSpy = jest.spyOn(UpdatableModel.prototype, "get").mockImplementation(async function (this: any) { + this.title = existingTitle; + this.body = existingBody; + this.count = 5; + return this; }); - - expect(count).toBe(3); - expect(count).toBe(findAllResults.length); + + const result = await UpdatableModel.update( + mockPerspective, + "literal://string:abc123", + { title: "New Title" }, + ); + + // get() must be called to fetch the pre-existing state + expect(getSpy).toHaveBeenCalledTimes(1); + + // save() must be called after merging + expect(saveSpy).toHaveBeenCalledTimes(1); + + // Only the provided field was overwritten + expect(result.title).toBe("New Title"); + + // Other fields are preserved from the fetched state + expect(result.body).toBe(existingBody); + expect(result.count).toBe(5); + + getSpy.mockRestore(); + saveSpy.mockRestore(); }); - it("should apply JS-level filtering for author filtering in SurrealDB count()", async () => { - // Mock SurrealDB results: 3 recipes by Alice and 2 by Bob - const surrealResults = [ - ...Array.from({ length: 3 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - })), - ...Array.from({ length: 2 }, (_, i) => ({ - source: `node:def${i+4}`, - source_uri: `literal://recipe${i+4}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+4}`, author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" }, - { predicate: "recipe://rating", target: "5", author: "did:key:bob", timestamp: "2023-01-02T00:00:00Z" } - ] - })) - ]; - - mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); + it("returns the updated instance", async () => { + const saveSpy = jest.spyOn(UpdatableModel.prototype, "save").mockResolvedValue(); + const getSpy = jest.spyOn(UpdatableModel.prototype, "get").mockImplementation(async function (this: any) { + this.title = "Old"; + this.body = "kept"; + return this; + }); - // Count recipes by Alice (should match 3 recipes) - const count = await Recipe.count(mockPerspective, { where: { author: "did:key:alice" } }); - - // Verify count matches the number of instances that would be returned by findAll - const findAllResults = await Recipe.findAll(mockPerspective, { where: { author: "did:key:alice" } }); - - expect(count).toBe(3); - expect(count).toBe(findAllResults.length); + const mockPerspective = {} as any; + const result = await UpdatableModel.update(mockPerspective, "literal://string:xyz", { title: "New" }); + + expect(result).toBeInstanceOf(UpdatableModel); + expect(result.id).toBe("literal://string:xyz"); + + getSpy.mockRestore(); + saveSpy.mockRestore(); }); - it("should apply JS-level filtering in ModelQueryBuilder.count() with gt operator", async () => { - // Mock SurrealDB results: 5 recipes with ratings 1, 2, 3, 4, 5 - const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" }, - { predicate: "recipe://rating", target: `${i+1}`, author: "did:key:alice", timestamp: "2023-01-01T00:00:00Z" } - ] - })); - - mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); + it("passes batchId through to save()", async () => { + const saveSpy = jest.spyOn(UpdatableModel.prototype, "save").mockResolvedValue(); + const getSpy = jest.spyOn(UpdatableModel.prototype, "get").mockImplementation(async function (this: any) { + return this; + }); - // Count recipes with rating > 3 using ModelQueryBuilder - const count = await Recipe.query(mockPerspective) - .where({ rating: { gt: 3 } }) - .count(); - - // Verify count matches the number of instances that would be returned by get() - const getResults = await Recipe.query(mockPerspective) - .where({ rating: { gt: 3 } }) - .get(); - - expect(count).toBe(2); - expect(count).toBe(getResults.length); + await UpdatableModel.update({} as any, "literal://string:id1", {}, "batch-42"); + expect(saveSpy).toHaveBeenCalledWith("batch-42"); + + getSpy.mockRestore(); + saveSpy.mockRestore(); }); +}); - it("should apply JS-level filtering in ModelQueryBuilder.count() with timestamp between", async () => { - // Mock SurrealDB results: 5 recipes with different timestamps - const surrealResults = Array.from({ length: 5 }, (_, i) => ({ - source: `node:abc${i+1}`, - source_uri: `literal://recipe${i+1}`, - links: [ - { predicate: "recipe://name", target: `Recipe ${i+1}`, author: "did:key:alice", timestamp: `2023-01-0${i+1}T00:00:00Z` }, - { predicate: "recipe://rating", target: "5", author: "did:key:alice", timestamp: `2023-01-0${i+1}T00:00:00Z` } - ] - })); - - mockPerspective.querySurrealDB.mockResolvedValue(surrealResults); +describe("Ad4mModel.delete() — static convenience method", () => { + @Model({ name: "DeletableModel" }) + class DeletableModel extends Ad4mModel { + @Property({ through: "test://label" }) + label: string = ""; + } - const startTimestamp = new Date("2023-01-02T00:00:00Z").getTime(); - const endTimestamp = new Date("2023-01-04T00:00:00Z").getTime(); - - // Count using ModelQueryBuilder - const count = await Recipe.query(mockPerspective) - .where({ timestamp: { between: [startTimestamp, endTimestamp] } }) - .count(); - - // Verify count matches the number of instances that would be returned by get() - const getResults = await Recipe.query(mockPerspective) - .where({ timestamp: { between: [startTimestamp, endTimestamp] } }) - .get(); - - expect(count).toBe(3); - expect(count).toBe(getResults.length); + it("calls instance.delete() with no batchId by default", async () => { + const deleteSpy = jest.spyOn(DeletableModel.prototype, "delete").mockResolvedValue(); + + await DeletableModel.delete({} as any, "literal://string:del1"); + + expect(deleteSpy).toHaveBeenCalledTimes(1); + expect(deleteSpy).toHaveBeenCalledWith(undefined); + + deleteSpy.mockRestore(); }); - it("should handle count() with Prolog for gt operator (legacy)", async () => { - const prologResults = [{ TotalCount: 2 }]; - mockPerspective.infer.mockResolvedValue(prologResults); + it("forwards batchId to instance.delete()", async () => { + const deleteSpy = jest.spyOn(DeletableModel.prototype, "delete").mockResolvedValue(); - const count = await Recipe.count(mockPerspective, { where: { rating: { gt: 3 } } }, false); - - expect(mockPerspective.infer).toHaveBeenCalledTimes(1); - expect(mockPerspective.querySurrealDB).not.toHaveBeenCalled(); - expect(count).toBe(2); + await DeletableModel.delete({} as any, "literal://string:del2", "batch-99"); + + expect(deleteSpy).toHaveBeenCalledWith("batch-99"); + + deleteSpy.mockRestore(); }); - it("should handle count() with Prolog for timestamp between (legacy)", async () => { - const prologResults = [{ TotalCount: 3 }]; - mockPerspective.infer.mockResolvedValue(prologResults); + it("constructs an instance with the given id", async () => { + const deleteSpy = jest.spyOn(DeletableModel.prototype, "delete").mockResolvedValue(); - const startTimestamp = new Date("2023-01-02T00:00:00Z").getTime(); - const endTimestamp = new Date("2023-01-04T00:00:00Z").getTime(); - - const count = await Recipe.count( - mockPerspective, - { where: { timestamp: { between: [startTimestamp, endTimestamp] } } }, - false - ); - - expect(mockPerspective.infer).toHaveBeenCalledTimes(1); - expect(mockPerspective.querySurrealDB).not.toHaveBeenCalled(); - expect(count).toBe(3); + const targetId = "literal://string:target-id"; + await DeletableModel.delete({} as any, targetId); + + // The static method must target the correct id + expect((deleteSpy.mock.instances[0] as unknown as DeletableModel).id).toBe(targetId); + + deleteSpy.mockRestore(); }); }); - diff --git a/core/src/model/Ad4mModel.ts b/core/src/model/Ad4mModel.ts index fc636cfea..918c8cad8 100644 --- a/core/src/model/Ad4mModel.ts +++ b/core/src/model/Ad4mModel.ts @@ -1,3408 +1,644 @@ import { Literal } from "../Literal"; import { Link } from "../links/Links"; +import { LinkQuery } from "../perspectives/LinkQuery"; import { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; -import { makeRandomPrologAtom, PropertyOptions, CollectionOptions, ModelOptions } from "./decorators"; -import { singularToPlural, pluralToSingular, propertyNameToSetterName, collectionToAdderName, collectionToRemoverName, collectionToSetterName } from "./util"; -import { escapeSurrealString } from "../utils"; - -// JSON Schema type definitions -interface JSONSchemaProperty { - type: string | string[]; - items?: JSONSchemaProperty; - properties?: { [key: string]: JSONSchemaProperty }; - required?: string[]; - "x-ad4m"?: { - through?: string; - resolveLanguage?: string; - local?: boolean; - writable?: boolean; - initial?: string; - }; -} - -interface JSONSchema { - $schema?: string; - title?: string; - $id?: string; - type?: string; - properties?: { [key: string]: JSONSchemaProperty }; - required?: string[]; - "x-ad4m"?: { - namespace?: string; - className?: string; - }; -} - -interface JSONSchemaToModelOptions { - name: string; - namespace?: string; - predicateTemplate?: string; - predicateGenerator?: (title: string, property: string) => string; - propertyMapping?: Record; - resolveLanguage?: string; - local?: boolean; - propertyOptions?: Record>; -} - -type ValueTuple = [name: string, value: any, resolve?: boolean]; -type WhereOps = { - not: string | number | boolean | string[] | number[]; - between: [number, number]; - lt: number; // less than - lte: number; // less than or equal to - gt: number; // greater than - gte: number; // greater than or equal to - contains: string | number; // substring/element check -}; -type WhereCondition = string | number | boolean | string[] | number[] | { [K in keyof WhereOps]?: WhereOps[K] }; -type Where = { [propertyName: string]: WhereCondition }; -type Order = { [propertyName: string]: "ASC" | "DESC" }; - -export type Query = { - source?: string; - properties?: string[]; - collections?: string[]; // replace with include: Query[] - where?: Where; - order?: Order; - offset?: number; - limit?: number; - count?: boolean; -}; - -export type AllInstancesResult = { AllInstances: Ad4mModel[]; TotalCount?: number; isInit?: boolean }; -export type ResultsWithTotalCount = { results: T[]; totalCount?: number }; -export type PaginationResult = { results: T[]; totalCount?: number; pageSize: number; pageNumber: number }; - -/** - * Metadata for a single property extracted from decorators. - */ -export interface PropertyMetadata { - /** The property name */ - name: string; - /** The predicate URI (through value) */ - predicate: string; - /** Whether the property is required */ - required: boolean; - /** Whether the property is writable */ - writable: boolean; - /** Initial value if specified */ - initial?: string; - /** Language for resolution (e.g., "literal") */ - resolveLanguage?: string; - /** Custom Prolog getter code */ - prologGetter?: string; - /** Custom Prolog setter code */ - prologSetter?: string; - /** Custom SurrealQL getter code */ - getter?: string; - /** Whether stored locally only */ - local?: boolean; - /** Transform function */ - transform?: (value: any) => any; - /** Whether this is a flag property */ - flag?: boolean; -} - -/** - * Metadata for a single collection extracted from decorators. - */ -export interface CollectionMetadata { - /** The collection name */ - name: string; - /** The predicate URI (through value) */ - predicate: string; - /** Filter conditions */ - where?: { isInstance?: any; prologCondition?: string; condition?: string }; - /** Custom SurrealQL getter code */ - getter?: string; - /** Whether stored locally only */ - local?: boolean; -} - -/** - * Complete model metadata extracted from decorators. - */ -export interface ModelMetadata { - /** The model class name from @ModelOptions */ - className: string; - /** Map of property name to metadata */ - properties: Record; - /** Map of collection name to metadata */ - collections: Record; -} - -function capitalize(word: string): string { - return word.charAt(0).toUpperCase() + word.slice(1); -} - -function buildSourceQuery(source?: string): string { - // Constrains the query to instances that have the provided source - if (!source) return ""; - return `triple("${source}", "ad4m://has_child", Base)`; -} - -// todo: only return Timestamp & Author from query (Base, AllLinks, and SortLinks not required) -function buildAuthorAndTimestampQuery(): string { - // Gets the author and timestamp of a Ad4mModel instance (based on the first link mentioning the base) - return ` - findall( - [T, A], - link(Base, _, _, T, A), - AllLinks - ), - sort(AllLinks, SortedLinks), - SortedLinks = [[Timestamp, Author]|_] - `; -} - -function buildPropertiesQuery(properties?: string[]): string { - // Gets the name, value, and resolve boolean for all (or some) properties on a Ad4mModel instance - // Resolves literals (if property_resolve/2 is true) to their value - either the data field if it is - // an Expression in JSON literal, or the direct literal value if it is a simple literal - // If no properties are provided, all are included - return ` - findall([PropertyName, PropertyValue, Resolve], ( - % Constrain to specified properties if provided - ${properties ? `member(PropertyName, [${properties.map((name) => `"${name}"`).join(", ")}]),` : ""} - resolve_property(SubjectClass, Base, PropertyName, PropertyValue, Resolve) - ), Properties) - `; -} - -function buildCollectionsQuery(collections?: string[]): string { - // Gets the name and array of values for all (or some) collections on a Ad4mModel instance - // If no collections are provided, all are included - return ` - findall([CollectionName, CollectionValues], ( - % Constrain to specified collections if provided - ${collections ? `member(CollectionName, [${collections.map((name) => `"${name}"`).join(", ")}]),` : ""} - - collection(SubjectClass, CollectionName), - collection_getter(SubjectClass, Base, CollectionName, CollectionValues) - ), Collections) - `; -} - -function buildWhereQuery(where: Where = {}): string { - // Constrains the query to instances that match the provided where conditions - - function formatValue(value) { - // Wrap strings in quotes - return typeof value === "string" ? `"${value}"` : value; - } - - return (Object.entries(where) as [string, WhereCondition][]) - .map(([key, value]) => { - const isSpecial = ["base", "author", "timestamp"].includes(key); - const getter = `resolve_property(SubjectClass, Base, "${key}", Value${key}, _)`; - // const getter = `property_getter(SubjectClass, Base, "${key}", URI), literal_from_url(URI, V, _)`; - const field = capitalize(key); - - // Handle direct array values (for IN conditions) - if (Array.isArray(value)) { - const formattedValues = value.map((v) => formatValue(v)).join(", "); - if (isSpecial) return `member(${field}, [${formattedValues}])`; - else return `${getter}, member(Value${key}, [${formattedValues}])`; - } - - // Handle operation object - if (typeof value === "object" && value !== null) { - const { not, between, lt, lte, gt, gte } = value; - - // Handle NOT operation - if (not !== undefined) { - if (Array.isArray(not)) { - // NOT IN array - const formattedValues = not.map((v) => formatValue(v)).join(", "); - if (isSpecial) return `\\+ member(${field}, [${formattedValues}])`; - else return `${getter}, \\+ member(Value${key}, [${formattedValues}])`; - } else { - // NOT EQUAL - if (isSpecial) return `${field} \\= ${formatValue(not)}`; - else return `${getter}, Value${key} \\= ${formatValue(not)}`; - } - } - - // Handle BETWEEN - if (between !== undefined && Array.isArray(between) && between.length === 2) { - if (isSpecial) return `${field} >= ${between[0]}, ${field} =< ${between[1]}`; - else return `${getter}, Value${key} >= ${between[0]}, Value${key} =< ${between[1]}`; - } - - // Handle lt, lte, gt, & gte operations - const operators = [ - { value: lt, symbol: "<" }, // LESS THAN - { value: lte, symbol: "=<" }, // LESS THAN OR EQUAL TO - { value: gt, symbol: ">" }, // GREATER THAN - { value: gte, symbol: ">=" }, // GREATER THAN OR EQUAL TO - ]; - - for (const { value, symbol } of operators) { - if (value !== undefined) - return isSpecial ? `${field} ${symbol} ${value}` : `${getter}, Value${key} ${symbol} ${value}`; - } - } - - // Default to direct equality - if (isSpecial) return `${field} = ${formatValue(value)}`; - else return `${getter}, Value${key} = ${formatValue(value)}`; - }) - .join(", "); -} - -function buildCountQuery(count?: boolean): string { - return count ? "length(UnsortedInstances, TotalCount)" : ""; -} - -function buildOrderQuery(order?: Order): string { - if (!order) return "SortedInstances = UnsortedInstances"; - const [propertyName, direction] = Object.entries(order)[0]; - return `sort_instances(UnsortedInstances, "${propertyName}", "${direction}", SortedInstances)`; -} - -function buildOffsetQuery(offset?: number): string { - if (!offset || offset < 0) return "InstancesWithOffset = SortedInstances"; - return `skipN(SortedInstances, ${offset}, InstancesWithOffset)`; -} - -function buildLimitQuery(limit?: number): string { - if (!limit || limit < 0) return "AllInstances = InstancesWithOffset"; - return `takeN(InstancesWithOffset, ${limit}, AllInstances)`; -} - -function normalizeNamespaceString(namespace: string): string { - if (!namespace) return ''; - if (namespace.includes('://')) { - const [scheme, rest] = namespace.split('://'); - const path = (rest || '').replace(/\/+$/,''); - return `${scheme}://${path}`; - } else { - return namespace.replace(/\/+$/,''); - } -} - -function normalizeSchemaType(type?: string | string[]): string | undefined { - if (!type) return undefined; - if (typeof type === "string") return type; - if (Array.isArray(type) && type.length > 0) { - const nonNull = type.find((t) => t !== "null"); - return nonNull || type[0]; - } - return undefined; -} - -function isSchemaType(schema: JSONSchemaProperty, expectedType: string): boolean { - return normalizeSchemaType(schema.type) === expectedType; -} - -function isArrayType(schema: JSONSchemaProperty): boolean { - return isSchemaType(schema, "array"); -} - -function isObjectType(schema: JSONSchemaProperty): boolean { - return isSchemaType(schema, "object"); -} - -function isNumericType(schema: JSONSchemaProperty): boolean { - const normalized = normalizeSchemaType(schema.type); - return normalized === "number" || normalized === "integer"; -} +import { SHACLShape } from "../shacl/SHACLShape"; +import { makeRandomId, getRelationsMetadata } from "./decorators"; +import { resolveParentPredicate } from "./parentUtils"; +export { resolveParentPredicate, normalizeParentQuery } from "./parentUtils"; +import * as mutation from "./mutation"; +export type { MutationContext } from "./mutation"; +import { capitalize } from "./util"; + +// ── Public types (re-exported so consumers see no change) ────────────────── +export type { + Query, + Where, + Order, + WhereCondition, + AllInstancesResult, + ResultsWithTotalCount, + PaginationResult, + PropertyMetadata, + RelationMetadata, + ModelMetadata, + IncludeMap, + SubscribeOptions, + Subscription, + ParentQuery, + ParentQueryByPredicate, + ParentQueryByModel, +} from "./types"; /** - * Base class for defining data models in AD4M. - * - * @description - * Ad4mModel provides the foundation for creating data models that are stored in AD4M perspectives. - * Each model instance is represented as a subgraph in the perspective, with properties and collections - * mapped to links in that graph. The class uses Prolog-based queries to efficiently search and filter - * instances based on their properties and relationships. - * - * Key concepts: - * - Each model instance has a unique base expression that serves as its identifier - * - Properties are stored as links with predicates defined by the `through` option - * - Collections represent one-to-many relationships as sets of links - * - Queries are translated to Prolog for efficient graph pattern matching - * - Changes are tracked through the perspective's subscription system - * + * Options for linking a newly created instance to an existing parent node. + * Pass as the third argument to `Model.create()`. + * * @example - * ```typescript - * // Define a recipe model - * @ModelOptions({ name: "Recipe" }) - * class Recipe extends Ad4mModel { - * // Required property with literal value - * @Property({ - * through: "recipe://name", - * resolveLanguage: "literal" - * }) - * name: string = ""; - * - * // Optional property with custom initial value - * @Optional({ - * through: "recipe://status", - * initial: "recipe://draft" - * }) - * status: string = ""; - * - * // Read-only computed property - * @ReadOnly({ - * through: "recipe://rating", - * getter: ` - * findall(Rating, triple(Base, "recipe://user_rating", Rating), Ratings), - * sum_list(Ratings, Sum), - * length(Ratings, Count), - * Value is Sum / Count - * ` - * }) - * averageRating: number = 0; - * - * // Collection of ingredients - * @Collection({ through: "recipe://ingredient" }) - * ingredients: string[] = []; - * - * // Collection of comments that are instances of another model - * @Collection({ - * through: "recipe://comment", - * where: { isInstance: Comment } - * }) - * comments: Comment[] = []; - * } - * - * // Create and save a new recipe - * const recipe = new Recipe(perspective); - * recipe.name = "Chocolate Cake"; - * recipe.ingredients = ["flour", "sugar", "cocoa"]; - * await recipe.save(); - * - * // Query recipes in different ways - * // Get all recipes - * const allRecipes = await Recipe.findAll(perspective); - * - * // Find recipes with specific criteria - * const desserts = await Recipe.findAll(perspective, { - * where: { - * status: "recipe://published", - * averageRating: { gt: 4 } - * }, - * order: { name: "ASC" }, - * limit: 10 - * }); - * - * // Use the fluent query builder - * const popularRecipes = await Recipe.query(perspective) - * .where({ averageRating: { gt: 4.5 } }) - * .order({ averageRating: "DESC" }) - * .limit(5) - * .get(); - * - * // Subscribe to real-time updates - * await Recipe.query(perspective) - * .where({ status: "recipe://cooking" }) - * .subscribe(recipes => { - * console.log("Currently being cooked:", recipes); - * }); - * - * // Paginate results - * const { results, totalCount, pageNumber } = await Recipe.query(perspective) - * .where({ status: "recipe://published" }) - * .paginate(10, 1); - * ``` + * // Field inferred — only one @HasMany on Channel points to Poll + * await Poll.create(perspective, { title }, { parent: { model: Channel, id: channelId } }) + * + * // Field explicit — needed when multiple @HasMany on the parent point to the same type + * await Poll.create(perspective, { title }, { parent: { model: Channel, id: channelId, field: 'featuredPolls' } }) */ -export class Ad4mModel { - #baseExpression: string; - #subjectClassName: string; - #source: string; - #perspective: PerspectiveProxy; - author: string; - createdAt: any; - updatedAt: any; - - private static classNamesByClass = new WeakMap(); - - static async getClassName(perspective: PerspectiveProxy) { - // Check if this is the Ad4mModel class itself or a subclass - const isBaseClass = this === Ad4mModel; - - // For the base Ad4mModel class, we can't use the cache - if (isBaseClass) { - return await perspective.stringOrTemplateObjectToSubjectClassName(this); - } - - // Get or create the cache for this class - let classCache = this.classNamesByClass.get(this); - if (!classCache) { - classCache = {}; - this.classNamesByClass.set(this, classCache); - } - - // Get or create the cached name for this perspective - const perspectiveID = perspective.uuid; - if (!classCache[perspectiveID]) { - classCache[perspectiveID] = await perspective.stringOrTemplateObjectToSubjectClassName(this); - } - - return classCache[perspectiveID]; - } - - /** - * Backwards compatibility alias for createdAt. - * @deprecated Use createdAt instead. This will be removed in a future version. - */ - get timestamp(): any { - return (this as any).createdAt; - } - - /** - * Extracts metadata from decorators for query building. - * - * @description - * This method reads the metadata stored by decorators (@Property, @Collection, etc.) - * and returns it in a structured format that's easier to work with for query builders - * and other systems that need to introspect model structure. - * - * The metadata includes: - * - Class name from @ModelOptions - * - Property metadata (predicates, types, constraints, etc.) - * - Collection metadata (predicates, filters, etc.) - * - * For models created via `fromJSONSchema()`, this method will derive metadata from - * the stored `__properties` and `__collections` structures that were populated during - * the dynamic class creation. If these structures are empty but a JSON schema was - * attached to the class, it can fall back to deriving metadata from that schema. - * - * @returns Structured metadata object containing className, properties, and collections - * @throws Error if the class doesn't have @ModelOptions decorator - * - * @example - * ```typescript - * @ModelOptions({ name: "Recipe" }) - * class Recipe extends Ad4mModel { - * @Property({ through: "recipe://name", resolveLanguage: "literal" }) - * name: string = ""; - * - * @Collection({ through: "recipe://ingredient" }) - * ingredients: string[] = []; - * } - * - * const metadata = Recipe.getModelMetadata(); - * console.log(metadata.className); // "Recipe" - * console.log(metadata.properties.name.predicate); // "recipe://name" - * console.log(metadata.collections.ingredients.predicate); // "recipe://ingredient" - * ``` - */ - public static getModelMetadata(): ModelMetadata { - // Access the prototype with any type to access decorator-added properties - const prototype = this.prototype as any; - - // Validate that the class has @ModelOptions decorator - // The decorator sets prototype.className, so we check for its existence - if (!prototype.className || prototype.className === 'Ad4mModel') { - throw new Error("Model class must be decorated with @ModelOptions"); - } - - // Extract className - const className = prototype.className; - - // Extract properties from prototype.__properties - const propertiesMetadata: Record = {}; - const prototypeProperties = prototype.__properties || {}; - - for (const [propertyName, opts] of Object.entries(prototypeProperties)) { - const options = opts as PropertyOptions & { required?: boolean; flag?: boolean }; - propertiesMetadata[propertyName] = { - name: propertyName, - predicate: options.through || "", - required: options.required || false, - writable: options.writable || false, - ...(options.initial !== undefined && { initial: options.initial }), - ...(options.resolveLanguage !== undefined && { resolveLanguage: options.resolveLanguage }), - ...(options.prologGetter !== undefined && { prologGetter: options.prologGetter }), - ...(options.getter !== undefined && { getter: options.getter }), - ...(options.prologSetter !== undefined && { prologSetter: options.prologSetter }), - ...(options.local !== undefined && { local: options.local }), - ...(options.transform !== undefined && { transform: options.transform }), - ...(options.flag !== undefined && { flag: options.flag }) - }; - } - - // Extract collections from prototype.__collections - const collectionsMetadata: Record = {}; - const prototypeCollections = prototype.__collections || {}; - - for (const [collectionName, opts] of Object.entries(prototypeCollections)) { - const options = opts as CollectionOptions; - collectionsMetadata[collectionName] = { - name: collectionName, - predicate: options.through || "", - ...(options.where !== undefined && { where: options.where }), - ...(options.local !== undefined && { local: options.local }), - ...(options.getter !== undefined && { getter: options.getter }) - }; - } - - // Fallback: If both structures are empty but a JSON schema is attached, derive from it - // This handles edge cases where fromJSONSchema() was called but metadata wasn't properly populated - const hasProperties = Object.keys(propertiesMetadata).length > 0; - const hasCollections = Object.keys(collectionsMetadata).length > 0; - const hasMetadata = hasProperties || hasCollections; - - if (!hasMetadata && prototype.__jsonSchema) { - // Derive metadata from the attached JSON schema - const schema = prototype.__jsonSchema; - const options = prototype.__jsonSchemaOptions || {}; - - if (schema.properties) { - for (const [propertyName, propertySchema] of Object.entries(schema.properties)) { - const isArray = isArrayType(propertySchema as JSONSchemaProperty); - const predicate = this.determinePredicate( - schema, - propertyName, - propertySchema as JSONSchemaProperty, - this.determineNamespace(schema, options), - options - ); - - if (isArray) { - collectionsMetadata[propertyName] = { - name: propertyName, - predicate: predicate, - ...(propertySchema["x-ad4m"]?.local !== undefined && { local: propertySchema["x-ad4m"].local }) - }; - } else { - const isRequired = schema.required?.includes(propertyName) || false; - propertiesMetadata[propertyName] = { - name: propertyName, - predicate: predicate, - required: isRequired, - writable: propertySchema["x-ad4m"]?.writable !== false, - ...(propertySchema["x-ad4m"]?.resolveLanguage && { resolveLanguage: propertySchema["x-ad4m"].resolveLanguage }), - ...(propertySchema["x-ad4m"]?.initial && { initial: propertySchema["x-ad4m"].initial }), - ...(propertySchema["x-ad4m"]?.local !== undefined && { local: propertySchema["x-ad4m"].local }) - }; - } - } - } - } - - return { - className, - properties: propertiesMetadata, - collections: collectionsMetadata - }; - } - - /** - * Constructs a new model instance. - * - * @param perspective - The perspective where this model will be stored - * @param baseExpression - Optional unique identifier for this instance - * @param source - Optional source expression this instance is linked to - * - * @example - * ```typescript - * // Create a new recipe with auto-generated base expression - * const recipe = new Recipe(perspective); - * - * // Create with specific base expression - * const recipe = new Recipe(perspective, "recipe://chocolate-cake"); - * - * // Create with source link - * const recipe = new Recipe(perspective, undefined, "cookbook://desserts"); - * ``` - */ - constructor(perspective: PerspectiveProxy, baseExpression?: string, source?: string) { - this.#baseExpression = baseExpression ? baseExpression : Literal.from(makeRandomPrologAtom(24)).toUrl(); - this.#perspective = perspective; - this.#source = source || "ad4m://self"; - } - - /** - * Gets the base expression of the subject. - */ - get baseExpression() { - return this.#baseExpression; - } - - /** - * Protected getter for the perspective. - * Allows subclasses to access the perspective while keeping it private from external code. - */ - protected get perspective(): PerspectiveProxy { - return this.#perspective; - } - - /** - * Get property metadata from decorator (Phase 1: Prolog-free refactor) - * @private - */ - private getPropertyMetadata(key: string): PropertyOptions | undefined { - const proto = Object.getPrototypeOf(this); - return proto.__properties?.[key]; - } - - /** - * Get collection metadata from decorator (Phase 1: Prolog-free refactor) - * @private - */ - private getCollectionMetadata(key: string): CollectionOptions | undefined { - const proto = Object.getPrototypeOf(this); - return proto.__collections?.[key]; - } - - /** - * Generate property setter action from metadata (Phase 1: Prolog-free refactor) - * Replaces Prolog query: property_setter(C, key, Setter) - * @private - */ - private generatePropertySetterAction(key: string, metadata: PropertyOptions): any[] { - // Check if property is read-only - if (metadata.writable === false) { - throw new Error(`Property "${key}" is read-only and cannot be written`); - } - - if (metadata.prologSetter) { - // Custom Prolog setter - throw error for now (Phase 2) - throw new Error( - `Custom Prolog setter for property "${key}" not yet supported without Prolog. ` + - `Use standard @Property decorator or enable Prolog for custom setters.` - ); - } - - if (!metadata.through) { - throw new Error(`Property "${key}" has no 'through' predicate defined`); - } - - return [{ - action: "setSingleTarget", - source: "this", - predicate: metadata.through, - target: "value", - ...(metadata.local && { local: true }) - }]; - } - - /** - * Generate collection action from metadata (Phase 1: Prolog-free refactor) - * Replaces Prolog queries: collection_adder, collection_remover, collection_setter - * @private - */ - private generateCollectionAction(key: string, actionType: 'adder' | 'remover' | 'setter'): any[] { - const metadata = this.getCollectionMetadata(key); - if (!metadata) { - throw new Error(`Collection "${key}" has no metadata defined`); - } - - if (!metadata.through) { - throw new Error(`Collection "${key}" has no 'through' predicate defined`); - } - - const actionMap = { - adder: "addLink", - remover: "removeLink", - setter: "collectionSetter" - }; - - return [{ - action: actionMap[actionType], - source: "this", - predicate: metadata.through, - target: "value", - ...(metadata.local && { local: true }) - }]; - } - - public static async assignValuesToInstance(perspective: PerspectiveProxy, instance: Ad4mModel, values: ValueTuple[]) { - // Map properties to object - const propsObject = Object.fromEntries( - await Promise.all( - values.map(async ([name, value, resolve]) => { - let finalValue = value; - - // Handle UTF-8 byte sequences from Prolog URL decoding - if (!resolve && typeof value === 'string') { - // Only attempt reconstruction if the string looks like a byte string (all code points <= 0xFF) - // and contains at least one high byte (>= 0x80). This avoids mangling valid Unicode. - const codePoints = Array.from(value, ch => ch.codePointAt(0)!); - const looksByteString = codePoints.every(cp => cp <= 0xFF); - const hasHighByte = codePoints.some(cp => cp >= 0x80); - if (looksByteString && hasHighByte) { - try { - const bytes = Uint8Array.from(codePoints); - const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - if (decoded !== value) finalValue = decoded; - } catch (error) { - // If UTF-8 conversion fails, keep the original value - console.warn(`UTF-8 byte reconstruction failed for property "${name}"`, { value, error }); - } - } - } - - // Resolve the value if necessary - if (resolve) { - let resolvedExpression = await perspective.getExpression(value); - if (resolvedExpression) { - try { - // Attempt to parse the data as JSON - finalValue = JSON.parse(resolvedExpression.data); - } catch (error) { - // If parsing fails, keep the original data - finalValue = resolvedExpression.data; - } - } - } - // Apply transform function if it exists - const transform = instance["__properties"]?.[name]?.transform; - if (transform && typeof transform === "function") { - finalValue = transform(finalValue); - } - return [name, finalValue]; - }) - ) - ); - // Filter out properties that are read-only (getters without setters) - const writableProps = Object.fromEntries( - Object.entries(propsObject).filter(([key]) => { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), key); - if (!descriptor) { - // No descriptor means it's a regular property on the instance, allow it - return true; - } - // Check if it's an accessor descriptor (has get/set) vs data descriptor (has value/writable) - const isAccessor = descriptor.get !== undefined || descriptor.set !== undefined; - if (isAccessor) { - // Accessor descriptor: only allow if it has a setter - return descriptor.set !== undefined; - } else { - // Data descriptor: only allow if writable is not explicitly false - return descriptor.writable !== false; - } - }) - ); - // Assign properties to instance - Object.assign(instance, writableProps); - } - - private async getData() { - // Builds an object with the author, timestamp, all properties, & all collections on the Ad4mModel and saves it to the instance - // Use SurrealDB for data queries - try { - const ctor = this.constructor as typeof Ad4mModel; - const metadata = ctor.getModelMetadata(); - - // Query for all links from this specific node (base expression) - // Using formatSurrealValue to prevent SQL injection by properly escaping the value - const safeBaseExpression = ctor.formatSurrealValue(this.#baseExpression); - // Note: We use ORDER BY timestamp ASC because: - // - For collections: we want chronological order (oldest to newest) - // - For properties: we select the LAST element to get "latest wins" semantics - const linksQuery = ` - SELECT id, predicate, out.uri AS target, author, timestamp - FROM link - WHERE in.uri = ${safeBaseExpression} - ORDER BY timestamp ASC - `; - const links = await this.#perspective.querySurrealDB(linksQuery); - - if (links && links.length > 0) { - let minTimestamp = null; - let maxTimestamp = null; - let latestAuthor = null; - let originalAuthor = null; - - // Process properties (skip those with custom getter) - for (const [propName, propMeta] of Object.entries(metadata.properties)) { - if (propMeta.getter) continue; // Handle via custom getter evaluation - const matching = links.filter((l: any) => l.predicate === propMeta.predicate); - if (matching.length > 0) { - // "Latest wins" semantics: select the last element since links are ordered ASC. - // The last element has the most recent timestamp and represents the current property value. - const link = matching[matching.length - 1]; - let value = link.target; - - // Track timestamps/authors for createdAt and updatedAt - if (link.timestamp) { - if (!minTimestamp || link.timestamp < minTimestamp) { - minTimestamp = link.timestamp; - originalAuthor = link.author; - } - if (!maxTimestamp || link.timestamp > maxTimestamp) { - maxTimestamp = link.timestamp; - latestAuthor = link.author; - } - } - - // Handle resolveLanguage - if (propMeta.resolveLanguage && propMeta.resolveLanguage !== 'literal') { - try { - const expression = await this.#perspective.getExpression(value); - if (expression) { - try { - value = JSON.parse(expression.data); - } catch { - value = expression.data; - } - } - } catch (e) { - console.warn(`Failed to resolve expression for ${propName}:`, e); - } - } else if (propMeta.resolveLanguage === 'literal' && typeof value === 'string' && value.startsWith('literal://')) { - // Only parse literal URIs when resolveLanguage is explicitly 'literal'. - // Without this guard, properties pointing to baseExpressions of other models - // (which may be literal:// strings) would get unwrapped, breaking link URI validation. - try { - const parsed = Literal.fromUrl(value).get(); - value = parsed.data !== undefined ? parsed.data : parsed; - } catch (e) { - // Keep original value - } - } - - // Apply transform if exists - if (propMeta.transform && typeof propMeta.transform === 'function') { - value = propMeta.transform(value); - } - - (this as any)[propName] = value; - } - } - - // Process collections (skip those with custom getter) - for (const [collName, collMeta] of Object.entries(metadata.collections)) { - if (collMeta.getter) continue; // Handle via custom getter evaluation - const matching = links.filter((l: any) => l.predicate === collMeta.predicate); - // Collections preserve chronological order: links are sorted ASC by timestamp, - // so the collection reflects the order in which items were added (oldest to newest). - let values = matching.map((l: any) => l.target); - - // Apply where.condition filtering if present - if (collMeta.where?.condition && values.length > 0) { - try { - // Filter values by evaluating condition for each value - const filteredValues: string[] = []; - - for (const value of values) { - let condition = collMeta.where.condition - .replace(/\$perspective/g, `'${this.#perspective.uuid}'`) - .replace(/\$base/g, `'${this.#baseExpression}'`) - .replace(/Target/g, `'${value.replace(/'/g, "\\'")}'`); - - // If condition starts with WHERE, wrap it in array length check pattern - // Using array::len() to properly count matching links - if (condition.trim().startsWith('WHERE')) { - condition = `array::len(SELECT * FROM link ${condition}) > 0`; - } - - const filterQuery = `RETURN ${condition}`; - const result = await this.#perspective.querySurrealDB(filterQuery); - - // RETURN can return the value directly or in an array - const isTrue = result === true || (Array.isArray(result) && result.length > 0 && result[0] === true); - if (isTrue) { - filteredValues.push(value); - } - } - - values = filteredValues; - } catch (error) { - console.warn(`Failed to apply condition filter for ${collName}:`, error); - // Keep unfiltered values on error - } - } - - // Apply where.isInstance filtering if present - if (collMeta.where?.isInstance && values.length > 0) { - try { - const className = typeof collMeta.where.isInstance === 'string' - ? collMeta.where.isInstance - : collMeta.where.isInstance.name; - - const filterMetadata = await this.#perspective.getSubjectClassMetadataFromSDNA(className); - if (filterMetadata) { - values = await this.#perspective.batchCheckSubjectInstances(values, filterMetadata); - } - } catch (error) { - // Keep unfiltered values on error - } - } - - (this as any)[collName] = values; - } - - // Set author and timestamps - if (originalAuthor) { - (this as any).author = originalAuthor; - } - if (minTimestamp) { - (this as any).createdAt = minTimestamp; - } - if (maxTimestamp) { - (this as any).updatedAt = maxTimestamp; - } - } - - // Evaluate SurrealQL getters - await ctor.evaluateCustomGettersForInstance(this, this.#perspective, metadata); - - // Apply where.isInstance filtering to getter collections - // (non-getter collections were already filtered above) - for (const [collName, collMeta] of Object.entries(metadata.collections)) { - if (collMeta.getter && collMeta.where?.isInstance && (this as any)[collName]?.length > 0) { - try { - const className = typeof collMeta.where.isInstance === 'string' - ? collMeta.where.isInstance - : collMeta.where.isInstance.name; - - const filterMetadata = await this.#perspective.getSubjectClassMetadataFromSDNA(className); - if (filterMetadata) { - const filtered = await this.#perspective.batchCheckSubjectInstances((this as any)[collName], filterMetadata); - (this as any)[collName] = filtered; - } - } catch (error) { - // Keep unfiltered values on error - } - } - } - } catch (e) { - console.error(`SurrealDB getData also failed for ${this.#baseExpression}:`, e); - } - - return this; - } - - // Todo: Only return AllInstances (InstancesWithOffset, SortedInstances, & UnsortedInstances not required) - public static async queryToProlog(perspective: PerspectiveProxy, query: Query, modelClassName?: string | null) { - const { source, properties, collections, where, order, offset, limit, count } = query; - const className = modelClassName || (await this.getClassName(perspective)); - - const instanceQueries = [ - buildAuthorAndTimestampQuery(), - buildSourceQuery(source), - buildPropertiesQuery(properties), - buildCollectionsQuery(collections), - buildWhereQuery(where), - ]; - - const resultSetQueries = [buildCountQuery(count), buildOrderQuery(order), buildOffsetQuery(offset), buildLimitQuery(limit)]; - - const fullQuery = ` - findall([Base, Properties, Collections, Timestamp, Author], ( - subject_class("${className}", SubjectClass), - instance(SubjectClass, Base), - ${instanceQueries.filter((q) => q).join(", ")} - ), UnsortedInstances), - ${resultSetQueries.filter((q) => q).join(", ")} - `; - - return fullQuery; - } - - /** - * Evaluates custom SurrealQL getters for properties and collections on a specific instance. - * @private - */ - private static async evaluateCustomGettersForInstance( - instance: any, - perspective: PerspectiveProxy, - metadata: any - ) { - const safeBaseExpression = this.formatSurrealValue(instance.baseExpression); - - // Evaluate property getters - for (const [propName, propMeta] of Object.entries(metadata.properties)) { - if ((propMeta as any).getter) { - try { - // Replace 'Base' placeholder with actual base expression - const query = (propMeta as any).getter.replace(/Base/g, safeBaseExpression); - // Query from node table to have graph traversal context - const result = await perspective.querySurrealDB( - `SELECT (${query}) AS value FROM node WHERE uri = ${safeBaseExpression}` - ); - if (result && result.length > 0 && result[0].value !== undefined && result[0].value !== null && result[0].value !== 'None' && result[0].value !== '') { - instance[propName] = result[0].value; - } - } catch (error) { - console.warn(`Failed to evaluate getter for ${propName}:`, error); - } - } - } - - // Evaluate collection getters - for (const [collName, collMeta] of Object.entries(metadata.collections)) { - if ((collMeta as any).getter) { - try { - // Replace 'Base' placeholder with actual base expression - const query = (collMeta as any).getter.replace(/Base/g, safeBaseExpression); - // Query from node table to have graph traversal context - const result = await perspective.querySurrealDB( - `SELECT (${query}) AS value FROM node WHERE uri = ${safeBaseExpression}` - ); - if (result && result.length > 0 && result[0].value !== undefined && result[0].value !== null) { - // Filter out 'None' from collection results - const value = result[0].value; - instance[collName] = Array.isArray(value) - ? value.filter((v: any) => v !== undefined && v !== null && v !== '' && v !== 'None') - : value; - } - } catch (error) { - console.warn(`Failed to evaluate getter for ${collName}:`, error); - } - } - } - } - - /** - * Generates a SurrealQL query from a Query object. - * - * @description - * This method translates high-level query parameters into a SurrealQL query string - * that can be executed against the SurrealDB backend. Unlike Prolog queries which - * operate on SDNA-aware predicates, SurrealQL queries operate directly on raw links - * stored in SurrealDB. - * - * The generated query uses a CTE (Common Table Expression) pattern: - * 1. First, identify candidate base expressions by filtering links based on where conditions - * 2. Then, for each candidate base, resolve properties and collections via subqueries - * 3. Finally, apply ordering, pagination (LIMIT/START) at the SQL level - * - * Key architectural notes: - * - SurrealDB stores only raw links (source, predicate, target, author, timestamp) - * - No SDNA knowledge at the database level - * - Properties are resolved via subqueries that look for links with specific predicates - * - Collections are similar but return multiple values instead of one - * - Special fields (base, author, timestamp) are accessed directly, not via subqueries - * - * @param perspective - The perspective to query (used for metadata extraction) - * @param query - Query parameters (where, order, limit, offset, properties, collections) - * @returns Complete SurrealQL query string ready for execution - * - * @example - * ```typescript - * const query = Recipe.queryToSurrealQL(perspective, { - * where: { name: "Pasta", rating: { gt: 4 } }, - * order: { timestamp: "DESC" }, - * limit: 10 - * }); - * // Returns: SELECT source AS base, array::first(target[WHERE predicate = ...]) AS name, ... - * // FROM link WHERE ... GROUP BY source ORDER BY timestamp DESC LIMIT 10 - * ``` - */ - public static async queryToSurrealQL(perspective: PerspectiveProxy, query: Query): Promise { - const metadata = this.getModelMetadata(); - const { source, where, order, offset, limit } = query; - - // Build list of graph traversal filters for required predicates - const graphTraversalFilters: string[] = []; - - // Add source filter if specified (filter to nodes that are children of this source) - // Source filter means: find targets of 'ad4m://has_child' links from the specified source - if (source) { - // Use graph traversal: node must be target of has_child link from source - graphTraversalFilters.push( - `count(<-link[WHERE perspective = $perspective AND in.uri = ${this.formatSurrealValue(source)} AND predicate = 'ad4m://has_child']) > 0` - ); - } - - // Add filters for required properties - for (const [propName, propMeta] of Object.entries(metadata.properties)) { - if (propMeta.required) { - // For flag properties, also filter by the target value - if (propMeta.flag && propMeta.initial) { - graphTraversalFilters.push( - `count(->link[WHERE perspective = $perspective AND predicate = '${escapeSurrealString(propMeta.predicate)}' AND out.uri = '${escapeSurrealString(propMeta.initial)}']) > 0` - ); - } else { - graphTraversalFilters.push( - `count(->link[WHERE perspective = $perspective AND predicate = '${escapeSurrealString(propMeta.predicate)}']) > 0` - ); - } - } - } - - // If no required properties, we need at least one property to define the model - // Use any property with an initial value as the defining characteristic - if (graphTraversalFilters.length === 0) { - for (const [propName, propMeta] of Object.entries(metadata.properties)) { - if (propMeta.initial) { - // For flag properties, also filter by the target value - if (propMeta.flag) { - graphTraversalFilters.push( - `count(->link[WHERE perspective = $perspective AND predicate = '${escapeSurrealString(propMeta.predicate)}' AND out.uri = '${escapeSurrealString(propMeta.initial)}']) > 0` - ); - } else { - graphTraversalFilters.push( - `count(->link[WHERE perspective = $perspective AND predicate = '${escapeSurrealString(propMeta.predicate)}']) > 0` - ); - } - break; // Just need one defining property - } - } - } - - // Build user WHERE clause filters using graph traversal - const userWhereClause = this.buildGraphTraversalWhereClause(metadata, where); - - // Build complete WHERE clause using graph traversal filters - const whereConditions: string[] = []; - - // Add all graph traversal filters for required properties - whereConditions.push(...graphTraversalFilters); - - // Add user where conditions if any - if (userWhereClause) { - whereConditions.push(userWhereClause); - } - - // Always ensure node has at least one link in this perspective - whereConditions.push(`count(->link[WHERE perspective = $perspective]) > 0`); - - // Build the query FROM node using direct graph traversal in WHERE - // This avoids slow subqueries and uses graph indexes for fast traversal - const fullQuery = ` -SELECT - id AS source, - uri AS source_uri, - ->link[WHERE perspective = $perspective] AS links -FROM node -WHERE ${whereConditions.join(' AND ')} - `.trim(); - - return fullQuery; - } - - /** - * Builds the WHERE clause for SurrealQL queries using graph traversal syntax. - * - * @description - * Translates where conditions into graph traversal filters: `->link[WHERE ...]` - * This is more efficient than nested SELECTs because SurrealDB can optimize graph traversals. - * - * Handles several condition types: - * - Simple equality: `{ name: "Pasta" }` → `->link[WHERE predicate = 'X' AND out.uri = 'Pasta']` - * - Arrays (IN clause): `{ name: ["Pasta", "Pizza"] }` → `->link[WHERE predicate = 'X' AND out.uri IN [...]]` - * - NOT operators: Use `NOT` prefix - * - Comparison operators (gt, gte, lt, lte, etc.): Handled in post-query JavaScript filtering - * - Special fields: base uses `uri` directly, author/timestamp handled post-query - * - * @param metadata - Model metadata containing property predicates - * @param where - Where conditions from the query - * @returns Graph traversal WHERE clause filters, or empty string if no conditions - * - * @private - */ - private static buildGraphTraversalWhereClause(metadata: ModelMetadata, where?: Where): string { - if (!where) return ''; - - const conditions: string[] = []; - - for (const [propertyName, condition] of Object.entries(where)) { - // Check if this is a special field (base, author, timestamp) - // Note: author and timestamp filtering is done in JavaScript after query - const isSpecial = ['base', 'author', 'timestamp'].includes(propertyName); - - if (isSpecial) { - // Skip author and timestamp - they'll be filtered in JavaScript - // Only handle 'base' (which maps to 'uri') here - if (propertyName === 'author' || propertyName === 'timestamp') { - continue; // Skip - will be filtered post-query - } - - const columnName = 'uri'; // base maps to uri in node table - - // Handle base/uri field directly - if (Array.isArray(condition)) { - // Array values (IN clause) - const formattedValues = condition.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`${columnName} IN [${formattedValues}]`); - } else if (typeof condition === 'object' && condition !== null) { - // Operator object - const ops = condition as any; - if (ops.not !== undefined) { - if (Array.isArray(ops.not)) { - const formattedValues = ops.not.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`${columnName} NOT IN [${formattedValues}]`); - } else { - conditions.push(`${columnName} != ${this.formatSurrealValue(ops.not)}`); - } - } - if (ops.between !== undefined && Array.isArray(ops.between) && ops.between.length === 2) { - conditions.push(`${columnName} >= ${this.formatSurrealValue(ops.between[0])} AND ${columnName} <= ${this.formatSurrealValue(ops.between[1])}`); - } - if (ops.gt !== undefined) { - conditions.push(`${columnName} > ${this.formatSurrealValue(ops.gt)}`); - } - if (ops.gte !== undefined) { - conditions.push(`${columnName} >= ${this.formatSurrealValue(ops.gte)}`); - } - if (ops.lt !== undefined) { - conditions.push(`${columnName} < ${this.formatSurrealValue(ops.lt)}`); - } - if (ops.lte !== undefined) { - conditions.push(`${columnName} <= ${this.formatSurrealValue(ops.lte)}`); - } - if (ops.contains !== undefined) { - conditions.push(`${columnName} CONTAINS ${this.formatSurrealValue(ops.contains)}`); - } - } else { - // Simple equality - conditions.push(`${columnName} = ${this.formatSurrealValue(condition)}`); - } - } else { - // Handle regular properties via graph traversal - const propMeta = metadata.properties[propertyName]; - if (!propMeta) continue; // Skip if property not found in metadata - - const predicate = escapeSurrealString(propMeta.predicate); - // Use fn::parse_literal() for properties with resolveLanguage - const targetField = propMeta.resolveLanguage === 'literal' ? 'fn::parse_literal(out.uri)' : 'out.uri'; - - if (Array.isArray(condition)) { - // Array values (IN clause) - const formattedValues = condition.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`count(->link[WHERE perspective = $perspective AND predicate = '${predicate}' AND ${targetField} IN [${formattedValues}]]) > 0`); - } else if (typeof condition === 'object' && condition !== null) { - // Operator object - const ops = condition as any; - if (ops.not !== undefined) { - if (Array.isArray(ops.not)) { - // For NOT IN with array: must NOT have a link with value in the array - const formattedValues = ops.not.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`count(->link[WHERE perspective = $perspective AND predicate = '${predicate}' AND ${targetField} IN [${formattedValues}]]) = 0`); - } else { - // For NOT with single value: must NOT have this value - conditions.push(`count(->link[WHERE perspective = $perspective AND predicate = '${predicate}' AND ${targetField} = ${this.formatSurrealValue(ops.not)}]) = 0`); - } - } - // Note: gt, gte, lt, lte, between, contains operators are filtered in JavaScript - // post-query because fn::parse_literal() comparisons in SurrealDB - // don't work reliably with numeric comparisons. - // These are handled in instancesFromSurrealResult along with author/timestamp filtering. - // However, we still need to ensure the property exists - const hasComparisonOps = ops.gt !== undefined || ops.gte !== undefined || - ops.lt !== undefined || ops.lte !== undefined || - ops.between !== undefined || ops.contains !== undefined; - if (hasComparisonOps) { - // Ensure we only get nodes that have this property - conditions.push(`count(->link[WHERE perspective = $perspective AND predicate = '${predicate}']) > 0`); - } - } else { - // Simple equality - conditions.push(`count(->link[WHERE perspective = $perspective AND predicate = '${predicate}' AND ${targetField} = ${this.formatSurrealValue(condition)}]) > 0`); - } - } - } - - return conditions.join(' AND '); - } - - /** - * Builds the WHERE clause for SurrealQL queries. - * - * @description - * Translates the where conditions from the Query object into SurrealQL WHERE clause fragments. - * For each property filter, generates a subquery that checks for links with the appropriate - * predicate and target value. - * - * Handles several condition types: - * - Simple equality: `{ name: "Pasta" }` → subquery checking for predicate and target match - * - Arrays (IN clause): `{ name: ["Pasta", "Pizza"] }` → target IN [...] - * - Operators: `{ rating: { gt: 4 } }` → target > '4' - * - gt, gte, lt, lte: comparison operators - * - not: negation (single value or array) - * - between: range check - * - contains: substring/element check (uses SurrealQL CONTAINS) - * - Special fields: base, author, timestamp are accessed directly, not via subqueries - * - * All conditions are joined with AND. - * - * @param metadata - Model metadata containing property predicates - * @param where - Where conditions from the query - * @returns WHERE clause string (without the "WHERE" keyword), or empty string if no conditions - * - * @private - */ - private static buildSurrealWhereClause(metadata: ModelMetadata, where?: Where): string { - if (!where) return ''; - - const conditions: string[] = []; - - for (const [propertyName, condition] of Object.entries(where)) { - // Check if this is a special field (base, author, timestamp) - // Note: author and timestamp filtering is done in JavaScript after GROUP BY - // because they need to be computed from the grouped links first - const isSpecial = ['base', 'author', 'timestamp'].includes(propertyName); - - if (isSpecial) { - // Skip author and timestamp - they'll be filtered in JavaScript - // Only handle 'base' (which maps to 'source') here - if (propertyName === 'author' || propertyName === 'timestamp') { - continue; // Skip - will be filtered post-query - } - - const columnName = 'source'; // base maps to source - - // Handle base/source field directly - if (Array.isArray(condition)) { - // Array values (IN clause) - const formattedValues = condition.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`${columnName} IN [${formattedValues}]`); - } else if (typeof condition === 'object' && condition !== null) { - // Operator object - const ops = condition as any; - if (ops.not !== undefined) { - if (Array.isArray(ops.not)) { - const formattedValues = ops.not.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`${columnName} NOT IN [${formattedValues}]`); - } else { - conditions.push(`${columnName} != ${this.formatSurrealValue(ops.not)}`); - } - } - if (ops.between !== undefined && Array.isArray(ops.between) && ops.between.length === 2) { - conditions.push(`${columnName} >= ${this.formatSurrealValue(ops.between[0])} AND ${columnName} <= ${this.formatSurrealValue(ops.between[1])}`); - } - if (ops.gt !== undefined) { - conditions.push(`${columnName} > ${this.formatSurrealValue(ops.gt)}`); - } - if (ops.gte !== undefined) { - conditions.push(`${columnName} >= ${this.formatSurrealValue(ops.gte)}`); - } - if (ops.lt !== undefined) { - conditions.push(`${columnName} < ${this.formatSurrealValue(ops.lt)}`); - } - if (ops.lte !== undefined) { - conditions.push(`${columnName} <= ${this.formatSurrealValue(ops.lte)}`); - } - if (ops.contains !== undefined) { - conditions.push(`${columnName} CONTAINS ${this.formatSurrealValue(ops.contains)}`); - } - } else { - // Simple equality - conditions.push(`${columnName} = ${this.formatSurrealValue(condition)}`); - } - } else { - // Handle regular properties via subqueries - const propMeta = metadata.properties[propertyName]; - if (!propMeta) continue; // Skip if property not found in metadata - - const predicate = escapeSurrealString(propMeta.predicate); - // Use fn::parse_literal() for properties with resolveLanguage - const targetField = propMeta.resolveLanguage === 'literal' ? 'fn::parse_literal(target)' : 'target'; - - if (Array.isArray(condition)) { - // Array values (IN clause) - const formattedValues = condition.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`source IN (SELECT VALUE source FROM link WHERE predicate = '${predicate}' AND ${targetField} IN [${formattedValues}])`); - } else if (typeof condition === 'object' && condition !== null) { - // Operator object - const ops = condition as any; - if (ops.not !== undefined) { - if (Array.isArray(ops.not)) { - // For NOT IN with array: exclude sources that HAVE a value in the array - const formattedValues = ops.not.map(v => this.formatSurrealValue(v)).join(', '); - conditions.push(`source NOT IN (SELECT VALUE source FROM link WHERE predicate = '${predicate}' AND ${targetField} IN [${formattedValues}])`); - } else { - // For NOT with single value: exclude sources that HAVE this value - conditions.push(`source NOT IN (SELECT VALUE source FROM link WHERE predicate = '${predicate}' AND ${targetField} = ${this.formatSurrealValue(ops.not)})`); - } - } - // Note: gt, gte, lt, lte, between, contains operators are filtered in JavaScript - // post-query because fn::parse_literal() comparisons in SurrealDB subqueries - // don't work reliably with numeric comparisons. - // These are handled in instancesFromSurrealResult along with author/timestamp filtering. - // However, we still need to ensure the property exists by filtering on the predicate - const hasComparisonOps = ops.gt !== undefined || ops.gte !== undefined || - ops.lt !== undefined || ops.lte !== undefined || - ops.between !== undefined || ops.contains !== undefined; - if (hasComparisonOps) { - // Ensure we only get instances that have this property - conditions.push(`source IN (SELECT VALUE source FROM link WHERE predicate = '${predicate}')`); - } - } else { - // Simple equality - conditions.push(`source IN (SELECT VALUE source FROM link WHERE predicate = '${predicate}' AND ${targetField} = ${this.formatSurrealValue(condition)})`); - } - } - } - - return conditions.join(' AND '); - } - - /** - * Builds the SELECT fields for SurrealQL queries. - * - * @description - * Generates the field list for the SELECT clause, resolving properties and collections - * via subqueries. Each property is fetched with a subquery that finds the link with the - * appropriate predicate and returns its target. Collections are similar but don't use LIMIT 1. - * - * Field types: - * - Properties: `(SELECT VALUE target FROM link WHERE source = $parent.base AND predicate = 'X' LIMIT 1) AS propName` - * - Collections: `(SELECT VALUE target FROM link WHERE source = $parent.base AND predicate = 'X') AS collName` - * - Author/Timestamp: Always included to provide metadata about each instance - * - * If properties or collections arrays are provided, only those fields are included. - * Otherwise, all properties/collections from metadata are included. - * - * @param metadata - Model metadata containing property and collection predicates - * @param properties - Optional array of property names to include (default: all) - * @param collections - Optional array of collection names to include (default: all) - * @returns Comma-separated SELECT field list - * - * @private - */ - private static buildSurrealSelectFields(metadata: ModelMetadata, properties?: string[], collections?: string[]): string { - const fields: string[] = []; - - // Determine properties to fetch - const propsToFetch = properties || Object.keys(metadata.properties); - for (const propName of propsToFetch) { - const propMeta = metadata.properties[propName]; - if (!propMeta) continue; // Skip if not found - - // Reference source directly since we're selecting from link table - const escapedPredicate = escapeSurrealString(propMeta.predicate); - fields.push(`(SELECT VALUE target FROM link WHERE source = source AND predicate = '${escapedPredicate}' LIMIT 1) AS ${propName}`); - } - - // Determine collections to fetch - const collsToFetch = collections || Object.keys(metadata.collections); - for (const collName of collsToFetch) { - const collMeta = metadata.collections[collName]; - if (!collMeta) continue; // Skip if not found - - // Reference source directly since we're selecting from link table - const escapedPredicate = escapeSurrealString(collMeta.predicate); - fields.push(`(SELECT VALUE target FROM link WHERE source = source AND predicate = '${escapedPredicate}') AS ${collName}`); - } - - // Always add author and timestamp fields - fields.push(`(SELECT VALUE author FROM link WHERE source = source ORDER BY timestamp ASC LIMIT 1) AS author`); - fields.push(`(SELECT VALUE timestamp FROM link WHERE source = source ORDER BY timestamp ASC LIMIT 1) AS createdAt`); - fields.push(`(SELECT VALUE timestamp FROM link WHERE source = source ORDER BY timestamp DESC LIMIT 1) AS updatedAt`); - - return fields.join(',\n '); - } - - /** - * Builds the SELECT fields for SurrealQL queries using aggregation functions. - * Compatible with GROUP BY source queries. - * - * @private - */ - private static buildSurrealSelectFieldsWithAggregation(metadata: ModelMetadata, properties?: string[], collections?: string[]): string { - const fields: string[] = []; - - // Determine properties to fetch - const propsToFetch = properties || Object.keys(metadata.properties); - for (const propName of propsToFetch) { - const propMeta = metadata.properties[propName]; - if (!propMeta) continue; // Skip if not found - - // Use array::first to get the first target value for this predicate - const escapedPredicate = escapeSurrealString(propMeta.predicate); - fields.push(`array::first(target[WHERE predicate = '${escapedPredicate}']) AS ${propName}`); - } - - // Determine collections to fetch - const collsToFetch = collections || Object.keys(metadata.collections); - for (const collName of collsToFetch) { - const collMeta = metadata.collections[collName]; - if (!collMeta) continue; // Skip if not found - - // Use array filtering to get all target values for this predicate - const escapedPredicate = escapeSurrealString(collMeta.predicate); - fields.push(`target[WHERE predicate = '${escapedPredicate}'] AS ${collName}`); - } - - // Always add author and timestamp fields - fields.push(`array::first(author) AS author`); - fields.push(`array::first(timestamp) AS createdAt`); - fields.push(`array::last(timestamp) AS updatedAt`); - - return fields.join(',\n '); - } - - - /** - * Formats a value for use in SurrealQL queries. - * - * @description - * Handles different value types: - * - Strings: Wrapped in single quotes with backslash-escaped special characters - * - Numbers/booleans: Converted to string - * - Arrays: Recursively formatted and wrapped in brackets - * - * @param value - The value to format - * @returns Formatted value string ready for SurrealQL - * - * @private - */ - private static formatSurrealValue(value: any): string { - if (typeof value === 'string') { - // Escape backslashes first, then single quotes and other special characters - const escaped = value - .replace(/\\/g, '\\\\') // Backslash -> \\ - .replace(/'/g, "\\'") // Single quote -> \' - .replace(/"/g, '\\"') // Double quote -> \" - .replace(/\n/g, '\\n') // Newline -> \n - .replace(/\r/g, '\\r') // Carriage return -> \r - .replace(/\t/g, '\\t'); // Tab -> \t - return `'${escaped}'`; - } else if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } else if (Array.isArray(value)) { - return `[${value.map(v => this.formatSurrealValue(v)).join(', ')}]`; - } else { - return String(value); - } - } - - public static async instancesFromPrologResult( - this: typeof Ad4mModel & (new (...args: any[]) => T), - perspective: PerspectiveProxy, - query: Query, - result: AllInstancesResult - ): Promise> { - if (!result?.[0]?.AllInstances) return { results: [], totalCount: 0 }; - // Map results to instances - const requestedAttribtes = [...(query?.properties || []), ...(query?.collections || [])]; - const allInstances = await Promise.all( - result[0].AllInstances.map(async ([Base, Properties, Collections, Timestamp, Author]) => { - try { - const instance = new this(perspective, Base) as any; - // Remove unrequested attributes from instance - if (requestedAttribtes.length) { - Object.keys(instance).forEach((key) => { - if (!requestedAttribtes.includes(key)) delete instance[key]; - }); - } - // Collect values to assign to instance - const values = [...Properties, ...Collections, ["createdAt", Timestamp], ["author", Author]]; - await Ad4mModel.assignValuesToInstance(perspective, instance, values); - - return instance; - } catch (error) { - console.error(`Failed to process instance ${Base}:`, error); - // Return null for failed instances - we'll filter these out below - return null; - } - }) - ); - return { results: allInstances.filter((instance) => instance !== null), totalCount: result[0].TotalCount }; - } - - /** - * Converts SurrealDB query results to Ad4mModel instances. - * - * @param perspective - The perspective context - * @param query - The query parameters used - * @param result - Array of result objects from SurrealDB - * @returns Promise resolving to results with total count - * - * @internal - */ - public static async instancesFromSurrealResult( - this: typeof Ad4mModel & (new (...args: any[]) => T), - perspective: PerspectiveProxy, - query: Query, - result: any[] - ): Promise> { - if (!result || result.length === 0) return { results: [], totalCount: 0 }; - - const metadata = this.getModelMetadata(); - const requestedProperties = query?.properties || []; - const requestedCollections = query?.collections || []; - - // The query used GROUP BY with graph traversal, so each row has: - // - source: the node ID (e.g., "node:abc123") - // - source_uri: the actual URI (the base expression) - // - links: array of link objects with {predicate, target, author, timestamp} - - const instances: T[] = []; - for (const row of result) { - let base; - try { - // Use source_uri as the base (the actual URI), not the node ID - base = row.source_uri; - - // Skip rows without a source_uri field - if (!base) { - continue; - } - - const links = row.links || []; - - const instance = new this(perspective, base) as any; - - // Track both earliest (createdAt) and most recent (updatedAt) timestamps - let minTimestamp = null; - let maxTimestamp = null; - let originalAuthor = null; - let latestAuthor = null; - - // Process each link (track index for collection ordering) - for (let linkIndex = 0; linkIndex < links.length; linkIndex++) { - const link = links[linkIndex]; - const predicate = link.predicate; - const target = link.target; - - // Skip 'None' values - if (target === 'None') continue; - - // Track both earliest (createdAt) and latest (updatedAt) timestamps with their authors - if (link.timestamp) { - if (!minTimestamp || link.timestamp < minTimestamp) { - minTimestamp = link.timestamp; - originalAuthor = link.author; - } - if (!maxTimestamp || link.timestamp > maxTimestamp) { - maxTimestamp = link.timestamp; - latestAuthor = link.author; - } - } - - // Find matching property (skip those with getter) - let foundProperty = false; - for (const [propName, propMeta] of Object.entries(metadata.properties)) { - if (propMeta.getter) continue; // Handle via getter evaluation - if (propMeta.predicate === predicate) { - // For properties, take the first value (or we could use timestamp to get latest) - // Note: Empty objects {} are truthy, so we need to check for them explicitly - const currentValue = instance[propName]; - const isEmptyObject = typeof currentValue === 'object' && currentValue !== null && !Array.isArray(currentValue) && Object.keys(currentValue).length === 0; - if (!currentValue || currentValue === "" || currentValue === 0 || isEmptyObject) { - let convertedValue = target; - - // Only process if target has a value - if (target !== undefined && target !== null && target !== '') { - // Check if we need to resolve a non-literal language expression. - // resolveLanguage must be defined and not 'literal' to trigger expression resolution. - // Also skip if the target itself is a literal:// URI — those are handled by the - // literal-parsing branch below (avoids calling getExpression on empty literals like - // "literal://string:" which would cause a deserialization error). - if (propMeta.resolveLanguage != undefined && propMeta.resolveLanguage !== 'literal' && typeof target === 'string' && !target.startsWith('literal://')) { - // For non-literal languages, resolve the expression via perspective.getExpression() - // Note: Literals are already parsed by SurrealDB's fn::parse_literal() - try { - const expression = await perspective.getExpression(target); - if (expression) { - // Parse the expression data if it's a JSON string - try { - convertedValue = JSON.parse(expression.data); - } catch { - // If parsing fails, use the data as-is - convertedValue = expression.data; - } - } - } catch (e) { - console.warn(`Failed to resolve expression for ${propName} with target "${target}":`, e); - console.warn("Falling back to raw value"); - convertedValue = target; // Fall back to raw value - } - } else if (propMeta.resolveLanguage === 'literal' && typeof target === 'string' && target.startsWith('literal://')) { - // Only parse literal URIs when resolveLanguage is explicitly set to 'literal'. - // Without this check, properties pointing to baseExpressions of other models - // (which may be literal:// strings) would get unwrapped, breaking link URI validation. - try { - const parsed = Literal.fromUrl(target).get(); - if(parsed.data !== undefined) { - convertedValue = parsed.data; - } else { - convertedValue = parsed; - } - } catch (e) { - // If literal parsing fails, just use the value as-is (don't bail) - convertedValue = target; - } - } else if (typeof target === 'string') { - // Type conversion: check the instance property's current type - const expectedType = typeof instance[propName]; - if (expectedType === 'number') { - convertedValue = Number(target); - } else if (expectedType === 'boolean') { - convertedValue = target === 'true' || target === '1'; - } - } - } - - // Apply transform function if it exists - if (propMeta.transform && typeof propMeta.transform === 'function') { - convertedValue = propMeta.transform(convertedValue); - } - - instance[propName] = convertedValue; - } - foundProperty = true; - break; - } - } - - // If not a property, check if it's a collection (skip those with getter) - if (!foundProperty) { - for (const [collName, collMeta] of Object.entries(metadata.collections)) { - if (collMeta.getter) continue; // Handle via getter evaluation - if (collMeta.predicate === predicate) { - // For collections, accumulate all values with their timestamps and indices for sorting - if (!instance[collName]) { - instance[collName] = []; - } - // Initialize timestamp tracking array if not already done - const timestampsKey = `__${collName}_timestamps`; - const indicesKey = `__${collName}_indices`; - if (!instance[timestampsKey]) { - instance[timestampsKey] = []; - } - if (!instance[indicesKey]) { - instance[indicesKey] = []; - } - if (!instance[collName].includes(target)) { - instance[collName].push(target); - instance[timestampsKey].push(link.timestamp || ''); - // Track original position in the links array for stable sorting - instance[indicesKey].push(linkIndex); - } - break; - } - } - } - } - - // Set author and timestamps - if (originalAuthor) { - instance.author = originalAuthor; - } - - // Set createdAt from earliest timestamp - if (minTimestamp) { - if (typeof minTimestamp === 'string' && minTimestamp.includes('T')) { - instance.createdAt = new Date(minTimestamp).getTime(); - } else if (typeof minTimestamp === 'string') { - const parsed = parseInt(minTimestamp, 10); - instance.createdAt = isNaN(parsed) ? minTimestamp : parsed; - } else { - instance.createdAt = minTimestamp; - } - } - - // Set updatedAt from most recent timestamp - if (maxTimestamp) { - if (typeof maxTimestamp === 'string' && maxTimestamp.includes('T')) { - instance.updatedAt = new Date(maxTimestamp).getTime(); - } else if (typeof maxTimestamp === 'string') { - const parsed = parseInt(maxTimestamp, 10); - instance.updatedAt = isNaN(parsed) ? maxTimestamp : parsed; - } else { - instance.updatedAt = maxTimestamp; - } - } - - // Sort collections by timestamp to maintain insertion order - for (const [collName, collMeta] of Object.entries(metadata.collections)) { - const timestampsKey = `__${collName}_timestamps`; - const indicesKey = `__${collName}_indices`; - if (instance[collName] && instance[timestampsKey]) { - // Create array of [value, timestamp, index] tuples - const pairs = instance[collName].map((value: any, index: number) => ({ - value, - timestamp: instance[timestampsKey][index] || '', - originalIndex: instance[indicesKey]?.[index] ?? index - })); - // Sort by timestamp first, then by original index for stable sorting - pairs.sort((a, b) => { - const tsA = String(a.timestamp || ''); - const tsB = String(b.timestamp || ''); - const tsCompare = tsA.localeCompare(tsB); - if (tsCompare !== 0) return tsCompare; - // Use original index as tiebreaker for stable sorting - return a.originalIndex - b.originalIndex; - }); - // Replace collection with sorted values, filtering out empty strings and None - instance[collName] = pairs - .map(p => p.value) - .filter((v: any) => v !== undefined && v !== null && v !== '' && v !== 'None'); - // Clean up temporary arrays - delete instance[timestampsKey]; - delete instance[indicesKey]; - } - } - - // Filter by requested attributes if specified - if (requestedProperties.length > 0 || requestedCollections.length > 0) { - const requestedAttributes = [...requestedProperties, ...requestedCollections]; - Object.keys(instance).forEach((key) => { - // Keep only requested attributes, plus always keep createdAt, updatedAt, author, and baseExpression - // Note: timestamp is a getter alias for createdAt, so we preserve createdAt instead - if (!requestedAttributes.includes(key) && key !== 'createdAt' && key !== 'updatedAt' && key !== 'author' && key !== 'baseExpression') { - delete instance[key]; - } - }); - } - - instances.push(instance); - } catch (error) { - console.error(`Failed to process SurrealDB instance ${base}:`, error); - } - } - - // Evaluate custom getters for all instances (single pass) - // This populates collection values needed for where.isInstance filtering - for (const instance of instances) { - await this.evaluateCustomGettersForInstance(instance, perspective, metadata); - } - - // Filter collections by where.isInstance if specified - // Do this after initial evaluation so collection values exist for filtering - for (const instance of instances) { - for (const [collName, collMeta] of Object.entries(metadata.collections)) { - if (collMeta.where?.isInstance && instance[collName]?.length > 0) { - try { - const targetClass = collMeta.where.isInstance; - const subjects = instance[collName]; - - // Get the class metadata from SDNA to pass to batchCheckSubjectInstances - const targetClassName = typeof targetClass === 'string' - ? targetClass - : (targetClass as any).prototype?.className || targetClass.name; - const classMetadata = await perspective.getSubjectClassMetadataFromSDNA(targetClassName); - - if (!classMetadata) { - continue; - } - - // Check which subjects are instances of the target class - const validSubjects = await perspective.batchCheckSubjectInstances(subjects, classMetadata); - - // Update the collection with filtered instances - instance[collName] = validSubjects; - } catch (error) { - // On error, leave the collection unfiltered rather than breaking everything - } - } - } - } - - // Filter by where conditions that couldn't be filtered in SQL - // This includes: - // - author/timestamp (computed from grouped links) - // - Properties with comparison operators (gt, gte, lt, lte, between, contains) - // because fn::parse_literal() comparisons in SurrealDB subqueries don't work reliably - let filteredInstances = instances; - if (query.where) { - filteredInstances = instances.filter(instance => { - for (const [propertyName, condition] of Object.entries(query.where!)) { - // Skip 'base' as it's filtered in SQL - if (propertyName === 'base') continue; - - // For author and timestamp, always filter in JS - if (propertyName === 'author' || propertyName === 'timestamp') { - if (!this.matchesCondition(instance[propertyName], condition)) { - return false; - } - continue; - } - - // For regular properties, only filter comparison operators in JS - // Simple equality and NOT are handled in SQL, but gt/gte/lt/lte/between/contains need JS - if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) { - const ops = condition as any; - // Check if any comparison operators are present - const hasComparisonOps = ops.gt !== undefined || ops.gte !== undefined || - ops.lt !== undefined || ops.lte !== undefined || - ops.between !== undefined || ops.contains !== undefined; - if (hasComparisonOps) { - if (!this.matchesCondition(instance[propertyName], condition)) { - return false; - } - } - } - } - return true; - }); - } - - // Apply ordering in JavaScript - // If limit/offset is used but no explicit order, default to ordering by timestamp (ASC) - // This ensures consistent pagination behavior - const effectiveOrder = query.order || - (query.limit !== undefined || query.offset !== undefined ? { timestamp: 'ASC' as 'ASC' } : null); - - if (effectiveOrder) { - const orderPropName = Object.keys(effectiveOrder)[0]; - const orderDirection = Object.values(effectiveOrder)[0]; - - filteredInstances.sort((a: any, b: any) => { - let aVal = a[orderPropName]; - let bVal = b[orderPropName]; - - // Handle undefined values - push them to the end - if (aVal === undefined && bVal === undefined) return 0; - if (aVal === undefined) return orderDirection === 'ASC' ? 1 : -1; - if (bVal === undefined) return orderDirection === 'ASC' ? -1 : 1; - - // Compare values - let comparison = 0; - if (typeof aVal === 'number' && typeof bVal === 'number') { - comparison = aVal - bVal; - } else if (typeof aVal === 'string' && typeof bVal === 'string') { - comparison = aVal.localeCompare(bVal); - } else { - // Convert to strings for comparison - comparison = String(aVal).localeCompare(String(bVal)); - } - - return orderDirection === 'DESC' ? -comparison : comparison; - }); - } - - // Calculate totalCount BEFORE applying limit/offset - const totalCount = filteredInstances.length; - - // Apply offset and limit in JavaScript - let paginatedInstances = filteredInstances; - if (query.offset !== undefined || query.limit !== undefined) { - const start = query.offset || 0; - const end = query.limit ? start + query.limit : undefined; - paginatedInstances = filteredInstances.slice(start, end); - } - - return { - results: paginatedInstances, - totalCount - }; - } - - /** - * Checks if a value matches a condition (for post-query filtering). - * @private - */ - private static matchesCondition(value: any, condition: WhereCondition): boolean { - // Handle array values (IN clause) - if (Array.isArray(condition)) { - return (condition as any[]).includes(value); - } - - // Handle operator object - if (typeof condition === 'object' && condition !== null) { - const ops = condition as any; - - // Special case: 'not' operator (exclusive with other operators) - if (ops.not !== undefined) { - if (Array.isArray(ops.not)) { - return !(ops.not as any[]).includes(value); - } else { - return value !== ops.not; - } - } - - // Special case: 'between' operator (inclusive range, exclusive with gt/gte/lt/lte) - if (ops.between !== undefined && Array.isArray(ops.between) && ops.between.length === 2) { - return value >= ops.between[0] && value <= ops.between[1]; - } - - // For all other operators (gt, gte, lt, lte, contains), we need to check ALL of them - // and return true only if ALL conditions are satisfied - let allConditionsMet = true; - - if (ops.gt !== undefined) { - allConditionsMet = allConditionsMet && (value > ops.gt); - } - - if (ops.gte !== undefined) { - allConditionsMet = allConditionsMet && (value >= ops.gte); - } - - if (ops.lt !== undefined) { - allConditionsMet = allConditionsMet && (value < ops.lt); - } - - if (ops.lte !== undefined) { - allConditionsMet = allConditionsMet && (value <= ops.lte); - } - - if (ops.contains !== undefined) { - if (typeof value === 'string') { - allConditionsMet = allConditionsMet && value.includes(String(ops.contains)); - } else if (Array.isArray(value)) { - allConditionsMet = allConditionsMet && value.includes(ops.contains); - } else { - allConditionsMet = false; - } - } - - return allConditionsMet; - } - - // Simple equality - return value === condition; - } - - /** - * Gets all instances of the model in the perspective that match the query params. - * - * @param perspective - The perspective to search in - * @param query - Optional query parameters to filter results - * @param useSurrealDB - Whether to use SurrealDB (default: true, 10-100x faster) or Prolog (legacy) - * @returns Array of matching models - * - * @example - * ```typescript - * // Get all recipes (uses SurrealDB by default) - * const allRecipes = await Recipe.findAll(perspective); - * - * // Get recipes with specific criteria (uses SurrealDB) - * const recipes = await Recipe.findAll(perspective, { - * where: { - * name: "Pasta", - * rating: { gt: 4 } - * }, - * order: { createdAt: "DESC" }, - * limit: 10 - * }); - * - * // Explicitly use Prolog (legacy, for backward compatibility) - * const recipesProlog = await Recipe.findAll(perspective, {}, false); - * ``` - */ - static async findAll( - this: typeof Ad4mModel & (new (...args: any[]) => T), - perspective: PerspectiveProxy, - query: Query = {}, - useSurrealDB: boolean = true - ): Promise { - if (useSurrealDB) { - const surrealQuery = await this.queryToSurrealQL(perspective, query); - const result = await perspective.querySurrealDB(surrealQuery); - const { results } = await this.instancesFromSurrealResult(perspective, query, result); - return results; - } else { - const prologQuery = await this.queryToProlog(perspective, query); - const result = await perspective.infer(prologQuery); - const { results } = await this.instancesFromPrologResult(perspective, query, result); - return results; - } - } - - /** - * Gets all instances with count of total matches without offset & limit applied. - * - * @param perspective - The perspective to search in - * @param query - Optional query parameters to filter results - * @param useSurrealDB - Whether to use SurrealDB (default: true, 10-100x faster) or Prolog (legacy) - * @returns Object containing results array and total count - * - * @example - * ```typescript - * const { results, totalCount } = await Recipe.findAllAndCount(perspective, { - * where: { category: "Dessert" }, - * limit: 10 - * }); - * console.log(`Showing 10 of ${totalCount} dessert recipes`); - * - * // Use Prolog explicitly (legacy) - * const { results, totalCount } = await Recipe.findAllAndCount(perspective, {}, false); - * ``` - */ - static async findAllAndCount( - this: typeof Ad4mModel & (new (...args: any[]) => T), - perspective: PerspectiveProxy, - query: Query = {}, - useSurrealDB: boolean = true - ): Promise> { - if (useSurrealDB) { - const surrealQuery = await this.queryToSurrealQL(perspective, query); - const result = await perspective.querySurrealDB(surrealQuery); - return await this.instancesFromSurrealResult(perspective, query, result); - } else { - const prologQuery = await this.queryToProlog(perspective, query); - const result = await perspective.infer(prologQuery); - return await this.instancesFromPrologResult(perspective, query, result); - } - } - - /** - * Helper function for pagination with explicit page size and number. - * - * @param perspective - The perspective to search in - * @param pageSize - Number of items per page - * @param pageNumber - Which page to retrieve (1-based) - * @param query - Optional additional query parameters - * @param useSurrealDB - Whether to use SurrealDB (default: true, 10-100x faster) or Prolog (legacy) - * @returns Paginated results with metadata - * - * @example - * ```typescript - * const page = await Recipe.paginate(perspective, 10, 1, { - * where: { category: "Main Course" } - * }); - * console.log(`Page ${page.pageNumber} of recipes, ${page.results.length} items`); - * - * // Use Prolog explicitly (legacy) - * const pageProlog = await Recipe.paginate(perspective, 10, 1, {}, false); - * ``` - */ - static async paginate( - this: typeof Ad4mModel & (new (...args: any[]) => T), - perspective: PerspectiveProxy, - pageSize: number, - pageNumber: number, - query?: Query, - useSurrealDB: boolean = true - ): Promise> { - const paginationQuery = { ...(query || {}), limit: pageSize, offset: pageSize * (pageNumber - 1), count: true }; - if (useSurrealDB) { - const surrealQuery = await this.queryToSurrealQL(perspective, paginationQuery); - const result = await perspective.querySurrealDB(surrealQuery); - const { results, totalCount } = await this.instancesFromSurrealResult(perspective, paginationQuery, result); - return { results, totalCount, pageSize, pageNumber }; - } else { - const prologQuery = await this.queryToProlog(perspective, paginationQuery); - const result = await perspective.infer(prologQuery); - const { results, totalCount } = await this.instancesFromPrologResult(perspective, paginationQuery, result); - return { results, totalCount, pageSize, pageNumber }; - } - } - - static async countQueryToProlog(perspective: PerspectiveProxy, query: Query = {}, modelClassName?: string | null) { - const { source, where } = query; - const className = modelClassName || (await this.getClassName(perspective)); - const instanceQueries = [buildAuthorAndTimestampQuery(), buildSourceQuery(source), buildWhereQuery(where)]; - const resultSetQueries = [buildCountQuery(true), buildOrderQuery(), buildOffsetQuery(), buildLimitQuery()]; - - const fullQuery = ` - findall([Base, Properties, Collections, Timestamp, Author], ( - subject_class("${className}", SubjectClass), - instance(SubjectClass, Base), - ${instanceQueries.filter((q) => q).join(", ")} - ), UnsortedInstances), - ${resultSetQueries.filter((q) => q).join(", ")} - `; - - return fullQuery; - } - - /** - * Generates a SurrealQL COUNT query for the model. - * - * @param perspective - The perspective context - * @param query - Query parameters to filter the count - * @returns SurrealQL COUNT query string - * - * @private - */ - public static async countQueryToSurrealQL(perspective: PerspectiveProxy, query: Query): Promise { - // Use the same query as the main query (with GROUP BY), just without LIMIT/OFFSET - // We'll count the number of rows returned (one row per source) - const countQuery = { ...query }; - delete countQuery.limit; - delete countQuery.offset; - return await this.queryToSurrealQL(perspective, countQuery); - } - - /** - * Gets a count of all matching instances. - * - * @param perspective - The perspective to search in - * @param query - Optional query parameters to filter results - * @param useSurrealDB - Whether to use SurrealDB (default: true, 10-100x faster) or Prolog (legacy) - * @returns Total count of matching entities - * - * @example - * ```typescript - * const totalRecipes = await Recipe.count(perspective); - * const activeRecipes = await Recipe.count(perspective, { - * where: { status: "active" } - * }); - * - * // Use Prolog explicitly (legacy) - * const countProlog = await Recipe.count(perspective, {}, false); - * ``` - */ - static async count(perspective: PerspectiveProxy, query: Query = {}, useSurrealDB: boolean = true) { - if (useSurrealDB) { - const surrealQuery = await this.queryToSurrealQL(perspective, query); - const result = await perspective.querySurrealDB(surrealQuery); - // Use instancesFromSurrealResult to apply JS-level filtering for advanced where conditions - // (e.g., gt, gte, lt, lte, between, contains on properties and author/timestamp) - // This ensures count() returns the same number as findAll().length - const { totalCount } = await this.instancesFromSurrealResult(perspective, query, result); - return totalCount; - } else { - const result = await perspective.infer(await this.countQueryToProlog(perspective, query)); - return result?.[0]?.TotalCount || 0; - } - } - - private async setProperty(key: string, value: any, batchId?: string) { - // Phase 1: Use metadata instead of Prolog queries - const metadata = this.getPropertyMetadata(key); - if (!metadata) { - console.warn(`Property "${key}" has no metadata, skipping`); - return; - } - - // Generate actions from metadata (replaces Prolog query) - const actions = this.generatePropertySetterAction(key, metadata); - - // Get resolve language from metadata (replaces Prolog query) - let resolveLanguage = metadata.resolveLanguage; - - // Skip storing empty/null/undefined values to avoid invalid empty literals (e.g. literal://string:) - if (value === undefined || value === null || value === "") { - return; - } - - if (resolveLanguage) { - value = await this.#perspective.createExpression(value, resolveLanguage); - } - - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }], batchId); - } - - private async setCollectionSetter(key: string, value: any, batchId?: string) { - // Phase 1: Use metadata instead of Prolog queries - const metadata = this.getCollectionMetadata(key); - if (!metadata) { - console.warn(`Collection "${key}" has no metadata, skipping`); - return; - } - - // Generate actions from metadata (replaces Prolog query) - const actions = this.generateCollectionAction(key, 'setter'); - - if (value != null) { - if (Array.isArray(value)) { - await this.#perspective.executeAction( - actions, - this.#baseExpression, - value.map((v) => ({ name: "value", value: v })), - batchId - ); - } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }], batchId); - } - } - } - - private async setCollectionAdder(key: string, value: any, batchId?: string) { - // Phase 1: Use metadata instead of Prolog queries - const metadata = this.getCollectionMetadata(key); - if (!metadata) { - console.warn(`Collection "${key}" has no metadata, skipping`); - return; - } - - // Generate actions from metadata (replaces Prolog query) - const actions = this.generateCollectionAction(key, 'adder'); - - if (value != null) { - if (Array.isArray(value)) { - await Promise.all( - value.map((v) => - this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value: v }], batchId) - ) - ); - } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }], batchId); - } - } - } - - private async setCollectionRemover(key: string, value: any, batchId?: string) { - // Phase 1: Use metadata instead of Prolog queries - const metadata = this.getCollectionMetadata(key); - if (!metadata) { - console.warn(`Collection "${key}" has no metadata, skipping`); - return; - } - - // Generate actions from metadata (replaces Prolog query) - const actions = this.generateCollectionAction(key, 'remover'); - - if (value != null) { - if (Array.isArray(value)) { - await Promise.all( - value.map((v) => - this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value: v }], batchId) - ) - ); - } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }], batchId); - } - } - } - +export type ParentOptions = { + /** The parent model class (must have a `@HasMany` pointing to this child type). */ + model: typeof Ad4mModel; + /** The ID of the parent instance. */ + id: string; /** - * Saves the model instance to the perspective. - * Creates a new instance with the base expression and links it to the source. - * - * @param batchId - Optional batch ID for batch operations - * @throws Will throw if instance creation, linking, or updating fails - * - * @example - * ```typescript - * const recipe = new Recipe(perspective); - * recipe.name = "Spaghetti"; - * recipe.ingredients = ["pasta", "tomato sauce"]; - * await recipe.save(); - * - * // Or with batch operations: - * const batchId = await perspective.createBatch(); - * await recipe.save(batchId); - * await perspective.commitBatch(batchId); - * ``` + * The `@HasMany` field on the parent whose predicate is used for the link. + * Optional when exactly one `@HasMany` on the parent points to this child type. */ - async save(batchId?: string) { - // We use createSubject's initialValues to set properties (but not collections) - // We then later use innerUpdate to set collections - - let batchCreatedHere = false; - if(!batchId) { - batchId = await this.perspective.createBatch() - batchCreatedHere = true; - } - - - // First filter out the properties that are not collections (arrays) - const initialValues = {}; - for (const [key, value] of Object.entries(this)) { - if (value !== undefined && value !== null && !(Array.isArray(value) && value.length > 0) && !value?.action) { - initialValues[key] = value; - } - } - - // Get the class name instead of passing the instance to avoid Prolog query generation - const className = await this.perspective.stringOrTemplateObjectToSubjectClassName(this); - - // Create the subject with the initial values - await this.perspective.createSubject( - className, - this.#baseExpression, - initialValues, - batchId - ); + field?: string; +}; +import type { + Query, + ResultsWithTotalCount, + PaginationResult, + ModelMetadata, + IncludeMap, + SubscribeOptions, + Subscription, +} from "./types"; + +// ── JSON Schema factory ──────────────────────────────────────────────────── +import { createModelFromJSONSchema } from "./schema/fromJSONSchema"; +import type { + JSONSchema, + JSONSchemaToModelOptions, +} from "./schema/fromJSONSchema"; +export type { + JSONSchema, + JSONSchemaToModelOptions, + JSONSchemaProperty, // keep re-exporting for external consumers +} from "./schema/fromJSONSchema"; + +// ── Fluent query builder (re-exported for consumers) ────────────────────── +import { ModelQueryBuilder } from "./query/ModelQueryBuilder"; +export { ModelQueryBuilder }; + +// ── SurrealDB query helpers (used internally, also re-exported) ──────────── +export { + buildSurrealQuery, + buildSurrealCountQuery, + formatSurrealValue, + matchesCondition, + buildGraphTraversalWhereClause, +} from "./query/surrealCompiler"; + +// ── Hydration utilities (re-exported for advanced consumers) ────────────── +export { + hydrateInstanceFromLinks, + evaluateCustomGetters, + normalizeTimestamp, +} from "./query/hydration"; +export type { RawLink } from "./query/hydration"; + +// ── Static query operations (each static method below delegates here) ───────── +import * as ops from "./query/operations"; +import { fetchInstanceData } from "./query/fetchInstance"; + +// ── Metadata helpers ──────────────────────────────────────────────────────── +import { getModelMetadata as _getModelMetadata } from "./schema/metadata"; + +// ── Transaction API ────────────────────────────────────────────────────────── +import { runTransaction } from "./transaction"; +export type { TransactionContext } from "./transaction"; +// ── Subscription API ─────────────────────────────────────────────────────────────── +import { createSubscription } from "./subscription"; +/** + * Base class for all AD4M data models. + * + * Instances are subgraphs in a {@link PerspectiveProxy}; properties and relations + * map to typed links. Decorators (`@Property`, `@HasMany`, etc.) declare the schema; + * query helpers (`findAll`, `query`, `subscribe`) run SurrealQL against the + * perspective's local graph engine. + * + * See [README.md](./README.md) for a full worked example and decorator reference. + */ +export class Ad4mModel { + // NOTE: Using TypeScript `private` (not JS `#`) so these properties work through Vue reactive Proxies. + private _id: string; + private _perspective: PerspectiveProxy; + /** Tracks whether save() has ever been called on this instance (even in an uncommitted batch). */ + private _savedOnce: boolean = false; + author: string; + createdAt: any; + updatedAt: any; - // Link the subject to the source - await this.#perspective.add( - new Link({ source: this.#source, predicate: "ad4m://has_child", target: this.baseExpression }), - 'shared', - batchId - ); + private static classNamesByClass = new WeakMap< + typeof Ad4mModel, + { [perspectiveId: string]: string } + >(); - // Set collections - await this.innerUpdate(false, batchId) + static async getClassName(perspective: PerspectiveProxy) { + // Check if this is the Ad4mModel class itself or a subclass + const isBaseClass = this === Ad4mModel; - // If we got a batchId passed in, we let the caller decide when to commit. - // But then we can call getData() since the instance won't exist in the perspective - // until the bacht is committedl - if (batchCreatedHere) { - await this.perspective.commitBatch(batchId) - await this.getData(); + // For the base Ad4mModel class, we can't use the cache + if (isBaseClass) { + return await perspective.stringOrTemplateObjectToSubjectClassName(this); } - } - private cleanCopy() { - const cleanCopy = {}; - const props = Object.entries(this); - for (const [key, value] of props) { - if (value !== undefined && value !== null && key !== "author" && key !== "timestamp") { - cleanCopy[key] = value; - } + // Get or create the cache for this class + let classCache = this.classNamesByClass.get(this); + if (!classCache) { + classCache = {}; + this.classNamesByClass.set(this, classCache); } - return cleanCopy; - } - - private async innerUpdate(setProperties: boolean = true, batchId?: string) { - this.#subjectClassName = await this.#perspective.stringOrTemplateObjectToSubjectClassName(this.cleanCopy()); - const entries = Object.entries(this); - for (const [key, value] of entries) { - if (value !== undefined && value !== null) { - if (value?.action) { - switch (value.action) { - case "setter": - await this.setCollectionSetter(key, value.value, batchId); - break; - case "adder": - await this.setCollectionAdder(key, value.value, batchId); - break; - case "remover": - await this.setCollectionRemover(key, value.value, batchId); - break; - default: - await this.setCollectionSetter(key, value.value, batchId); - break; - } - } else if (Array.isArray(value)) { - // Handle all arrays as collections, including empty ones (which clears the collection) - await this.setCollectionSetter(key, value, batchId); - } else if (value !== undefined && value !== null && value !== "") { - if (setProperties) { - // Check if this is a collection property (has collection metadata) - const collMetadata = this.getCollectionMetadata(key); - if (collMetadata) { - // Skip - it's a collection, not a regular property - continue; - } - await this.setProperty(key, value, batchId); - } - } - } + // Get or create the cached name for this perspective + const perspectiveID = perspective.uuid; + if (!classCache[perspectiveID]) { + classCache[perspectiveID] = + await perspective.stringOrTemplateObjectToSubjectClassName(this); } + + return classCache[perspectiveID]; } /** - * Updates the model instance's properties and collections. - * - * @param batchId - Optional batch ID for batch operations - * @throws Will throw if property setting or collection updates fail - * - * @example - * ```typescript - * const recipe = await Recipe.findAll(perspective)[0]; - * recipe.rating = 5; - * recipe.ingredients.push("garlic"); - * await recipe.update(); - * - * // Or with batch operations: - * const batchId = await perspective.createBatch(); - * await recipe.update(batchId); - * await perspective.commitBatch(batchId); - * ``` + * Backwards compatibility alias for createdAt. + * @deprecated Use createdAt instead. This will be removed in a future version. */ - async update(batchId?: string) { - await this.innerUpdate(true, batchId); - await this.getData(); + get timestamp(): any { + return (this as any).createdAt; + } + + /** Returns the class name, property predicates, and relation predicates from decorators. */ + public static getModelMetadata(): ModelMetadata { + return _getModelMetadata(this); } /** - * Gets the model instance with all properties and collections populated. - * - * @returns The populated model instance - * @throws Will throw if data retrieval fails - * - * @example - * ```typescript - * const recipe = new Recipe(perspective, existingId); - * await recipe.get(); - * console.log(recipe.name, recipe.ingredients); - * ``` + * Installs the SHACL/SDNA subject class in `perspective` (idempotent). + * + * @example `await Promise.all([Post, Comment, Tag].map(M => M.register(perspective)));` */ - async get() { - this.#subjectClassName = await this.#perspective.stringOrTemplateObjectToSubjectClassName(this.cleanCopy()); - - return await this.getData(); + static async register(perspective: PerspectiveProxy): Promise { + await perspective.ensureSDNASubjectClass(this); } /** - * Deletes the model instance from the perspective. - * - * @param batchId - Optional batch ID for batch operations - * @throws Will throw if removal fails - * + * One-shot factory: constructs, assigns `data`, saves, and returns the new instance. + * * @example * ```typescript - * const recipe = await Recipe.findAll(perspective)[0]; - * await recipe.delete(); - * - * // Or with batch operations: - * const batchId = await perspective.createBatch(); - * await recipe.delete(batchId); - * await perspective.commitBatch(batchId); + * const post = await Post.create(perspective, { title: 'Hello', body: 'World' }); * ``` */ - async delete(batchId?: string) { - await this.#perspective.removeSubject(this, this.#baseExpression, batchId); + static async create( + this: new (perspective: PerspectiveProxy) => T, + perspective: PerspectiveProxy, + data: Partial>, + options?: { parent?: ParentOptions; batchId?: string }, + ): Promise { + const instance = new this(perspective); + Object.assign(instance, data); + await instance.save(options?.batchId); + if (options?.parent) { + const { model, id, field } = options.parent; + const predicate = resolveParentPredicate( + model.getModelMetadata(), + this as unknown as new (...args: any[]) => any, + field, + ); + await perspective.addLinks( + [new Link({ source: id, predicate, target: instance.id })], + "shared", + options?.batchId, + ); + } + return instance; } /** - * Creates a query builder for fluent query construction. - * - * @param perspective - The perspective to query - * @param query - Optional initial query parameters - * @returns A new query builder instance - * - * @example + * Fetches the instance, merges `data` into it, then saves. + * + * This is a convenience alternative to the three-step pattern: * ```typescript - * const recipes = await Recipe.query(perspective) - * .where({ category: "Dessert" }) - * .order({ rating: "DESC" }) - * .limit(5) - * .run(); - * - * // With real-time updates - * await Recipe.query(perspective) - * .where({ status: "cooking" }) - * .subscribe(recipes => { - * console.log("Currently cooking:", recipes); - * }); + * // Before + * const instance = new Vote(perspective, id); + * instance.score = newScore; + * await instance.save(); + * + * // After + * await Vote.update(perspective, id, { score: newScore }); * ``` + * + * @param data - Partial set of properties to merge into the stored instance. + * @param batchId - Optional batch identifier for grouping writes. + * @returns The updated instance with all fields populated. */ - static query( - this: typeof Ad4mModel & (new (...args: any[]) => T), - perspective: PerspectiveProxy, - query?: Query - ): ModelQueryBuilder { - return new ModelQueryBuilder(perspective, this as any, query); + static async update( + this: (new (perspective: PerspectiveProxy, id: string) => T) & + typeof Ad4mModel, + perspective: PerspectiveProxy, + id: string, + data: Partial>, + batchId?: string, + ): Promise { + const instance = new this(perspective, id); + await instance.get(); + Object.assign(instance, data); + await instance.save(batchId); + return instance; } /** - * Creates an Ad4mModel class from a JSON Schema definition. - * - * @description - * This method dynamically generates an Ad4mModel subclass from a JSON Schema, - * enabling integration with systems that use JSON Schema for type definitions. - * - * The method follows a cascading approach for determining predicates: - * 1. Explicit configuration in options parameter (highest precedence) - * 2. x-ad4m metadata in the JSON Schema - * 3. Inference from schema title and property names - * 4. Error if no namespace can be determined - * - * @example + * Deletes the instance with the given `id` from the perspective. + * + * This is a convenience alternative to: * ```typescript - * // With explicit configuration - * const PersonClass = Ad4mModel.fromJSONSchema(schema, { - * name: "Person", - * namespace: "person://", - * resolveLanguage: "literal" - * }); - * - * // With property mapping - * const ContactClass = Ad4mModel.fromJSONSchema(schema, { - * name: "Contact", - * namespace: "contact://", - * propertyMapping: { - * "name": "foaf://name", - * "email": "foaf://mbox" - * } - * }); - * - * // With x-ad4m metadata in schema - * const schema = { - * "title": "Product", - * "x-ad4m": { "namespace": "product://" }, - * "properties": { - * "name": { - * "type": "string", - * "x-ad4m": { "through": "product://title" } - * } - * } - * }; - * const ProductClass = Ad4mModel.fromJSONSchema(schema, { name: "Product" }); + * // Before + * const instance = new Poll(perspective, id); + * await instance.delete(); + * + * // After + * await Poll.delete(perspective, id); * ``` - * - * @param schema - JSON Schema definition - * @param options - Configuration options - * @returns Generated Ad4mModel subclass - * @throws Error when namespace cannot be inferred + * + * @param batchId - Optional batch identifier for grouping removals. */ - static fromJSONSchema( - schema: JSONSchema, - options: JSONSchemaToModelOptions - ): typeof Ad4mModel { - // Disallow top-level "author" property since Ad4mModel provides it implicitly via link authorship - if (schema?.properties && Object.prototype.hasOwnProperty.call(schema.properties, "author")) { - throw new Error('JSON Schema must not define a top-level "author" property because Ad4mModel already exposes it. Please rename the property (e.g., "writer").'); - } - // Determine namespace with cascading precedence - const namespace = this.determineNamespace(schema, options); - - // Create the dynamic class - const DynamicModelClass = class extends Ad4mModel {}; - - // Set up class metadata - if (!options.name || options.name.trim() === '') { - throw new Error("options.name is required and cannot be empty"); - } - (DynamicModelClass as any).className = options.name; - (DynamicModelClass.prototype as any).className = options.name; - - // Generate properties and collections metadata - const properties: any = {}; - const collections: any = {}; - - if (schema.properties) { - for (const [propertyName, propertySchema] of Object.entries(schema.properties)) { - const predicate = this.determinePredicate(schema, propertyName, propertySchema, namespace, options); - const isRequired = schema.required?.includes(propertyName) || false; - const propertyType = normalizeSchemaType(propertySchema.type); - const isArray = isArrayType(propertySchema); - - if (isArray) { - // Handle arrays as collections - // Store the singular form as the collection key since SDNA generation expects singular - collections[propertyName] = { - through: predicate, - local: this.getPropertyOption(propertyName, propertySchema, options, 'local') - }; - - // Define the property on prototype - Object.defineProperty(DynamicModelClass.prototype, propertyName, { - configurable: true, - writable: true, - value: [] - }); - - // Add collection methods - const adderName = `add${capitalize(propertyName)}`; - const removerName = `remove${capitalize(propertyName)}`; - const setterName = `setCollection${capitalize(propertyName)}`; - - (DynamicModelClass.prototype as any)[adderName] = function() { - // Placeholder function for SDNA generation - }; - (DynamicModelClass.prototype as any)[removerName] = function() { - // Placeholder function for SDNA generation - }; - (DynamicModelClass.prototype as any)[setterName] = function() { - // Placeholder function for SDNA generation - }; - - } else { - // Handle regular properties - let resolveLanguage = this.getPropertyOption(propertyName, propertySchema, options, 'resolveLanguage'); - // If no specific resolveLanguage for this property, use the global one - if (!resolveLanguage && options.resolveLanguage) { - resolveLanguage = options.resolveLanguage; - } - const local = this.getPropertyOption(propertyName, propertySchema, options, 'local'); - const writable = this.getPropertyOption(propertyName, propertySchema, options, 'writable', true); - let initial = this.getPropertyOption(propertyName, propertySchema, options, 'initial'); - - // Handle nested objects by serializing to JSON - if (isObjectType(propertySchema) && !resolveLanguage) { - resolveLanguage = 'literal'; - console.warn(`Property "${propertyName}" is an object type. It will be stored as JSON. Consider flattening complex objects for better semantic querying.`); - } - - // Ensure numeric properties use literal language for correct typing - if ((resolveLanguage === undefined || resolveLanguage === null) && isNumericType(propertySchema)) { - resolveLanguage = 'literal'; - } - - // If property is required, ensure it has an initial value - if (isRequired && !initial) { - if (isObjectType(propertySchema)) { - initial = 'literal://json:{}'; - } else { - initial = "ad4m://undefined"; - } - } - - properties[propertyName] = { - through: predicate, - required: isRequired, - writable: writable, - ...(resolveLanguage && { resolveLanguage }), - ...(local !== undefined && { local }), - ...(initial && { initial }) - }; - - // Define the property on prototype - Object.defineProperty(DynamicModelClass.prototype, propertyName, { - configurable: true, - writable: true, - value: this.getDefaultValueForType(propertyType) - }); - - // Add setter function if writable - if (writable) { - const setterName = propertyNameToSetterName(propertyName); - (DynamicModelClass.prototype as any)[setterName] = function() { - // This is a placeholder function that the SDNA generation looks for - // The actual setter logic is handled by the Ad4mModel base class - }; - } - } - } - } - - // Validate that at least one property has an initial value (needed for valid SDNA constructor) - // Collections don't create constructor actions, only properties with initial values do - const hasPropertyWithInitial = Object.values(properties).some((prop: any) => prop.initial); - - if (!hasPropertyWithInitial) { - // If no properties have initial values, add a type identifier automatically - const typeProperty = `ad4m://type`; - let typeValue: string; - if (namespace.includes('://')) { - const [scheme, rest] = namespace.split('://'); - const path = (rest || '').replace(/\/+$/,''); - if (path) { - typeValue = `${scheme}://${path}/instance`; - } else { - typeValue = `${scheme}://instance`; - } - } else { - const path = namespace.replace(/\/+$/,''); - typeValue = `${path}/instance`; - } - - properties['__ad4m_type'] = { - through: typeProperty, - required: true, - writable: false, - initial: typeValue, - flag: true - }; - - // Add the type property to the prototype - Object.defineProperty(DynamicModelClass.prototype, '__ad4m_type', { - configurable: true, - writable: false, - value: typeValue - }); - - console.warn(`No properties with initial values found. Added automatic type flag: ${typeProperty} = ${typeValue}`); - } - - // Attach metadata to prototype - (DynamicModelClass.prototype as any).__properties = properties; - (DynamicModelClass.prototype as any).__collections = collections; - - // Store the JSON schema and options on the prototype for potential fallback use by getModelMetadata() - (DynamicModelClass.prototype as any).__jsonSchema = schema; - (DynamicModelClass.prototype as any).__jsonSchemaOptions = options; - - // Apply the ModelOptions decorator to set up the generateSDNA method - const ModelOptionsDecorator = ModelOptions({ name: options.name }); - ModelOptionsDecorator(DynamicModelClass); - - return DynamicModelClass as typeof Ad4mModel; + static async delete( + perspective: PerspectiveProxy, + id: string, + batchId?: string, + ): Promise { + const instance = new this(perspective, id); + await instance.delete(batchId); } - + /** - * Determines the namespace for predicates using cascading precedence + * Generates the SHACL shape for this model class. + * Attached dynamically by the `@Model` decorator. */ - private static determineNamespace(schema: JSONSchema, options: JSONSchemaToModelOptions): string { - // 1. Explicit namespace in options (highest precedence) - if (options.namespace) { - return options.namespace; - } - - // 2. x-ad4m metadata in schema - if (schema["x-ad4m"]?.namespace) { - return schema["x-ad4m"].namespace; - } - - // 3. Infer from schema title - if (schema.title) { - return `${schema.title.toLowerCase()}://`; - } - - // 4. Try to extract from $id if it's a URL - if (schema.$id) { - try { - const url = new URL(schema.$id); - const pathParts = url.pathname.split('/').filter(p => p); - if (pathParts.length > 0) { - const lastPart = pathParts[pathParts.length - 1]; - const baseName = lastPart.replace(/\.schema\.json$/, '').replace(/\.json$/, ''); - return `${baseName.toLowerCase()}://`; - } - } catch { - // If $id is not a valid URL, continue to error - } - } - - // 5. Error if no namespace can be determined + static generateSHACL(): { shape: SHACLShape; name: string } { throw new Error( - `Cannot infer namespace for JSON Schema. Please provide one of: - - options.namespace - - schema["x-ad4m"].namespace - - schema.title - - valid schema.$id` + "generateSHACL() is only available on classes decorated with @Model", ); } - - /** - * Determines the predicate for a specific property using cascading precedence - */ - private static determinePredicate( - schema: JSONSchema, - propertyName: string, - propertySchema: JSONSchemaProperty, - namespace: string, - options: JSONSchemaToModelOptions - ): string { - // 1. Explicit property mapping (highest precedence) - if (options.propertyMapping?.[propertyName]) { - return options.propertyMapping[propertyName]; - } - - // 2. x-ad4m metadata in property schema - if (propertySchema["x-ad4m"]?.through) { - return propertySchema["x-ad4m"].through; - } - - // 3. Generate from namespace + property name - if (options.predicateTemplate) { - const normalizedNs = normalizeNamespaceString(namespace); - const [scheme, rest] = normalizedNs.includes('://') ? normalizedNs.split('://') : ['', normalizedNs]; - const nsNoScheme = rest || ''; - return options.predicateTemplate - .replace('${namespace}', nsNoScheme) - .replace('${scheme}', scheme) - .replace('${ns}', nsNoScheme) - .replace('${title}', schema.title || '') - .replace('${property}', propertyName); - } - - // 4. Custom predicate generator - if (options.predicateGenerator) { - return options.predicateGenerator(schema.title || '', propertyName); - } - - // 5. Default: namespace + property name - const normalizedNs = normalizeNamespaceString(namespace); - if (normalizedNs.includes('://')) { - // For namespaces like "product://", append property directly - return `${normalizedNs}${propertyName}`; - } else { - return `${normalizedNs}://${propertyName}`; - } - } - - /** - * Gets property-specific options using cascading precedence - */ - private static getPropertyOption( - propertyName: string, - propertySchema: JSONSchemaProperty, - options: JSONSchemaToModelOptions, - optionName: keyof PropertyOptions, - defaultValue?: any - ): any { - // 1. Property-specific options - if (options.propertyOptions?.[propertyName]?.[optionName] !== undefined) { - return options.propertyOptions[propertyName][optionName]; - } - - // 2. x-ad4m metadata in property - if (propertySchema["x-ad4m"]?.[optionName as keyof JSONSchemaProperty["x-ad4m"]] !== undefined) { - return propertySchema["x-ad4m"][optionName as keyof JSONSchemaProperty["x-ad4m"]]; - } - - // 3. Global option - if (options[optionName as keyof JSONSchemaToModelOptions] !== undefined) { - return options[optionName as keyof JSONSchemaToModelOptions]; + + /** @param id - Auto-generated from a random literal if omitted. */ + constructor(perspective: PerspectiveProxy, id?: string) { + this._id = id ? id : Literal.from(makeRandomId(24)).toUrl(); + this._perspective = perspective; + + // Wire up real relation adder/remover/setter methods for decorator-based classes. + // The @HasMany / @HasOne decorators place empty stubs on the prototype at class-definition + // time (e.g. `addLocations = () => {}`). Here, at instance-creation time, we replace each + // stub with a closure that actually calls the private implementation so that callers like + // `instance.addLocations(value)` persist the link in the perspective. + const proto = Object.getPrototypeOf(this); + const relations: Record = getRelationsMetadata( + proto.constructor, + ); + for (const key of Object.keys(relations)) { + // Reverse relations (@BelongsToOne / @BelongsToMany) are read-only traversals — + // the link is owned by the other side, so no mutator methods should exist here. + if (relations[key].direction === "reverse") continue; + + const cap = capitalize(key); + this[`add${cap}`] = (value: any, batchId?: string) => + mutation.setRelationAdder(this._mutationContext(), key, value, batchId); + this[`remove${cap}`] = (value: any, batchId?: string) => + mutation.setRelationRemover( + this._mutationContext(), + key, + value, + batchId, + ); + this[`set${cap}`] = (value: any, batchId?: string) => + mutation.setRelationSetter( + this._mutationContext(), + key, + value, + batchId, + ); } - - // 4. Default value - return defaultValue; } - + /** - * Gets default value for a JSON Schema type + * The unique identifier (base expression URI) of this instance. */ - private static getDefaultValueForType(type?: string): any { - switch (type) { - case 'string': return ''; - case 'number': return 0; - case 'integer': return 0; - case 'boolean': return false; - case 'array': return []; - case 'object': return {}; - default: return ''; - } + get id() { + return this._id; } -} -/** Query builder for Ad4mModel queries. - * Allows building queries with a fluent interface and either running them once - * or subscribing to updates. - * - * @example - * ```typescript - * const builder = Recipe.query(perspective) - * .where({ category: "Dessert" }) - * .order({ rating: "DESC" }) - * .limit(10); - * - * // Run once - * const recipes = await builder.run(); - * - * // Or subscribe to updates - * await builder.subscribe(recipes => { - * console.log("Updated recipes:", recipes); - * }); - * ``` - */ -export class ModelQueryBuilder { - private perspective: PerspectiveProxy; - private queryParams: Query = {}; - private modelClassName: string | null = null; - private ctor: typeof Ad4mModel; - private currentSubscription?: any; - private useSurrealDBFlag: boolean = true; + /** Read-only perspective access for subclasses. */ + protected get perspective(): PerspectiveProxy { + return this._perspective; + } - constructor(perspective: PerspectiveProxy, ctor: typeof Ad4mModel, query?: Query) { - this.perspective = perspective; - this.ctor = ctor; - if (query) this.queryParams = query; + /** Builds the context object for all mutation functions. */ + private _mutationContext(): mutation.MutationContext { + return { + perspective: this._perspective, + id: this._id, + instance: this, + }; } /** - * Disposes of the current subscription if one exists. - * - * This method: - * 1. Stops the keepalive signals to the subscription - * 2. Unsubscribes from GraphQL subscription updates - * 3. Notifies the backend to clean up subscription resources - * 4. Clears the subscription reference - * - * You should call this method when you're done with a subscription - * to prevent memory leaks and ensure proper cleanup. + * Generates a SurrealQL query string for this model. + * + * @param perspective - The perspective context + * @param query - Query parameters (where, order, limit, offset, properties, relations) + * @returns Complete SurrealQL query string ready for execution */ - dispose() { - if (this.currentSubscription) { - this.currentSubscription.dispose(); - this.currentSubscription = undefined; - } + public static async queryToSurrealQL( + perspective: PerspectiveProxy, + query: Query, + ): Promise { + return ops.queryToSurrealQL(this as any, perspective, query); } - /** - * Adds where conditions to the query. - * - * @param conditions - The conditions to filter by - * @returns The query builder for chaining - * - * @example - * ```typescript - * .where({ - * category: "Dessert", - * rating: { gt: 4 }, - * tags: ["vegan", "quick"], - * published: true - * }) - * ``` - */ - where(conditions: Where): ModelQueryBuilder { - this.queryParams.where = conditions; - return this; + /** @internal */ + public static async instancesFromSurrealResult( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + query: Query, + result: any[], + _hydrateRelations = true, + ): Promise> { + return ops.instancesFromSurrealResult( + this as any, + perspective, + query, + result, + _hydrateRelations, + ); } /** - * Sets the order for the query results. - * - * @param orderBy - The ordering criteria - * @returns The query builder for chaining - * - * @example - * ```typescript - * .order({ createdAt: "DESC" }) - * ``` + * Internal implementation used by findAll and eager relation hydration. + * Pass `_hydrateRelations = false` to prevent recursive model hydration (depth guard). */ - order(orderBy: Order): ModelQueryBuilder { - this.queryParams.order = orderBy; - return this; + static async _findAllInternal( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + query: Query = {}, + _hydrateRelations = true, + ): Promise { + return ops._findAllInternal( + this as any, + perspective, + query, + _hydrateRelations, + ); } /** - * Sets the maximum number of results to return. - * - * @param limit - Maximum number of results - * @returns The query builder for chaining - * + * Returns all instances matching `query`. + * * @example * ```typescript - * .limit(10) + * const recipes = await Recipe.findAll(perspective, { + * where: { rating: { gt: 4 } }, order: { createdAt: "DESC" }, limit: 10, + * }); * ``` */ - limit(limit: number): ModelQueryBuilder { - this.queryParams.limit = limit; - return this; + static async findAll( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + query: Query = {}, + ): Promise { + return ops.findAll(this as any, perspective, query); } /** - * Sets the number of results to skip. - * - * @param offset - Number of results to skip - * @returns The query builder for chaining - * + * Returns the first matching instance, or `null` if none found. + * * @example * ```typescript - * .offset(20) // Skip first 20 results + * const post = await TestPost.findOne(perspective, { where: { id: someId } }); * ``` */ - offset(offset: number): ModelQueryBuilder { - this.queryParams.offset = offset; - return this; + static async findOne( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + query: Query = {}, + ): Promise { + return ops.findOne(this as any, perspective, query); } /** - * Sets the source filter for the query. - * - * @param source - The source to filter by - * @returns The query builder for chaining - * + * Like `findAll` but also returns the unfiltered total count (useful for pagination UI). + * * @example * ```typescript - * .source("ad4m://self") + * const { results, totalCount } = await Recipe.findAllAndCount(perspective, { limit: 10 }); + * console.log(`Showing ${results.length} of ${totalCount}`); * ``` */ - source(source: string): ModelQueryBuilder { - this.queryParams.source = source; - return this; + static async findAllAndCount( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + query: Query = {}, + ): Promise> { + return ops.findAllAndCount(this as any, perspective, query); } /** - * Specifies which properties to include in the results. - * - * @param properties - Array of property names to include - * @returns The query builder for chaining - * + * Fetches a single page of results (`pageNumber` is 1-based). + * * @example * ```typescript - * .properties(["name", "description", "rating"]) + * const page = await Recipe.paginate(perspective, 10, 1); + * console.log(`Page ${page.pageNumber}, ${page.results.length} items`); * ``` */ - properties(properties: string[]): ModelQueryBuilder { - this.queryParams.properties = properties; - return this; + static async paginate( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + pageSize: number, + pageNumber: number, + query?: Query, + ): Promise> { + return ops.paginate( + this as any, + perspective, + pageSize, + pageNumber, + query ?? {}, + ); } /** - * Specifies which collections to include in the results. - * - * @param collections - Array of collection names to include - * @returns The query builder for chaining - * - * @example - * ```typescript - * .collections(["ingredients", "steps"]) - * ``` + * Generates a SurrealQL COUNT query for this model. + * @private */ - collections(collections: string[]): ModelQueryBuilder { - this.queryParams.collections = collections; - return this; + public static async countQueryToSurrealQL( + perspective: PerspectiveProxy, + query: Query, + ): Promise { + return ops.countQueryToSurrealQL(this as any, perspective, query); } - overrideModelClassName(className: string): ModelQueryBuilder { - this.modelClassName = className; - return this; + /** Returns the count of instances matching `query`. */ + static async count(perspective: PerspectiveProxy, query: Query = {}) { + return ops.count(this as any, perspective, query); } /** - * Enables or disables SurrealDB query path. - * - * @param enabled - Whether to use SurrealDB (default: true, 10-100x faster) or Prolog (legacy) - * @returns The query builder for chaining - * + * Persists the instance (create if new, update if existing). + * + * @param batchId - When provided the caller must call `perspective.commitBatch(batchId)` + * * @example * ```typescript - * // Use SurrealDB (default) - * const recipes = await Recipe.query(perspective) - * .where({ category: "Dessert" }) - * .useSurrealDB(true) - * .get(); - * - * // Use Prolog (legacy) - * const recipesProlog = await Recipe.query(perspective) - * .where({ category: "Dessert" }) - * .useSurrealDB(false) - * .get(); + * const recipe = new Recipe(perspective); + * recipe.name = "Spaghetti"; + * await recipe.save(); // create + * recipe.name = "Bolognese"; + * await recipe.save(); // update (detected automatically) * ``` - * - * @remarks - * Note: Subscriptions (subscribe(), countSubscribe(), paginateSubscribe()) default to SurrealDB live queries - * if useSurrealDB(true) is set (default). */ - useSurrealDB(enabled: boolean = true): ModelQueryBuilder { - this.useSurrealDBFlag = enabled; - return this; + async save(batchId?: string) { + await mutation.saveInstance( + this._mutationContext(), + batchId, + this._savedOnce, + ); + this._savedOnce = true; } /** - * Executes the query once and returns the results. - * - * @returns Array of matching entities - * + * Gets the model instance with all properties and relations populated. + * + * @returns The populated model instance + * @throws Will throw if data retrieval fails + * * @example * ```typescript - * const recipes = await Recipe.query(perspective) - * .where({ category: "Dessert" }) - * .get(); + * const recipe = new Recipe(perspective, existingId); + * await recipe.get(); + * console.log(recipe.name, recipe.ingredients); * ``` */ - async get(): Promise { - if (this.useSurrealDBFlag) { - const surrealQuery = await this.ctor.queryToSurrealQL(this.perspective, this.queryParams); - const result = await this.perspective.querySurrealDB(surrealQuery); - const { results } = await this.ctor.instancesFromSurrealResult(this.perspective, this.queryParams, result); - return results as T[]; - } else { - const query = await this.ctor.queryToProlog(this.perspective, this.queryParams, this.modelClassName); - const result = await this.perspective.infer(query); - const { results } = await this.ctor.instancesFromPrologResult(this.perspective, this.queryParams, result); - return results as T[]; - } + async get(include?: IncludeMap): Promise { + const metadata = (this.constructor as typeof Ad4mModel).getModelMetadata(); + return fetchInstanceData( + this, + this._perspective, + this._id, + metadata, + include, + ); } /** - * Subscribes to the query and receives updates when results change. - * - * This method: - * 1. Creates and initializes a SurrealDB live query subscription (default) - * 2. Sets up the callback to process future updates - * 3. Returns the initial results immediately - * - * Remember to call dispose() when you're done with the subscription - * to clean up resources. - * - * @param callback - Function to call with updated results - * @returns Initial results array + * Removes this instance from the perspective, including all incoming links + * that point to it (e.g. parent→child membership links created by + * `create({ parent: ... })`). This ensures that `findAll({ parent: ... })` + * will no longer return this instance after deletion. * * @example - * ```typescript - * const builder = Recipe.query(perspective) - * .where({ status: "cooking" }); - * - * const initialRecipes = await builder.subscribe(recipes => { - * console.log("Updated recipes:", recipes); - * }); - * - * // When done with subscription: - * builder.dispose(); - * ``` + * await vote.delete(); * - * @remarks - * By default, this uses SurrealDB live queries for real-time updates. - * Prolog subscriptions remain available via `.useSurrealDB(false)`. + * @param batchId - Optional batch identifier for grouping removals. */ - async subscribe(callback: (results: T[]) => void): Promise { - // Clean up any existing subscription - this.dispose(); - - if (this.useSurrealDBFlag) { - const surrealQuery = await this.ctor.queryToSurrealQL(this.perspective, this.queryParams); - this.currentSubscription = await this.perspective.subscribeSurrealDB(surrealQuery); - - const processResults = async (result: any) => { - // The result from live query subscription update (handled in PerspectiveInstance listener) - // is the new full set of results (because we re-query in Rust). - // So we just need to map it to instances. - const { results } = await this.ctor.instancesFromSurrealResult(this.perspective, this.queryParams, result); - callback(results as T[]); - }; - - this.currentSubscription.onResult(processResults); - - // Process initial result - const { results } = await this.ctor.instancesFromSurrealResult( - this.perspective, - this.queryParams, - this.currentSubscription.result - ); - return results as T[]; - } else { - // Note: Subscriptions currently only work with Prolog - const query = await this.ctor.queryToProlog(this.perspective, this.queryParams, this.modelClassName); - this.currentSubscription = await this.perspective.subscribeInfer(query); - - const processResults = async (result: AllInstancesResult) => { - const { results } = await this.ctor.instancesFromPrologResult(this.perspective, this.queryParams, result); - callback(results as T[]); - }; - - this.currentSubscription.onResult(processResults); - const { results } = await this.ctor.instancesFromPrologResult( - this.perspective, - this.queryParams, - this.currentSubscription.result - ); - return results as T[]; + async delete(batchId?: string) { + // Remove all incoming links that point to this instance so that parent + // collections (parent queries) are kept consistent. + const incomingLinks = await this._perspective.get( + new LinkQuery({ target: this._id }), + ); + for (const link of incomingLinks) { + await this._perspective.remove(link); } + + await this._perspective.removeSubject(this, this._id, batchId); } /** - * Gets the total count of matching entities. - * - * @returns Total count - * + * Returns a fluent query builder for this model. + * * @example * ```typescript - * const totalDesserts = await Recipe.query(perspective) + * const top5 = await Recipe.query(perspective) * .where({ category: "Dessert" }) - * .count(); + * .order({ rating: "DESC" }) + * .limit(5) + * .run(); * ``` */ - async count(): Promise { - if (this.useSurrealDBFlag) { - const surrealQuery = await this.ctor.queryToSurrealQL(this.perspective, this.queryParams); - const result = await this.perspective.querySurrealDB(surrealQuery); - // Use instancesFromSurrealResult to apply JS-level filtering for advanced where conditions - // (e.g., gt, gte, lt, lte, between, contains on properties and author/timestamp) - // This ensures count() returns the same number as get().length - const { totalCount } = await this.ctor.instancesFromSurrealResult(this.perspective, this.queryParams, result); - return totalCount; - } else { - const query = await this.ctor.countQueryToProlog(this.perspective, this.queryParams, this.modelClassName); - const result = await this.perspective.infer(query); - return result?.[0]?.TotalCount || 0; - } + static query( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + query?: Query, + ): ModelQueryBuilder { + return new ModelQueryBuilder(perspective, this as any, query); } /** - * Subscribes to count updates for matching entities. - * - * This method: - * 1. Creates and initializes a SurrealDB live query subscription for the count (default) - * 2. Sets up the callback to process future count updates - * 3. Returns the initial count immediately - * - * Remember to call dispose() when you're done with the subscription - * to clean up resources. - * - * @param callback - Function to call with updated count - * @returns Initial count + * Runs `callback` in an atomic batch; commits on success, aborts + rethrows on failure. * * @example * ```typescript - * const builder = Recipe.query(perspective) - * .where({ status: "active" }); - * - * const initialCount = await builder.countSubscribe(count => { - * console.log("Active items:", count); + * await Ad4mModel.transaction(perspective, async (tx) => { + * await post.save(tx.batchId); + * await comment.save(tx.batchId); * }); - * - * // When done with subscription: - * builder.dispose(); * ``` - * - * @remarks - * By default, this uses SurrealDB live queries for real-time updates. - * Prolog subscriptions remain available via `.useSurrealDB(false)`. */ - async countSubscribe(callback: (count: number) => void): Promise { - // Clean up any existing subscription - this.dispose(); - - if (this.useSurrealDBFlag) { - const surrealQuery = await this.ctor.queryToSurrealQL(this.perspective, this.queryParams); - this.currentSubscription = await this.perspective.subscribeSurrealDB(surrealQuery); - - const processResults = async (result: any) => { - const { totalCount } = await this.ctor.instancesFromSurrealResult(this.perspective, this.queryParams, result); - callback(totalCount); - }; - - this.currentSubscription.onResult(processResults); - const { totalCount } = await this.ctor.instancesFromSurrealResult( - this.perspective, - this.queryParams, - this.currentSubscription.result - ); - return totalCount; - } else { - const query = await this.ctor.countQueryToProlog(this.perspective, this.queryParams, this.modelClassName); - this.currentSubscription = await this.perspective.subscribeInfer(query); - - const processResults = async (result: any) => { - const newCount = result?.[0]?.TotalCount || 0; - callback(newCount); - }; - - this.currentSubscription.onResult(processResults); - return this.currentSubscription.result?.[0]?.TotalCount || 0; - } + static async transaction( + perspective: PerspectiveProxy, + callback: (tx: import("./transaction").TransactionContext) => Promise, + ): Promise { + return runTransaction(perspective, callback); } /** - * Gets a page of results with pagination metadata. - * - * @param pageSize - Number of items per page - * @param pageNumber - Which page to retrieve (1-based) - * @returns Paginated results with metadata - * + * Fires `callback` immediately with current results, then on every relevant perspective change. + * + * @param options - Query params plus optional `debounce` (ms) and `onError` handler + * @param callback - Receives the fresh result set on each delivery + * @returns `Subscription` with `unsubscribe()` — call it in cleanup to avoid leaks + * * @example * ```typescript - * const page = await Recipe.query(perspective) - * .where({ category: "Main" }) - * .paginate(10, 1); - * console.log(`Page ${page.pageNumber}, ${page.results.length} of ${page.totalCount}`); + * const sub = Post.subscribe( + * perspective, + * { where: { published: true }, debounce: 300 }, + * (posts) => setPosts(posts), + * ); + * sub.unsubscribe(); // in cleanup * ``` */ - async paginate(pageSize: number, pageNumber: number): Promise> { - const paginationQuery = { ...(this.queryParams || {}), limit: pageSize, offset: pageSize * (pageNumber - 1), count: true }; - if (this.useSurrealDBFlag) { - const surrealQuery = await this.ctor.queryToSurrealQL(this.perspective, paginationQuery); - const result = await this.perspective.querySurrealDB(surrealQuery); - const { results, totalCount } = (await this.ctor.instancesFromSurrealResult(this.perspective, paginationQuery, result)) as ResultsWithTotalCount; - return { results, totalCount, pageSize, pageNumber }; - } else { - const prologQuery = await this.ctor.queryToProlog(this.perspective, paginationQuery, this.modelClassName); - const result = await this.perspective.infer(prologQuery); - const { results, totalCount } = (await this.ctor.instancesFromPrologResult(this.perspective, paginationQuery, result)) as ResultsWithTotalCount; - return { results, totalCount, pageSize, pageNumber }; - } + static subscribe( + this: typeof Ad4mModel & (new (...args: any[]) => T), + perspective: PerspectiveProxy, + options: SubscribeOptions, + callback: (results: T[]) => void, + ): Subscription { + return createSubscription( + (p, q) => this.findAll(p, q ?? {}), + () => this.getModelMetadata(), + perspective, + options, + callback, + this as any, + ); } /** - * Subscribes to paginated results updates. - * - * This method: - * 1. Creates and initializes a SurrealDB live query subscription for the paginated results (default) - * 2. Sets up the callback to process future page updates - * 3. Returns the initial page immediately - * - * Remember to call dispose() when you're done with the subscription - * to clean up resources. + * Generates an `Ad4mModel` subclass from a JSON Schema definition. * - * @param pageSize - Number of items per page - * @param pageNumber - Which page to retrieve (1-based) - * @param callback - Function to call with updated pagination results - * @returns Initial pagination results + * Predicate resolution order: explicit options → `x-ad4m` in schema → inferred from title/names. * * @example * ```typescript - * const builder = Recipe.query(perspective) - * .where({ category: "Main" }); - * - * const initialPage = await builder.paginateSubscribe(10, 1, page => { - * console.log("Updated page:", page.results); + * const PersonClass = Ad4mModel.fromJSONSchema(schema, { + * name: "Person", namespace: "person://", resolveLanguage: "literal", * }); - * - * // When done with subscription: - * builder.dispose(); * ``` - * - * @remarks - * By default, this uses SurrealDB live queries for real-time updates. - * Prolog subscriptions remain available via `.useSurrealDB(false)`. */ - async paginateSubscribe( - pageSize: number, - pageNumber: number, - callback: (results: PaginationResult) => void - ): Promise> { - // Clean up any existing subscription - this.dispose(); - - const paginationQuery = { ...(this.queryParams || {}), limit: pageSize, offset: pageSize * (pageNumber - 1), count: true }; - - if (this.useSurrealDBFlag) { - const surrealQuery = await this.ctor.queryToSurrealQL(this.perspective, paginationQuery); - this.currentSubscription = await this.perspective.subscribeSurrealDB(surrealQuery); - - const processResults = async (result: any) => { - const { results, totalCount } = (await this.ctor.instancesFromSurrealResult(this.perspective, paginationQuery, result)) as ResultsWithTotalCount; - callback({ results, totalCount, pageSize, pageNumber }); - }; - - this.currentSubscription.onResult(processResults); - const { results, totalCount } = (await this.ctor.instancesFromSurrealResult(this.perspective, paginationQuery, this.currentSubscription.result)) as ResultsWithTotalCount; - return { results, totalCount, pageSize, pageNumber }; - } else { - const prologQuery = await this.ctor.queryToProlog(this.perspective, paginationQuery, this.modelClassName); - this.currentSubscription = await this.perspective.subscribeInfer(prologQuery); - - const processResults = async (r: AllInstancesResult) => { - const { results, totalCount } = (await this.ctor.instancesFromPrologResult(this.perspective, this.queryParams, r)) as ResultsWithTotalCount; - callback({ results, totalCount, pageSize, pageNumber }); - }; - - this.currentSubscription.onResult(processResults); - const { results, totalCount } = (await this.ctor.instancesFromPrologResult(this.perspective, paginationQuery, this.currentSubscription.result)) as ResultsWithTotalCount; - return { results, totalCount, pageSize, pageNumber }; - } + static fromJSONSchema( + schema: JSONSchema, + options: JSONSchemaToModelOptions, + ): typeof Ad4mModel { + return createModelFromJSONSchema(this, schema, options) as typeof Ad4mModel; } } diff --git a/core/src/model/IMPROVEMENTS.md b/core/src/model/IMPROVEMENTS.md new file mode 100644 index 000000000..9c4c19dd7 --- /dev/null +++ b/core/src/model/IMPROVEMENTS.md @@ -0,0 +1,482 @@ +# Ad4mModel — Improvement Backlog + +Identified gaps and improvement areas for future PRs. + +--- + +## 1. Dirty Tracking (Unit of Work) + +**Problem:** Every `save()` writes all properties unconditionally. If a model has 20 properties and only one changes, all 20 setter actions are executed, creating unnecessary link churn and network noise. + +**Solution:** Maintain a `#dirtyFields: Set` on each instance. Intercept property assignments (via `Proxy` or setter generation in `Property()`) to mark fields dirty. `save()` only writes dirty fields; after commit the set is cleared. + +**Prior art:** TypeORM Unit of Work, MikroORM identity map. + +--- + +## 2. SurrealDB-Side Range Filter Push-Down + +**Problem:** Comparison operators (`gt`, `lt`, `gte`, `lte`, `between`, `contains`) on scalar properties fall through to post-query JavaScript filtering. Because scalar values are stored as `literal://` URIs, SurrealDB cannot index them for range comparisons without `fn::parse_literal()` unwrapping. This means `findAll(p, { where: { rating: { gt: 4 } } })` fetches **all** instances and filters in JS — a full table scan at scale. + +**Options:** + +- Store numeric/boolean values in a SurrealDB-native way (separate `value` field alongside the `literal://` URI) so the DB can index them directly. +- Define SurrealDB computed fields that cache the unwrapped scalar value, then filter against the computed field. +- Use `fn::parse_literal(out.uri)` inside the graph traversal WHERE clause directly (requires SurrealDB to evaluate the function per-link, no index — but still better than JS filtering). + +--- + +## 3. N+1 Prevention for Typed Relation Hydration + +**Problem:** When `relatedModel` is set on a `@HasMany`, hydrating relations calls `_findAllInternal` once per parent instance. 10 posts × 5 comments each = 10 separate `findAll()` queries. No batching exists. + +**Solution:** In `hydrateInstanceFromLinks` (or a post-hydration pass in `operations.ts`), collect all relation IDs across the entire result set, then run a single batched `findAll({ where: { base: [...ids] } })` for each related model type. Equivalent to a DataLoader / SQL `IN (...)` pattern. + +--- + +## 4. Runtime Validation + +**Problem:** There is no validation layer. Setting `recipe.rating = "not a number"` silently persists a type-incorrect value. Errors surface only later (if at all) during hydration. + +**Solution:** Add an optional `validate` option to `@Property` accepting a predicate function or a class-validator-style decorator. Add a `validate()` method to `Ad4mModel` that checks all properties before `save()`. Optionally throw by default or expose errors as a return value. + +```typescript +@Property({ through: "recipe://rating", validate: (v) => typeof v === "number" && v >= 0 }) +rating: number = 0; +``` + +--- + +## 5. TypeScript Type Inference for `fromJSONSchema` + +**Problem:** `fromJSONSchema` returns `typeof Ad4mModel` — TypeScript has no knowledge of the generated class's property types. Autocomplete and type-checking are completely lost for dynamically generated models. + +**Options:** + +- A type-level schema builder (fluent API returning typed class) that preserves inference without codegen: + ```typescript + const Recipe = Ad4mModel.define({ + name: field({ through: "recipe://name" }), + rating: field({ through: "recipe://rating" }), + }); + // Recipe is typed: { name: string; rating: number } + ``` +- Code generation from JSON Schema (Prisma-style): a CLI command that produces a `.ts` file with a properly typed class from a `.json` schema file. + +--- + +## 6. Robust Create-vs-Update Detection + +**Problem:** `#savedOnce` tracks whether `save()` was called on the current JS instance. Constructing a model wrapper around a known existing ID (`new Recipe(perspective, existingId)`) starts with `#savedOnce = false`, so the first `save()` runs the create path (calling `createSubject`), which may conflict with the already-existing subject. + +**Solution:** Before the first `save()` on an instance constructed with an explicit `id`, check the perspective for the subject's actual existence (e.g. `isSubjectInstance`). Cache the result so subsequent saves don't pay the round-trip cost. + +--- + +## 7. Stage 3 Decorator Migration + +**Problem:** The decorator system uses TypeScript's legacy `experimentalDecorators` (Stage 2) format. TypeScript 5.x introduced Stage 3 decorators with different metadata semantics (`context` object instead of `target`/`key`). The current code will need migration when Stage 3 becomes the ecosystem default. + +**Action:** Track TC39 Stage 3 stabilisation. When `reflect-metadata` is no longer required by major libraries, plan a migration. The `WeakMap`-keyed registry (`propertyRegistry`, `relationRegistry`) already decouples metadata storage from the decorator protocol, so migration should be largely mechanical — change decorator signatures, keep the registry logic intact. + +--- + +## 8. `getter` Option — N+1 Footgun + +**Problem:** `@Property({ getter: "..." })` and `@HasMany({ getter: "..." })` allow arbitrary SurrealQL expressions to be evaluated per-instance after the main link fetch. This fires one additional `querySurrealDB` call _per instance_ in `evaluateCustomGetters()` — a guaranteed N+1 on every `findAll`. + +**Distinction from `transform`:** `transform` operates on a value already resolved from a link triple (sync, no extra query). `getter` is for properties with **no backing link triple** — values computed entirely from other nodes' data (e.g. `array::len(string::split(...))` aggregations). It covers ground `transform` cannot, so it cannot simply be removed. + +**Additional limitations:** + +- `getter` properties are invisible to `where` clauses (skipped in `hydrateInstanceFromLinks`) — `findAll(p, { where: { wordCount: { gt: 100 } } })` silently ignores the filter. +- Not subscribed correctly — every subscription re-query re-fires all getter calls. +- Not represented in the SHACL shape — the Rust executor has no knowledge of it. + +**Proper fix:** Emit getter expressions as SurrealDB computed fields in the SHACL shape (see item #2), so they are evaluated server-side inside the bulk query rather than in a separate per-instance round-trip. Until that exists, `getter` is a necessary escape hatch but should be used only for single-instance `.find()` calls, never in `findAll`. + +**Action:** Add a dev-time `console.warn` when `getter` is present without `readOnly: true`, reinforcing that it is not a writable/filterable property. Document the N+1 caveat clearly. + +--- + +## 9. Abort / Rollback for Transactions + +**Problem:** `runTransaction` catches errors and logs a debug message, but there is no explicit `abortBatch()` on `PerspectiveProxy`. The uncommitted batch is silently discarded by the runtime GC. This is invisible to the caller and relies on runtime-level cleanup. + +**Solution:** If `PerspectiveProxy` exposes an `abortBatch(id)` call (or can be extended to), call it explicitly in the `catch` block of `runTransaction` for predictable, immediate cleanup rather than relying on GC timing. + +--- + +## 10. tests/js Integration Test Migration + +**Background:** The `ad4m/tests/js` Mocha suite is the correct long-term home for all `Ad4mModel` integration tests. The target file is `tests/js/tests/model-decorator-api.test.ts`. Currently scenario 08 (14 tests) lives in the `we` playground. The `tests/js` suite imports deprecated symbols and needs a cleanup pass before new tests can land cleanly. + +**Action:** + +1. Fix deprecated imports in `prolog-and-literals.test.ts` and `multi-user-simple.test.ts` +2. Establish consistent port allocation and setup pattern across the suite +3. Port scenario 08 models + all current tests into `model-decorator-api.test.ts` +4. Add subscription lifecycle tests (Phase 3d coverage at executor level) +5. Write remaining scenarios (01–07, 09–10) directly in `tests/js` + +**Priority:** Post-merge. Blocking a dedicated follow-up PR. + +--- + +## 11. CRDT Ordering (Phase 5) + +**Background:** Concurrent link writes from multiple agents can produce non-deterministic ordering — last-writer-wins SurrealDB timestamp semantics may not match causal intent in a distributed graph. + +**Goals:** + +- Make P2P-safe ordering the default, not an afterthought +- Eliminate boilerplate across all future list-based plugins +- Provide clear migration path as algorithms improve +- Enable A/B testing of different strategies + +--- + +### Developer-Facing API + +```typescript +@HasMany(() => Task, { + through: 'ad4m://has_child', + ordering: { + strategy: 'linkedList', // 'linkedList' | 'fractionalIndex' | 'timestamp' | 'manual' + sortBy: 'taskName', // Fallback for ties (optional) + } +}) +orderedTasks: Task[] = []; +``` + +Developers use normal array operations — the framework generates CRDT links: + +```typescript +column.orderedTasks.push(newTask); // Append +column.orderedTasks.splice(2, 0, task); // Insert at index 2 +column.orderedTasks.splice(1, 1); // Remove at index 1 +await column.update(); // Framework generates CRDT links +``` + +--- + +### Architecture + +**Layer 1 — Model Decorator (API Surface)** + +```typescript +interface OrderingConfig { + strategy: "linkedList" | "fractionalIndex" | "timestamp" | "manual"; + sortBy?: string; // Property name for tiebreaker + conflictResolution?: "lww" | "merge"; // Last-write-wins or merge +} +``` + +**Layer 2 — Ordering Strategy Interface** + +```typescript +interface OrderingStrategy { + reconstruct(items: Ad4mModel[], links: Link[]): Ad4mModel[]; + generateLinks( + items: Ad4mModel[], + operation: Operation, + context: OperationContext, + ): Link[]; + garbageCollect?(links: Link[], cutoffDate: Date): Link[]; +} + +interface Operation { + type: "insert" | "delete" | "move" | "reorder"; + index: number; + item?: Ad4mModel; + targetIndex?: number; // For moves +} + +interface OperationContext { + agentDID: string; + timestamp: number; + collectionId: string; +} +``` + +**Layer 3 — Concrete Strategies** + +LinkedList Strategy (RGA-based) uses predicates `ad4m://ordered_after`, `ad4m://position_id`, `ad4m://position_deleted`. Reconstructs by building an `afterMap` from links, sorting same-position items by `${timestamp}_${agentDID}` positionId, then traversing the linked list from `null` head. + +Fractional Index Strategy uses predicates `ad4m://fractional_position`, `ad4m://position_id`. Reconstructs by sorting items lexicographically by their stored position string with positionId as tiebreaker. Generates new positions as the midpoint between neighbours. + +Strategy Factory: + +```typescript +class OrderingStrategyFactory { + static create(config?: OrderingConfig): OrderingStrategy { + switch (config?.strategy) { + case "linkedList": + return new LinkedListStrategy(); + case "fractionalIndex": + return new FractionalIndexStrategy(); + case "timestamp": + return new TimestampStrategy(); + default: + return new ManualStrategy(); // current behaviour + } + } +} +``` + +Custom strategies are supported: `ordering: { strategy: new MyCustomStrategy() }`. + +**Layer 4 — Array Proxy** + +`wrapCollectionWithOrdering()` on `Ad4mModel` returns a `Proxy` that intercepts `push`, `unshift`, `splice` and records a pending `Operation`. On `update()`, pending operations are passed to the strategy's `generateLinks()` and the resulting links are batched into the perspective write. + +--- + +### Migration Strategy + +Old string-array properties still work. Migration helper: + +```typescript +async migrateToOrderedCollection() { + const oldIds = JSON.parse(this.orderedTaskIds); + const batchId = await this.perspective.createBatch(); + let previousId = null; + for (const taskId of oldIds) { + await this.perspective.addLink({ source: taskId, predicate: 'ad4m://ordered_after', target: previousId || 'null' }, batchId); + await this.perspective.addLink({ source: taskId, predicate: 'ad4m://position_id', target: `literal://string:${Date.now()}_migration` }, batchId); + previousId = taskId; + } + await this.perspective.commitBatch(batchId); + this.orderedTaskIds = '[]'; + await this.update(); +} +``` + +--- + +### Strategy Comparison + +| | RGA (LinkedList) | YATA | Fractional Index | +| ----------------------- | ---------------- | --------- | ---------------- | +| **Insert** | O(n) | O(1)\* | O(1) | +| **Delete** | O(n) | O(1) | O(n) | +| **Move** | O(n)×2 | O(1)×2 | O(1) | +| **Memory/item** | ~64 bytes | ~96 bytes | ~64 bytes | +| **Links/item** | 2 | 5 | 2 | +| **Conflict resolution** | Good | Excellent | Good | + +\*Amortized + +**RGA/LinkedList** — recommended default. Simple, debuggable, natural for AD4M's triple store. Best for lists < 50 items. + +**Fractional Index** — simpler to understand, good for deterministic ordering, lists that don't change structure often. Position strings can grow over time and need occasional rebalancing. + +**YATA** — future option. 3× storage but O(1) amortised ops and best-in-class conflict semantics. Only worth it for heavy concurrent editing (document editors, etc.). + +--- + +### Open Questions + +1. **Bulk replace** (`tasks = newArray`) — treat as diff of old vs new, generate appropriate insert/delete operations. +2. **Custom strategies** — supported via plugin: `ordering: { strategy: new MyCustomStrategy() }`. +3. **Conflict visualisation** — AD4M inspector extension showing link graph. +4. **Garbage collection cadence** — run on perspective sync, configurable tombstone threshold (e.g. 30 days). +5. **Lazy vs eager reconstruction** — lazy on read, cache result, invalidate on link change. + +--- + +### Implementation Phases + +- [ ] Add `OrderingStrategy` interface + `LinkedListStrategy` (RGA) +- [ ] Add `ordering` option to `@HasMany` decorator options type +- [ ] Create Array Proxy layer in `Ad4mModel` +- [ ] Update `update()` to flush pending ordering operations +- [ ] Unit tests for each strategy in isolation +- [ ] `FractionalIndexStrategy` + `TimestampStrategy` +- [ ] Garbage collection +- [ ] Migration helper utility +- [ ] Integration tests (scenario 10 in playground) +- [ ] Debugging tools / inspector + +--- + +### References + +- [Figma's Fractional Indexing](https://www.figma.com/blog/realtime-editing-of-ordered-sequences/) +- [CRDT Survey Paper](https://hal.inria.fr/hal-00932836/document) +- [RGA Algorithm](https://pages.lip6.fr/Marc.Shapiro/papers/RGA-TPDS-2011.pdf) +- [Yjs Documentation](https://docs.yjs.dev/) +- [Automerge CRDT](https://automerge.org/) + +--- + +**Scope:** Changes to `hydration.ts`, `SurrealQueryBuilder.ts`, `@HasMany` decorator options, possibly SHACL-level ordering predicates, and the Rust executor's link ordering. Fills in scenario 10 of the playground test harness. + +**Priority:** Post-merge. High impact for multi-user apps. + +--- + +## 12. External Consumer Migration — SubjectRepository (Phase G) + +**Background:** `flux/packages/api/src/factory/SubjectRepository` and `ad4m-hooks/helpers/src/factory/SubjectRepository` still use Prolog `infer()` + the Subject proxy (`subject.init()`, async getters) for all model queries. They have no knowledge of `Ad4mModel`. + +**Migration path:** + +- Replace `getAll()` + `getSubjectData()` with `Ad4mModel.findAll()` +- Replace paginated `getAll()` with `Ad4mModel.findPage()` +- Replace Subject proxy getters with `Ad4mModel.getData()` +- Wire `useSubjects`/`useSubject` hooks to `Ad4mModel.subscribe()` for live updates + +**Removals after migration:** + +- `PerspectiveProxy.getSubjectData()` + `getSubjectProxy()` +- `PerspectiveClient.getSubjectData()` +- Rust `get_subject_data()` + GQL resolver (currently fires 5 Prolog queries per call) +- Both `SubjectRepository` classes + +**Prerequisite:** Flux Phase F (decorator rename) must complete first. + +**Priority:** Post-merge. Gated on Flux and hooks teams. + +--- + +## 13. sh:inversePath — Rust/Prolog Side + +**Background:** `@BelongsToOne`/`@BelongsToMany` set `inversePath: true` on the SHACL shape. The TypeScript SurrealDB hydration path works correctly (reverse `WHERE out.uri = ...` query). However `shacl_parser.rs` has zero handling of `sh:inversePath` — it never emits reverse Prolog predicates, so any Prolog-side lookup for a reverse relation silently returns nothing. `generatePrologFacts.ts` likewise has no reverse-predicate clause. + +**Action:** + +- `shacl_parser.rs`: detect `sh:inversePath`, emit reverse Prolog clauses (e.g. `channel_of(X, Y) :- triple(Y, 'predicate', X).`) +- `generatePrologFacts.ts`: handle `inversePath: true` shapes, emit reverse predicate clause + +**Priority:** Low — only matters if a consumer writes explicit Prolog queries against reverse relations. The SurrealDB path (the common case) already works. + +--- + +## 14. Parameterised SurrealQL Queries + +**Problem:** `SurrealQueryBuilder.ts` and `getData()` still use string interpolation with `formatSurrealValue()` to prevent injection. SurrealDB supports parameterized queries via `querySurrealDB(query, bindings)` which are safe by construction. + +```typescript +// Current: +`SELECT ... FROM link WHERE in.uri = ${formatSurrealValue(base)}`; + +// Target: +perspective.querySurrealDB("SELECT ... FROM link WHERE in.uri = $base", { + base, +}); +``` + +**Scope:** `queryToSurrealQL`, `getData()`, `fetchInstance.ts`, all raw SurrealQL construction throughout the query layer. + +**Priority:** Medium. Not a regression (`formatSurrealValue` prevents injection today) but parameterized queries are the industry standard and eliminate an entire class of potential escaping bugs. + +--- + +## 15. SDNA Wire Protocol Rename (`collection*` → `relation*`) + +**Background:** The SDNA wire protocol still uses `collection`-prefixed predicate names: `collection/2`, `collection_getter/4`, `collection_adder/3`, `collection_remover/3`, `collection_setter/3`. These appear in 78+ locations across bootstrap languages and are queried by name throughout the Rust executor. + +**Why deferred:** Renaming is a breaking change to the SDNA wire protocol — any live perspective with existing SDNA would stop working. Requires a versioned format, compatibility shim, or coordinated breaking release. + +**Scope:** + +1. Choose new names (`relation/2`, `relation_getter/4`, etc.) +2. Update all Rust executor references (`engine_pool.rs`, `sdna.rs`, `perspective_instance.rs`) +3. Update all 78+ bootstrap language `.pl` files +4. Update `tests/js/sdna/subject.pl` +5. Write migration strategy + +**Priority:** Major version / coordinated release item. Not before Phase G is complete. + +--- + +## 16. Relation Action Duplication — TypeScript vs Rust SHACL + +**Problem:** Relation adder/remover/setter mutations call `executeAction` with a TypeScript-generated action array. The Rust executor has `get_collection_adder_actions()` that derives the same structure from SHACL. Two implementations of the same logic that must stay in sync manually. + +**Solution:** Have relation mutations fetch SHACL-derived actions from the executor (the same way `createSubject` does for property setters) rather than generating them independently in TypeScript. + +**Priority:** Low. The implementations are simple and unlikely to diverge in practice, but the duplication is a maintenance risk for anyone modifying SHACL action formats. + +--- + +## 17. Lifecycle Hooks + +**Problem:** No mechanism to run logic before/after model persistence operations. Common patterns (validation before save, cascading deletes, post-load transforms) require subclass overrides with manual `super` calls. + +**Proposed API:** Override async methods on the model class: + +```typescript +@ModelOptions({ name: "Recipe" }) +class Recipe extends Ad4mModel { + async beforeSave() { + if (!this.name) throw new Error("Name required"); + } + + async afterLoad() { + // post-process after loading from perspective + } + + async beforeDelete() { + // cascade deletes, cleanup, etc. + await Promise.all(this.ingredients.map((i) => i.delete())); + } +} +``` + +**Scope:** `Ad4mModel.ts` — add protected no-op stub methods; call them at the appropriate points in `save()`, `getData()`/`findAll()`, and `delete()`. Hooks should be `async` and awaited so they can perform perspective operations. + +**Priority:** Medium. Low effort, high ergonomics win. + +--- + +## 18. Auto-Inverse Relations + +**Problem:** Defining both sides of a bidirectional relationship is repetitive and error-prone. If the predicates or models diverge the relationship silently breaks. + +**Proposed API:** An `inverse` option on `@BelongsToOne` / `@BelongsToMany` that auto-registers the forward `@HasMany` on the related class: + +```typescript +@ModelOptions({ name: "Message" }) +class Message extends Ad4mModel { + @BelongsToOne(() => Channel, { + through: "ad4m://has_child", + inverse: "messages", // auto-registers @HasMany on Channel + }) + channel: Channel; +} + +// Channel no longer needs to manually declare: +// @HasMany(() => Message, { through: 'ad4m://has_child' }) +// messages: Message[] = []; +``` + +**Scope:** Decorator registration in `decorators.ts` — after `@BelongsToOne`/`@BelongsToMany` registers its own metadata, if `inverse` is set, call `registerRelation` on the related class's prototype for the forward side. Requires deferred resolution (the related class may not be fully defined yet) — use a post-decoration pass or lazy initialisation at first metadata read. + +**Priority:** Low-medium. Reduces boilerplate in bidirectional models but the manual two-sided declaration is clear and explicit. + +--- + +## Priority Order (suggested) + +| # | Item | Impact | Effort | +| --- | ----------------------------- | ------ | ------ | +| 2 | Range filter push-down | High | Medium | +| 1 | Dirty tracking | High | Medium | +| 3 | N+1 batching | High | Medium | +| 8 | `getter` N+1 / push-down | High | Medium | +| 11 | CRDT ordering | High | High | +| 4 | Runtime validation | Medium | Low | +| 6 | Create-vs-update detection | Medium | Low | +| 14 | Parameterised SurrealQL | Medium | Medium | +| 10 | tests/js migration | Medium | Medium | +| 12 | External consumer migration | Medium | High | +| 5 | fromJSONSchema type inference | Medium | High | +| 13 | sh:inversePath Rust side | Low | Low | +| 16 | Relation action duplication | Low | Medium | +| 9 | Transaction abort | Low | Low | +| 15 | SDNA wire rename | Low | High | +| 17 | Lifecycle hooks | Medium | Low | +| 18 | Auto-inverse relations | Low | Medium | +| 7 | Stage 3 decorators | Low | High | diff --git a/core/src/model/PREDICATE_COLLISION_FIX.md b/core/src/model/PREDICATE_COLLISION_FIX.md new file mode 100644 index 000000000..55021c595 --- /dev/null +++ b/core/src/model/PREDICATE_COLLISION_FIX.md @@ -0,0 +1,651 @@ +# Fix Predicate Collision: Type-Safe Relation Hydration + +> **Status:** Agreed — implementing Path 3 (Hybrid) +> **Created:** 2026-02-28 +> **Priority:** High — this is the root cause of the channel pinning duplication bug + +--- + +## Table of Contents + +1. [The Bug](#the-bug) +2. [Root Cause](#root-cause) +3. [Why This Happens](#why-this-happens) +4. [The Deeper Problem](#the-deeper-problem) +5. [Solution Options](#solution-options) +6. [Recommendation](#recommendation) +7. [Implementation Plan](#implementation-plan) +8. [Migration Strategy](#migration-strategy) + +--- + +## The Bug + +When a user pins a channel in Flux, all conversations inside that channel get +duplicated. The channel ends up with two copies of every conversation link. + +### Reproduction + +1. Create a channel with 3 conversations +2. Pin the channel (toggle `isPinned`) +3. Observe: the channel now has 6 conversations (3 originals + 3 duplicates) + +### Immediate cause + +`Channel.findOne()` hydrates the full channel, including all relations. When +`save()` is called to persist the `isPinned` change, it re-writes **all** +relation links — including `conversations` — creating duplicates. + +This was partially fixed by the **dirty tracking** system (see `snapshot.ts`), +which detects unchanged relations and skips them during `save()`. But the dirty +tracking fix treats the symptom. The underlying predicate collision problem +remains. + +--- + +## Root Cause + +The `Channel` model has two different relations that use the **same predicate**: + +```ts +@Model("Channel") +class Channel extends Ad4mModel { + @HasMany({ type: "Conversation", through: "ad4m://has_child" }) + conversations: Conversation[] = []; + + @HasMany({ type: "Channel", through: "ad4m://has_child" }) + childChannels: Channel[] = []; +} +``` + +Both `conversations` and `childChannels` use `"ad4m://has_child"` as their +predicate. In the link store, a channel's children look like: + +``` +channel-1 --ad4m://has_child--> conversation-A +channel-1 --ad4m://has_child--> conversation-B +channel-1 --ad4m://has_child--> child-channel-X +``` + +When the ORM hydrates `conversations`, it queries: + +```sql +SELECT * FROM links WHERE source = 'channel-1' AND predicate = 'ad4m://has_child' +``` + +This returns **all three links** — including the child channel link. The ORM +then tries to instantiate all of them as `Conversation` objects. The child +channel link either: + +1. Gets silently instantiated as a malformed `Conversation` (wrong type), or +2. Gets filtered out during hydration because it doesn't have the right SHACL + type marker + +In practice, the ORM relies on SurrealDB's subject-class index to filter by +type, so the hydration usually works correctly for **reading**. But when `save()` +re-writes the relation links, it doesn't know which links in the store are +"conversations" and which are "child channels" — they all have the same +predicate. + +--- + +## Why This Happens + +### RDF predicates are untyped + +In RDF, a triple `(subject, predicate, object)` says nothing about the **type** +of the object. The predicate `ad4m://has_child` means "this thing has a child" +but doesn't say whether the child is a Conversation, a Channel, or a Cat. + +This is by design in RDF — predicates are intentionally generic to enable +schema-free linking. But the ORM pretends predicates carry type information. When +it sees `@HasMany({ type: "Conversation", through: "ad4m://has_child" })`, it +assumes all `has_child` links from a Channel point to Conversations. + +### The ORM reads correctly but writes naively + +**Reading** works because the ORM doesn't just query by predicate — it also +checks the subject class (via SurrealDB's SHACL index). So when hydrating +`conversations`, it effectively does: + +``` +WHERE predicate = 'ad4m://has_child' AND target.type = 'Conversation' +``` + +**Writing** is where things break. When `save()` re-writes `conversations`, it: + +1. Removes all links with predicate `ad4m://has_child` from the source +2. Re-adds links for each item in the `conversations` array + +Step 1 also removes the `childChannels` links (same predicate). Step 2 doesn't +add them back because they weren't in the `conversations` array. + +With dirty tracking, `save()` now skips unchanged relations entirely. But if +`conversations` **is** changed (say, a new conversation is added), the +re-write in step 1 still blows away the child channel links. + +--- + +## The Deeper Problem + +This isn't specific to Channel. **Any model that reuses a predicate across +multiple relations** will have the same issue. And it's not obvious to the +developer that they're creating a collision — `has_child` is a perfectly natural +predicate for both "a channel's conversations" and "a channel's sub-channels". + +### Examples of potential collisions + +```ts +// Collision: same predicate for different target types +@HasMany({ type: "Message", through: "ad4m://has_item" }) +messages: Message[]; + +@HasMany({ type: "Attachment", through: "ad4m://has_item" }) +attachments: Attachment[]; + +// Collision: @HasMany and @HasOne with same predicate +@HasMany({ type: "Tag", through: "ad4m://tagged_with" }) +tags: Tag[]; + +@HasOne({ type: "Tag", through: "ad4m://tagged_with" }) +primaryTag: Tag; +``` + +--- + +## Solution Options + +### Option A: Enforce Unique Predicates (Recommended First Step) + +**Rule:** Within a single `@Model`, every `through` predicate must be unique +across all `@HasMany`, `@HasOne`, `@BelongsToOne`, and `@BelongsToMany` +decorators. + +**Implementation:** + +```ts +// In the @Model decorator: +function Model(name: string) { + return function (constructor: Function) { + const relations = relationRegistry.get(constructor) ?? []; + const predicates = new Map(); // predicate → field name + + for (const rel of relations) { + const existing = predicates.get(rel.through); + if (existing) { + throw new Error( + `@Model("${name}"): predicate "${rel.through}" is used by both ` + + `"${existing}" and "${rel.key}". Each relation must use a unique ` + + `predicate. Use "ad4m://has_conversation" and ` + + `"ad4m://has_child_channel" instead of reusing "ad4m://has_child".`, + ); + } + predicates.set(rel.through, rel.key); + } + + // ... rest of @Model logic + }; +} +``` + +**Migration for Channel:** + +```ts +// Before (collision): +@HasMany({ type: "Conversation", through: "ad4m://has_child" }) +conversations: Conversation[] = []; + +@HasMany({ type: "Channel", through: "ad4m://has_child" }) +childChannels: Channel[] = []; + +// After (unique predicates): +@HasMany({ type: "Conversation", through: "ad4m://has_conversation" }) +conversations: Conversation[] = []; + +@HasMany({ type: "Channel", through: "ad4m://has_child_channel" }) +childChannels: Channel[] = []; +``` + +**Pros:** + +- Simple to implement (~20 lines) +- Catches the bug at definition time (loud error, not silent data corruption) +- No changes to the query/mutation pipeline +- Aligns with how the ORM actually uses predicates (as typed foreign keys) + +**Cons:** + +- Loses some RDF generality (predicates become model-specific keys) +- Doesn't solve the case where two different models use the same predicate + pointing at the same target type (but that's not a real problem in practice) + +--- + +### Option B: Type-Filtered Hydration and Mutation + +**Rule:** The ORM always considers the target type when reading and writing +relation links. A relation `@HasMany({ type: "Conversation", through: "ad4m://has_child" })` +only reads/writes links where the target is a `Conversation`. + +**Reading (already works):** +SurrealDB's subject-class index already filters by type during hydration. + +**Writing (needs change):** + +```ts +// In mutation.ts, when updating a HasMany relation: +async function updateRelation(ctx, key, relMeta) { + const currentLinks = await ctx.perspective.get( + new LinkQuery({ + source: ctx.id, + predicate: relMeta.through, + }), + ); + + // Only touch links whose targets are of the correct type + const relevantLinks = currentLinks.filter((link) => + isOfType(link.target, relMeta.type), + ); + + const desiredIds = new Set(ctx.instance[key].map((v) => v.id)); + const currentIds = new Set(relevantLinks.map((l) => l.data.target)); + + // Remove links that are no longer in the array + for (const link of relevantLinks) { + if (!desiredIds.has(link.data.target)) { + await ctx.perspective.remove(link); + } + } + + // Add links for new items + for (const id of desiredIds) { + if (!currentIds.has(id)) { + await ctx.perspective.add( + new Link({ + source: ctx.id, + predicate: relMeta.through, + target: id, + }), + ); + } + } +} +``` + +The key change is `isOfType()` — checking whether a target has the expected +subject-class marker before touching its link. + +**Pros:** + +- No breaking changes to existing predicates +- Preserves RDF generality (same predicate, different types, handled correctly) +- More aligned with how RDF actually works + +**Cons:** + +- **Mutation cost:** `isOfType()` requires querying each target's type + (additional DB lookups). For a `HasMany` with N items, that's up to N extra + SurrealDB queries per `save()`. These could be batched into a single + `WHERE id IN [...]` query, but the round-trip still exists. +- **Query cost: none.** The read path already uses SurrealDB's subject-class + index to filter by type — this is how hydration works today regardless of + which option you choose. Option B doesn't change the query path at all. + The cost is purely on the write side. +- Adds complexity to the mutation path (harder to debug) +- Still doesn't prevent the developer from accidentally creating ambiguous + schemas — the ORM silently handles it, so the developer may never realise + two relations share a predicate + +--- + +### Option C: Compound Predicates + +**Rule:** The ORM automatically generates unique predicates by combining the +user-specified predicate with the target type. + +```ts +@HasMany({ type: "Conversation", through: "ad4m://has_child" }) +conversations: Conversation[] = []; + +// Internally becomes: predicate = "ad4m://has_child#Conversation" + +@HasMany({ type: "Channel", through: "ad4m://has_child" }) +childChannels: Channel[] = []; + +// Internally becomes: predicate = "ad4m://has_child#Channel" +``` + +**Pros:** + +- No breaking API changes for the developer +- Unique predicates without manual naming +- Query and mutation just work + +**Cons:** + +- Breaks RDF interop (non-standard predicate URIs) +- Existing data uses un-suffixed predicates — needs migration +- Hides what's actually happening (developer sees `has_child`, store has `has_child#Conversation`) +- Fragile if model names change + +--- + +## Recommendation + +### These are alternative strategies, not sequential steps + +**Important:** Options A and B are mutually exclusive philosophies. If Option B +is implemented (the ORM correctly handles shared predicates via type filtering), +then Option A's hard error is wrong — it forbids something the ORM now handles +correctly. You can't enforce "predicates must be unique" and then build a system +that correctly handles non-unique predicates. Pick one: + +### Path 1: Option A alone — Enforce Unique Predicates + +Choose this if you believe: + +- The ORM should treat predicates as typed foreign keys (one predicate = one relation) +- RDF generality within a single model isn't needed +- Developers should be explicit about their link semantics + +**What you get:** + +- ~20 lines of code in `@Model` +- Zero changes to query or mutation logic +- Bugs caught at definition time with a clear error message +- Self-documenting schemas (`ad4m://has_conversation` is clearer than `ad4m://has_child`) + +**What you give up:** + +- Developers can't reuse a predicate across relations within a model +- Existing data with shared predicates needs migration (rename links) + +**This is the simpler path.** No new runtime complexity. The fix is in the +schema, not the engine. + +### Path 2: Option B alone — Type-Filtered Mutation + +Choose this if you believe: + +- Shared predicates are a valid RDF pattern the ORM should support +- The ORM should be smart enough to handle them correctly +- Forcing predicate renaming across existing data is too costly + +**What you get:** + +- Correct read and write behaviour even with shared predicates +- No breaking changes to existing models or data +- Better RDF alignment + +**What you give up:** + +- Extra `isOfType()` lookups during mutation (write-side cost only — the read + path already uses SurrealDB's type index and is unaffected) +- More complex mutation logic (harder to debug) +- Developers still won't get a warning when they create an ambiguous schema — + the ORM silently handles it, which means the developer may not even realise + two relations share a predicate until they inspect the link store + +### Path 3: Option A as warning + Option B as fix (Hybrid) + +There is one valid combination: use Option A as a **warning** (not error) and +Option B as the actual correctness mechanism: + +```ts +// In @Model: +if (predicateCollision) { + console.warn( + `[AD4M ORM] Model "${name}": predicate "${rel.through}" is shared by ` + + `"${existing}" and "${rel.key}". The ORM handles this correctly via ` + + `type-filtered mutation, but consider using unique predicates for clarity.`, + ); +} +``` + +This gives developers visibility ("hey, you're sharing predicates") without +breaking their code, while the type-filtering ensures correctness regardless. + +**This is the recommended path.** It fixes the bug at the engine level, +gives developers visibility without breaking changes, and leaves predicate +renaming as an optional clean-up rather than a hard prerequisite. + +### Our recommendation: Path 3 (Hybrid — Warning + Type-Filtered Mutation) + +After further discussion, Path 3 is the optimal route. Here's why: + +1. **Correctness by default.** Type-filtered mutation (Option B) means the ORM + handles shared predicates correctly regardless of whether the developer + notices the collision. Silent data corruption is eliminated at the engine + level, not just at the schema level. + +2. **The warning is still valuable.** Shared predicates are almost always a + modelling mistake. The `console.warn` in `@Model` gives developers immediate + visibility without breaking existing code or requiring a forced migration. + It's a nudge, not a blocker. + +3. **Write-side cost is acceptable.** The `isOfType()` lookups during mutation + can be batched into a single `WHERE id IN [...]` query per relation, keeping + the extra round-trip to one per dirty relation per `save()`. For the typical + model this is negligible, and the correctness guarantee outweighs it. + +4. **No forced migration.** Path 1 requires renaming all colliding predicates + and writing a data migration before shipping. Path 3 fixes the bug + immediately in the engine, then lets predicate renaming happen at a more + comfortable pace — or not at all, if developers prefer to leave their schemas + as-is. + +5. **Future-proof.** If RDF interop with external agents becomes a requirement, + shared predicates are already handled correctly. Nothing needs to be + re-architected later. + +--- + +## Implementation Plan + +### Phase 1: Audit All Models + +Search all model files across Flux and We for predicate collisions: + +```bash +# Find all @HasMany/@HasOne/@BelongsTo* decorators and their predicates +grep -rn '@Has\|@BelongsTo' --include='*.ts' | grep 'through:' | \ + sed 's/.*through:\s*["'\'']\([^"'\'']*\)["'\''].*/\1/' | \ + sort | uniq -c | sort -rn +``` + +Confirm the Channel collision is the only one, or identify others. + +### Phase 2: Implement Type-Filtered Mutation (Option B — the core fix) + +Update `mutation.ts` so that when re-writing a relation, the ORM only touches +links whose targets are of the correct type. This eliminates the data corruption +regardless of whether predicates are shared. + +```ts +// In mutation.ts, when updating a HasMany relation: +async function updateRelation(ctx, key, relMeta) { + const currentLinks = await ctx.perspective.get( + new LinkQuery({ + source: ctx.id, + predicate: relMeta.through, + }), + ); + + // Only touch links whose targets are of the correct type — this ensures + // shared predicates across different relation fields don't clobber each other. + const relevantLinks = await filterByType(currentLinks, relMeta.type); + + const desiredIds = new Set(ctx.instance[key].map((v) => v.id)); + const currentIds = new Set(relevantLinks.map((l) => l.data.target)); + + for (const link of relevantLinks) { + if (!desiredIds.has(link.data.target)) { + await ctx.perspective.remove(link); + } + } + + for (const id of desiredIds) { + if (!currentIds.has(id)) { + await ctx.perspective.add( + new Link({ source: ctx.id, predicate: relMeta.through, target: id }), + ); + } + } +} +``` + +`filterByType` should batch the type lookups into a single `WHERE id IN [...]` +query to keep the extra round-trip to one per dirty relation per `save()`. + +### Phase 3: Add @Model Collision Warning (Option A as warning) + +Add the duplicate-predicate check in the `@Model` decorator, emitting a +**warning** (not an error) so developers are alerted without breaking existing +code: + +```ts +// In the @Model decorator: +const relations = relationRegistry.get(constructor) ?? []; +const predicates = new Map(); + +for (const rel of relations) { + const existing = predicates.get(rel.through); + if (existing) { + console.warn( + `[AD4M ORM] Model "${name}": predicate "${rel.through}" is shared by ` + + `"${existing}" and "${rel.key}". The ORM handles this correctly via ` + + `type-filtered mutation, but consider using unique predicates for clarity.`, + ); + } + predicates.set(rel.through, rel.key); +} +``` + +### Phase 4: (Optional) Rename Colliding Predicates + +With Phases 2 and 3 in place the bug is already fixed. Renaming predicates is +now a clean-up exercise, not a hard requirement. Do it when convenient: + +```ts +// Before: +@HasMany({ type: "Conversation", through: "ad4m://has_child" }) +conversations: Conversation[] = []; + +@HasMany({ type: "Channel", through: "ad4m://has_child" }) +childChannels: Channel[] = []; + +// After: +@HasMany({ type: "Conversation", through: "ad4m://has_conversation" }) +conversations: Conversation[] = []; + +@HasMany({ type: "Channel", through: "ad4m://has_child_channel" }) +childChannels: Channel[] = []; +``` + +If predicates are renamed, ship a one-time data migration run on app startup +(gated by a version flag) to rewrite existing links: + +```ts +async function migrateChannelPredicates(perspective: PerspectiveProxy) { + const links = await perspective.get( + new LinkQuery({ predicate: "ad4m://has_child" }), + ); + for (const link of links) { + const targetType = await getSubjectClass(link.data.target); + let newPredicate: string; + if (targetType === "Conversation") { + newPredicate = "ad4m://has_conversation"; + } else if (targetType === "Channel") { + newPredicate = "ad4m://has_child_channel"; + } else { + continue; // leave non-Channel/Conversation has_child links untouched + } + await perspective.remove(link); + await perspective.add(new Link({ ...link.data, predicate: newPredicate })); + } +} +``` + +### Phase 5: Verify + +- All existing tests pass +- Channel pinning no longer duplicates conversations +- New conversations appear correctly +- Child channels appear correctly +- Shared-predicate warning fires in dev console for any colliding models + +--- + +## Migration Strategy + +### Identifying Collisions + +Run this audit across all model files: + +```bash +# Find all @HasMany/@HasOne/@BelongsTo* decorators and their predicates +grep -rn '@Has\|@BelongsTo' --include='*.ts' | grep 'through:' | \ + sed 's/.*through:\s*["'\'']\([^"'\'']*\)["'\''].*/\1/' | \ + sort | uniq -c | sort -rn +``` + +### Known Collisions (from Flux audit) + +| Model | Field 1 | Field 2 | Shared Predicate | +| ------- | --------------- | --------------- | ------------------ | +| Channel | `conversations` | `childChannels` | `ad4m://has_child` | + +> **Note:** This list should be expanded with a full audit of all models in +> Flux (~22 models) and We (~10 models). + +### Renaming Convention + +When splitting a shared predicate, use descriptive relation-specific names: + +| Before | After | +| --------------------------------------- | -------------------------- | +| `ad4m://has_child` (for conversations) | `ad4m://has_conversation` | +| `ad4m://has_child` (for child channels) | `ad4m://has_child_channel` | +| `ad4m://has_item` (for messages) | `ad4m://has_message` | +| `ad4m://has_item` (for attachments) | `ad4m://has_attachment` | + +The pattern is: `ad4m://has_`. + +### Data Migration Considerations + +1. **Local-first:** Each user's local perspective needs migration. Ship the + migration as part of the app update — run it once on startup if a version + flag isn't set. + +2. **Shared neighbourhoods:** Other agents may still create links with the old + predicate. The ORM should accept both old and new predicates during a + transition period, but only write new predicates. + +3. **Ordering:** Migrate reads first (accept both predicates), then writes (use + new predicate), then drop old-predicate support. + +### Transition Period Query + +During migration, the ORM can query both predicates: + +```ts +// In operations.ts, during include hydration: +const predicates = relMeta.legacyPredicates + ? [relMeta.through, ...relMeta.legacyPredicates] + : [relMeta.through]; + +// Query each predicate and merge results +``` + +This could be supported via a decorator option: + +```ts +@HasMany({ + type: "Conversation", + through: "ad4m://has_conversation", + legacyPredicates: ["ad4m://has_child"], // also read from old predicate +}) +conversations: Conversation[] = []; +``` + +Once all data is migrated, remove the `legacyPredicates` option. diff --git a/core/src/model/README.md b/core/src/model/README.md new file mode 100644 index 000000000..e09428ea7 --- /dev/null +++ b/core/src/model/README.md @@ -0,0 +1,317 @@ +# Ad4mModel — ORM layer for AD4M perspectives + +`Ad4mModel` is a class-based ORM that maps TypeScript objects to subgraphs inside an +AD4M [`PerspectiveProxy`](../perspectives/PerspectiveProxy.ts). Each instance is a +cluster of RDF-style link triples sharing a common **base URI** (the instance `id`). + +--- + +## Quick start + +```typescript +import { Ad4mModel, Model, Property, Flag, HasMany } from "@coasys/ad4m"; + +@Model({ name: "Recipe" }) +export class Recipe extends Ad4mModel { + @Flag({ through: "recipe://type", value: "recipe://Recipe" }) + type: string = ""; + + @Property({ through: "recipe://title", resolveLanguage: "literal" }) + title: string = ""; + + @Property({ through: "recipe://status", initial: "recipe://draft" }) + status: string = ""; + + @HasMany({ through: "recipe://ingredient" }) + ingredients: string[] = []; +} + +// Install the SHACL/SDNA subject class once (idempotent): +await Recipe.register(perspective); + +// Create +const r = await Recipe.create(perspective, { title: "Pasta" }); + +// Read +const loaded = new Recipe(perspective, r.id); +await loaded.get(); +console.log(loaded.title); // "Pasta" + +// Update +loaded.title = "Pasta al Pomodoro"; +await loaded.save(); + +// Delete +await loaded.delete(); +``` + +--- + +## Architecture + +``` +PerspectiveProxy (graph store) + └── "subject" node ←→ Ad4mModel instance + ├── ad4m://type → "recipe://Recipe" (@Flag) + ├── recipe://title → Literal("Pasta") (@Property) + └── recipe://ingredient → … (@HasMany) +``` + +- **`id`** — the base URI of the instance subgraph (auto-generated from a random + 24-char literal if not supplied to the constructor). +- **Properties** — single-valued link triples `(id, predicate, value)` described by + `@Property` or `@Flag`. +- **Relations** — multi-valued link sets described by `@HasMany`, `@HasOne`, + `@BelongsToOne`, `@BelongsToMany`; each generates `add*`, `remove*`, `set*` methods. +- **Queries** — `findAll`, `findOne`, `query()` builder, `paginate`, `count` — all + compiled to SurrealQL and executed against the perspective's local graph engine. +- **Subscriptions** — `subscribe()` / `query().live()` re-run the query on each + relevant link change and deliver fresh results via callback. + +> **Layer boundary**: `PerspectiveProxy` still uses `baseExpression` in its +> protocol-level Subject API. `Ad4mModel` exposes `id` as its ORM abstraction — +> the two refer to the same URI. + +--- + +## Decorator reference + +### `@Model({ name })` + +**Required** on every class extending `Ad4mModel`. Registers the SDNA subject class +name used by the perspective's Prolog engine and enables all static query methods. + +```typescript +@Model({ name: "Comment" }) +class Comment extends Ad4mModel { ... } +``` + +--- + +### `@Property(opts)` + +Single-valued property backed by one link triple. + +| Option | Description | +| ----------------- | --------------------------------------------------------------------- | +| `through` | Predicate URI **(required)** | +| `initial` | Default value added by the constructor action | +| `required` | Adds `sh:minCount 1` | +| `writable` | Generates a setter action (default `true` when `through` is set) | +| `resolveLanguage` | `"literal"` to store/retrieve via the Literal language | +| `local` | Store only in the local perspective (not synced to the network) | +| `getter` | Custom SurrealQL expression; used for computed / read-only properties | +| `transform` | Post-fetch `(rawValue) => transformedValue` function | + +```typescript +@Property({ through: "post://title", resolveLanguage: "literal" }) +title: string = ""; + +// Read-only computed value +@Property({ + through: "post://wordCount", + writable: false, + getter: `array::len(string::split((<-link[WHERE predicate='post://body'].in.uri)[0], ' '))`, +}) +wordCount: number = 0; +``` + +--- + +### `@Flag({ through, value })` + +Immutable type-marker: a `@Property` whose value is fixed at creation and never +changed. Useful for type discrimination. + +```typescript +@Flag({ through: "ad4m://type", value: "flux://Message" }) +type: string = ""; +``` + +--- + +### `@HasMany(opts)` / `@HasOne(opts)` + +Forward relation: generates `add*`, `remove*`, and `set*` instance methods. + +```typescript +@HasMany({ through: "post://tag" }) +tags: string[] = []; + +// Typed — only returns URIs that are instances of the related model +@HasMany(() => Comment, { through: "post://comment" }) +comments: Comment[] = []; +export interface Post extends HasManyMethods<"tags" | "comments"> {} +``` + +`@HasOne` is the same but limits the collection to a single value (`sh:maxCount 1`). + +--- + +### `@BelongsToOne(relatedModel, opts)` / `@BelongsToMany(relatedModel, opts)` + +Reverse relation: traverses links **owned by the other side** — read-only, no mutator +methods generated. + +```typescript +@BelongsToOne(() => Post, { through: "post://comment" }) +post: string = ""; +``` + +--- + +## Querying + +### Static helpers + +```typescript +// All instances +const all = await Recipe.findAll(perspective); + +// Filtered +const hot = await Recipe.findAll(perspective, { + where: { status: "recipe://published", rating: { gt: 4 } }, + order: { createdAt: "DESC" }, + limit: 20, + offset: 0, +}); + +// Count +const n = await Recipe.count(perspective, { + where: { status: "recipe://draft" }, +}); + +// With total (for pagination UI) +const { results, totalCount } = await Recipe.findAllAndCount(perspective, { + limit: 10, +}); + +// Explicit page +const page = await Recipe.paginate(perspective, 10, 2); +``` + +### Fluent query builder + +```typescript +const results = await Recipe.query(perspective) + .where({ status: "recipe://published" }) + .order({ createdAt: "DESC" }) + .limit(10) + .get(); + +// First match +const one = await Recipe.query(perspective).where({ title: "Pasta" }).first(); + +// Count matching records +const n = await Recipe.query(perspective) + .where({ status: "recipe://draft" }) + .count(); +``` + +### Subscriptions + +```typescript +const sub = Recipe.subscribe( + perspective, + { where: { status: "recipe://cooking" }, debounce: 300 }, + (recipes) => setRecipes(recipes), +); + +// Or via the builder: +const sub2 = Recipe.query(perspective) + .where({ status: "recipe://cooking" }) + .live((recipes) => setRecipes(recipes)); + +// Always clean up: +sub.unsubscribe(); +sub2.unsubscribe(); +``` + +--- + +## Transactions (batch operations) + +```typescript +await Ad4mModel.transaction(perspective, async (tx) => { + const post = new Post(perspective); + post.title = "Hello world"; + await post.save(tx.batchId); + + const comment = new Comment(perspective); + comment.body = "First!"; + await comment.save(tx.batchId); +}); +// Commits atomically; any throw aborts the batch +``` + +--- + +## `fromJSONSchema` — dynamic model generation + +Build an `Ad4mModel` subclass from a JSON Schema at runtime (useful for generic tooling +or schema-driven UIs): + +```typescript +const schema = { + title: "Person", + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, +}; + +// Option 1 — explicit namespace +const PersonClass = Ad4mModel.fromJSONSchema(schema, { + name: "Person", + namespace: "person://", + resolveLanguage: "literal", +}); + +// Option 2 — property-level predicate override +const ContactClass = Ad4mModel.fromJSONSchema(schema, { + name: "Contact", + namespace: "contact://", + propertyMapping: { + name: "foaf://name", + email: "foaf://mbox", + }, +}); + +// Option 3 — embed predicates in the schema itself +const schemaWithMeta = { + title: "Product", + "x-ad4m": { namespace: "product://" }, + properties: { + name: { type: "string", "x-ad4m": { through: "product://title" } }, + }, +}; +const ProductClass = Ad4mModel.fromJSONSchema(schemaWithMeta, { + name: "Product", +}); + +await ProductClass.register(perspective); +const p = await ProductClass.create(perspective, { name: "Widget" }); +``` + +--- + +## Inheritance + +Use `@Model` on the parent **and** the child. The SHACL generator emits `sh:node` to +reference the parent shape instead of duplicating its properties: + +```typescript +@Model({ name: "BaseBlock" }) +class BaseBlock extends Ad4mModel { + @Property({ through: "block://createdBy" }) + createdBy: string = ""; +} + +@Model({ name: "PollBlock" }) +class PollBlock extends BaseBlock { + @Property({ through: "poll://question" }) + question: string = ""; +} +// PollBlock SHACL shape: sh:node + own poll:// properties only +``` diff --git a/core/src/model/SHACL_MUTATION_DECOUPLING.md b/core/src/model/SHACL_MUTATION_DECOUPLING.md new file mode 100644 index 000000000..4384378a0 --- /dev/null +++ b/core/src/model/SHACL_MUTATION_DECOUPLING.md @@ -0,0 +1,356 @@ +# Decouple SHACL from the Mutation Hot Path + +> **Status:** Planning +> **Created:** 2026-02-28 +> **Priority:** High — removes a class of bugs and simplifies the mutation path + +--- + +## Table of Contents + +1. [Problem](#problem) +2. [Current Mutation Flow](#current-mutation-flow) +3. [What's Wrong With This](#whats-wrong-with-this) +4. [Proposed Architecture](#proposed-architecture) +5. [What SHACL Should Still Be Used For](#what-shacl-should-still-be-used-for) +6. [Implementation Plan](#implementation-plan) +7. [Risks and Considerations](#risks-and-considerations) + +--- + +## Problem + +SHACL shapes are currently used as an **instruction set** for mutations. The Rust +executor reads constructor/setter/adder/remover actions from the SHACL shape and +interprets them to create and update links. This means the ORM's decorator +metadata is serialised into SHACL, transmitted to Rust, parsed back into action +lists, and then executed as link operations — a round-trip through a +serialisation format when the ORM already has all the information it needs to +create links directly. + +--- + +## Current Mutation Flow + +### Create path (first `save()`) + +``` +ORM: instance.save() [TypeScript] + → perspective.createSubject(instance, id) + → Rust executor reads SHACL shape + → interprets ad4m://constructorActions + → executes addLink() for each constructor action + → reads initialValues from instance + → calls setter actions to overwrite constructor placeholders + → returns to TypeScript +``` + +The create path goes through: + +1. TypeScript decorator metadata → SHACL Turtle serialisation +2. Turtle → Rust SHACL parser +3. Parser → constructor action list +4. Action list → link operations +5. Then separately: initial values → setter action list → more link operations + +### Update path (`save()` on existing instance) + +``` +ORM: instance.save() [TypeScript] + → mutation.innerUpdate() + → for each dirty property: setProperty() → addLink() directly + → for each dirty relation: setRelationSetter() → addLink()/removeLink() directly +``` + +The update path **already skips SHACL entirely**. It reads decorator metadata +(predicate URIs, relation types) from the registry and creates/removes links +directly via the perspective API. This is simpler, faster, and easier to debug. + +### The asymmetry + +| | Create | Update | +| ----------------------- | ---------------------------------- | -------------------------- | +| **Reads metadata from** | SHACL shape (via Rust) | Decorator registry (JS) | +| **Mutation logic in** | Rust SHACL interpreter | TypeScript `mutation.ts` | +| **Debuggable in** | Rust (requires executor source) | TypeScript (same codebase) | +| **Latency** | SHACL parse + interpret + link ops | Direct link ops | + +The update path is the correct architecture. The create path should work the same way. + +--- + +## What's Wrong With This + +### 1. SHACL action generation and ORM mutation logic can disagree + +The SHACL shape is generated from decorator metadata by `generateSHACL()` in +`decorators.ts`. The mutation logic in `mutation.ts` also reads from decorator +metadata. These are **two independent code paths** that must stay in sync: + +- If a decorator option is added (e.g. `local: true`), it must be handled in + both `generateSHACL()` (for the constructor action) and `mutation.ts` (for + updates). If one is missed, creates and updates behave differently. + +- The `createSubject` call in Rust has its own logic for "initial values" that + overwrite constructor placeholders. This mechanism is invisible from + TypeScript — if it fails silently, the ORM has no way to detect or recover. + +### 2. The Rust round-trip adds latency + +Every first `save()` goes: JS → Rust SHACL parser → action interpreter → link +API → SurrealDB reindex → back to JS. The update path skips the SHACL +parser/interpreter entirely. Moving creates to the same path removes a +serialisation boundary. + +### 3. Debugging mutations requires tracing into Rust + +When a `save()` produces unexpected links (wrong predicate, missing property, +duplicate link), debugging the create path requires reading the Rust executor's +SHACL interpreter. The update path is debuggable entirely in TypeScript — you +can set breakpoints in `mutation.ts` and inspect every link operation. + +### 4. The SHACL "constructor placeholder" pattern is fragile + +The current SHACL constructor emits placeholder links like: + +```turtle +ad4m://constructorActions → addLink(this, "test://title", "literal://string:") +``` + +Then `createSubject` calls setter actions to overwrite the placeholder with the +real value. This means every property is written **twice** on creation — once as +a placeholder, once as the real value. If the setter action fails, the +placeholder persists as stale data. + +--- + +## Proposed Architecture + +### Create path (proposed) + +``` +ORM: instance.save() [TypeScript] + → mutation.innerCreate() [new function] + → for each @Flag: addLink(id, predicate, flagValue) + → for each @Property: addLink(id, predicate, literal://value) + → for each @HasMany: links already exist (added via addX()) + → captureSnapshot(instance) + → done +``` + +This mirrors the update path. The ORM reads decorator metadata directly and +creates links without going through SHACL. Each property is written **once** +with the correct value — no placeholder/overwrite dance. + +### What `innerCreate()` would look like + +```ts +async function innerCreate(ctx: MutationContext): Promise { + const metadata = getModelMetadata(ctx.instance.constructor); + + // 1. Write flag links (type markers) + for (const [key, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.flag) { + await ctx.perspective.add( + new Link({ + source: ctx.id, + predicate: propMeta.through, + target: propMeta.initial, // flag value + }), + ); + } + } + + // 2. Write scalar property links + for (const [key, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.flag || propMeta.readOnly) continue; + const value = ctx.instance[key]; + if (value === undefined || value === null) continue; + + const target = encodeAsLiteral(value, propMeta); + await ctx.perspective.add( + new Link({ + source: ctx.id, + predicate: propMeta.through, + target, + ...(propMeta.local ? { local: true } : {}), + }), + ); + } + + // 3. Write forward-relation links + // (Relations added via addX() before save() already have links — + // but relations set as initial data in create() need links too) + for (const [key, relMeta] of Object.entries(metadata.relations)) { + if (relMeta.direction === "reverse") continue; + const value = ctx.instance[key]; + if (!value) continue; + + const ids = Array.isArray(value) + ? value.map((v) => (typeof v === "string" ? v : v.id)) + : [typeof value === "string" ? value : value.id]; + + for (const targetId of ids) { + await ctx.perspective.add( + new Link({ + source: ctx.id, + predicate: relMeta.through, + target: targetId, + ...(relMeta.local ? { local: true } : {}), + }), + ); + } + } +} +``` + +### What `save()` becomes + +```ts +async save(batchId?: string) { + if (this._savedOnce) { + await mutation.innerUpdate(this._mutationContext(), batchId); + } else { + await mutation.innerCreate(this._mutationContext(), batchId); + } + this._savedOnce = true; +} +``` + +This is **much simpler** than the current flow which goes through +`createSubject` → SHACL → Rust. + +--- + +## What SHACL Should Still Be Used For + +SHACL shapes are valuable. They should stay — just not in the mutation path. + +### 1. Validation + +"Does this subgraph conform to the Channel shape?" SHACL was literally designed +for this. After creating/updating an instance, the runtime could optionally +validate the resulting subgraph against its shape. + +### 2. Interop / Schema Discovery + +Other agents or tools can read a perspective's SHACL shapes to understand what +data structures exist. "What does a Message look like in this neighbourhood?" +→ read the Message SHACL shape. + +### 3. `ensureSDNASubjectClass` / SurrealDB Indexing + +The Rust executor uses SHACL shapes to configure SurrealDB's indexing — which +predicates to index, what the expected cardinality is, etc. This is the right +use of SHACL and should continue unchanged. + +### 4. Documentation + +SHACL shapes are human-readable (in Turtle format) and describe the schema in a +standards-compliant way. They serve as living documentation of the data model. + +### Summary + +| Use case | Keep SHACL? | On critical path? | +| ----------------------------- | ----------------------------------- | ------------------------------------- | +| Creating instances | **No** → ORM creates links directly | Was on path, move off | +| Updating instances | Already not used | Already off path | +| Validation | **Yes** | Optional / background | +| Schema discovery / interop | **Yes** | Not on mutation path | +| SurrealDB index configuration | **Yes** | On init path (once), not per-mutation | +| Documentation | **Yes** | Not on any runtime path | + +--- + +## Implementation Plan + +### Step 1: Implement `innerCreate()` in `mutation.ts` + +Write the direct-link-creation function as shown above. It reads from the +decorator metadata registry (same source as `innerUpdate()`) and creates links +via the perspective API. + +**Prerequisite:** Understand how `encodeAsLiteral()` should work — the current +SHACL path uses `resolveLanguage` to determine how to encode values. The new +path needs the same logic. This likely already exists somewhere in the codebase +(check how `setProperty` in `mutation.ts` encodes values for updates). + +### Step 2: Add a feature flag + +```ts +const USE_DIRECT_CREATE = true; // flip to false to revert +``` + +In `save()`, when `!this._savedOnce`: + +- If flag is on: call `innerCreate()` directly +- If flag is off: call `createSubject()` (current SHACL path) + +This allows A/B testing and safe rollback. + +### Step 3: Ensure `ensureSDNASubjectClass` still works + +The SHACL shape must still be generated and registered with the perspective so +SurrealDB knows how to index the model. This is the `ensureSDNASubjectClass` / +`register()` call that happens at app startup — it's separate from per-instance +mutation and should continue unchanged. + +Verify that SurrealDB correctly indexes instances created via direct links +(without going through `createSubject`). The index should be based on link +patterns matching the SHACL shape, not on how the links were created. + +### Step 4: Run full test suite + +- All `model-query.test.ts` tests +- All `model-transactions.test.ts` tests +- Flux app smoke test (create channel, send message, pin channel) +- Verify SHACL shapes are still generated correctly (`generateSHACL()` is unchanged) + +### Step 5: Remove the SHACL mutation code path + +Once validated, remove `createSubject()` from the ORM's save path. Keep: + +- `generateSHACL()` for shape generation +- `ensureSDNASubjectClass()` for registration +- The Rust SHACL parser for index configuration + +Remove: + +- The `constructorActions` / `setterActions` interpretation in Rust (or leave it + for backward compatibility with older clients, but the ORM no longer uses it) +- The `initialValues` mechanism in `createSubject` + +--- + +## Risks and Considerations + +### 1. `createSubject` may do more than just create links + +The Rust `createSubject` implementation may have side effects beyond link +creation — e.g. notifying neighbourhood sync, triggering specific SurrealDB +operations, or updating internal state. These would need to be replicated or +triggered separately in the direct-create path. + +**Mitigation:** Audit the Rust `createSubject` implementation before starting. +If it does more than link creation, those side effects need to be available via +a separate API call. + +### 2. Batch operations + +The current `save(batchId)` pattern passes a batch ID to `createSubject`. The +direct-create path needs to pass the same batch ID to each `addLink()` call so +they're grouped in the same transaction. + +### 3. Backward compatibility + +Older versions of the Rust executor expect `createSubject` calls. If the +TypeScript ORM starts creating links directly, it must still work with both old +and new executor versions during the transition period. + +**Mitigation:** The feature flag (Step 2) allows reverting per-deployment. + +### 4. Neighbourhood sync + +When an instance is created via `createSubject`, the executor may signal +neighbourhood peers differently than when individual links are added. Verify +that direct link creation triggers the same sync behaviour. diff --git a/core/src/model/SUBSCRIPTION_STRATEGY.md b/core/src/model/SUBSCRIPTION_STRATEGY.md new file mode 100644 index 000000000..646f31775 --- /dev/null +++ b/core/src/model/SUBSCRIPTION_STRATEGY.md @@ -0,0 +1,317 @@ +# Ad4mModel Subscription Strategy + +## Background + +There are two distinct subscription systems in ad4m that need to be understood separately: + +1. **Prolog query subscriptions** — `perspective.subscribeInfer(query)` → `QuerySubscriptionProxy` → `perspectiveQuerySubscription` GQL subscription. Used for live Prolog inference. **Actively used, not touched.** +2. **SurrealDB query subscriptions (server-push)** — `perspective.subscribeSurrealDB(query)` → `QuerySubscriptionProxy (isSurrealDB=true)` → `perspectiveSubscribeSurrealQuery` mutation → Rust server-side re-query loop → pubsub push → `perspectiveQuerySubscription` GQL subscription. **This is the old system being replaced.** + +`Ad4mModel.subscribe()` is the new system that supersedes #2 entirely. + +--- + +## The Old Architecture (Server-Push) + +When a consumer called `perspective.subscribeSurrealDB(query)`, this is what happened: + +``` +Client Rust Executor +────── ───────────── +subscribeSurrealDB(query) + → perspectiveSubscribeSurrealQuery mutation ──────────→ subscribe_and_query_surreal() + runs query immediately + inserts SurrealSubscribedQuery { + query, last_result, + last_keepalive: Instant::now(), + user_email, + } + ← { subscriptionId, initialResult } ←──────────────── + + opens perspectiveQuerySubscription WS ──────────────→ (GQL subscription stream) + + [every 30s] + → perspectiveKeepAliveSurrealQuery ───────────────────→ query.last_keepalive = Instant::now() + + [on any link change in perspective] + trigger_surreal_subscription_check = true + surreal_subscription_cleanup_loop wakes up + for each SurrealSubscribedQuery: + re-runs SurrealQL query + if result changed: + publishes to PERSPECTIVE_QUERY_SUBSCRIPTION_TOPIC + + ← perspectiveQuerySubscription fires ←─────────────── + + [on dispose] + → perspectiveDisposeSurrealQuerySubscription ─────────→ removes from HashMap +``` + +**Problems:** + +- **Server-side state accumulation**: every active subscription is a `SurrealSubscribedQuery` entry in a `HashMap` inside `PerspectiveInstance`. The Rust process holds this state indefinitely until the client explicitly disposes or the keepalive times out. +- **Keepalive fragility**: if the client crashes, navigates away, or loses connectivity, the subscription leaks until the 30s timeout fires and the cleanup loop evicts it. During that window the server keeps re-running SurrealQL on every link change for a client that isn't listening. +- **Cleanup loop complexity**: `surreal_subscription_cleanup_loop`, `check_surreal_subscribed_queries`, `trigger_surreal_subscription_check`, `keepalive_surreal_query` — ~150 lines of Rust dedicated purely to managing lifecycle of a feature that can be eliminated. +- **Polling semantics hidden behind push API**: despite the push appearance, the Rust loop re-runs the full query on every link change. For large perspectives with many subscriptions, this is N query executions per link write where N = number of active subscriptions. No batching, no predicate filtering before re-query. +- **Round-trip latency for every update**: link change → Rust detects → re-queries SurrealDB → pubsub → WS → client. The client has the SurrealDB connection anyway and could have queried directly. + +--- + +## The New Architecture (Client-Side) + +`Ad4mModel.subscribe()` uses `PerspectiveProxy.addListener('link-added', ...)` and `addListener('link-removed', ...)`, which are backed by the existing `perspectiveLinkAdded` / `perspectiveLinkRemoved` GraphQL subscriptions — persistent WebSocket streams that already exist on every `PerspectiveProxy` instance. + +``` +Client (PerspectiveProxy + createSubscription) Rust Executor +────────────────────────────────────────── ───────────── +Ad4mModel.subscribe(perspective, options, cb) + + registers listener on perspectiveLinkAdded/Removed + (these WS subscriptions already exist — no new + connections opened) + + runs initial findAll() immediately ──────────────────→ SurrealQL query + ← results ←───────────────────────────────────────── + + invokes cb(results) immediately + + [on any link change] + perspectiveLinkAdded fires on WS ──→ + checkPredicateRelevance(link, metadata) + (is this predicate used by this model?) + if relevant: + re-runs findAll() ───────────────────────────────→ SurrealQL query + ← results ←───────────────────────────────────── + invokes cb(results) + + [on unsubscribe()] + removes listener from PerspectiveProxy arrays + (no server call needed — no server state to clean up) +``` + +**Properties:** + +- **Zero server state**: no Rust HashMap entries, no keepalive, no cleanup loop, no timeout +- **No extra connections**: piggybacks on the link-event WS subscriptions that `PerspectiveProxy` already maintains for every perspective +- **Client controls re-query**: the debounce and predicate relevance check happen in the same process as the caller — no round-trip before deciding whether to re-query +- **Composable with IncludeMap**: `findAll()` with `include` runs multiple SurrealDB queries client-side. The old system had no concept of includes — it ran a single flat query and the client would have needed to hydrate separately anyway +- **Correct failure mode**: if the client disconnects, the listener is garbage-collected with the `PerspectiveProxy`. There is nothing to clean up on the server. + +--- + +## Multi-User Node Compatibility + +This was the primary concern when evaluating whether the client-side approach was safe to adopt. + +### How multi-user nodes actually work + +Ad4m's multi-user node is **not** a stateless HTTP server. Every user connects via their own **persistent WebSocket connection** to the same executor process. Authentication is via JWT token containing `user_email`, resolved by `AgentContext::from_auth_token()`. + +The `perspectiveLinkAdded` subscription resolver in Rust already handles multi-user isolation: + +```rust +// subscription_resolvers.rs +async fn perspective_link_added(..., uuid: String) -> ... { + // 1. Verify the user's token grants access to this perspective + let user_email = user_email_from_token(context.auth_token.clone()); + if !can_access_perspective(&user_email, &handle) { + return Err("Access denied"); + } + + // 2. Filter pubsub events by "uuid|agent_did" + // Each user's WS only receives link events for perspectives they own/can access + let filter = get_agent_did_filter(context.auth_token.clone(), ...); + subscribe_and_process::(pubsub, topic, filter).await +} +``` + +**Consequence:** when `Ad4mModel.subscribe()` attaches a listener via `addListener('link-added', cb)`, that listener is only triggered by link events that have already passed the `uuid|agent_did` filter on the server. The access control that the old server-push system had to re-implement on `SurrealSubscribedQuery.user_email` is inherited for free from the link-event WS subscription. + +### Comparison + +| Concern | Old server-push | New client-side | +| ---------------------------- | -------------------------------------------------- | ------------------------------------------- | +| Works with multi-user nodes | ✅ (explicit `user_email` on subscription state) | ✅ (inherits WS-level DID filter) | +| Access control correctness | ⚠️ race window between link event and cleanup loop | ✅ no race — filter applied at event source | +| Requires persistent WS | ✅ (for keepalive) | ✅ (for link-event stream) | +| Works with HTTP-only (no WS) | ✅ keepalive via polling | ❌ (see Future section) | + +Both approaches require a persistent WebSocket. The new approach is not weaker — it has the same transport requirement but fewer moving parts. + +--- + +## Shared Subscription Registry (`createSubscription`) + +A naive client-side implementation would create one `addListener` call per `Ad4mModel.subscribe()` call. If 10 React components each subscribe to `Post.subscribe(perspective, { where: { published: true } }, cb)`, that's 10 independent listeners each firing a SurrealDB query on every link change. + +`createSubscription()` in `subscription.ts` prevents this via a shared registry: + +``` +WeakMap> +``` + +- **Same query + same perspective** → single shared `SubscriptionEntry` with one listener +- All 10 components share that one listener; only one SurrealDB query fires per link change +- When the last subscriber calls `unsubscribe()`, the `SubscriptionEntry` is torn down and the listener removed +- `stableQueryKey(query)` produces a deterministic fingerprint from the `Query` object (JSON-stable, handles object key ordering) +- Late subscribers (attaching after the first result has arrived) immediately receive the cached result, then receive live updates — no initial re-query + +This registry is the reason the client-side approach scales well for typical application use cases (multiple UI components observing the same model). + +--- + +## Debounce + +Rapid writes — e.g. a transaction writing 20 links at once — would trigger 20 re-queries without debouncing. `SubscribeOptions.debounce` (default: configurable, recommended 100–300ms for UI) collapses the burst into a single re-query after the last event. + +Debounce is on `subscribe()` options, not on `Query`, because it's a delivery concern not a data concern. `findAll()` is unaffected. + +--- + +## Predicate Relevance Check + +Before re-running a query, `createSubscription` checks whether the changed link's predicate is one that the model cares about. This is a fast in-memory check against `ModelMetadata.properties` and `ModelMetadata.relations` predicate lists. If the link change is for an unrelated predicate (e.g. a `rdf://comment` link changing when you're subscribed to `Post` which uses `rdf://title` and `flux://content`), no query is issued. + +This is a significant optimisation in busy perspectives with many concurrent model types. + +--- + +## Future Scaling Path + +The current approach has one real limitation: it requires a persistent WebSocket connection between each client and the executor. This is fine for the current deployment model (Electron app, `we` multi-user node, ad4m-connect). It would not work for a future hypothetical HTTP-only API. + +**If that becomes necessary, the correct path is SurrealDB native `LIVE SELECT`**, not the old polling loop: + +```sql +-- SurrealDB native live query (available today) +LIVE SELECT * FROM link WHERE predicate = 'rdf://title'; +-- SurrealDB pushes a diff to the connection on every matching write +``` + +SurrealDB's native live queries eliminate the server-side polling loop entirely — the database itself pushes deltas. The Rust executor would subscribe to relevant `LIVE SELECT` results and push them via pubsub, which is a much cleaner server-push implementation than re-running full queries on every link change. + +This is a future item, not a current concern. The client-side approach is correct for all current deployment targets. + +--- + +## AI Agents via MCP — Why This Is Not a Problem + +AI agents interacting with AD4M through a Model Context Protocol server are a **request-response client**, not a reactive subscriber. The concern — that removing the server-push SurrealDB system breaks MCP agent access — does not hold. + +### What MCP agents actually do + +Every MCP tool call is a discrete invocation: + +``` +Agent → MCP tool: listPosts(perspectiveId, { where: { status: "published" } }) + MCP server → Ad4mModel.findAll(perspective, { where: { status: "published" } }) + → SurrealQL query → results + MCP tool → Agent: [{ id, title, ... }, ...] +``` + +This is a plain `findAll()` call. Subscriptions never enter the picture. The agent queries when it needs data and receives an answer. There is no persistent listener to maintain, no keepalive to send, and nothing that requires the server-push infrastructure we deleted. + +### The right MCP server design + +An MCP server wrapping AD4M should be a **long-running process** that maintains a persistent `PerspectiveProxy` connection to the executor — the same as any other AD4M client (Flux, `we`, ad4m-connect). This means: + +- The WS connection for `perspectiveLinkAdded` / `perspectiveLinkRemoved` already exists +- If a tool needs to watch for changes (e.g., `waitForNewMessage`), `Ad4mModel.subscribe()` works correctly — it piggybacks on that existing WS stream +- `findAll()` tool calls are pure query → response, no subscription required + +A **stateless per-request** MCP server (Lambda-style, new connection per call) would make `Ad4mModel.subscribe()` pointless — a subscription that fires once and is then GC'd is just `findAll()`. But that design would have been equally broken with the old server-push system: the keepalive would never arrive, the `SurrealSubscribedQuery` entry would linger for 30 seconds, and the agent would have received zero change notifications anyway. + +### The old server-push was actually worse for agents + +An MCP server implementing "watch for changes" via the old `subscribeSurrealDB()` would have needed to: + +1. Call `perspectiveSubscribeSurrealQuery` mutation and store the `subscriptionId` +2. Open a `perspectiveQuerySubscription` WebSocket stream to receive push updates +3. Send `perspectiveKeepAliveSurrealQuery` every 30 seconds or lose the subscription +4. Explicitly call `perspectiveDisposeSurrealQuerySubscription` on cleanup + +That's four separate concerns, a persistent timer, and a resource leak risk if any step fails. The client-side approach needs none of it — attaching a listener and calling `unsubscribe()` is the entire API. + +### If the MCP server is stateless (HTTP-only) + +If the MCP server is genuinely stateless and has no persistent connection to the executor, the correct pattern for agents is **polling** — the agent re-calls the query tool whenever it needs fresh data. This is a deliberate choice that matches how LLM agents actually work in practice: an agent re-queries between reasoning steps, not via a background push stream. + +The "HTTP-only / no subscriptions" limitation row in the summary table below applies equally to a stateless MCP server. The solution remains the same: SurrealDB native `LIVE SELECT` if a server-push path is ever needed for stateless HTTP clients. + +--- + +## Prolog Subscriptions (`subscribeInfer`) — Why They Stay Server-Side + +### Are Prolog subscriptions still needed? + +Yes. The SHACL migration (PR #654) disabled Prolog for the _model system's internal query pipeline_ — `Ad4mModel.findAll()` no longer generates Prolog queries, and the SDNA pipeline no longer stores model definitions as Prolog facts. But Prolog is explicitly preserved as an **opt-in tool for hand-crafted queries** — recursive graph traversal, multi-hop reachability, constraint solving — where it has genuine expressive advantages over SurrealQL. `subscribeInfer` is the live/reactive version of `infer()` for those use cases, and it is actively tested in `tests/js/tests/perspective.ts`. + +### Why server-side is correct for Prolog (and not just a legacy holdover) + +The critical difference from SurrealDB: **there is no Prolog engine on the client**. SurrealDB queries are sent over the same Apollo WS connection and executed against a process the client shares. Prolog inference requires a SWI-Prolog (or similar) engine with the perspective's triple facts loaded — that engine lives entirely inside the Rust executor. + +The `PrologService` in `rust-executor` maintains a `SimpleEngine` struct per perspective per user, containing two dedicated `PrologEngine` instances: + +- `query_engine` — for ad-hoc `infer()` calls +- `subscription_engine` — a **separate, dedicated engine kept warm specifically for subscription re-runs** + +When `subscribeInfer(query)` registers a subscription, the Rust executor: + +1. Calls `ensure_engine_updated()` to load/refresh the perspective's Prolog facts into the `subscription_engine` +2. Runs the query immediately on that warm engine for the initial result +3. On every subsequent link change, `check_subscribed_queries()` re-runs the query on the **already-warm** `subscription_engine` — no re-initialization, no facts reload + +This dedicated warm engine is important. Loading a perspective's triple base into a fresh Prolog engine is non-trivial — the subscription pool exists specifically to amortize that cost across many re-runs. + +### Could you do Prolog subscriptions client-side anyway? + +Technically you could mirror the `Ad4mModel.subscribe()` pattern for Prolog: + +```typescript +// Hypothetical client-side Prolog subscription +addListener("link-added", async () => { + const result = await perspective.infer(query); + callback(result); +}); +``` + +This would work, but it is _worse_ than the server-push approach for Prolog specifically: + +| Factor | Server-push (current) | Client-side `infer()` on each change | +| -------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------- | +| Engine warm-up cost | Paid once, engine stays warm | Paid on every re-run (or via separate keep-warm mechanism) | +| Filtered pool reference counting | `subscription_ended()` decrements pool refs correctly | Never decremented — pool resources would leak | +| Fact freshness | Server coordinates fact update → re-query atomically | Race: `infer()` may arrive before facts are updated for the new link | +| `run_query_smart` routing | Dedicated `subscription_engine` path | Goes through general `query_engine`, competing with ad-hoc queries | + +The server-push model for Prolog is _architecturally motivated_, not just legacy. The server has context the client doesn't: it knows when the fact base has been updated, it has a pre-warmed engine, and it can reference-count pool resources correctly. + +### The fundamental asymmetry + +| | SurrealDB query subscription | Prolog query subscription | +| ----------------------------- | -------------------------------------------- | ------------------------------------------------------------ | +| Engine lives... | Client can connect to same SurrealDB | Server only (SWI-Prolog in Rust process) | +| Re-query cost | Stateless SQL-like query, negligible warm-up | Prolog engine init + fact loading, significant | +| Warm state benefit | None — each query is independent | High — `subscription_engine` stays loaded | +| Client can re-query directly? | ✅ Yes, same result, no overhead | ❌ No — must call `infer()` which goes to server anyway | +| Server-side advantage | None | Warm engine, atomic fact-update coordination, pool lifecycle | + +**Conclusion:** `subscribeInfer` staying server-side is the _correct_ architecture for Prolog, for the same reasons `Ad4mModel.subscribe()` moving client-side is the correct architecture for SurrealDB. The asymmetry isn't inconsistency — it reflects the fundamentally different nature of the two query engines. + +--- + +## Summary + +| Property | Old server-push | New client-side | +| ------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------- | +| Server state per subscription | ✅ HashMap entry + keepalive timer | ❌ none | +| Keepalive required | ✅ every 30s | ❌ not needed | +| Cleanup loop on server | ✅ ~150 lines Rust | ❌ deleted | +| Extra round-trip per update | ✅ link event → server re-query → push | ❌ client re-queries directly | +| IncludeMap hydration | ❌ server only ran flat query | ✅ full findAll() with includes | +| Shared registry (N components → 1 listener) | ❌ N server subscriptions | ✅ 1 shared listener | +| Debounce | ❌ not supported | ✅ configurable | +| Predicate relevance filtering | ❌ re-queries on every link change | ✅ skips irrelevant predicates | +| Multi-user node compatible | ✅ | ✅ (inherits WS-level DID filter) | +| HTTP-only compatible (future) | ✅ | ❌ (SurrealDB LIVE SELECT when needed) | +| MCP / AI agent compatible | ⚠️ keepalive burden, 4-step lifecycle | ✅ `findAll()` is plain query; `subscribe()` works in long-running MCP server | diff --git a/core/src/model/Subject.ts b/core/src/model/Subject.ts deleted file mode 100644 index 82a586a60..000000000 --- a/core/src/model/Subject.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; -import { collectionSetterToName, collectionToAdderName, collectionToRemoverName, collectionToSetterName, propertyNameToSetterName } from "./util"; - -/** - * Represents a subject in the perspective. - * A subject is an entity that has properties and collections. - */ -export class Subject { - #baseExpression: string; - #subjectClassName: string; - #perspective: PerspectiveProxy - - /** - * Constructs a new subject. - * @param perspective - The perspective that the subject belongs to. - * @param baseExpression - The base expression of the subject. - * @param subjectClassName - The class name of the subject. - */ - constructor(perspective: PerspectiveProxy, baseExpression: string, subjectClassName: string) { - this.#baseExpression = baseExpression - this.#subjectClassName = subjectClassName - this.#perspective = perspective - } - - /** - * Gets the base expression of the subject. - */ - get baseExpression() { - return this.#baseExpression - } - - /** - * Initializes the subject by validating it and defining its properties and collections dynamically. - * - * NOTE: This method should be called before using the subject. All the properties and collections of the subject defined are not type-checked. - */ - async init() { - // Check if the subject is a valid instance of the subject class - let isInstance = await this.#perspective.isSubjectInstance(this.#baseExpression, this.#subjectClassName) - if(!isInstance) { - throw `Not a valid subject instance of ${this.#subjectClassName} for ${this.#baseExpression}` - } - - // Define properties and collections dynamically - let results = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property(C, Property)`) - let properties = results.map(result => result.Property) - - for(let p of properties) { - const resolveExpressionURI = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_resolve(C, "${p}")`) - Object.defineProperty(this, p, { - configurable: true, - get: async () => { - // Use SurrealDB for data queries - try { - return await this.#perspective.getPropertyValueViaSurreal(this.#baseExpression, this.#subjectClassName, p); - } catch (err) { - console.warn(`Failed to get property ${p} via SurrealDB:`, err); - return undefined; - } - } - }) - } - - // Define setters - const setters = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_setter(C, Property, Setter)`) - - for(let setter of (setters ? setters : [])) { - if(setter) { - const property = setter.Property - const actions = eval(setter.Setter) - const resolveLanguageResults = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_resolve_language(C, "${property}", Language)`) - let resolveLanguage - if(resolveLanguageResults && resolveLanguageResults.length > 0) { - resolveLanguage = resolveLanguageResults[0].Language - } - this[propertyNameToSetterName(property)] = async (value: any) => { - if(resolveLanguage) { - value = await this.#perspective.createExpression(value, resolveLanguage) - } - await this.#perspective.executeAction(actions, this.#baseExpression, [{name: "value", value}]) - } - } - } - - // Define collections - let results2 = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection(C, Collection)`) - if(!results2) results2 = [] - let collections = results2.map(result => result.Collection) - - for(let c of collections) { - Object.defineProperty(this, c, { - configurable: true, - get: async () => { - // Use SurrealDB for data queries - try { - return await this.#perspective.getCollectionValuesViaSurreal(this.#baseExpression, this.#subjectClassName, c); - } catch (err) { - console.warn(`Failed to get collection ${c} via SurrealDB:`, err); - return []; - } - } - }) - } - - // Define collection adders - let adders = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_adder(C, Collection, Adder)`) - if(!adders) adders = [] - - for(let adder of adders) { - if(adder) { - const collection = adder.Collection - const actions = eval(adder.Adder) - this[collectionToAdderName(collection)] = async (value: any) => { - if (Array.isArray(value)) { - await Promise.all(value.map(v => this.#perspective.executeAction(actions, this.#baseExpression, [{name: "value", value: v}]))) - } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{name: "value", value}]) - } - } - } - } - - // Define collection removers - let removers = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_remover(C, Collection, Remover)`) - if(!removers) removers = [] - - for(let remover of removers) { - if(remover) { - const collection = remover.Collection - const actions = eval(remover.Remover) - this[collectionToRemoverName(collection)] = async (value: any) => { - if (Array.isArray(value)) { - await Promise.all(value.map(v => this.#perspective.executeAction(actions, this.#baseExpression, [{name: "value", value: v}]))) - } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{name: "value", value}]) - } - } - } - } - - // Define collection setters - let collectionSetters = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_setter(C, Collection, Setter)`) - if(!collectionSetters) collectionSetters = [] - - for(let collectionSetter of collectionSetters) { - if(collectionSetter) { - const collection = collectionSetter.Collection - const actions = eval(collectionSetter.Setter) - this[collectionToSetterName(collection)] = async (value: any) => { - if (Array.isArray(value)) { - await this.#perspective.executeAction(actions, this.#baseExpression, value.map(v => ({name: "value", value: v}))) - } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{name: "value", value}]) - } - } - } - } - } -} \ No newline at end of file diff --git a/core/src/model/TC39_STAGE_3_DECORATORS.md b/core/src/model/TC39_STAGE_3_DECORATORS.md new file mode 100644 index 000000000..2ee411e2e --- /dev/null +++ b/core/src/model/TC39_STAGE_3_DECORATORS.md @@ -0,0 +1,609 @@ +# TC39 Stage 3 Decorator Migration Plan + +> **Status:** Planning +> **Created:** 2026-02-28 +> **Estimated effort:** ~7–9 days + +--- + +## Table of Contents + +1. [Problem Statement](#problem-statement) +2. [Why TC39 Decorators](#why-tc39-decorators) +3. [Current Architecture (Legacy Decorators)](#current-architecture-legacy-decorators) +4. [Target Architecture (TC39 Decorators)](#target-architecture-tc39-decorators) +5. [Implementation Roadmap](#implementation-roadmap) + - [Phase 1: Upgrade TypeScript](#phase-1-upgrade-typescript) + - [Phase 2: Add `declare` to Flux Model Fields](#phase-2-add-declare-to-flux-model-fields) + - [Phase 3: Rewrite Decorators to TC39 Spec](#phase-3-rewrite-decorators-to-tc39-spec) + - [Phase 4: Update Ad4mModel Constructor](#phase-4-update-ad4mmodel-constructor) + - [Phase 5: Fix Projection (Remove Hacks)](#phase-5-fix-projection-remove-hacks) + - [Phase 6: Update All Consumer tsconfigs](#phase-6-update-all-consumer-tsconfigs) + - [Phase 7: Integration Testing](#phase-7-integration-testing) +6. [Effort Estimate](#effort-estimate) +7. [What You Get](#what-you-get) +8. [Gotchas & Risks](#gotchas--risks) +9. [Appendix: Current State Audit](#appendix-current-state-audit) + +--- + +## Problem Statement + +Ad4m's model system needs to: + +1. **Map class fields ↔ link-triple predicates** (`title` ↔ `"test://title"`) +2. **Generate SHACL shapes** (W3C standard) from the schema for `ensureSDNASubjectClass` +3. **Hydrate instances** from SurrealDB query results +4. **Persist via link mutations** (add/remove/set links) +5. **Provide instance methods** (`save()`, `addComments()`, `get()`, `delete()`) +6. **Support inheritance** (`TestDerivedModel extends TestBaseModel`) +7. **Work with Vue/React reactivity** (no `#private` fields) + +The current system uses **legacy TypeScript experimental decorators** (`experimentalDecorators: true`). These decorators receive `target` = the **class prototype**, and every decorator writes to it: + +```ts +// decorators.ts — current @Property implementation +Object.defineProperty(target, key, { configurable: true, writable: true }); +``` + +This creates properties like `TestPost.prototype.body = undefined`, `TestPost.prototype.title = undefined`, etc. on the **prototype**, not the instance. + +### Concrete bugs caused by this + +- **`delete instance.body` doesn't fully work** — removes the own property, but `'body' in instance` still returns `true` because `TestPost.prototype.body` exists. This broke our `properties` field projection feature. +- **Setter stubs on the prototype** — `target[setTitle] = () => {}` puts empty functions on the prototype that could mask real setter wiring if the constructor fails. +- **`Object.keys(instance)` includes hydrated data but not prototype-shadow fields** — leading to asymmetric behaviour depending on whether a field was hydrated or not. + +--- + +## Why TC39 Decorators + +| Aspect | Legacy (`experimentalDecorators`) | TC39 Stage 3 (TS ≥ 5.0) | +| --------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- | +| **Spec status** | Deprecated direction; based on an abandoned proposal | JavaScript standard (Stage 3, shipping in engines) | +| **`target` parameter** | The **prototype** | N/A — field decorators receive `undefined` + a `context` object | +| **Per-instance init** | Not available — must write to prototype | `context.addInitializer()` runs code in the constructor | +| **Metadata sharing** | Manual WeakMaps keyed on constructor | `context.metadata` — TC39-standard shared object per class | +| **`emitDecoratorMetadata`** | Supported (Reflect.getMetadata) | Not supported — use `context.metadata` instead | +| **Prototype pollution** | Inherent to the pattern | Impossible — field decorators don't receive the prototype | + +The public API stays **identical** — `@Property({ through: "test://title" })` looks exactly the same to consumers. + +--- + +## Current Architecture (Legacy Decorators) + +### How `@Property` works today + +```ts +// core/src/model/decorators.ts (current) +export function Property(opts: PropertyOptions) { + return function (target: T, key: keyof T) { + // 1. Register metadata in WeakMap keyed on constructor + const existing = propertyRegistry.get((target as any).constructor) ?? {}; + propertyRegistry.set((target as any).constructor, { + ...existing, + [key as string]: { ...existing[key as string], ...opts }, + }); + + // 2. Place setter stub on PROTOTYPE (pollution) + if (!opts.readOnly) { + target[`set${capitalize(key as string)}`] = () => {}; + } + + // 3. Place field descriptor on PROTOTYPE (pollution) + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; +} +``` + +**Steps 2 and 3 write to the prototype**, which is the root cause of all the issues. + +### How `@HasMany` / `@HasOne` work today + +Same pattern — register metadata, place `addX`/`removeX`/`setX` stubs on the prototype, call `Object.defineProperty` on the prototype. + +### How `@Model` works today + +Class decorator. Sets `target.prototype.className`, generates SHACL via `target.generateSHACL = function() { ... }`. This part is fine — class decorators always receive the constructor, not the prototype. + +### tsconfig settings + +| Project | `experimentalDecorators` | `emitDecoratorMetadata` | `target` | `useDefineForClassFields` | TypeScript version | +| ------------------- | ------------------------ | ----------------------- | -------- | ------------------------------------- | ------------------ | +| `ad4m/core` | `true` | `true` | `ES2020` | not set (default: `false` for ES2020) | `^4.6.2` | +| `flux/packages/api` | `true` | not set | `ES6` | not set (default: `false` for ES6) | inherited | +| `we` playground | `true` | `true` | `ES2022` | **`false`** (explicit) | inherited | + +### Field declaration patterns + +| Project | Pattern | Example | Count | +| -------------- | ------------------------------------- | --------------------- | --------------------------- | +| **Flux** | Bare type annotation (no initializer) | `body: string;` | ~60 fields across 22 models | +| **Flux** | With initializer (arrays) | `views: App[] = [];` | ~20 fields | +| **We** | Always has initializer | `body: string = "";` | ~30 fields across 10 models | +| **ad4m tests** | Always has initializer | `title: string = "";` | ~15 fields across 6 models | + +--- + +## Target Architecture (TC39 Decorators) + +### Core idea: field decorators only store metadata, class decorator collects and registers + +``` +┌─────────────────────────────────────────────────────────────┐ +│ @Property / @HasMany / @Flag (field decorators) │ +│ → write to context.metadata.__ad4m_properties / _relations │ +│ → NO writes to prototype │ +│ → return initializer function (pass-through or flag value) │ +└──────────────────────┬──────────────────────────────────────┘ + │ context.metadata is shared + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ @Model (class decorator) │ +│ → reads context.metadata to get all field/relation info │ +│ → registers in propertyRegistry / relationRegistry WeakMap │ +│ → attaches className, generateSHACL │ +└─────────────────────────────────────────────────────────────┘ +``` + +### `@Property` — TC39 version + +```ts +function Property(opts: PropertyOptions) { + return function ( + _value: undefined, // field decorators receive undefined + context: ClassFieldDecoratorContext, // TC39 context object + ) { + const key = String(context.name); + + // Store metadata in the shared context.metadata object. + // All field decorators on the same class share this object. + const meta = context.metadata as any; + meta.__ad4m_properties ??= {}; + meta.__ad4m_properties[key] = { + ...(meta.__ad4m_properties[key] ?? {}), + ...opts, + }; + + // Return an initializer function that runs when the field is assigned. + // For most fields this is a pass-through — the class field initializer + // (e.g. `body: string = ""`) provides the default value. + return (initialValue: any) => initialValue; + + // NO Object.defineProperty on prototype + // NO setter stubs on prototype + }; +} +``` + +### `@Flag` — TC39 version + +```ts +function Flag(opts: FlagOptions) { + return function (_value: undefined, context: ClassFieldDecoratorContext) { + const key = String(context.name); + + const meta = context.metadata as any; + meta.__ad4m_properties ??= {}; + meta.__ad4m_properties[key] = { + through: opts.through, + initial: opts.value, + flag: true, + readOnly: true, + required: true, + }; + + // Return initializer that sets the flag value — regardless of + // what the class field initializer says, flags always equal opts.value + return () => opts.value; + }; +} +``` + +### `@HasMany` — TC39 version + +```ts +function HasMany( + relatedModelOrOpts: (() => any) | RelationOptions, + opts?: RelationOptions, +) { + const resolvedOpts = + typeof relatedModelOrOpts === "function" ? opts! : relatedModelOrOpts; + const relatedModel = + typeof relatedModelOrOpts === "function" ? relatedModelOrOpts : undefined; + + return function (_value: undefined, context: ClassFieldDecoratorContext) { + const key = String(context.name); + + const meta = context.metadata as any; + meta.__ad4m_relations ??= {}; + meta.__ad4m_relations[key] = { + ...resolvedOpts, + direction: "forward" as const, + ...(relatedModel ? { relatedModel } : {}), + }; + + // Pass through the initializer (typically `= []`) + return (initialValue: any) => initialValue; + + // NO addX/removeX/setX stubs on prototype — + // these are wired in the Ad4mModel constructor already + }; +} +``` + +### `@HasOne`, `@BelongsToOne`, `@BelongsToMany` — same pattern + +Each stores its metadata in `context.metadata.__ad4m_relations` with the appropriate `direction` and `maxCount` values. No prototype writes. + +### `@Model` — TC39 version + +```ts +function Model(opts: ModelConfig) { + return function any>( + target: T, + context: ClassDecoratorContext, + ) { + // At this point, ALL field decorators have already run and populated + // context.metadata with __ad4m_properties and __ad4m_relations. + const meta = context.metadata as any; + const properties = meta.__ad4m_properties ?? {}; + const relations = meta.__ad4m_relations ?? {}; + + // Register in WeakMaps (same as today, but populated cleanly) + propertyRegistry.set(target, properties); + relationRegistry.set(target, relations); + + // Attach className (same as today) + target.prototype.className = opts.name; + target.className = opts.name; + + // Attach generateSHACL (same logic as today — reads from registries) + target.generateSHACL = function () { + // ... identical SHACL generation code ... + }; + + return target; + }; +} +``` + +### Inheritance support + +TC39 `context.metadata` uses prototype-based inheritance automatically: + +```ts +@Model({ name: "Base" }) +class Base extends Ad4mModel { + @Property({ through: "base://content" }) + content: string = ""; +} + +@Model({ name: "Derived" }) +class Derived extends Base { + @Property({ through: "derived://extra" }) + extra: string = ""; +} +// Derived[Symbol.metadata].__ad4m_properties inherits Base's via prototype chain +// getPropertiesMetadata(Derived) returns { content: {...}, extra: {...} } +``` + +The existing `getPropertiesMetadata()` / `getRelationsMetadata()` functions that walk the constructor prototype chain continue to work unchanged. + +--- + +## Implementation Roadmap + +### Phase 1: Upgrade TypeScript + +**Files:** root `package.json`, `core/package.json`, all `tsconfig.json` files +**Effort:** 1–2 days +**Risk:** High (touches all projects) + +1. Bump TypeScript to `^5.4` (or latest stable 5.x) in: + - `ad4m/core/package.json` (currently `^4.6.2`) + - Any other packages that pin their own TS version +2. In every `tsconfig.json`: + - Remove `"experimentalDecorators": true` + - Remove `"emitDecoratorMetadata": true` + - Set `"target": "ES2022"` or higher + - Set `"useDefineForClassFields": true` (explicit, even though it's the default for ES2022+) +3. Fix any type errors from the TS version bump (likely minimal) +4. Validate builds pass: `pnpm build` in ad4m root, `yarn build` in flux + +> **Note:** Do this on a dedicated branch. The tsconfig changes alone will break decorators until Phase 3 is complete, so Phases 1–3 should land together. + +### Phase 2: Add `declare` to Flux Model Fields + +**Files:** All ~22 model files in `flux/packages/api/src/` +**Effort:** 1 day +**Risk:** Low (mechanical transformation) + +With `useDefineForClassFields: true`, a bare field like: + +```ts +@Property({ through: BODY }) +body: string; +``` + +would emit `this.body = undefined` in the constructor, clobbering whatever the hydration pipeline sets. The fix: + +```ts +@Property({ through: BODY }) +declare body: string; +``` + +`declare` tells TypeScript "this field exists for type-checking but don't emit any runtime code." The decorator and hydration pipeline handle the actual value. + +**Rules:** + +| Field pattern | Action needed | +| --------------------------------------------- | ------------------------------------------------------------------------- | +| `body: string;` (no initializer) | Add `declare` → `declare body: string;` | +| `body: string = "";` (with initializer) | No change — initializer runs in constructor, creates own property | +| `views: App[] = [];` (array with initializer) | No change | +| `@Flag type: string;` | Add `declare` → `declare type: string;` (Flag initializer sets the value) | + +**We models** already use initializers on all fields, so they need **no changes** for this phase. + +**Ad4m test models** already use initializers on all fields, so they need **no changes** for this phase. + +### Phase 3: Rewrite Decorators to TC39 Spec + +**Files:** `core/src/model/decorators.ts` +**Effort:** 2–3 days +**Risk:** Medium (core logic unchanged, just new signatures) + +This is the core of the migration. Each decorator changes its function signature but keeps the same metadata-registration logic. See the [Target Architecture](#target-architecture-tc39-decorators) section above for the full new implementations. + +**Summary of changes per decorator:** + +| Decorator | What changes | What stays the same | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `@Property` | Signature → `(undefined, ClassFieldDecoratorContext)`. No `Object.defineProperty`. No setter stubs. Stores in `context.metadata`. | PropertyOptions interface. Metadata shape. | +| `@Flag` | Same as Property. Returns `() => opts.value` as initializer. | FlagOptions interface. | +| `@HasMany` | Signature → `(undefined, ClassFieldDecoratorContext)`. No `Object.defineProperty`. No add/remove/set stubs. Stores in `context.metadata.__ad4m_relations`. | RelationOptions interface. relatedModel factory. | +| `@HasOne` | Same as HasMany but adds `maxCount: 1`. | | +| `@BelongsToOne` | Same pattern, `direction: "reverse"`, `maxCount: 1`. | | +| `@BelongsToMany` | Same pattern, `direction: "reverse"`. | | +| `@Model` | Signature → `(target, ClassDecoratorContext)`. Reads `context.metadata` instead of WeakMaps directly. Registers in WeakMaps. SHACL generation unchanged. | ModelConfig interface. All SHACL logic. | + +**What's deleted:** + +- Every `Object.defineProperty(target, key, { configurable: true, writable: true })` line +- Every `target[setterName] = () => {}` line (setter stubs on prototype) +- Every `target[adderName] = () => {}` / `target[removerName] = () => {}` line + +**`Symbol.metadata` polyfill** — TC39 `context.metadata` requires `Symbol.metadata` to exist. TypeScript 5.2+ emits the polyfill automatically. For older environments, add at the top of `decorators.ts`: + +```ts +Symbol.metadata ??= Symbol("Symbol.metadata"); +``` + +### Phase 4: Update Ad4mModel Constructor + +**File:** `core/src/model/Ad4mModel.ts` +**Effort:** 0.5 day +**Risk:** Low + +The constructor already wires `addX`/`removeX`/`setX` methods from the relation registry: + +```ts +constructor(perspective: PerspectiveProxy, id?: string) { + this._id = id ? id : Literal.from(makeRandomId(24)).toUrl(); + this._perspective = perspective; + + // Wire up real relation mutator methods (already exists, no change needed) + const relations = getRelationsMetadata(proto.constructor); + for (const key of Object.keys(relations)) { + if (relations[key].direction === "reverse") continue; + const cap = capitalize(key); + this[`add${cap}`] = (value, batchId?) => mutation.setRelationAdder(...); + this[`remove${cap}`] = (value, batchId?) => mutation.setRelationRemover(...); + this[`set${cap}`] = (value, batchId?) => mutation.setRelationSetter(...); + } +} +``` + +This **doesn't need to change** — it reads from `relationRegistry` which is populated by `@Model` in Phase 3. The setter stubs that were previously on the prototype are no longer needed because the constructor was already overwriting them with real implementations. + +**Optional enhancement:** add a `getModelMetadata()` path that can read from `Symbol.metadata` on the constructor as a fallback: + +```ts +static getModelMetadata(): ModelMetadata { + // Primary: WeakMap (populated by @Model) + const fromRegistry = _getModelMetadata(this); + if (fromRegistry) return fromRegistry; + + // Fallback: Symbol.metadata (TC39 standard) + const meta = (this as any)[Symbol.metadata]; + if (meta) { + return { + className: this.prototype.className, + properties: meta.__ad4m_properties ?? {}, + relations: meta.__ad4m_relations ?? {}, + }; + } + + throw new Error("Model metadata not found — is @Model applied?"); +} +``` + +### Phase 5: Fix Projection (Remove Hacks) + +**Files:** + +- `core/src/model/query/operations.ts` +- `tests/js/tests/model/model-query.test.ts` + +**Effort:** 0.5 day +**Risk:** Low + +With no prototype-shadow properties, `delete instance.body` will make `'body' in instance` return `false` correctly. + +**Revert test assertions** from: + +```ts +expect(r).to.not.have.own.property("body"); // current hack +``` + +back to: + +```ts +expect(r).to.not.have.property("body"); // clean assertion +``` + +The projection code in `operations.ts` (`delete instance[key]`) works correctly with zero workarounds. + +### Phase 6: Update All Consumer tsconfigs + +**Files:** Every `tsconfig.json` across `ad4m`, `flux`, `we` +**Effort:** 0.5 day +**Risk:** Low + +Ensure all projects have: + +```jsonc +{ + "compilerOptions": { + // Remove these: + // "experimentalDecorators": true, + // "emitDecoratorMetadata": true, + + // Add/ensure these: + "target": "ES2022", + "useDefineForClassFields": true, + }, +} +``` + +**Affected tsconfig counts:** + +- Flux: ~20 tsconfig files with `experimentalDecorators: true` +- We: ~4 tsconfig files +- Ad4m: core + test-runner configs + +### Phase 7: Integration Testing + +**Effort:** 1–2 days +**Risk:** Medium + +1. **Ad4m model tests:** `cd tests/js && pnpm test-model` — all query, transaction, and dirty-tracking tests +2. **SHACL validation:** Compare generated Turtle output before/after migration — must be byte-identical +3. **Flux build + dev:** `cd flux && yarn build && yarn dev` — ensure all 22 models hydrate correctly +4. **We playground:** Build and run the ad4m-model-testing playground +5. **Flux channel pinning:** The original bug that triggered this whole investigation — verify pinning a channel no longer duplicates conversations + +--- + +## Effort Estimate + +| Phase | Effort | Risk | +| -------------------------------- | ------------- | --------------------------------------------- | +| 1. Upgrade TypeScript | 1–2 days | High — touches all projects | +| 2. Add `declare` to Flux models | 1 day | Low — mechanical | +| 3. Rewrite decorators | 2–3 days | Medium — core logic unchanged, new signatures | +| 4. Update Ad4mModel constructor | 0.5 day | Low | +| 5. Fix projection (remove hacks) | 0.5 day | Low | +| 6. Update tsconfigs | 0.5 day | Low | +| 7. Integration testing | 1–2 days | Medium | +| **Total** | **~7–9 days** | | + +> **Phases 1–3 must land together** on a single branch. The tsconfig changes break legacy decorators, and the new decorators require the tsconfig changes. + +--- + +## What You Get + +- **Zero prototype pollution** — decorators never write to the prototype +- **`delete instance.field` works correctly** — no phantom properties from prototype chain +- **Future-proof** — using the JavaScript standard, not a deprecated TS experiment +- **Cleaner separation of concerns** — field decorators only store metadata, class decorator collects and registers +- **`context.metadata`** — the TC39-standard way to share data between decorators, replacing manual WeakMap coordination +- **Same public API** — `@Property({ through: "..." })`, `@HasMany(...)`, etc. look identical to consumers +- **Same SHACL output** — the generated shapes are unchanged +- **Dirty tracking just works** — no prototype shadows to confuse snapshot comparisons +- **Projection just works** — `delete` fully removes properties, no `.own.property` hacks needed + +--- + +## Gotchas & Risks + +### 1. `emitDecoratorMetadata` is not supported by TC39 decorators + +If anything relies on `Reflect.getMetadata()` at runtime, it needs to be replaced with `context.metadata`. The Ad4m codebase **does not appear to use** `Reflect.getMetadata()`, so this should be fine. Verify with: + +```bash +grep -r "Reflect.getMetadata\|Reflect.defineMetadata\|reflect-metadata" --include="*.ts" core/src/ +``` + +### 2. Decorator execution order changes slightly + +In legacy decorators, field decorators run when the class is defined (at class parse time). In TC39, field decorator **initializers** run in the constructor. However, the metadata-via-`context.metadata` approach avoids timing issues because: + +- Field decorators populate `context.metadata` at class definition time (the decorator function itself runs at definition time — only the _initializer_ it returns runs per-instance) +- The `@Model` class decorator runs at class definition time and reads the already-populated `context.metadata` + +### 3. `Symbol.metadata` polyfill + +TC39 `context.metadata` requires `Symbol.metadata` to exist. TypeScript 5.2+ emits the polyfill automatically. For older environments or bundlers that strip it, add at the top of `decorators.ts`: + +```ts +Symbol.metadata ??= Symbol("Symbol.metadata"); +``` + +### 4. Flux models without initializers need `declare` + +With `useDefineForClassFields: true`, a bare `body: string;` emits `this.body = undefined` in the constructor. This would clobber hydrated values. The fix is `declare body: string;`. + +**Enforcement:** Add an ESLint rule or a custom lint check that flags model fields (decorated with `@Property`, `@HasMany`, etc.) that lack either `declare` or an initializer. + +### 5. Third-party decorator libraries + +Check if any dependencies use `experimentalDecorators` in their published types. Most libraries ship plain `.d.ts` files that don't depend on decorator mode, but verify. + +### 6. Vue reactivity + +Vue 3's reactivity system uses Proxy-based tracking. `declare` fields that are later set by hydration will still be reactive — Vue tracks property access/mutation on the proxy, regardless of whether the property was declared with `declare` or an initializer. The `#private` concern (which breaks Vue proxies) is unrelated and already avoided in Ad4mModel. + +--- + +## Appendix: Current State Audit + +### Model count across projects + +| Project | Model classes | Fields without initializer | Uses `declare` | +| ---------- | ------------- | -------------------------------------------- | -------------- | +| Flux | 22 | ~60 fields (all `@Property`/`@Flag` scalars) | No | +| We | 10 | 0 (all have initializers) | No | +| Ad4m tests | 6 | 0 (all have initializers) | No | + +### Prototype properties created by current decorators + +For a model like `TestPost` with `@Property title`, `@Property body`, `@Property viewCount`, `@HasMany tags`, `@HasMany comments`, `@HasOne pinnedComment`: + +``` +TestPost.prototype own keys: + constructor, type, setTitle, title, setBody, body, + setViewCount, viewCount, addTags, removeTags, setTags, tags, + addComments, removeComments, setComments, comments, + addPinnedComment, removePinnedComment, setPinnedComment, pinnedComment, className +``` + +Every `@Property` creates 2 prototype entries (field + setter stub). +Every `@HasMany`/`@HasOne` creates 4 prototype entries (field + add + remove + set stubs). + +**After TC39 migration:** `TestPost.prototype` will only have `constructor` and `className`. Everything else is either an own property (from class field initializers) or wired in the `Ad4mModel` constructor (mutator methods). + +### Files to modify + +| File | Phase | Change | +| ----------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------ | +| `ad4m/core/package.json` | 1 | Bump `typescript` to `^5.4` | +| All `tsconfig.json` (~24 files) | 1, 6 | Remove `experimentalDecorators`/`emitDecoratorMetadata`, set `target: ES2022`, add `useDefineForClassFields: true` | +| `flux/packages/api/src/**/*.ts` (~22 files) | 2 | Add `declare` to ~60 bare-typed fields | +| `ad4m/core/src/model/decorators.ts` | 3 | Full decorator rewrite | +| `ad4m/core/src/model/Ad4mModel.ts` | 4 | Optional: add `Symbol.metadata` fallback in `getModelMetadata()` | +| `ad4m/core/src/model/query/operations.ts` | 5 | No code change needed — `delete` just works now | +| `ad4m/tests/js/tests/model/model-query.test.ts` | 5 | Revert `.own.property` → `.have.property` | diff --git a/core/src/model/decorators.ts b/core/src/model/decorators.ts index 2b8afcdcc..dd769268f 100644 --- a/core/src/model/decorators.ts +++ b/core/src/model/decorators.ts @@ -1,1116 +1,759 @@ import { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; -import { Subject } from "./Subject"; -import { capitalize, propertyNameToSetterName, singularToPlural, stringifyObjectLiteral } from "./util"; +import { + capitalize, + propertyNameToSetterName, + stringifyObjectLiteral, +} from "./util"; import { SHACLShape, SHACLPropertyShape } from "../shacl/SHACLShape"; -export class PerspectiveAction { - action: string - source: string - predicate: string - target: string -} - -export function addLink(source: string, predicate: string, target: string): PerspectiveAction { - return { - action: "addLink", - source, - predicate, - target, - }; +// Module-level WeakMaps keyed on the constructor function (not the prototype). +// Each class constructor is a unique key, so subclass decorators write into their +// own entry rather than the parent prototype's — preventing silent metadata corruption +// across inherited classes (e.g. BaseBlock and PollBlock stay independent). +export const propertyRegistry = new WeakMap>(); +export const relationRegistry = new WeakMap>(); + +/** Returns the own + inherited property metadata for a given constructor, with own values winning. */ +export function getPropertiesMetadata(ctor: Function): Record { + if (!ctor) return {}; + const own = propertyRegistry.get(ctor) ?? {}; + const parent = Object.getPrototypeOf(ctor); + if (!parent || parent === Function.prototype) return own; + return { ...getPropertiesMetadata(parent), ...own }; } -export function hasLink(predicate: string): string { - return `triple(this, "${predicate}", _)` +/** Returns the own + inherited relation metadata for a given constructor, with own values winning. */ +export function getRelationsMetadata(ctor: Function): Record { + if (!ctor) return {}; + const own = relationRegistry.get(ctor) ?? {}; + const parent = Object.getPrototypeOf(ctor); + if (!parent || parent === Function.prototype) return own; + return { ...getRelationsMetadata(parent), ...own }; } -export interface InstanceQueryParams { -/** - * An object representing the WHERE clause of the query. - */ -where?: object; - /** - * A string representing the Prolog condition clause of the query. + * Builds a plain, JSON-safe object from a model instance. + * + * Only includes `id` (if present) and decorator-registered \@Property keys. + * All other own enumerable properties are stripped — most importantly the + * TypeScript `private _perspective` field which is stored as a regular + * enumerable property at runtime and holds a PerspectiveProxy whose Apollo + * InMemoryCache creates a circular reference in `JSON.stringify`. + * + * Falls back to a shallow spread for plain objects with no registered props + * (e.g. raw link objects passed through before model hydration). */ -prologCondition?: string; +export function instanceToSerializable(instance: any): Record { + if (!instance || typeof instance !== "object") return {}; + const ctor = Object.getPrototypeOf(instance)?.constructor as + | Function + | undefined; + const registeredProps = ctor ? getPropertiesMetadata(ctor) : null; + const registeredRels = ctor ? getRelationsMetadata(ctor) : null; + const propKeys = registeredProps ? Object.keys(registeredProps) : null; + const relKeys = registeredRels ? Object.keys(registeredRels) : null; + const allKeys = + (propKeys ?? relKeys) ? [...(propKeys ?? []), ...(relKeys ?? [])] : null; + if (!allKeys || allKeys.length === 0) { + // Plain object / no @Property decorators — safe to spread. + return { ...(instance as object) }; + } + const obj: Record = {}; + if ((instance as any).id !== undefined) { + obj.id = (instance as any).id; + } + for (const key of allKeys) { + const val = (instance as any)[key]; + // For relation arrays containing model instances, serialize only their IDs + // to avoid circular references and keep the fingerprint lightweight. + if (Array.isArray(val)) { + obj[key] = val.map((item: any) => + item && typeof item === "object" && item.id !== undefined + ? item.id + : item, + ); + } else { + obj[key] = val; + } + } + return obj; } -/** - * Decorator for querying instances of a model class. - * - * @category Decorators - * - * @description - * Allows you to define static query methods on your model class to retrieve instances based on custom conditions. - * This decorator can only be applied to static async methods that return a Promise of an array of model instances. - * - * The query can be constrained using either: - * - A `where` clause that matches property values - * - A custom Prolog `condition` for more complex queries - * - * @example - * ```typescript - * class Recipe extends Ad4mModel { - * @Property({ through: "recipe://name" }) - * name: string = ""; - * - * @Property({ through: "recipe://rating" }) - * rating: number = 0; - * - * // Get all recipes - * @InstanceQuery() - * static async all(perspective: PerspectiveProxy): Promise { return [] } - * - * // Get recipes by name - * @InstanceQuery({ where: { name: "Chocolate Cake" }}) - * static async findByName(perspective: PerspectiveProxy): Promise { return [] } - * - * // Get highly rated recipes using a custom condition - * @InstanceQuery({ prologCondition: "triple(Instance, 'recipe://rating', Rating), Rating > 4" }) - * static async topRated(perspective: PerspectiveProxy): Promise { return [] } - * } - * ``` - * - * @param {Object} [options] - Query options - * @param {object} [options.where] - Object with property-value pairs to match - * @param {string} [options.prologCondition] - Custom Prolog condition for more complex queries - */ -export function InstanceQuery(options?: InstanceQueryParams) { - return function (target: T, key: keyof T, descriptor: PropertyDescriptor) { - const originalMethod = descriptor.value; - if(typeof originalMethod !== "function") { - throw new Error("InstanceQuery decorator can only be applied to methods"); - } +export class PerspectiveAction { + action: string; + source: string; + predicate: string; + target: string; +} - descriptor.value = async function(perspective: PerspectiveProxy): Promise { - let instances: T[] = [] - //@ts-ignore - let subjectClassName = target.name - let query = `subject_class("${subjectClassName}", C), instance(C, Instance)` - if(options && options.where) { - for(let prop in options.where) { - let value = options.where[prop] - query += `, property_getter(C, Instance, "${prop}", "${value}")` - } - } - - if(options && options.prologCondition) { - query += ', ' + options.prologCondition - } - - // Try Prolog first - try { - let results = await perspective.infer(query) - if(results && results !== false && typeof results !== "string" && results.length > 0) { - for(let result of results) { - let instance = result.Instance - let subject = new Subject(perspective, instance, subjectClassName) - await subject.init() - instances.push(subject as T) - } - return instances - } - } catch (e) { - // Prolog failed, fall through to SurrealDB - } - - // Fallback to SurrealDB (SdnaOnly mode) - // Get all instances first - pass the class constructor, not just the name - let allInstances = await perspective.getAllSubjectInstances(target) - - // Filter by where clause if provided - if(options && options.where) { - let filtered = [] - for(let instance of allInstances) { - let matches = true - for(let prop in options.where) { - let expectedValue = options.where[prop] - //@ts-ignore - let actualValue = await instance[prop] - if(actualValue !== expectedValue) { - matches = false - break - } - } - if(matches) { - filtered.push(instance as T) - } - } - return filtered - } - - return allInstances as T[] - } - }; +export function addLink( + source: string, + predicate: string, + target: string, +): PerspectiveAction { + return { + action: "addLink", + source, + predicate, + target, + }; } +export function hasLink(predicate: string): string { + return `triple(this, "${predicate}", _)`; +} export interface PropertyOptions { - /** - * The predicate of the property. All properties must have this option. - */ - through?: string; - - /** - * The initial value of the property. Required if the property is marked as required. - */ - initial?: string; - - /** - * Indicates whether the property is required. If true, an initial value must be provided. - */ - required?: boolean; - - /** - * Indicates whether the property is writable. If true, a setter will be available in the prolog engine. - */ - writable?: boolean; - - /** - * The language used to store the property. Can be the default `Literal` Language or a custom language address. - */ - resolveLanguage?: string; - - /** - * Custom Prolog getter to get the value of the property. If not provided, the default getter will be used. - */ - prologGetter?: string; - - /** - * Custom Prolog setter to set the value of the property. Only available if the property is writable. - */ - prologSetter?: string; - - /** - * Custom SurrealQL getter to resolve the property value. Use this for custom graph traversals. - * The expression can reference 'Base' which will be replaced with the instance's base expression. - * Example: "(<-link[WHERE predicate = 'flux://has_reply'].in.uri)[0]" - */ - getter?: string; - - /** - * Indicates whether the property is stored locally in the perspective and not in the network. Useful for properties that are not meant to be shared with the network. - */ - local?: boolean; - - /** - * Optional transform function to modify the property value after it is retrieved. - * This is useful for transforming raw data into a more usable format. - * The function takes the raw value as input and returns the transformed value. - */ - transform?: (value: any) => any; + /** + * The predicate of the property. All properties must have this option. + */ + through?: string; + + /** + * The initial value written by the SHACL constructor action. + * + * For non-readOnly properties this is **optional** — Ad4mModel + * automatically derives a placeholder and overwrites it with the real + * instance field value when `save()` is called. Only set this explicitly + * when you need a specific non-literal default URI (e.g. a custom + * `resolveLanguage` address) or want a sentinel value if the property + * is never set. + */ + initial?: string; + + /** + * Indicates whether the property is required. If true, an initial value must be provided. + */ + required?: boolean; + + /** + * Marks the property as read-only — no setter action will be generated and + * the property cannot be updated via the model layer. Defaults to false + * (writable) when `through` is set. + */ + readOnly?: boolean; + + /** + * The language used to resolve the stored expression into a JS value. + * + * Omitting this (the common case) is equivalent to `"literal"` — scalar values + * (string / number / boolean) are encoded as `literal://` URIs automatically. + * Only specify this when you need a non-literal language (e.g. a custom IPFS + * language address) or want to be explicit for documentation purposes. + */ + resolveLanguage?: string; + + /** + * Custom SurrealQL getter to resolve the property value. Use this for custom graph traversals. + * The expression can reference 'Base' which will be replaced with the instance's base expression. + * Example: "(<-link[WHERE predicate = 'flux://has_reply'].in.uri)[0]" + */ + getter?: string; + + /** + * Indicates whether the property is stored locally in the perspective and not in the network. Useful for properties that are not meant to be shared with the network. + */ + local?: boolean; + + /** + * Optional transform function to modify the property value after it is retrieved. + * This is useful for transforming raw data into a more usable format. + * The function takes the raw value as input and returns the transformed value. + */ + transform?: (value: any) => any; + + /** + * Indicates that this property is a @Flag — a fixed predicate/value pair written + * once by the createSubject constructor action and never changed. Flag properties + * are immutable and will be skipped during updates. + */ + flag?: boolean; } - /** - * Decorator for defining optional properties on model classes. - * - * @category Decorators - * - * @description - * The most flexible property decorator that allows you to define properties with full control over: - * - Whether the property is required - * - Whether the property is writable - * - How values are stored and retrieved - * - Custom getter/setter logic - * - Local vs network storage - * - * Both @Property and @ReadOnly are specialized versions of @Optional with preset configurations. - * - * @example - * ```typescript - * class Recipe extends Ad4mModel { - * // Basic optional property - * @Optional({ - * through: "recipe://description" - * }) - * description?: string; - * - * // Optional property with custom initial value - * @Optional({ - * through: "recipe://status", - * initial: "recipe://draft", - * required: true - * }) - * status: string = ""; - * - * // Read-only property with custom getter - * @Optional({ - * through: "recipe://rating", - * writable: false, - * getter: ` - * findall(Rating, triple(Base, "recipe://user_rating", Rating), Ratings), - * sum_list(Ratings, Sum), - * length(Ratings, Count), - * Value is Sum / Count - * ` - * }) - * averageRating: number = 0; - * - * // Property that resolves to a Literal and is stored locally - * @Optional({ - * through: "recipe://notes", - * resolveLanguage: "literal", - * local: true - * }) - * notes?: string; - * - * // Property with custom getter and setter logic - * @Optional({ - * through: "recipe://ingredients", - * getter: ` - * triple(Base, "recipe://ingredients", RawValue), - * atom_json_term(RawValue, Value) - * `, - * setter: ` - * atom_json_term(Value, JsonValue), - * Actions = [{"action": "setSingleTarget", "source": "this", "predicate": "recipe://ingredients", "target": JsonValue}] - * ` - * }) - * ingredients: string[] = []; - * } - * ``` - * - * @param {PropertyOptions} opts - Property configuration options - * @param {string} opts.through - The predicate URI for the property - * @param {string} [opts.initial] - Initial value (required if property is required) - * @param {boolean} [opts.required] - Whether the property must have a value - * @param {boolean} [opts.writable=true] - Whether the property can be modified - * @param {string} [opts.resolveLanguage] - Language to use for value resolution (e.g. "literal") - * @param {string} [opts.prologGetter] - Custom Prolog code for getting the property value - * @param {string} [opts.prologSetter] - Custom Prolog code for setting the property value - * @param {boolean} [opts.local] - Whether the property should only be stored locally + * Declares a typed property backed by a single link triple in the perspective. + * + * @param opts.through - Predicate URI (required) + * @param opts.initial - Default value written by the constructor action (auto-derived for non-readOnly properties; only needed for custom URIs) + * @param opts.required - Adds `sh:minCount 1` to the SHACL shape + * @param opts.readOnly - Skips setter generation; property cannot be updated after creation (default: false) + * @param opts.resolveLanguage - Language for value resolution (`"literal"`, etc.) + * @param opts.local - Store only in local perspective, not shared with the network + * @param opts.getter - Custom SurrealQL expression for computed / read-only properties + * @param opts.transform - Post-fetch transform applied to the raw value */ -export function Optional(opts: PropertyOptions) { - return function (target: T, key: keyof T) { - if(typeof opts.writable === "undefined" && opts.through) { - opts.writable = true - } - - if (opts.required && !opts.initial) { - throw new Error("SubjectProperty requires an 'initial' option if 'required' is true"); - } - - if (!opts.through && !opts.prologGetter) { - throw new Error("SubjectProperty requires either 'through' or 'prologGetter' option") - } +export function Property(opts: PropertyOptions) { + return function (target: T, key: keyof T) { + if (!opts.through) { + throw new Error("@Property requires a 'through' option"); + } - target["__properties"] = target["__properties"] || {}; - target["__properties"][key] = target["__properties"][key] || {}; - target["__properties"][key] = { ...target["__properties"][key], ...opts } + const _propertyExisting = + propertyRegistry.get((target as any).constructor) ?? {}; + const _propertyExistingKey = _propertyExisting[key as string] ?? {}; + propertyRegistry.set((target as any).constructor, { + ..._propertyExisting, + [key as string]: { ..._propertyExistingKey, ...opts }, + }); - if (opts.writable) { - const value = key as string - target[`set${capitalize(value)}`] = () => {} - } + if (!opts.readOnly) { + const value = key as string; + target[`set${capitalize(value)}`] = () => {}; + } - Object.defineProperty(target, key, {configurable: true, writable: true}); - }; + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; } export interface FlagOptions { - /** - * The predicate of the property. All properties must have this option. - */ - through: string; - - /** - * The value of the property. - */ - value: string; + /** + * The predicate of the property. All properties must have this option. + */ + through: string; + + /** + * The value of the property. + */ + value: string; } /** - * Decorator for defining flags on model classes. - * - * @category Decorators - * - * @description - * A specialized property decorator for defining immutable type flags or markers on model instances. - * Flags are always required properties with a fixed value that cannot be changed after creation. - * - * Common uses for flags: - * - Type discrimination between different kinds of models - * - Marking models with specific capabilities or features - * - Versioning or compatibility markers - * - * Note: Use of Flag is discouraged unless you specifically need type-based filtering or - * discrimination between different kinds of models. For most cases, regular properties - * with @Property or @Optional are more appropriate. - * + * Immutable type-marker property: written once at creation and never modified. + * + * Use for type-discrimination predicates (e.g. `ad4m://type = "ad4m://message"`). + * For mutable data prefer `@Property`. + * * @example * ```typescript - * class Message extends Ad4mModel { - * // Type flag to identify message models - * @Flag({ - * through: "ad4m://type", - * value: "ad4m://message" - * }) - * type: string = ""; - * - * // Version flag for compatibility - * @Flag({ - * through: "ad4m://version", - * value: "1.0.0" - * }) - * version: string = ""; - * - * // Feature flag - * @Flag({ - * through: "message://feature", - * value: "message://encrypted" - * }) - * feature: string = ""; - * } - * - * // Later you can query for specific types: - * const messages = await Message.query(perspective) - * .where({ type: "ad4m://message" }) - * .run(); + * @Flag({ through: "ad4m://type", value: "ad4m://message" }) + * type: string = ""; * ``` - * - * @param {FlagOptions} opts - Flag configuration - * @param {string} opts.through - The predicate URI for the flag - * @param {string} opts.value - The fixed value for the flag */ export function Flag(opts: FlagOptions) { - return function (target: T, key: keyof T) { - if (!opts.through && !opts.value) { - throw new Error("SubjectFlag requires a 'through' and 'value' option") - } + return function (target: T, key: keyof T) { + if (!opts.through && !opts.value) { + throw new Error("SubjectFlag requires a 'through' and 'value' option"); + } - if (!opts.through) { - throw new Error("SubjectFlag requires a 'through' option") - } + if (!opts.through) { + throw new Error("SubjectFlag requires a 'through' option"); + } - if (!opts.value) { - throw new Error("SubjectFlag requires a 'value' option") - } + if (!opts.value) { + throw new Error("SubjectFlag requires a 'value' option"); + } - target["__properties"] = target["__properties"] || {}; - target["__properties"][key] = target["__properties"][key] || {}; - target["__properties"][key] = { - ...target["__properties"][key], - through: opts.through, - required: true, - initial: opts.value, - flag: true - } + const _flagExisting = + propertyRegistry.get((target as any).constructor) ?? {}; + propertyRegistry.set((target as any).constructor, { + ..._flagExisting, + [key as string]: { + ...(_flagExisting[key as string] ?? {}), + through: opts.through, + required: true, + initial: opts.value, + flag: true, + readOnly: true, // Flags are always immutable after creation + }, + }); - // @ts-ignore - target[key] = opts.value; + // @ts-ignore + target[key] = opts.value; - Object.defineProperty(target, key, {configurable: true, writable: true}); - }; + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; } -interface WhereOptions { - isInstance?: any - prologCondition?: string - condition?: string +export interface RelationOptions { + /** + * The predicate of the property. All properties must have this option. + */ + through: string; + + /** + * Custom SurrealQL getter to resolve the related values. Use this for custom graph traversals. + * The expression can reference 'Base' which will be replaced with the instance's base expression. + * Example: "(<-link[WHERE predicate = 'flux://has_reply'].in.uri)" + */ + getter?: string; + + /** + * Indicates whether the property is stored locally in the perspective and not in the network. Useful for properties that are not meant to be shared with the network. + */ + local?: boolean; } -export interface CollectionOptions { - /** - * The predicate of the property. All properties must have this option. - */ - through: string; - - /** - * An object representing the WHERE clause of the query. - */ - where?: WhereOptions; - - /** - * Custom SurrealQL getter to resolve the collection values. Use this for custom graph traversals. - * The expression can reference 'Base' which will be replaced with the instance's base expression. - * Example: "(<-link[WHERE predicate = 'flux://has_reply'].in.uri)" - */ - getter?: string; - - /** - * Indicates whether the property is stored locally in the perspective and not in the network. Useful for properties that are not meant to be shared with the network. - */ - local?: boolean; +/** Minimal structural type for Ad4mModel instances — used in mutator signatures to avoid circular imports. */ +export interface Ad4mModelLike { + readonly id: string; } /** - * Decorator for defining collections on model classes. - * - * @category Decorators - * - * @description - * Defines a property that represents a collection of values linked to the model instance. - * Collections are always arrays and support operations for adding, removing, and setting values. - * - * For each collection property, the following methods are automatically generated: - * - `addX(value)` - Add a value to the collection - * - `removeX(value)` - Remove a value from the collection - * - `setCollectionX(values)` - Replace all values in the collection - * - * Where X is the capitalized property name. - * - * Collections can be filtered using the `where` option to only include values that: - * - Are instances of a specific model class - * - Match a custom Prolog condition - * + * Utility type that generates the runtime methods produced by \@HasMany / \@HasOne decorators. + * + * For each relation property `foo`, the decorator generates: + * - `addFoo(value)` — Add a value (string ID or model instance) + * - `removeFoo(value)` — Remove a value + * - `setFoo(values)` — Replace all values + * + * Pass a string union of your \@HasMany/\@HasOne property names and use interface merging: * @example * ```typescript - * class Recipe extends Ad4mModel { - * // Basic collection of ingredients - * @Collection({ - * through: "recipe://ingredient" - * }) - * ingredients: string[] = []; - * - * // Collection that only includes instances of another model - * @Collection({ - * through: "recipe://comment", - * where: { isInstance: Comment } - * }) - * comments: string[] = []; - * - * // Collection with custom Prolog filter condition - * @Collection({ - * through: "recipe://step", - * where: { prologCondition: `triple(Target, "step://order", Order), Order < 3` } - * }) - * firstSteps: string[] = []; - * - * // Collection with custom SurrealDB filter condition - * @Collection({ - * through: "recipe://entries", - * where: { condition: `WHERE in.uri = Target AND predicate = 'recipe://has_ingredient' AND out.uri = 'recipe://test')` - * }) - * ingredients: string[] = []; - * - * // Local-only collection not shared with network - * @Collection({ - * through: "recipe://note", - * local: true - * }) - * privateNotes: string[] = []; + * \@Model({ name: 'Post' }) + * export class Post extends Ad4mModel { + * \@HasMany(() => Comment, { through: 'post://comment' }) + * comments: Comment[] = []; * } - * - * // Using the generated methods: - * const recipe = new Recipe(perspective); - * await recipe.addIngredients("ingredient://flour"); - * await recipe.removeIngredients("ingredient://sugar"); - * await recipe.setCollectionIngredients(["ingredient://butter", "ingredient://eggs"]); + * export interface Post extends HasManyMethods<'comments'> {} * ``` - * - * @param {CollectionOptions} opts - Collection configuration - * @param {string} opts.through - The predicate URI for collection links - * @param {WhereOptions} [opts.where] - Filter conditions for collection values - * @param {any} [opts.where.isInstance] - Model class to filter instances by - * @param {string} [opts.where.prologCondition] - Custom Prolog condition for filtering - * @param {boolean} [opts.local] - Whether collection links are stored locally only */ -export function Collection(opts: CollectionOptions) { - return function (target: T, key: keyof T) { - target["__collections"] = target["__collections"] || {}; - target["__collections"][key] = opts; +export type HasManyMethods = { + [K in Keys as `add${Capitalize}`]: ( + value: string | Ad4mModelLike, + batchId?: string, + ) => Promise; +} & { + [K in Keys as `remove${Capitalize}`]: ( + value: string | Ad4mModelLike, + batchId?: string, + ) => Promise; +} & { + [K in Keys as `set${Capitalize}`]: ( + values: (string | Ad4mModelLike)[], + batchId?: string, + ) => Promise; +}; + +export function HasMany( + relatedModelOrOpts: (() => any) | RelationOptions, + opts?: RelationOptions, +) { + const resolvedOpts: RelationOptions = + typeof relatedModelOrOpts === "function" ? opts! : relatedModelOrOpts; + const relatedModel: (() => any) | undefined = + typeof relatedModelOrOpts === "function" ? relatedModelOrOpts : undefined; + return function (target: T, key: keyof T) { + if (!resolvedOpts?.through) { + throw new Error( + `@HasMany on "${String(key)}" requires a "through" predicate in RelationOptions`, + ); + } + const _hasManyExisting = + relationRegistry.get((target as any).constructor) ?? {}; + relationRegistry.set((target as any).constructor, { + ..._hasManyExisting, + [key as string]: { + ...resolvedOpts, + direction: "forward" as const, + ...(relatedModel ? { relatedModel } : {}), + }, + }); - const value = key as string - target[`add${capitalize(value)}`] = () => {} - target[`remove${capitalize(value)}`] = () => {} - target[`setCollection${capitalize(value)}`] = () => {} + const value = key as string; + target[`add${capitalize(value)}`] = () => {}; + target[`remove${capitalize(value)}`] = () => {}; + target[`set${capitalize(value)}`] = () => {}; - Object.defineProperty(target, key, {configurable: true, writable: true}); - }; + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; } -export function makeRandomPrologAtom(length: number): string { - let result = ''; - let characters = 'abcdefghijklmnopqrstuvwxyz'; - let charactersLength = characters.length; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); +export function HasOne( + relatedModelOrOpts: (() => any) | RelationOptions, + opts?: RelationOptions, +) { + const resolvedOpts: RelationOptions = + typeof relatedModelOrOpts === "function" ? opts! : relatedModelOrOpts; + const relatedModel: (() => any) | undefined = + typeof relatedModelOrOpts === "function" ? relatedModelOrOpts : undefined; + return function (target: T, key: keyof T) { + if (!resolvedOpts?.through) { + throw new Error( + `@HasOne on "${String(key)}" requires a "through" predicate in RelationOptions`, + ); } - return result; - } - -export interface ModelOptionsOptions { - /** - * The name of the entity. - */ - name: string; -} + const _hasOneExisting = + relationRegistry.get((target as any).constructor) ?? {}; + relationRegistry.set((target as any).constructor, { + ..._hasOneExisting, + [key as string]: { + ...resolvedOpts, + direction: "forward" as const, + maxCount: 1, + ...(relatedModel ? { relatedModel } : {}), + }, + }); -/** - * Decorator for defining model classes in AD4M. - * - * @category Decorators - * - * @description - * The root decorator that must be applied to any class that represents a model in AD4M. - * It registers the class as a Social DNA (SDNA) subject class and provides the infrastructure - * for storing and retrieving instances. - * - * This decorator: - * - Registers the class with a unique name in the AD4M system - * - Generates the necessary SDNA code for the model's properties and collections - * - Enables the use of other model decorators (@Property, @Collection, etc.) - * - Provides static query methods through the Ad4mModel base class - * - * @example - * ```typescript - * @ModelOptions({ name: "Recipe" }) - * class Recipe extends Ad4mModel { - * @Property({ - * through: "recipe://name", - * resolveLanguage: "literal" - * }) - * name: string = ""; - * - * @Collection({ through: "recipe://ingredient" }) - * ingredients: string[] = []; - * - * // Static query methods from Ad4mModel: - * static async findByName(perspective: PerspectiveProxy, name: string) { - * return Recipe.query(perspective) - * .where({ name }) - * .run(); - * } - * } - * - * // Using the model: - * const recipe = new Recipe(perspective); - * recipe.name = "Chocolate Cake"; - * await recipe.save(); - * - * // Querying instances: - * const recipes = await Recipe.query(perspective) - * .where({ name: "Chocolate Cake" }) - * .run(); - * - * // Using with PerspectiveProxy: - * await perspective.ensureSDNASubjectClass(Recipe); - * ``` - * - * @param {ModelOptionsOptions} opts - Model configuration - * @param {string} opts.name - Unique name for the model class in AD4M - */ -export function ModelOptions(opts: ModelOptionsOptions) { - return function (target: any) { - target.prototype.className = opts.name; - target.className = opts.name; - - target.generateSDNA = function() { - let sdna = "" - let subjectName = opts.name - let obj = target.prototype; - - let uuid = makeRandomPrologAtom(8) - - sdna += `subject_class("${subjectName}", ${uuid}).\n` - - - let classRemoverActions = [] - - let constructorActions = [] - if(obj.subjectConstructor && obj.subjectConstructor.length) { - constructorActions = constructorActions.concat(obj.subjectConstructor) - } - - let instanceConditions = [] - if(obj.isSubjectInstance && obj.isSubjectInstance.length) { - instanceConditions = instanceConditions.concat(obj.isSubjectInstance) - } - - let propertiesCode = [] - let properties = obj.__properties || {} - for(let property in properties) { - let propertyCode = `property(${uuid}, "${property}").\n` - - let { through, initial, required, resolveLanguage, writable, flag, prologGetter, prologSetter, local } = properties[property] - - if(resolveLanguage) { - propertyCode += `property_resolve(${uuid}, "${property}").\n` - propertyCode += `property_resolve_language(${uuid}, "${property}", "${resolveLanguage}").\n` - } - - if(prologGetter) { - propertyCode += `property_getter(${uuid}, Base, "${property}", Value) :- ${prologGetter}.\n` - } else if(through) { - propertyCode += `property_getter(${uuid}, Base, "${property}", Value) :- triple(Base, "${through}", Value).\n` - - if(required) { - if(flag) { - instanceConditions.push(`triple(Base, "${through}", "${initial}")`) - } else { - instanceConditions.push(`triple(Base, "${through}", _)`) - } - } - } - - if(prologSetter) { - propertyCode += `property_setter(${uuid}, "${property}", Actions) :- ${prologSetter}.\n` - } else if (writable && through) { - let setter = obj[propertyNameToSetterName(property)] - if(typeof setter === "function") { - let action = [{ - action: "setSingleTarget", - source: "this", - predicate: through, - target: "value", - ...(local && { local: true }) - }] - propertyCode += `property_setter(${uuid}, "${property}", '${stringifyObjectLiteral(action)}').\n` - } - } - - propertiesCode.push(propertyCode) - - if(initial) { - constructorActions.push({ - action: "addLink", - source: "this", - predicate: through, - target: initial, - }) - - classRemoverActions.push({ - action: "removeLink", - source: "this", - predicate: through, - target: "*", - }) - } - } - - let collectionsCode = [] - let collections = obj.__collections || {} - for(let collection in collections) { - let collectionCode = `collection(${uuid}, "${collection}").\n` - - let { through, where, local} = collections[collection] - - if(through) { - if(where) { - if(!where.isInstance && !where.prologCondition && !where.condition) { - throw "'where' needs one of 'isInstance', 'prologCondition', or 'condition'" - } - - let conditions = [] - - if(where.isInstance) { - let otherClass - if(where.isInstance.name) { - otherClass = where.isInstance.name - } else { - otherClass = where.isInstance - } - conditions.push(`instance(OtherClass, Target), subject_class("${otherClass}", OtherClass)`) - } - - if(where.prologCondition) { - conditions.push(where.prologCondition) - } - - // If there are Prolog conditions (isInstance or prologCondition), use setof with conditions - // If only condition is present, use simple findall (SurrealDB will filter later) - if(conditions.length > 0) { - const conditionString = conditions.join(", ") - collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- setof(Target, (triple(Base, "${through}", Target), ${conditionString}), List).\n` - } else { - // Only SurrealDB condition present (no Prolog filtering) - collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- findall(C, triple(Base, "${through}", C), List).\n` - } - } else { - collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- findall(C, triple(Base, "${through}", C), List).\n` - } - - let collectionAdderAction = [{ - action: "addLink", - source: "this", - predicate: through, - target: "value", - ...(local && { local: true }) - }] - - let collectionRemoverAction = [{ - action: "removeLink", - source: "this", - predicate: through, - target: "value", - }] - - let collectionSetterAction = [{ - action: "collectionSetter", - source: "this", - predicate: through, - target: "value", - ...(local && { local: true }) - }] - collectionCode += `collection_adder(${uuid}, "${collection}", '${stringifyObjectLiteral(collectionAdderAction)}').\n` - collectionCode += `collection_remover(${uuid}, "${collection}", '${stringifyObjectLiteral(collectionRemoverAction)}').\n` - collectionCode += `collection_setter(${uuid}, "${collection}", '${stringifyObjectLiteral(collectionSetterAction)}').\n` - } - - collectionsCode.push(collectionCode) - } - - let subjectContructorJSONString = stringifyObjectLiteral(constructorActions) - sdna += `constructor(${uuid}, '${subjectContructorJSONString}').\n` - if(instanceConditions.length > 0) { - let instanceConditionProlog = instanceConditions.join(", ") - sdna += `instance(${uuid}, Base) :- ${instanceConditionProlog}.\n` - sdna += "\n" - } - sdna += `destructor(${uuid}, '${stringifyObjectLiteral(classRemoverActions)}').\n` - sdna += "\n" - sdna += propertiesCode.join("\n") - sdna += "\n" - sdna += collectionsCode.join("\n") - - return { - sdna, - name: subjectName - } - } + const value = key as string; + target[`add${capitalize(value)}`] = () => {}; + target[`remove${capitalize(value)}`] = () => {}; + target[`set${capitalize(value)}`] = () => {}; - // Generate SHACL shape (W3C standard + AD4M action definitions) - target.generateSHACL = function() { - const subjectName = opts.name; - const obj = target.prototype; - - // Determine namespace from first property or collection, or use default - let namespace = "ad4m://"; - const properties = obj.__properties || {}; - const collections = obj.__collections || {}; - - // Try properties first - if (Object.keys(properties).length > 0) { - const firstProp = properties[Object.keys(properties)[0]]; - if (firstProp.through) { - // Extract namespace from through predicate (e.g., "recipe://name" -> "recipe://") - const match = firstProp.through.match(/^([^:]+:\/\/)/); - if (match) { - namespace = match[1]; - } - } - } - // Fall back to collections if no properties - else if (Object.keys(collections).length > 0) { - const firstColl = collections[Object.keys(collections)[0]]; - if (firstColl.through) { - const match = firstColl.through.match(/^([^:]+:\/\/)/); - if (match) { - namespace = match[1]; - } - } - } - - // Create SHACL shape - const shapeUri = `${namespace}${subjectName}Shape`; - const targetClass = `${namespace}${subjectName}`; - const shape = new SHACLShape(shapeUri, targetClass); - - // === Extract Constructor Actions (same logic as generateSDNA) === - let constructorActions = []; - if(obj.subjectConstructor && obj.subjectConstructor.length) { - constructorActions = constructorActions.concat(obj.subjectConstructor); - } - - // === Extract Destructor Actions === - let destructorActions = []; - - // Convert properties to SHACL property shapes - for (const propName in properties) { - const propMeta = properties[propName]; - - if (!propMeta.through) continue; // Skip properties without predicates - - const propShape: SHACLPropertyShape = { - name: propName, // Property name for generating named URIs - path: propMeta.through, - }; - - // Determine datatype from initial value or resolveLanguage - if (propMeta.resolveLanguage === "literal") { - // If it resolves via literal language, it's likely a string - propShape.datatype = "xsd://string"; - } else if (propMeta.initial) { - // Try to infer from initial value type - const initialType = typeof obj[propName]; - if (initialType === "number") { - propShape.datatype = "xsd://integer"; - } else if (initialType === "boolean") { - propShape.datatype = "xsd://boolean"; - } else if (initialType === "string") { - propShape.datatype = "xsd://string"; - } - } - - // Cardinality constraints - if (propMeta.required) { - propShape.minCount = 1; - } - - // Single-valued properties get maxCount 1 - // (collections are handled separately below) - if (!propMeta.collection) { - propShape.maxCount = 1; - } - - // Flag properties have fixed value - if (propMeta.flag && propMeta.initial) { - propShape.hasValue = propMeta.initial; - } - - // AD4M-specific metadata - if (propMeta.local !== undefined) { - propShape.local = propMeta.local; - } - - if (propMeta.writable !== undefined) { - propShape.writable = propMeta.writable; - } - - if (propMeta.resolveLanguage) { - propShape.resolveLanguage = propMeta.resolveLanguage; - } - - // === Extract Setter Actions (same logic as generateSDNA) === - if (propMeta.setter) { - // Custom setter defined - not yet supported in SHACL - console.warn( - `[SHACL Generation] Custom Prolog setter for property '${propName}' in class '${subjectName}' is not yet supported. ` + - `The property will be created without setter actions. Consider using standard writable properties or provide explicit SHACL JSON.` - ); - // TODO: Parse custom Prolog setter to extract actions - } else if (propMeta.writable && propMeta.through) { - let setter = obj[propertyNameToSetterName(propName)]; - if (typeof setter === "function") { - propShape.setter = [{ - action: "setSingleTarget", - source: "this", - predicate: propMeta.through, - target: "value", - ...(propMeta.local && { local: true }) - }]; - } - } - - // Add to constructor actions if property has initial value - if (propMeta.initial) { - constructorActions.push({ - action: "addLink", - source: "this", - predicate: propMeta.through, - target: propMeta.initial, - }); - - // Add to destructor actions - destructorActions.push({ - action: "removeLink", - source: "this", - predicate: propMeta.through, - target: "*", - }); - } - - shape.addProperty(propShape); - } - - // Convert collections to SHACL property shapes - // (collections variable already declared above for namespace inference) - for (const collName in collections) { - const collMeta = collections[collName]; - - if (!collMeta.through) continue; - - const collShape: SHACLPropertyShape = { - name: collName, // Collection name for generating named URIs - path: collMeta.through, - // Collections have no maxCount (unlimited) - // minCount defaults to 0 (optional) - }; - - // Determine if it's a reference (IRI) or literal - // Collections typically contain references (IRIs) to other entities - // They're literals only if explicitly marked or contain primitive values - if (collMeta.where?.isInstance) { - // Collection of typed entities - definitely IRIs - collShape.nodeKind = 'IRI'; - } else { - // Default to IRI for collections (most common case) - // Literal collections are rare and would need explicit marking - collShape.nodeKind = 'IRI'; - } - - // AD4M-specific metadata - if (collMeta.local !== undefined) { - collShape.local = collMeta.local; - } - - if (collMeta.writable !== undefined) { - collShape.writable = collMeta.writable; - } - - // === Extract Collection Actions (adder/remover) === - // Adder action - adds a link to the collection - collShape.adder = [{ - action: "addLink", - source: "this", - predicate: collMeta.through, - target: "value", - ...(collMeta.local && { local: true }) - }]; - - // Remover action - removes a link from the collection - collShape.remover = [{ - action: "removeLink", - source: "this", - predicate: collMeta.through, - target: "value", - ...(collMeta.local && { local: true }) - }]; - - shape.addProperty(collShape); - } - - // Set constructor and destructor actions on the shape - if (constructorActions.length > 0) { - shape.setConstructorActions(constructorActions); - } - if (destructorActions.length > 0) { - shape.setDestructorActions(destructorActions); - } - - return { - shape, - name: subjectName - }; - } + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; +} - Object.defineProperty(target, 'type', {configurable: true}); +export function BelongsToOne(relatedModel: () => any, opts: RelationOptions) { + return function (target: T, key: keyof T) { + if (!opts?.through) { + throw new Error( + `@BelongsToOne on "${String(key)}" requires a "through" predicate in RelationOptions`, + ); } + const _b2oExisting = + relationRegistry.get((target as any).constructor) ?? {}; + relationRegistry.set((target as any).constructor, { + ..._b2oExisting, + [key as string]: { + ...opts, + direction: "reverse" as const, + maxCount: 1, + relatedModel, + }, + }); + + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; } -/** - * Decorator for defining required and writable properties on model classes. - * - * @category Decorators - * - * @description - * A convenience decorator that defines a required property that must have an initial value and is writable by default. - * This is equivalent to using @Optional with `required: true` and `writable: true`. - * - * Properties defined with this decorator: - * - Must have a value (required) - * - Can be modified after creation (writable) - * - Default to "literal://string:uninitialized" if no initial value is provided - * - * @example - * ```typescript - * class User extends Ad4mModel { - * // Basic required property with default initial value - * @Property({ - * through: "user://name" - * }) - * name: string = ""; - * - * // Required property with custom initial value - * @Property({ - * through: "user://status", - * initial: "user://active" - * }) - * status: string = ""; - * - * // Required property with literal resolution - * @Property({ - * through: "user://bio", - * resolveLanguage: "literal" - * }) - * bio: string = ""; - * - * // Required property with custom getter/setter - * @Property({ - * through: "user://age", - * getter: `triple(Base, "user://birthYear", Year), Value is 2024 - Year`, - * setter: `Year is 2024 - Value, Actions = [{"action": "setSingleTarget", "source": "this", "predicate": "user://birthYear", "target": Year}]` - * }) - * age: number = 0; - * } - * ``` - * - * @param {PropertyOptions} opts - Property configuration - * @param {string} opts.through - The predicate URI for the property - * @param {string} [opts.initial] - Initial value (defaults to "literal://string:uninitialized") - * @param {string} [opts.resolveLanguage] - Language to use for value resolution (e.g. "literal") - * @param {string} [opts.prologGetter] - Custom Prolog code for getting the property value - * @param {string} [opts.prologSetter] - Custom Prolog code for setting the property value - * @param {boolean} [opts.local] - Whether the property should only be stored locally - */ -export function Property(opts: PropertyOptions) { - return Optional({ - ...opts, - required: true, - writable: true, - initial: opts.initial || "literal://string:uninitialized" +export function BelongsToMany(relatedModel: () => any, opts: RelationOptions) { + return function (target: T, key: keyof T) { + if (!opts?.through) { + throw new Error( + `@BelongsToMany on "${String(key)}" requires a "through" predicate in RelationOptions`, + ); + } + const _b2mExisting = + relationRegistry.get((target as any).constructor) ?? {}; + relationRegistry.set((target as any).constructor, { + ..._b2mExisting, + [key as string]: { ...opts, direction: "reverse" as const, relatedModel }, }); + + Object.defineProperty(target, key, { configurable: true, writable: true }); + }; +} + +export function makeRandomId(length: number): string { + let result = ""; + let characters = "abcdefghijklmnopqrstuvwxyz"; + let charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +export interface ModelConfig { + /** + * The name of the entity. + */ + name: string; } /** - * Decorator for defining read-only properties on model classes. - * - * @category Decorators - * - * @description - * A convenience decorator that defines a property that can only be read and cannot be modified after initialization. - * This is equivalent to using @Optional with `writable: false`. - * - * Read-only properties are ideal for: - * - Computed or derived values - * - Properties that should never change after creation - * - Properties that are set by the system - * - Properties that represent immutable data - * + * Registers the class as an AD4M SDNA subject, enabling `Ad4mModel` static query methods. + * + * Must be applied to every class that extends `Ad4mModel`. + * * @example * ```typescript - * class Post extends Ad4mModel { - * // Read-only property with custom getter for computed value - * @ReadOnly({ - * through: "post://likes", - * getter: `findall(User, triple(Base, "post://liked_by", User), Users), length(Users, Value)` - * }) - * likeCount: number = 0; - * - * // Read-only property for creation timestamp - * @ReadOnly({ - * through: "post://created_at", - * initial: new Date().toISOString() - * }) - * createdAt: string = ""; - * - * // Read-only property that resolves to a Literal - * @ReadOnly({ - * through: "post://author", - * resolveLanguage: "literal" - * }) - * author: string = ""; - * - * // Read-only property for system-managed data - * @ReadOnly({ - * through: "post://version", - * initial: "1.0.0" - * }) - * version: string = ""; - * } + * @Model({ name: "Recipe" }) + * class Recipe extends Ad4mModel { ... } * ``` - * - * @param {PropertyOptions} opts - Property configuration - * @param {string} opts.through - The predicate URI for the property - * @param {string} [opts.initial] - Initial value (if property should have one) - * @param {string} [opts.resolveLanguage] - Language to use for value resolution (e.g. "literal") - * @param {string} [opts.prologGetter] - Custom Prolog code for getting the property value - * @param {boolean} [opts.local] - Whether the property should only be stored locally */ -export function ReadOnly(opts: PropertyOptions) { - return Optional({ - ...opts, - writable: false - }); -} \ No newline at end of file +export function Model(opts: ModelConfig) { + return function (target: any) { + target.prototype.className = opts.name; + target.className = opts.name; + + // Generate SHACL shape (W3C standard + AD4M action definitions) + target.generateSHACL = function () { + const subjectName = opts.name; + const obj = target.prototype; + + // Determine namespace from first property or relation, or use default + let namespace = "ad4m://"; + const fields = getPropertiesMetadata(target); + const relations = getRelationsMetadata(target); + + // Try fields first + if (Object.keys(fields).length > 0) { + const firstProp = fields[Object.keys(fields)[0]]; + if (firstProp.through) { + const match = firstProp.through.match(/^([^:]+:\/\/)/); + if (match) { + namespace = match[1]; + } + } + } + // Fall back to relations if no fields + else if (Object.keys(relations).length > 0) { + const firstRelation = relations[Object.keys(relations)[0]]; + if (firstRelation.through) { + const match = firstRelation.through.match(/^([^:]+:\/\/)/); + if (match) { + namespace = match[1]; + } + } + } + + // Create SHACL shape + const shapeUri = `${namespace}${subjectName}Shape`; + const targetClass = `${namespace}${subjectName}`; + const shape = new SHACLShape(shapeUri, targetClass); + + // ── Detect @Model parent for sh:node inheritance ────────────────────── + // If the immediate prototype constructor is also @Model-decorated, + // emit sh:node and use only OWN properties/relations + // rather than duplicating the parent's properties in the child shape. + const parentCtor = Object.getPrototypeOf(target); + const parentClassName: string | undefined = + parentCtor?.prototype?.className; + const isParentModel = + parentClassName && + parentClassName !== "Ad4mModel" && + (propertyRegistry.has(parentCtor) || relationRegistry.has(parentCtor)); + + let shapeFields: Record; + let shapeRelations: Record; + if (isParentModel) { + // Own-only fields/relations — parent's are covered by sh:node + shapeFields = propertyRegistry.get(target) ?? {}; + shapeRelations = relationRegistry.get(target) ?? {}; + + // Derive parent shape URI from parent's own properties' namespace + const parentOwnFields = Object.values( + propertyRegistry.get(parentCtor) ?? {}, + ) as any[]; + const parentOwnRelations = Object.values( + relationRegistry.get(parentCtor) ?? {}, + ) as any[]; + let parentNamespace = "ad4m://"; + if (parentOwnFields.length > 0 && parentOwnFields[0].through) { + const m = (parentOwnFields[0].through as string).match( + /^([^:]+:\/\/)/, + ); + if (m) parentNamespace = m[1]; + } else if ( + parentOwnRelations.length > 0 && + parentOwnRelations[0].through + ) { + const m = (parentOwnRelations[0].through as string).match( + /^([^:]+:\/\/)/, + ); + if (m) parentNamespace = m[1]; + } + const parentShapeUri = `${parentNamespace}${parentClassName}Shape`; + shape.addParentShape(parentShapeUri); + } else { + // No @Model parent — include all inherited properties directly + shapeFields = fields; + shapeRelations = relations; + } + // ────────────────────────────────────────────────────────────────────── + + // === Extract Constructor Actions (same logic as generateSDNA) === + let constructorActions = []; + if (obj.subjectConstructor && obj.subjectConstructor.length) { + constructorActions = constructorActions.concat(obj.subjectConstructor); + } + + // === Extract Destructor Actions === + let destructorActions = []; + + // Convert fields to SHACL property shapes + for (const propName in shapeFields) { + const propMeta = shapeFields[propName]; + + if (!propMeta.through) continue; // Skip properties without predicates + + const propShape: SHACLPropertyShape = { + name: propName, // Property name for generating named URIs + path: propMeta.through, + }; + + // Auto-derive a constructor placeholder for non-readOnly, non-flag properties + // that don't have an explicit initial. Only do this when the prototype + // has a defined default value — optional fields (title?: string) have + // no prototype default (undefined), and we must NOT write an empty + // literal link for them. The specific placeholder value doesn't matter + // for fields with an actual default — createSubject's initialValues + // mechanism overwrites it with the real value via the setter actions. + const protoDefault = obj[propName]; + const effectiveInitial: string | undefined = + propMeta.initial ?? + (!propMeta.readOnly && !propMeta.flag && protoDefault !== undefined + ? "literal://string:" + : undefined); + + // Determine datatype from the TypeScript default value type. + // resolveLanguage: "literal" is now the implicit default — literal:// + // URIs can carry any type (string/number/boolean), so always infer + // from the prototype's default value rather than forcing xsd://string. + const initialType = typeof obj[propName]; + if (initialType === "number") { + propShape.datatype = "xsd://integer"; + } else if (initialType === "boolean") { + propShape.datatype = "xsd://boolean"; + } else if (initialType === "string") { + propShape.datatype = "xsd://string"; + } + + // Cardinality constraints + if (propMeta.required) { + propShape.minCount = 1; + } + + // @Property fields are always single-valued; maxCount 1 is unconditional. + // Multi-valued relations live in relationRegistry and are handled below. + propShape.maxCount = 1; + + // Flag properties have fixed value + if (propMeta.flag && propMeta.initial) { + propShape.hasValue = propMeta.initial; + } + + // AD4M-specific metadata + if (propMeta.local !== undefined) { + propShape.local = propMeta.local; + } + + if (propMeta.readOnly) { + propShape.readOnly = true; + } + + if (propMeta.resolveLanguage) { + propShape.resolveLanguage = propMeta.resolveLanguage; + } else if (!propMeta.flag) { + // resolveLanguage: "literal" is the implicit default for scalar properties. + // The Rust executor requires this to be explicit in the SHACL shape to + // configure fn::parse_literal correctly in SurrealDB. The user doesn't + // need to write it in their @Property decorator, but the shape must have it. + propShape.resolveLanguage = "literal"; + } + + // === Extract Setter Actions (same logic as generateSDNA) === + if (propMeta.setter) { + // Custom setter defined - not yet supported in SHACL + console.warn( + `[SHACL Generation] Custom Prolog setter for property '${propName}' in class '${subjectName}' is not yet supported. ` + + `The property will be created without setter actions. Consider using standard writable properties or provide explicit SHACL JSON.`, + ); + // TODO: Parse custom Prolog setter to extract actions + } else if (!propMeta.readOnly && propMeta.through) { + let setter = obj[propertyNameToSetterName(propName)]; + if (typeof setter === "function") { + propShape.setter = [ + { + action: "setSingleTarget", + source: "this", + predicate: propMeta.through, + target: "value", + ...(propMeta.local && { local: true }), + }, + ]; + } + } + + // Add to constructor actions (always for non-readOnly, non-flag, using the + // effective placeholder — createSubject's initialValues will override + // the target with the real instance value via the setter actions). + if (effectiveInitial) { + constructorActions.push({ + action: "addLink", + source: "this", + predicate: propMeta.through, + target: effectiveInitial, + }); + } + + // Always add destructor action for non-readOnly, non-flag properties so + // delete() cleans them up regardless of whether initial was explicit. + if (!propMeta.readOnly && !propMeta.flag) { + destructorActions.push({ + action: "removeLink", + source: "this", + predicate: propMeta.through, + target: "*", + }); + } + + shape.addProperty(propShape); + } + + // Convert relations to SHACL property shapes + for (const relName in shapeRelations) { + const relMeta = shapeRelations[relName]; + + if (!relMeta.through) continue; + + const relShape: SHACLPropertyShape = { + name: relName, // Relation name for generating named URIs + path: relMeta.through, + // Relations have no maxCount (unlimited) + // minCount defaults to 0 (optional) + }; + + // Relations contain references (IRIs) to other entities + relShape.nodeKind = "IRI"; + + // AD4M-specific metadata + if (relMeta.local !== undefined) { + relShape.local = relMeta.local; + } + + // Relationship metadata + if (relMeta.maxCount !== undefined) { + relShape.maxCount = relMeta.maxCount; + } + + if (relMeta.direction === "reverse") { + relShape.inversePath = true; + } + + // === Extract Relation Actions (adder/remover) === + // Adder action - adds a link to the relation + relShape.adder = [ + { + action: "addLink", + source: "this", + predicate: relMeta.through, + target: "value", + ...(relMeta.local && { local: true }), + }, + ]; + + // Remover action - removes a link from the relation + relShape.remover = [ + { + action: "removeLink", + source: "this", + predicate: relMeta.through, + target: "value", + ...(relMeta.local && { local: true }), + }, + ]; + + shape.addProperty(relShape); + } + + // Set constructor and destructor actions on the shape. + // Always set constructor actions (even if empty) so the Rust SHACL parser + // emits an ad4m://constructor link — without it get_constructor_actions() + // throws "No SHACL constructor found" for classes with no @Flag or initial + // properties. An empty constructor is valid: it means "do nothing on creation". + shape.setConstructorActions(constructorActions); + if (destructorActions.length > 0) { + shape.setDestructorActions(destructorActions); + } + + return { + shape, + name: subjectName, + }; + }; + + Object.defineProperty(target, "type", { configurable: true }); + }; +} diff --git a/core/src/model/mutation.ts b/core/src/model/mutation.ts new file mode 100644 index 000000000..14ea3c991 --- /dev/null +++ b/core/src/model/mutation.ts @@ -0,0 +1,461 @@ +/** + * Mutation helpers for Ad4mModel — extracted Phase 3c. + * + * All persistence functions that previously lived as private methods on Ad4mModel. + * Each takes a `MutationContext` (perspective, id, instance) + * instead of using `this`, making them independently testable and composable. + * + * Functions exported here cover the full write path: + * - Pure action builders: `generatePropertySetterAction`, `generateRelationAction` + * - Per-field setters: `setProperty`, `setRelationSetter/Adder/Remover` + * - Instance-level persistence: `cleanCopy`, `innerUpdate`, `saveInstance` + */ + +import { Literal } from "../Literal"; +import type { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; +import type { PropertyOptions, RelationOptions } from "./decorators"; +import { + getPropertiesMetadata, + getRelationsMetadata, + propertyRegistry, +} from "./decorators"; +import { formatSurrealValue } from "./query/surrealCompiler"; +import { fetchInstanceData } from "./query/fetchInstance"; +import { isDirty } from "./query/snapshot"; +import { getModelMetadata as _getModelMetadata } from "./schema/metadata"; + +// ── Context ──────────────────────────────────────────────────────────────── + +/** + * Bundles the per-instance state needed by every mutation function, + * replacing the private `this.#*` fields from Ad4mModel. + */ +export interface MutationContext { + /** The perspective that owns this instance. */ + perspective: PerspectiveProxy; + /** URI of the instance's root node in the graph (the base expression). */ + id: string; + /** The Ad4mModel instance itself (for `Object.entries`, prototype lookups, etc.) */ + instance: any; +} + +// ── Pure action builders ─────────────────────────────────────────────────── + +/** + * Builds a `setSingleTarget` action descriptor from property metadata. + * Throws if the property is a flag, read-only, or has no predicate. + */ +export function generatePropertySetterAction( + key: string, + metadata: PropertyOptions, +): any[] { + if (metadata.flag) { + throw new Error( + `Property "${key}" is a @Flag and cannot be set after creation`, + ); + } + if (metadata.readOnly === true) { + throw new Error(`Property "${key}" is read-only and cannot be written`); + } + if (!metadata.through) { + throw new Error(`Property "${key}" has no 'through' predicate defined`); + } + return [ + { + action: "setSingleTarget", + source: "this", + predicate: metadata.through, + target: "value", + ...(metadata.local && { local: true }), + }, + ]; +} + +/** + * Builds an `addLink` / `removeLink` / `relationSetter` action descriptor + * from relation metadata. + */ +export function generateRelationAction( + key: string, + metadata: RelationOptions, + actionType: "adder" | "remover" | "setter", +): any[] { + if (!metadata.through) { + throw new Error(`Relation "${key}" has no 'through' predicate defined`); + } + const actionMap = { + adder: "addLink", + remover: "removeLink", + setter: "relationSetter", + }; + return [ + { + action: actionMap[actionType], + source: "this", + predicate: metadata.through, + target: "value", + ...(metadata.local && { local: true }), + }, + ]; +} + +// ── Property / relation setters ──────────────────────────────────────────── + +/** + * Persists a single scalar property value for `ctx.instance`. + * + * Values that already carry a URI scheme are passed through unchanged. + * Raw scalars are encoded as `literal://` URIs. + * When a `resolveLanguage` is set, the value is first stored via `createExpression`. + */ +export async function setProperty( + ctx: MutationContext, + key: string, + value: any, + batchId?: string, +): Promise { + const proto = Object.getPrototypeOf(ctx.instance); + const metadata = getPropertiesMetadata(proto.constructor)?.[key] as + | PropertyOptions + | undefined; + if (!metadata) { + throw new Error( + `setProperty called with unknown key "${key}" — ensure the field has a @Property decorator`, + ); + } + + const actions = generatePropertySetterAction(key, metadata); + const resolveLanguage = metadata.resolveLanguage; + + // Skip empty/null/undefined to avoid storing invalid empty literals. + if (value === undefined || value === null || value === "") return; + + if (resolveLanguage) { + value = await ctx.perspective.createExpression(value, resolveLanguage); + } else if ( + typeof value !== "string" || + !/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(value) + ) { + // Encode raw scalars as literal:// URIs — mirrors Rust's resolve_property_value. + value = Literal.from(value).toUrl(); + } + + await ctx.perspective.executeAction( + actions, + ctx.id, + [{ name: "value", value }], + batchId, + ); +} + +/** Normalises an Ad4mModel instance to its id URI, passing other values through unchanged. */ +const toId = (v: any): any => + v && typeof v === "object" && typeof v.id === "string" ? v.id : v; + +/** Sets (replaces) the full set of targets for a relation. */ +export async function setRelationSetter( + ctx: MutationContext, + key: string, + value: any, + batchId?: string, +): Promise { + const proto = Object.getPrototypeOf(ctx.instance); + const metadata = getRelationsMetadata(proto.constructor)?.[key] as + | RelationOptions + | undefined; + if (!metadata) { + console.warn(`Relation "${key}" has no metadata, skipping`); + return; + } + + const actions = generateRelationAction(key, metadata, "setter"); + + if (value != null) { + if (Array.isArray(value)) { + await ctx.perspective.executeAction( + actions, + ctx.id, + value.map((v) => ({ name: "value", value: toId(v) })), + batchId, + ); + } else { + await ctx.perspective.executeAction( + actions, + ctx.id, + [{ name: "value", value: toId(value) }], + batchId, + ); + } + } +} + +/** Adds one or more targets to a relation without removing existing ones. */ +export async function setRelationAdder( + ctx: MutationContext, + key: string, + value: any, + batchId?: string, +): Promise { + const proto = Object.getPrototypeOf(ctx.instance); + const metadata = getRelationsMetadata(proto.constructor)?.[key] as + | RelationOptions + | undefined; + if (!metadata) { + console.warn(`Relation "${key}" has no metadata, skipping`); + return; + } + + const actions = generateRelationAction(key, metadata, "adder"); + + if (value != null) { + if (Array.isArray(value)) { + await Promise.all( + value.map((v) => + ctx.perspective.executeAction( + actions, + ctx.id, + [{ name: "value", value: toId(v) }], + batchId, + ), + ), + ); + } else { + await ctx.perspective.executeAction( + actions, + ctx.id, + [{ name: "value", value: toId(value) }], + batchId, + ); + } + } +} + +/** Removes one or more targets from a relation. */ +export async function setRelationRemover( + ctx: MutationContext, + key: string, + value: any, + batchId?: string, +): Promise { + const proto = Object.getPrototypeOf(ctx.instance); + const metadata = getRelationsMetadata(proto.constructor)?.[key] as + | RelationOptions + | undefined; + if (!metadata) { + console.warn(`Relation "${key}" has no metadata, skipping`); + return; + } + + const actions = generateRelationAction(key, metadata, "remover"); + + if (value != null) { + if (Array.isArray(value)) { + await Promise.all( + value.map((v) => + ctx.perspective.executeAction( + actions, + ctx.id, + [{ name: "value", value: toId(v) }], + batchId, + ), + ), + ); + } else { + await ctx.perspective.executeAction( + actions, + ctx.id, + [{ name: "value", value: toId(value) }], + batchId, + ); + } + } +} + +// ── Persistence ──────────────────────────────────────────────────────────── + +/** + * Returns a shallow copy of `instance` with `null`, `undefined`, + * `author`, and `timestamp` fields omitted. + */ +export function cleanCopy(instance: any): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(instance)) { + if ( + value !== undefined && + value !== null && + key !== "author" && + key !== "timestamp" + ) { + clean[key] = value; + } + } + return clean; +} + +/** + * Iterates all instance fields and persists each one according to its metadata. + * + * - Fields with an `.action` shape → dispatched to the matching relation mutator. + * - Arrays → treated as relation setters (including empty arrays to clear relations). + * - Scalar values → written via `setProperty` when `setProperties` is `true` and the + * field is not a relation or a flag. + * + * @note `#subjectClassName` was written here in the original implementation but + * is never read anywhere — the write is intentionally omitted. + */ +export async function innerUpdate( + ctx: MutationContext, + setProperties: boolean = true, + batchId?: string, +): Promise { + const proto = Object.getPrototypeOf(ctx.instance); + + for (const [key, value] of Object.entries(ctx.instance)) { + if (value !== undefined && value !== null) { + if ((value as any)?.action) { + switch ((value as any).action) { + case "setter": + await setRelationSetter(ctx, key, (value as any).value, batchId); + break; + case "adder": + await setRelationAdder(ctx, key, (value as any).value, batchId); + break; + case "remover": + await setRelationRemover(ctx, key, (value as any).value, batchId); + break; + default: + await setRelationSetter(ctx, key, (value as any).value, batchId); + break; + } + } else if (Array.isArray(value)) { + // All arrays (including empty) treated as relation setters. + // Skip if the set hasn't changed since the last hydration/save. + if (!isDirty(ctx.instance, key, value)) continue; + await setRelationSetter(ctx, key, value, batchId); + } else if (value !== undefined && value !== null && value !== "") { + if (setProperties) { + // Skip relation fields — they are not scalar properties. + if (getRelationsMetadata(proto.constructor)?.[key]) continue; + const propMeta = getPropertiesMetadata(proto.constructor)?.[key]; + // No @Property decorator for this key — skip silently. + // This covers generated relation methods (addX / removeX / setX) + // that appear as own enumerable properties on the instance, and + // base-class fields like `author` / `createdAt` that have no + // associated predicate. + if (!propMeta) continue; + // Skip flag fields — flags are immutable, written once by createSubject. + if (propMeta.flag) continue; + // Skip if the value hasn't changed since the last hydration/save. + if (!isDirty(ctx.instance, key, value)) continue; + await setProperty(ctx, key, value, batchId); + } + } + } + } +} + +/** + * Persists `ctx.instance` to `ctx.perspective`. + * + * Auto-detects create vs update by checking whether any links already exist + * for `ctx.id`. + * + * - **Create path**: `createSubject` → `innerUpdate(false)` (relations only). + * - **Update path**: `innerUpdate(true)` (properties + relations). + * + * @param ctx - Mutation context (perspective, id, instance). + * @param batchId - Optional caller-managed batch. When omitted an internal batch + * is created, committed, and the instance is rehydrated automatically. + */ +export async function saveInstance( + ctx: MutationContext, + batchId?: string, + alreadyExists?: boolean, +): Promise { + const safeBase = formatSurrealValue(ctx.id); + // Skip the DB round-trip when the caller already knows the instance was + // saved once (e.g. the second save() call inside the same uncommitted batch). + let isNew: boolean; + if (alreadyExists === true) { + isNew = false; + } else { + const existingLinks = await ctx.perspective.querySurrealDB( + `SELECT 1 FROM link WHERE in.uri = ${safeBase} LIMIT 1`, + ); + isNew = !existingLinks || existingLinks.length === 0; + } + + let batchCreatedHere = false; + if (!batchId) { + batchId = await ctx.perspective.createBatch(); + batchCreatedHere = true; + } + + if (isNew) { + // ── CREATE PATH ───────────────────────────────────────────────────────── + // Build initialValues from decorator-registered scalar properties only. + // We must NOT use Object.entries(ctx.instance) because TypeScript `private` + // fields (e.g. _perspective) are regular enumerable properties at runtime + // and would cause JSON.stringify to throw a circular-reference error when + // the PerspectiveProxy's Apollo InMemoryCache is encountered. + const proto = Object.getPrototypeOf(ctx.instance); + const ownPropKeys = new Set( + Object.keys(propertyRegistry.get(proto.constructor) ?? {}), + ); + const allPropMeta = getPropertiesMetadata(proto.constructor); + const initialValues: Record = {}; + for (const key of ownPropKeys) { + const propMeta = allPropMeta[key]; + if (!propMeta) continue; + if ((propMeta as PropertyOptions).flag) continue; // flags handled by constructor actions + const value = (ctx.instance as any)[key]; + if ( + value !== undefined && + value !== null && + !(Array.isArray(value) && (value as any[]).length > 0) && + !(value as any)?.action + ) { + initialValues[key] = value; + } + } + + const className = + await ctx.perspective.stringOrTemplateObjectToSubjectClassName( + ctx.instance, + ); + await ctx.perspective.createSubject( + className, + ctx.id, + initialValues, + batchId, + ); + + await innerUpdate(ctx, false, batchId); + + // Write inherited properties not present in the class's own SHACL shape. + // When a derived class uses sh:node to reference its parent shape, createSubject + // only writes properties defined in the derived shape. Inherited @Property fields + // (registered on the parent constructor) are silently ignored by the Rust backend. + // We detect them by comparing the full merged metadata against the own-only registry. + for (const [key, propMeta] of Object.entries(allPropMeta)) { + if (ownPropKeys.has(key)) continue; // already handled by createSubject + if ((propMeta as PropertyOptions).flag) continue; // flags are immutable + const value = (ctx.instance as any)[key]; + if (value !== undefined && value !== null && value !== "") { + await setProperty(ctx, key, value, batchId); + } + } + } else { + // ── UPDATE PATH ───────────────────────────────────────────────────────── + // Instance already exists — update properties and relations only. + await innerUpdate(ctx, true, batchId); + } + + if (batchCreatedHere) { + await ctx.perspective.commitBatch(batchId); + + // Rehydrate the instance so callers see the persisted state. + const metadata = _getModelMetadata(ctx.instance.constructor); + await fetchInstanceData(ctx.instance, ctx.perspective, ctx.id, metadata); + } +} diff --git a/core/src/model/parentUtils.ts b/core/src/model/parentUtils.ts new file mode 100644 index 000000000..b6885c506 --- /dev/null +++ b/core/src/model/parentUtils.ts @@ -0,0 +1,91 @@ +import type { ModelMetadata, ParentQuery, ParentQueryByPredicate } from "./types"; + +/** + * Resolves the predicate URI for a parent→child relation, used by + * `Ad4mModel.create()` and the `useLiveQuery` hooks. + * + * If `field` is supplied, that exact relation is looked up on the parent. + * Otherwise, all forward relations on the parent whose `relatedModel()` factory + * returns `childCtor` are scanned; the result is used when exactly one match + * is found. + * + * @throws if the field doesn't exist, or inference is ambiguous / impossible. + * + * @example + * // Explicit field + * resolveParentPredicate(Channel.getModelMetadata(), Poll, 'polls') + * + * // Inferred — only one @HasMany on Channel points to Poll + * resolveParentPredicate(Channel.getModelMetadata(), Poll) + */ +export function resolveParentPredicate( + parentMeta: ModelMetadata, + childCtor: (new (...args: any[]) => any) | undefined, + field?: string, +): string { + if (field) { + const predicate = parentMeta.relations[field]?.predicate; + if (!predicate) { + throw new Error( + `resolveParentPredicate: field "${field}" not found in parent model ` + + `"${parentMeta.className}" relations. Check that @HasMany is declared on that field.`, + ); + } + return predicate; + } + + if (!childCtor) { + throw new Error( + `resolveParentPredicate: either "field" or a child model constructor must be provided.`, + ); + } + + const matches = Object.values(parentMeta.relations).filter( + (r) => r.direction !== "reverse" && r.relatedModel?.() === childCtor, + ); + + if (matches.length === 1) return matches[0].predicate; + + if (matches.length === 0) { + throw new Error( + `resolveParentPredicate: no forward relation pointing to ` + + `"${(childCtor as any).name ?? String(childCtor)}" found on parent "${parentMeta.className}". ` + + `Provide "field" explicitly or add a typed @HasMany(() => ${(childCtor as any).name ?? "ChildModel"}) on the parent.`, + ); + } + + // matches.length > 1 + const fieldNames = Object.entries(parentMeta.relations) + .filter((r) => r[1].direction !== "reverse" && r[1].relatedModel?.() === childCtor) + .map(([k]) => k) + .join(", "); + throw new Error( + `resolveParentPredicate: multiple relations on "${parentMeta.className}" point to ` + + `"${(childCtor as any).name ?? String(childCtor)}" (fields: ${fieldNames}). ` + + `Provide "field" to disambiguate.`, + ); +} + +/** + * Normalises a `Query.parent` option to its raw `{ id, predicate }` form. + * + * When the model-backed form `{ id, model, field? }` is supplied, the predicate + * is resolved via {@link resolveParentPredicate}. The raw `{ id, predicate }` + * form is returned as-is. + * + * @param lf - Either the raw or model-backed `parent` query value. + * @param childCtor - The child model constructor. Required when `field` is + * omitted (used for inference across `@HasMany` metadata). + */ +export function normalizeParentQuery( + lf: ParentQuery, + childCtor?: new (...args: any[]) => any, +): ParentQueryByPredicate { + if ("predicate" in lf) return lf; + const predicate = resolveParentPredicate( + lf.model.getModelMetadata(), + childCtor, + lf.field, + ); + return { id: lf.id, predicate }; +} diff --git a/core/src/model/prolog/generatePrologFacts.ts b/core/src/model/prolog/generatePrologFacts.ts new file mode 100644 index 000000000..ab3c83e4e --- /dev/null +++ b/core/src/model/prolog/generatePrologFacts.ts @@ -0,0 +1,176 @@ +import type { Ad4mModel } from "../Ad4mModel"; +import type { + ModelMetadata, + PropertyMetadata, + RelationMetadata, +} from "../Ad4mModel"; + +type ModelClass = typeof Ad4mModel & { + getModelMetadata(): ModelMetadata; +}; + +/** + * Convert a camelCase or PascalCase identifier to snake_case. + * Examples: "TestPost" -> "test_post", "createdAt" -> "created_at" + */ +function toSnakeCase(str: string): string { + return str + .replace(/([A-Z])/g, "_$1") + .toLowerCase() + .replace(/^_/, ""); +} + +/** + * Build the instance recognizer clause for a model. + * + * Uses @Flag properties (fixed-value predicates) as the primary recognition + * strategy, since they uniquely identify the type in the graph. Falls back to + * required properties if no flags are present. + * + * Examples: + * poll(X) :- triple(X, 'flux://entry_type', 'flux://has_poll'). + * note(X) :- triple(X, 'ad4m://title', _). + */ +function buildInstanceClause( + predicateName: string, + metadata: ModelMetadata, +): string | null { + const props = metadata.properties; + + // Collect flags first — these are the strongest recognizers + const flags = Object.values(props).filter( + (p) => p.flag && p.predicate && p.initial, + ); + if (flags.length > 0) { + const conditions = flags + .map((p) => `triple(X, '${p.predicate}', '${p.initial}')`) + .join(",\n "); + return `${predicateName}(X) :-\n ${conditions}.`; + } + + // Fallback: required non-flag properties + const required = Object.values(props).filter( + (p) => p.required && p.predicate && !p.flag, + ); + if (required.length > 0) { + const conditions = required + .map((p) => `triple(X, '${p.predicate}', _)`) + .join(",\n "); + return `${predicateName}(X) :-\n ${conditions}.`; + } + + return null; +} + +/** + * Build a property getter clause for a single property. + * + * Example: + * poll_title(X, Value) :- triple(X, 'rdf://title', Value). + */ +function buildPropertyClause( + modelPredicateName: string, + prop: PropertyMetadata, +): string | null { + // Flags are handled by the instance clause, not individual getters + if (prop.flag) return null; + // No predicate = no clause + if (!prop.predicate) return null; + + const clauseName = `${modelPredicateName}_${toSnakeCase(prop.name)}`; + return `${clauseName}(X, Value) :- triple(X, '${prop.predicate}', Value).`; +} + +/** + * Build a collection getter clause. + * + * Forward relations (@HasMany / @HasOne): + * poll_entries(X, Values) :- findall(V, triple(X, 'flux://entry', V), Values). + * + * Reverse relations (@BelongsToMany / @BelongsToOne): + * The link points TO this instance (OtherNode → predicate → ThisNode), so + * the subject and object are swapped: + * test_tag_posts(X, Values) :- findall(V, triple(V, 'test://has_tag', X), Values). + */ +function buildCollectionClause( + modelPredicateName: string, + coll: RelationMetadata, +): string | null { + if (!coll.predicate) return null; + + const clauseName = `${modelPredicateName}_${toSnakeCase(coll.name)}`; + if (coll.direction === "reverse") { + // Reverse traversal: find nodes whose outgoing predicate link points to X + return `${clauseName}(X, Values) :- findall(V, triple(V, '${coll.predicate}', X), Values).`; + } + return `${clauseName}(X, Values) :- findall(V, triple(X, '${coll.predicate}', V), Values).`; +} + +/** + * Generate Prolog predicate facts from a model class's SHACL metadata. + * + * Given a model class decorated with `@ModelOptions` (and its `@Flag`, + * `@Property`, `@Collection` decorators), this function emits a string of + * Prolog clauses that can be prepended to any `perspective.infer()` call. + * + * The generated predicates are: + * - **Instance recognizer** — `modelName(X)` — matches instances of the model + * - **Property getters** — `modelName_propName(X, Value)` — one per property + * - **Collection getters** — `modelName_collName(X, Values)` — one per collection + * + * @example + * ```typescript + * import { generatePrologFacts } from '@coasys/ad4m/model/prolog'; + * + * const facts = generatePrologFacts(Poll); + * const result = await perspective.infer(` + * ${facts} + * recent_popular_poll(X) :- + * poll(X), + * poll_vote_count(X, N), N > 10, + * poll_created_at(X, T), T > ${yesterday}. + * `); + * ``` + * + * @param ModelClass - A class decorated with `@ModelOptions` that extends `Ad4mModel` + * @returns A multi-line Prolog string ready for use with `perspective.infer()` + */ +export function generatePrologFacts(ModelClass: ModelClass): string { + const metadata = ModelClass.getModelMetadata(); + const predicateName = toSnakeCase(metadata.className); + const lines: string[] = []; + + lines.push(`% ${metadata.className} — generated Prolog facts`); + + // Instance recognizer + const instanceClause = buildInstanceClause(predicateName, metadata); + if (instanceClause) { + lines.push(""); + lines.push(`% Instance recognizer`); + lines.push(instanceClause); + } + + // Property getters + const propClauses = Object.values(metadata.properties) + .map((p) => buildPropertyClause(predicateName, p)) + .filter((c): c is string => c !== null); + + if (propClauses.length > 0) { + lines.push(""); + lines.push(`% Field getters`); + lines.push(...propClauses); + } + + // Relation getters + const collClauses = Object.values(metadata.relations) + .map((c) => buildCollectionClause(predicateName, c)) + .filter((c): c is string => c !== null); + + if (collClauses.length > 0) { + lines.push(""); + lines.push(`% Relation getters`); + lines.push(...collClauses); + } + + return lines.join("\n"); +} diff --git a/core/src/model/prolog/index.ts b/core/src/model/prolog/index.ts new file mode 100644 index 000000000..099be0f1d --- /dev/null +++ b/core/src/model/prolog/index.ts @@ -0,0 +1 @@ +export { generatePrologFacts } from './generatePrologFacts'; diff --git a/core/src/model/query/ModelQueryBuilder.ts b/core/src/model/query/ModelQueryBuilder.ts new file mode 100644 index 000000000..803cf0b60 --- /dev/null +++ b/core/src/model/query/ModelQueryBuilder.ts @@ -0,0 +1,173 @@ +/** + * Fluent query builder for Ad4mModel. + * + * Extracted from Ad4mModel.ts. The class holds a `Query` object and delegates + * all execution to `Ad4mModel` static methods via the `ctor` constructor + * reference, so there is no circular import (this module imports from the + * Ad4mModel module, but Ad4mModel does not import from here — it re-exports + * ModelQueryBuilder from its own file for backward compatibility). + */ + +import { PerspectiveProxy } from "../../perspectives/PerspectiveProxy"; +import { + Order, + PaginationResult, + Query, + Where, + IncludeMap, + SubscribeOptions, + Subscription, +} from "../types"; +import { createSubscription } from "../subscription"; + +// Forward-reference type only — avoids importing the full Ad4mModel +// module at the class level. +export type Ad4mModelCtor = typeof import("../Ad4mModel").Ad4mModel & + (new (...args: any[]) => T); + +/** + * Fluent builder for Ad4mModel queries. + * + * Create via `MyModel.query(perspective)`: + * ```typescript + * const posts = await Post.query(perspective) + * .where({ published: true }) + * .order({ createdAt: "DESC" }) + * .limit(10) + * .get(); + * ``` + */ +export class ModelQueryBuilder { + private perspective: PerspectiveProxy; + private queryParams: Query = {}; + private modelClassName: string | null = null; + private ctor: Ad4mModelCtor; + + constructor( + perspective: PerspectiveProxy, + ctor: Ad4mModelCtor, + query?: Query, + ) { + this.perspective = perspective; + this.ctor = ctor; + if (query) this.queryParams = query; + } + + where(conditions: Where): ModelQueryBuilder { + this.queryParams.where = conditions; + return this; + } + + order(orderBy: Order): ModelQueryBuilder { + this.queryParams.order = orderBy; + return this; + } + + limit(limit: number): ModelQueryBuilder { + this.queryParams.limit = limit; + return this; + } + + offset(offset: number): ModelQueryBuilder { + this.queryParams.offset = offset; + return this; + } + + properties(properties: string[]): ModelQueryBuilder { + this.queryParams.properties = properties; + return this; + } + + /** + * Eagerly load specific relations as full model instances. + * + * Key = relation field name on the model. + * Value = `true` (all instances) or a `Query` to filter/order/limit the nested set. + * + * @example + * ```typescript + * Recipe.query(perspective) + * .include({ + * comments: true, + * tags: { where: { active: true }, limit: 5 }, + * }) + * .get(); + * ``` + */ + include(map: IncludeMap): ModelQueryBuilder { + this.queryParams.include = map; + return this; + } + + overrideModelClassName(className: string): ModelQueryBuilder { + this.modelClassName = className; + return this; + } + + /** Executes the query and returns all matching instances. */ + async get(): Promise { + return this.ctor.findAll(this.perspective, this.queryParams) as Promise< + T[] + >; + } + + /** Returns the first matching instance, or `null` if none found. */ + async first(): Promise { + return this.ctor.findOne( + this.perspective, + this.queryParams, + ) as Promise; + } + + /** Returns the total count of matching entities. */ + async count(): Promise { + return this.ctor.count(this.perspective, this.queryParams); + } + + /** Returns a single page of results with pagination metadata. */ + async paginate( + pageSize: number, + pageNumber: number, + ): Promise> { + return this.ctor.paginate( + this.perspective, + pageSize, + pageNumber, + this.queryParams, + ) as Promise>; + } + + /** + * Terminal: creates a live subscription using the query parameters accumulated + * so far. Fires `callback` immediately with current results, then on every + * relevant link change. + * + * Pass additional delivery options (`debounce`, `onError`) in `deliveryOpts`. + * Call `sub.unsubscribe()` to detach. + * + * @example + * ```typescript + * const sub = Post.query(perspective) + * .where({ published: true }) + * .order({ createdAt: "DESC" }) + * .live((posts) => setPosts(posts), { debounce: 300 }); + * + * // cleanup: + * sub.unsubscribe(); + * ``` + */ + live( + callback: (results: T[]) => void, + deliveryOpts?: Pick, + ): Subscription { + const options: SubscribeOptions = { ...this.queryParams, ...deliveryOpts }; + return createSubscription( + (p, q) => this.ctor.findAll(p, q ?? {}), + () => this.ctor.getModelMetadata(), + this.perspective, + options, + callback, + this.ctor as any, + ); + } +} diff --git a/core/src/model/query/fetchInstance.ts b/core/src/model/query/fetchInstance.ts new file mode 100644 index 000000000..afc7b185f --- /dev/null +++ b/core/src/model/query/fetchInstance.ts @@ -0,0 +1,181 @@ +/** + * Single-instance hydration pipeline for Ad4mModel. + * + * Extracted from Ad4mModel.getData() (Phase 3a Part 5). + * + * `fetchInstanceData()` queries SurrealDB for all links belonging to one + * instance and populates the instance in-place: + * 1. Forward-link query → shared `hydrateInstanceFromLinks` + * 2. relatedModel eager hydration via `_findAllInternal` + * 3. Reverse-relation batch query + * 4. Custom SurrealQL getters (`evaluateCustomGetters`) + */ + +import type { PerspectiveProxy } from "../../perspectives/PerspectiveProxy"; +import type { ModelMetadata, IncludeMap, Query } from "../types"; +import { formatSurrealValue } from "./surrealCompiler"; +import { hydrateInstanceFromLinks, evaluateCustomGetters } from "./hydration"; +import { captureSnapshot } from "./snapshot"; +import { _findAllInternal } from "./operations"; + +/** + * Hydrates `instance` in-place from SurrealDB and returns it. + * + * @param instance - The Ad4mModel instance to populate + * @param perspective - Perspective that owns the instance + * @param id - The instance's base expression URI + * @param metadata - Pre-resolved model metadata (from `getModelMetadata()`) + * @param include - Optional eager-load map + */ +export async function fetchInstanceData( + instance: any, + perspective: PerspectiveProxy, + id: string, + metadata: ModelMetadata, + include?: IncludeMap, +): Promise { + try { + const safeBase = formatSurrealValue(id); + + // ── 1. Forward links ──────────────────────────────────────────────────── + const links = await perspective.querySurrealDB(` + SELECT id, predicate, out.uri AS target, author, timestamp + FROM link + WHERE in.uri = ${safeBase} + ORDER BY timestamp ASC + `); + + if (links && links.length > 0) { + // ── 2. Shared hydration: properties + forward relations + timestamps ── + await hydrateInstanceFromLinks(instance, links, metadata, perspective); + + // ── 2. relatedModel eager hydration ────────────────────────────────── + const forwardRelations = Object.entries(metadata.relations).filter( + ([, m]: [string, any]) => !m.getter && m.direction !== "reverse", + ); + + for (const [relationName, relationMeta] of forwardRelations) { + const current = instance[relationName]; + let values: string[] = Array.isArray(current) + ? [...current] + : current != null + ? [current as string] + : []; + + // relatedModel: eager hydration — only when caller asked for it via include + const includeEntry = include?.[relationName]; + if ( + includeEntry !== undefined && + (relationMeta as any).relatedModel && + values.length > 0 + ) { + try { + const RelatedModel = (relationMeta as any).relatedModel() as any; + const subQuery: Query = + includeEntry === true + ? { where: { id: values } } + : { + ...includeEntry, + where: { id: values, ...(includeEntry as Query).where }, + }; + const hydrated = await _findAllInternal( + RelatedModel, + perspective, + subQuery, + false, + ); + instance[relationName] = + (relationMeta as any).maxCount === 1 + ? (hydrated[0] ?? null) + : hydrated; + } catch (e) { + console.warn(`Failed to hydrate ${relationName}:`, e); + instance[relationName] = + (relationMeta as any).maxCount === 1 + ? (values[0] ?? null) + : values; + } + } else { + instance[relationName] = + (relationMeta as any).maxCount === 1 ? (values[0] ?? null) : values; + } + } + } + + // ── 3. Reverse relations ──────────────────────────────────────────────── + const reverseRelations = Object.entries(metadata.relations).filter( + ([, m]: [string, any]) => !m.getter && m.direction === "reverse", + ); + if (reverseRelations.length > 0) { + let reverseLinks: any[] = []; + try { + reverseLinks = + (await perspective.querySurrealDB(` + SELECT in.uri AS source, predicate, id, author, timestamp + FROM link + WHERE out.uri = ${safeBase} + ORDER BY timestamp ASC + `)) ?? []; + } catch { + // leave empty — instance just won't have reverse relation data + } + + for (const [relationName, relationMeta] of reverseRelations) { + const matching = reverseLinks.filter( + (l: any) => l.predicate === (relationMeta as any).predicate, + ); + const values = matching.map((l: any) => l.source); + + const includeEntry = include?.[relationName]; + if ( + includeEntry !== undefined && + (relationMeta as any).relatedModel && + values.length > 0 + ) { + try { + const RelatedModel = (relationMeta as any).relatedModel() as any; + const subQuery: Query = + includeEntry === true + ? { where: { id: values } } + : { + ...includeEntry, + where: { id: values, ...(includeEntry as Query).where }, + }; + const hydrated = await _findAllInternal( + RelatedModel, + perspective, + subQuery, + false, + ); + instance[relationName] = + (relationMeta as any).maxCount === 1 + ? (hydrated[0] ?? null) + : hydrated; + } catch (e) { + instance[relationName] = + (relationMeta as any).maxCount === 1 + ? (values[0] ?? null) + : values; + } + } else { + instance[relationName] = + (relationMeta as any).maxCount === 1 ? (values[0] ?? null) : values; + } + } + } + + // ── 4. Custom SurrealQL getters ───────────────────────────────────────── + await evaluateCustomGetters(instance, perspective, metadata); + + // ── 5. Snapshot capture — baseline for dirty tracking on next save() ──── + const schemaKeys = [ + ...Object.keys(metadata.properties), + ...Object.keys(metadata.relations), + ]; + captureSnapshot(instance, schemaKeys); + } catch (e) { + console.error(`SurrealDB getData failed for ${id}:`, e); + } + + return instance; +} diff --git a/core/src/model/query/hydration.ts b/core/src/model/query/hydration.ts new file mode 100644 index 000000000..41878a438 --- /dev/null +++ b/core/src/model/query/hydration.ts @@ -0,0 +1,273 @@ +/** + * Shared instance hydration utilities for Ad4mModel. + * + * Both `getData()` (single-instance path) and `instancesFromSurrealResult()` + * (bulk path) delegate to `hydrateInstanceFromLinks()` and + * `evaluateCustomGetters()` here, guaranteeing identical semantics. + * + * Previously the two implementations diverged — most notably, `getData()` used + * "latest-wins" for properties while `instancesFromSurrealResult()` used + * "first-wins". Both now use latest-wins (last ASC-ordered link per predicate). + */ + +import { Literal } from "../../Literal"; +import { PerspectiveProxy } from "../../perspectives/PerspectiveProxy"; +import type { ModelMetadata } from "../types"; +import { formatSurrealValue } from "./surrealCompiler"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** Raw link row as returned by SurrealDB queries. */ +export interface RawLink { + predicate: string; + target: string; + author?: string; + timestamp?: string | number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Normalise a SurrealDB timestamp to epoch-milliseconds. + * + * - ISO strings (`"2024-01-01T00:00:00Z"`) → `Date.getTime()` + * - Numeric strings (`"1700000000000"`) → `parseInt` + * - Numbers → returned as-is + * - Anything else → returned as-is (no data loss) + */ +export function normalizeTimestamp(ts: any): number | string { + if (typeof ts === "number") return ts; + if (typeof ts === "string") { + if (ts.includes("T")) { + const ms = new Date(ts).getTime(); + return isNaN(ms) ? ts : ms; + } + const parsed = parseInt(ts, 10); + return isNaN(parsed) ? ts : parsed; + } + return ts; +} + +/** + * Resolve a raw SurrealDB link target to a typed JavaScript value. + * + * Resolution order: + * 1. Non-literal `resolveLanguage` → `perspective.getExpression(target)` + * 2. `literal://` URL → `Literal.fromUrl(target).get().data` + * 3. Otherwise → raw string unchanged + */ +async function resolveValue( + raw: string, + resolveLanguage: string | undefined, + perspective: PerspectiveProxy, + propName: string, +): Promise { + // Non-literal language: fetch the expression via the perspective + if ( + resolveLanguage && + resolveLanguage !== "literal" && + typeof raw === "string" && + !raw.startsWith("literal://") + ) { + try { + const expression = await perspective.getExpression(raw); + if (expression) { + try { + return JSON.parse(expression.data); + } catch { + return expression.data; + } + } + } catch (e) { + console.warn(`Failed to resolve expression for ${propName}:`, e); + } + return raw; + } + + // Literal URL: parse inline — only when resolveLanguage is 'literal' or unset. + // A property with a non-literal resolveLanguage whose stored target happens to + // start with 'literal://' (e.g. a model baseExpression URI stored as a string + // property value) must NOT be unwrapped here, or the URI itself is destroyed. + // Note: unlike the old monolithic Ad4mModel.ts, ALL scalar values in our + // mutation layer are stored as literal:// (when they have no URI scheme), so + // resolveLanguage === undefined is the normal case for plain string/number props. + if ( + (resolveLanguage === "literal" || resolveLanguage === undefined) && + typeof raw === "string" && + raw.startsWith("literal://") + ) { + try { + const parsed = Literal.fromUrl(raw).get(); + return parsed.data !== undefined ? parsed.data : parsed; + } catch { + // fall through to raw + } + } + + return raw; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Hydrates a model instance from a flat array of raw SurrealDB link rows. + * + * **Guarantees:** + * - Properties use *"latest-wins"* semantics — links must be ordered + * `ASC` by `timestamp`; the last matching link per predicate wins. + * - Forward relations preserve insertion order (links already ASC). + * - `createdAt` / `updatedAt` / `author` are derived from the global + * min/max timestamps across **all** links (not just property links). + * + * **Does NOT handle** (left to the caller): + * - Reverse relations — require a separate `WHERE out.uri = $base` query. + * - Custom getter evaluation — call `evaluateCustomGetters()` afterwards. + * - `relatedModel` eager hydration — do that in a batch pass afterwards. + */ +export async function hydrateInstanceFromLinks( + instance: any, + links: RawLink[], + metadata: ModelMetadata, + perspective: PerspectiveProxy, +): Promise { + if (!links || links.length === 0) return; + + // ── Global timestamp / author tracking ───────────────────────────────────── + // Normalise to epoch-ms so numeric and ISO timestamps compare correctly. + let minTimestamp: number | null = null; + let maxTimestamp: number | null = null; + let originalAuthor: string | null = null; + + for (const link of links) { + const ts = link.timestamp; + if (ts == null) continue; + const t = Number(normalizeTimestamp(ts)); + if (isNaN(t)) continue; + if (minTimestamp === null || t < minTimestamp) { + minTimestamp = t; + originalAuthor = link.author ?? null; + } + if (maxTimestamp === null || t > maxTimestamp) { + maxTimestamp = t; + } + } + + // ── Properties ────────────────────────────────────────────────────────────── + // Links are ordered ASC by timestamp; the LAST match is the most recent value. + for (const [propName, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.getter) continue; // handled separately by evaluateCustomGetters + + const matching = links.filter( + (l) => l.predicate === propMeta.predicate && l.target !== "None", + ); + if (matching.length === 0) continue; + + const link = matching[matching.length - 1]; // latest wins + let value: any = await resolveValue( + link.target, + propMeta.resolveLanguage, + perspective, + propName, + ); + + if (propMeta.transform && typeof propMeta.transform === "function") { + value = propMeta.transform(value); + } + + instance[propName] = value; + } + + // ── Forward relations ─────────────────────────────────────────────────────── + // Collect targets in their natural (ASC timestamp) order; filter None/empty. + const forwardRelations = Object.entries(metadata.relations).filter( + ([, m]) => !m.getter && m.direction !== "reverse", + ); + for (const [relationName, relMeta] of forwardRelations) { + const matching = links.filter((l) => l.predicate === relMeta.predicate); + const values = matching + .map((l) => l.target) + .filter((v) => v !== undefined && v !== null && v !== "" && v !== "None"); + + // maxCount === 1: take first value (oldest) — "@HasOne" has only one link + // in the happy path; "first" vs "last" only differs in error/corrupt state. + instance[relationName] = + relMeta.maxCount === 1 ? (values[0] ?? null) : values; + } + + // ── Author & timestamps ────────────────────────────────────────────────────── + if (originalAuthor) instance.author = originalAuthor; + if (minTimestamp !== null) + instance.createdAt = normalizeTimestamp(minTimestamp); + if (maxTimestamp !== null) + instance.updatedAt = normalizeTimestamp(maxTimestamp); +} + +/** + * Evaluates custom SurrealQL getter expressions for all `getter`-decorated + * properties and relations on a single model instance. + * + * Called by both `getData()` (single-instance path) and + * `instancesFromSurrealResult()` (bulk path) — single implementation so + * neither path can diverge. + */ +export async function evaluateCustomGetters( + instance: any, + perspective: PerspectiveProxy, + metadata: ModelMetadata, +): Promise { + const safeBase = formatSurrealValue(instance.id); + + // Property getters + for (const [propName, propMeta] of Object.entries(metadata.properties)) { + if (!propMeta.getter) continue; + try { + const query = propMeta.getter.replace(/Base/g, safeBase); + const result = await perspective.querySurrealDB( + `SELECT (${query}) AS value FROM node WHERE uri = ${safeBase}`, + ); + if ( + result?.length > 0 && + result[0].value !== undefined && + result[0].value !== null && + result[0].value !== "None" && + result[0].value !== "" + ) { + instance[propName] = result[0].value; + } + } catch (error) { + console.warn(`Failed to evaluate getter for ${propName}:`, error); + } + } + + // Relation getters + for (const [relationName, relMeta] of Object.entries(metadata.relations)) { + if (!relMeta.getter) continue; + try { + const query = relMeta.getter.replace(/Base/g, safeBase); + const result = await perspective.querySurrealDB( + `SELECT (${query}) AS value FROM node WHERE uri = ${safeBase}`, + ); + if ( + result?.length > 0 && + result[0].value !== undefined && + result[0].value !== null + ) { + const value = result[0].value; + instance[relationName] = Array.isArray(value) + ? value.filter( + (v: any) => + v !== undefined && v !== null && v !== "" && v !== "None", + ) + : value; + } + } catch (error) { + console.warn(`Failed to evaluate getter for ${relationName}:`, error); + } + } +} diff --git a/core/src/model/query/operations.ts b/core/src/model/query/operations.ts new file mode 100644 index 000000000..30827c6a7 --- /dev/null +++ b/core/src/model/query/operations.ts @@ -0,0 +1,510 @@ +/** + * Static query operations for Ad4mModel. + * + * Extracted from Ad4mModel.ts (Phase 3a Part 3). Every function here is a + * pure-static helper that takes a model constructor (`ctor`) as its first + * argument instead of relying on `this`. Ad4mModel wraps each one with a + * thin static method so the public API is completely unchanged. + * + * The `Ad4mModelCtor` type is imported (type-only) from QueryBuilder so + * there is no runtime circular dependency. + */ + +import type { PerspectiveProxy } from "../../perspectives/PerspectiveProxy"; +import type { + Query, + Where, + ResultsWithTotalCount, + PaginationResult, +} from "../types"; +import type { Ad4mModelCtor } from "./ModelQueryBuilder"; +import { + buildSurrealQuery, + buildSurrealCountQuery, + matchesCondition, +} from "./surrealCompiler"; +import { hydrateInstanceFromLinks, evaluateCustomGetters } from "./hydration"; +import { captureSnapshot } from "./snapshot"; +import { escapeSurrealString } from "../../utils"; +import { normalizeParentQuery } from "../parentUtils"; + +// ───────────────────────────────────────────────────────────────────────────── +// Query-to-SurrealQL helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Translates a high-level {@link Query} object to a SurrealQL string. + * + * @param ctor - The Ad4mModel subclass (provides getModelMetadata) + * @param perspective - Not used for query building but kept for API symmetry + * @param query - High-level query parameters + */ +export async function queryToSurrealQL( + ctor: Ad4mModelCtor, + _perspective: PerspectiveProxy, + query: Query, +): Promise { + const normalizedQuery = query.parent + ? { ...query, parent: normalizeParentQuery(query.parent, ctor as any) } + : query; + return buildSurrealQuery((ctor as any).getModelMetadata(), normalizedQuery); +} + +/** + * Translates a high-level {@link Query} object to a SurrealQL COUNT string. + */ +export async function countQueryToSurrealQL( + ctor: Ad4mModelCtor, + _perspective: PerspectiveProxy, + query: Query, +): Promise { + const normalizedQuery = query.parent + ? { ...query, parent: normalizeParentQuery(query.parent, ctor as any) } + : query; + return buildSurrealCountQuery((ctor as any).getModelMetadata(), normalizedQuery); +} + +// ───────────────────────────────────────────────────────────────────────────── +// instancesFromSurrealResult — the core hydration/assembly pipeline +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Converts raw SurrealDB result rows to hydrated Ad4mModel instances. + * + * This is the single-pass pipeline that: + * 1. Creates a model instance for each `source_uri` row + * 2. Hydrates properties, forward relations, author and timestamps + * 3. Fetches reverse-relation links in one batch query + * 4. Batch-hydrates nested `relatedModel` relations (no N+1) + * 5. Evaluates custom SurrealQL getters + * 6. Post-filters and sorts in JS for operators that SurrealDB can't handle + * 7. Applies offset/limit pagination + * + * @internal + */ +export async function instancesFromSurrealResult( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + query: Query, + result: any[], + _hydrateRelations = true, +): Promise> { + if (!result || result.length === 0) return { results: [], totalCount: 0 }; + + const metadata = (ctor as any).getModelMetadata(); + const requestedProperties = query?.properties || []; + + // ── 0. Pre-validate the `properties` option ─────────────────────────────── + // + // An empty array is disallowed: it almost certainly indicates a bug in the + // caller (a computed list that produced no entries). Pass `undefined` or + // omit the field entirely to get all properties. + // + // This check is BEFORE the row loop so the error propagates to the caller + // instead of being swallowed by the per-row try/catch. + if (query?.properties !== undefined && requestedProperties.length === 0) { + throw new Error( + "Ad4mModel: properties[] must not be empty. Omit the field to retrieve all properties.", + ); + } + + // ── 1. Build instances from rows ────────────────────────────────────────────────── + const instances: T[] = []; + for (const row of result) { + try { + const base = row.source_uri; + if (!base) continue; + + const links: any[] = row.links || []; + const instance = new ctor(perspective, base) as any; + + // Shared hydration: properties + forward relations + author + timestamps + await hydrateInstanceFromLinks(instance, links, metadata, perspective); + + // If the query asked for specific properties only, strip schema-declared + // fields that weren't requested. We only touch fields that are declared in + // the schema (via @Property / relation decorators) plus the well-known + // metadata fields (author, createdAt, updatedAt). Internal machinery such + // as _id, _perspective, _savedOnce, and the dynamically-wired addX / + // removeX / setX methods must never be deleted — they are not enumerated + // by their public names in Object.keys, but _id and _perspective ARE plain + // own properties and would be wrongly removed by a naïve Object.keys scan. + // + // Note: `id` is always accessible regardless — it is a prototype getter + // backed by the private _id field and cannot be deleted from an instance. + if (requestedProperties.length > 0) { + const schemaKeys = [ + ...Object.keys(metadata.properties), + ...Object.keys(metadata.relations), + "author", + "createdAt", + "updatedAt", + ]; + for (const key of schemaKeys) { + if (!requestedProperties.includes(key)) { + // Don't delete relation keys that are listed in the include map. + // The include-hydration step (step 3 below) needs the raw IDs to + // batch-fetch related models, and the hydrated result should appear + // in the final output even when the relation is not in `properties`. + if (query?.include && key in query.include) continue; + delete instance[key]; + } + } + } + + instances.push(instance); + } catch (error) { + console.error( + `Failed to process SurrealDB instance ${(error as any)?.base ?? "unknown"}:`, + error, + ); + } + } + + // ── 2. Reverse relations (one batch query for all instances) ────────────── + // Forward links (->link) are in row.links; reverse links (<-link) are not. + const reverseRelationEntries = Object.entries(metadata.relations).filter( + ([, m]: [string, any]) => !m.getter && m.direction === "reverse", + ); + if (reverseRelationEntries.length > 0 && instances.length > 0) { + try { + const inList = instances + .map((i: any) => `'${escapeSurrealString(i.id)}'`) + .join(", "); + const reverseLinksQuery = ` + SELECT in.uri AS source, predicate, out.uri AS target, author, timestamp + FROM link + WHERE out.uri IN [${inList}] + ORDER BY timestamp ASC + `; + const reverseLinks: any[] = + (await perspective.querySurrealDB(reverseLinksQuery)) ?? []; + + for (const instance of instances) { + for (const [relationName, relationMeta] of reverseRelationEntries) { + const matching = reverseLinks.filter( + (l: any) => + l.target === (instance as any).id && + l.predicate === (relationMeta as any).predicate, + ); + const values = matching.map((l: any) => l.source); + (instance as any)[relationName] = + (relationMeta as any).maxCount === 1 ? (values[0] ?? null) : values; + } + } + } catch (e) { + console.warn("Failed to fetch reverse links for instances:", e); + } + } + + // ── 3. Batch-hydrate relations via explicit `include` map ───────────────── + // + // No hydration is performed unless `query.include` is set. + // Each key in the map is a relation field name; the value is either `true` + // (hydrate all with no filter) or a Query to filter/order/limit the nested set. + if (_hydrateRelations && query?.include) { + const includeMap = query.include; + const hydrateEntries = Object.entries(metadata.relations).filter( + ([name, m]: [string, any]) => + !m.getter && name in includeMap && !!m.relatedModel, + ); + for (const [relationName, relationMeta] of hydrateEntries) { + const allIds = Array.from( + new Set( + instances.flatMap((i: any) => { + const val = i[relationName]; + if (!val) return []; + return Array.isArray(val) ? val.filter(Boolean) : [val]; + }), + ), + ) as string[]; + if (allIds.length === 0) continue; + try { + // Resolve the model class from the relatedModel factory. + const RelatedModel = (relationMeta as any).relatedModel(); + // Merge caller's sub-query with the id pre-filter. + const entry = includeMap[relationName]; + const subQuery: Query = + entry === true + ? { where: { id: allIds } } + : { ...entry, where: { id: allIds, ...(entry as Query).where } }; + // Pass _hydrateRelations=true when the sub-entry itself carries an + // include map (nested eager loading); otherwise false to stop recursion. + const nestedHydrate = entry !== true && !!(entry as Query).include; + const allHydrated = await _findAllInternal( + RelatedModel, + perspective, + subQuery, + nestedHydrate, + ); + const hydratedMap = new Map( + allHydrated.map((h: any) => [h.id, h]), + ); + // When the sub-query specifies an `order`, the sorted order from + // _findAllInternal must be preserved. We filter allHydrated (which is + // already sorted) by IDs belonging to this instance, rather than + // iterating `val` (which is in the original link-insertion order). + const hasSubOrder = entry !== true && !!(entry as Query).order; + for (const instance of instances) { + const val = (instance as any)[relationName]; + if (!val) continue; + if (Array.isArray(val)) { + if (hasSubOrder) { + const valSet = new Set(val as string[]); + (instance as any)[relationName] = allHydrated.filter((h: any) => + valSet.has(h.id), + ); + } else { + (instance as any)[relationName] = val + .map((id: string) => hydratedMap.get(id)) + .filter((h: any) => h !== undefined); + } + } else if (typeof val === "string") { + (instance as any)[relationName] = hydratedMap.get(val) ?? null; + } + } + } catch (e) { + console.warn(`Failed to batch-hydrate ${relationName}:`, e); + } + } + } + + // ── 4. Custom SurrealQL getters (single pass, all instances) ───────────── + for (const instance of instances) { + await evaluateCustomGetters(instance as any, perspective, metadata); + } + + // ── 5. Snapshot capture — baseline for dirty tracking on next save() ────── + const schemaKeys = [ + ...Object.keys(metadata.properties), + ...Object.keys(metadata.relations), + ]; + for (const instance of instances) { + captureSnapshot(instance as object, schemaKeys); + } + + // ── 6. Post-filter: where conditions that SurrealDB can't handle in SQL ─── + // • author / timestamp (computed from grouped links) + // • Comparison operators: gt, gte, lt, lte, between, contains + let filteredInstances = instances; + if (query.where) { + filteredInstances = instances.filter((instance) => { + for (const [propertyName, condition] of Object.entries(query.where!)) { + // base/id filtering is already done in SQL + if (propertyName === "base" || propertyName === "id") continue; + + // author and timestamp: always filter in JS + if (propertyName === "author" || propertyName === "timestamp") { + if (!matchesCondition((instance as any)[propertyName], condition)) { + return false; + } + continue; + } + + // Comparison operators: only these need JS post-filtering + if ( + typeof condition === "object" && + condition !== null && + !Array.isArray(condition) + ) { + const cond = condition as any; + const hasComparisonOps = + cond.gt !== undefined || + cond.gte !== undefined || + cond.lt !== undefined || + cond.lte !== undefined || + cond.between !== undefined || + cond.contains !== undefined; + if (hasComparisonOps) { + if (!matchesCondition((instance as any)[propertyName], condition)) { + return false; + } + } + } + } + return true; + }); + } + + // ── 7. Sort in JavaScript ───────────────────────────────────────────────── + // If limit/offset is used but no explicit order, default to timestamp ASC + // to guarantee consistent pagination behaviour. + const effectiveOrder = + query.order || + (query.limit !== undefined || query.offset !== undefined + ? { timestamp: "ASC" as "ASC" } + : null); + + if (effectiveOrder) { + const orderEntries = Object.entries(effectiveOrder) as [ + string, + "ASC" | "DESC", + ][]; + filteredInstances.sort((a: any, b: any) => { + for (const [orderPropName, orderDirection] of orderEntries) { + let aVal = a[orderPropName]; + let bVal = b[orderPropName]; + if (aVal === undefined && bVal === undefined) continue; + if (aVal === undefined) return orderDirection === "ASC" ? 1 : -1; + if (bVal === undefined) return orderDirection === "ASC" ? -1 : 1; + let comparison = 0; + if (typeof aVal === "number" && typeof bVal === "number") { + comparison = aVal - bVal; + } else if (typeof aVal === "string" && typeof bVal === "string") { + comparison = aVal.localeCompare(bVal); + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + if (comparison !== 0) + return orderDirection === "DESC" ? -comparison : comparison; + } + return 0; + }); + } + + // ── 8. Calculate totalCount BEFORE limit/offset, then paginate ──────────── + const totalCount = filteredInstances.length; + let paginatedInstances = filteredInstances; + if (query.offset !== undefined || query.limit !== undefined) { + const start = query.offset || 0; + const end = query.limit ? start + query.limit : undefined; + paginatedInstances = filteredInstances.slice(start, end); + } + + return { results: paginatedInstances, totalCount }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// High-level find/count operations +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Internal findAll used by the public static and by eager-hydration depth-guards. + * Pass `_hydrateRelations = false` to prevent recursive nested-model hydration. + */ +export async function _findAllInternal( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + query: Query = {}, + _hydrateRelations = true, +): Promise { + const surrealQuery = await queryToSurrealQL(ctor, perspective, query); + const result = await perspective.querySurrealDB(surrealQuery); + const { results } = await instancesFromSurrealResult( + ctor, + perspective, + query, + result, + _hydrateRelations, + ); + return results; +} + +/** Returns all matching instances. */ +export async function findAll( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + query: Query, +): Promise { + return _findAllInternal(ctor, perspective, query, true); +} + +/** Returns the first matching instance, or `null` if none found. */ +export async function findOne( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + query: Query, +): Promise { + const results = await findAll(ctor, perspective, { ...query, limit: 1 }); + return results[0] ?? null; +} + +/** Returns all matching instances together with the total unfilterd count. */ +export async function findAllAndCount( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + query: Query, +): Promise> { + const surrealQuery = await queryToSurrealQL(ctor, perspective, query); + const result = await perspective.querySurrealDB(surrealQuery); + return instancesFromSurrealResult(ctor, perspective, query, result); +} + +/** + * Paginates results given an explicit page size and 1-based page number. + * Returns metadata needed to render pagination controls. + */ +export async function paginate( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + pageSize: number, + pageNumber: number, + query: Query, +): Promise> { + const paginationQuery: Query = { + ...query, + limit: pageSize, + offset: pageSize * (pageNumber - 1), + count: true, + }; + const surrealQuery = await queryToSurrealQL( + ctor, + perspective, + paginationQuery, + ); + const result = await perspective.querySurrealDB(surrealQuery); + const { results, totalCount } = await instancesFromSurrealResult( + ctor, + perspective, + paginationQuery, + result, + ); + return { results, totalCount, pageSize, pageNumber }; +} + +/** Returns true when `where` has conditions that require JS post-filtering. */ +function hasJsFilterConditions(where?: Where): boolean { + if (!where) return false; + return Object.entries(where).some(([k, v]) => { + if (k === "author" || k === "timestamp") return true; + if (typeof v === "object" && v !== null && !Array.isArray(v)) { + const ops = v as any; + return ( + ops.gt !== undefined || + ops.gte !== undefined || + ops.lt !== undefined || + ops.lte !== undefined || + ops.between !== undefined || + ops.contains !== undefined + ); + } + return false; + }); +} + +/** Returns a count of all matching instances. */ +export async function count( + ctor: Ad4mModelCtor, + perspective: PerspectiveProxy, + query: Query, +): Promise { + // Strip pagination — count always wants total, not the paginated slice. + const countQuery: Query = { ...query, limit: undefined, offset: undefined }; + const surrealQuery = await countQueryToSurrealQL( + ctor, + perspective, + countQuery, + ); + const result = await perspective.querySurrealDB(surrealQuery); + if (!result || result.length === 0) return 0; + // Fast path: SQL WHERE handles all conditions — skip full instance hydration. + if (!hasJsFilterConditions(countQuery.where)) return result.length; + // Slow path: hydrate so JS post-filters (gt/gte/author/etc.) can run. + const { totalCount } = await instancesFromSurrealResult( + ctor, + perspective, + countQuery, + result, + ); + return totalCount ?? 0; +} diff --git a/core/src/model/query/snapshot.ts b/core/src/model/query/snapshot.ts new file mode 100644 index 000000000..6741f764d --- /dev/null +++ b/core/src/model/query/snapshot.ts @@ -0,0 +1,126 @@ +/** + * Dirty-tracking snapshot registry for Ad4mModel instances. + * + * A lightweight WeakMap-based store that records the schema-declared field + * values of an instance immediately after hydration or save. `innerUpdate` + * consults this snapshot to skip writing fields that haven't changed since the + * last successful persist, preventing the "re-save duplicates relations" bug. + * + * ## Design notes + * + * - A **WeakMap** is used so snapshot entries are GC'd when the instance is + * collected — no manual cleanup needed. + * - Arrays are normalised to `string[]` on capture (model instances → their ID + * URI) so the comparison is stable regardless of whether `include` hydration + * was used. + * - There is **no snapshot** for a freshly constructed instance that has never + * been hydrated or saved. `isDirty` returns `true` in that case, preserving + * existing create-path behaviour (write everything). + * - Only Tier-1 saves (no caller-provided `batchId`) trigger a post-save + * rehydration via `fetchInstanceData`, which re-captures the snapshot. Tier-2 + * / Tier-3 (caller-managed batch / `transaction()`) skip rehydration by + * design; fields that were not changed may be re-sent on the next Tier-1 + * save, which is idempotent and acceptable. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +type SnapshotEntry = Record; + +// ───────────────────────────────────────────────────────────────────────────── +// Registry +// ───────────────────────────────────────────────────────────────────────────── + +const snapshots = new WeakMap(); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Normalise a field value for snapshot storage and comparison. + * + * - Arrays whose items may be model instances (from `include` hydration) are + * reduced to their `id` URI strings so comparisons are stable. + * - All other values are stored as-is. + */ +function normalizeValue(value: any): any { + if (Array.isArray(value)) { + return value.map((v) => + v && typeof v === "object" && typeof v.id === "string" ? v.id : v, + ); + } + return value; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Records the current schema-field values of `instance` as its clean baseline. + * + * Call this after every hydration (bulk or single-instance path) and after + * every successful internal-batch save (the latter comes for free because + * `saveInstance` calls `fetchInstanceData` which ends with a `captureSnapshot` + * call). + * + * @param instance - The Ad4mModel instance to snapshot. + * @param keys - Schema-declared field names to record (@Property keys + + * relation keys). Internal machinery (`_id`, `_perspective`, + * dynamically-wired `addX`/`removeX`/`setX` methods) must + * NOT be included. + */ +export function captureSnapshot(instance: object, keys: string[]): void { + const entry: SnapshotEntry = {}; + for (const key of keys) { + entry[key] = normalizeValue((instance as any)[key]); + } + snapshots.set(instance, entry); +} + +/** + * Returns the snapshot entry for `instance`, or `undefined` if none has been + * captured yet (i.e. the instance has never been hydrated or saved via an + * internal batch). + */ +export function readSnapshot(instance: object): SnapshotEntry | undefined { + return snapshots.get(instance); +} + +/** + * Returns `true` if `currentValue` differs from the snapshot value for `key`. + * + * Always returns `true` (→ write the field) when: + * - No snapshot has been captured (create path / instance never hydrated). + * - The key was not present in the snapshot (new field added after hydration). + * + * Array comparison is order-insensitive: both sides are sorted before checking + * element-wise equality, because a fresh hydration may return relation IDs in a + * different order than they were originally written. + */ +export function isDirty( + instance: object, + key: string, + currentValue: any, +): boolean { + const snap = snapshots.get(instance); + if (!snap) return true; // no snapshot → always write (create path) + if (!(key in snap)) return true; // field absent from snapshot → write + + const snapVal = snap[key]; + const currNorm = normalizeValue(currentValue); + + if (Array.isArray(currNorm) && Array.isArray(snapVal)) { + if (currNorm.length !== snapVal.length) return true; + // Relations are semantically sets; sort before comparing so reordering + // after a fresh hydration doesn't produce false positives. + const a = [...currNorm].sort(); + const b = [...snapVal].sort(); + return a.some((v, i) => v !== b[i]); + } + + return currNorm !== snapVal; +} diff --git a/core/src/model/query/surrealCompiler.ts b/core/src/model/query/surrealCompiler.ts new file mode 100644 index 000000000..a27be97c6 --- /dev/null +++ b/core/src/model/query/surrealCompiler.ts @@ -0,0 +1,376 @@ +/** + * Pure SurrealQL compiler — translates high-level `Query` objects into + * raw SurrealQL strings. + * + * All functions are stateless — they take `ModelMetadata` (and optionally a + * `Query`) as plain data and return strings or booleans. No dependency on the + * `Ad4mModel` class, which means they can be imported by tests and other + * modules without pulling in the full class graph. + * + * `Ad4mModel` keeps thin static wrapper methods with the original signatures so + * the public API is unchanged. + */ + +import { escapeSurrealString } from "../../utils"; +import { + ModelMetadata, + Query, + Where, + WhereCondition, + ParentQueryByPredicate, +} from "../types"; + +// ── Value formatting ──────────────────────────────────────────────────────── + +/** + * Formats a value for use in SurrealQL queries. + * + * - Strings: wrapped in single quotes, special characters escaped + * - Numbers / booleans: converted to string + * - Arrays: recursively formatted, wrapped in `[...]` + */ +export function formatSurrealValue(value: any): string { + if (typeof value === "string") { + const escaped = value + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); + return `'${escaped}'`; + } else if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } else if (Array.isArray(value)) { + return `[${value.map(formatSurrealValue).join(", ")}]`; + } else { + return String(value); + } +} + +// ── WHERE clause builders ─────────────────────────────────────────────────── + +/** + * Builds graph-traversal WHERE clause filters (`->link[WHERE ...]`). + * Used by `buildSurrealQuery` — more efficient than subqueries because + * SurrealDB can use graph indexes. + */ +export function buildGraphTraversalWhereClause( + metadata: ModelMetadata, + where?: Where, +): string { + if (!where) return ""; + + const conditions: string[] = []; + + for (const [propertyName, condition] of Object.entries(where)) { + const isSpecial = ["base", "id", "author", "timestamp"].includes( + propertyName, + ); + + if (isSpecial) { + if (propertyName === "author" || propertyName === "timestamp") { + continue; // filtered post-query in JavaScript + } + + const columnName = "uri"; // base/id → uri in node table + + if (Array.isArray(condition)) { + const formattedValues = condition.map(formatSurrealValue).join(", "); + conditions.push(`${columnName} IN [${formattedValues}]`); + } else if (typeof condition === "object" && condition !== null) { + const ops = condition as any; + if (ops.not !== undefined) { + if (Array.isArray(ops.not)) { + const formattedValues = ops.not.map(formatSurrealValue).join(", "); + conditions.push(`${columnName} NOT IN [${formattedValues}]`); + } else { + conditions.push(`${columnName} != ${formatSurrealValue(ops.not)}`); + } + } + if ( + ops.between !== undefined && + Array.isArray(ops.between) && + ops.between.length === 2 + ) { + conditions.push( + `${columnName} >= ${formatSurrealValue(ops.between[0])} AND ${columnName} <= ${formatSurrealValue(ops.between[1])}`, + ); + } + if (ops.gt !== undefined) + conditions.push(`${columnName} > ${formatSurrealValue(ops.gt)}`); + if (ops.gte !== undefined) + conditions.push(`${columnName} >= ${formatSurrealValue(ops.gte)}`); + if (ops.lt !== undefined) + conditions.push(`${columnName} < ${formatSurrealValue(ops.lt)}`); + if (ops.lte !== undefined) + conditions.push(`${columnName} <= ${formatSurrealValue(ops.lte)}`); + if (ops.contains !== undefined) + conditions.push( + `${columnName} CONTAINS ${formatSurrealValue(ops.contains)}`, + ); + } else { + conditions.push(`${columnName} = ${formatSurrealValue(condition)}`); + } + } else { + const propMeta = metadata.properties[propertyName]; + if (!propMeta) { + // ── Relation-based where clause ──────────────────────────────────── + // If the where key is a relation name (e.g. `where: { post: postId }`), + // generate a graph-traversal filter on the link predicate. + // + // • Reverse relations (@BelongsToOne / @BelongsToMany): + // The link is stored as parentId --predicate--> thisId. + // From this node we traverse <-link (incoming) and check in.uri. + // + // • Forward relations (@HasMany / @HasOne): + // The link is stored as thisId --predicate--> childId. + // From this node we traverse ->link (outgoing) and check out.uri. + const relMeta = metadata.relations[propertyName]; + if (!relMeta) continue; + + const predicate = escapeSurrealString(relMeta.predicate); + const isReverse = relMeta.direction === "reverse"; + const arrow = isReverse ? "<-" : "->"; + const uriField = isReverse ? "in.uri" : "out.uri"; + + if (Array.isArray(condition)) { + const formattedValues = condition.map(formatSurrealValue).join(", "); + conditions.push( + `count(${arrow}link[WHERE predicate = '${predicate}' AND ${uriField} IN [${formattedValues}]]) > 0`, + ); + } else if (typeof condition === "object" && condition !== null) { + const ops = condition as any; + if (ops.not !== undefined) { + if (Array.isArray(ops.not)) { + const formattedValues = ops.not + .map(formatSurrealValue) + .join(", "); + conditions.push( + `count(${arrow}link[WHERE predicate = '${predicate}' AND ${uriField} IN [${formattedValues}]]) = 0`, + ); + } else { + conditions.push( + `count(${arrow}link[WHERE predicate = '${predicate}' AND ${uriField} = ${formatSurrealValue(ops.not)}]) = 0`, + ); + } + } + // Comparison operators on relation IDs are unusual but handled: + // they'll be caught in JS post-filtering (step 6 in operations.ts). + } else { + conditions.push( + `count(${arrow}link[WHERE predicate = '${predicate}' AND ${uriField} = ${formatSurrealValue(condition)}]) > 0`, + ); + } + + continue; + } + + const predicate = escapeSurrealString(propMeta.predicate); + // Scalar properties (resolveLanguage === "literal" or unset) store values as + // literal:// URIs — use fn::parse_literal to unwrap before comparing. + // Only non-literal custom languages (e.g. IPFS) store raw expression URLs. + const targetField = + !propMeta.resolveLanguage || propMeta.resolveLanguage === "literal" + ? "fn::parse_literal(out.uri)" + : "out.uri"; + + if (Array.isArray(condition)) { + const formattedValues = condition.map(formatSurrealValue).join(", "); + conditions.push( + `count(->link[WHERE predicate = '${predicate}' AND ${targetField} IN [${formattedValues}]]) > 0`, + ); + } else if (typeof condition === "object" && condition !== null) { + const ops = condition as any; + if (ops.not !== undefined) { + if (Array.isArray(ops.not)) { + const formattedValues = ops.not.map(formatSurrealValue).join(", "); + conditions.push( + `count(->link[WHERE predicate = '${predicate}' AND ${targetField} IN [${formattedValues}]]) = 0`, + ); + } else { + conditions.push( + `count(->link[WHERE predicate = '${predicate}' AND ${targetField} = ${formatSurrealValue(ops.not)}]) = 0`, + ); + } + } + const hasComparisonOps = + ops.gt !== undefined || + ops.gte !== undefined || + ops.lt !== undefined || + ops.lte !== undefined || + ops.between !== undefined || + ops.contains !== undefined; + if (hasComparisonOps) { + conditions.push( + `count(->link[WHERE predicate = '${predicate}']) > 0`, + ); + } + } else { + conditions.push( + `count(->link[WHERE predicate = '${predicate}' AND ${targetField} = ${formatSurrealValue(condition)}]) > 0`, + ); + } + } + } + + return conditions.join(" AND "); +} + +// ── Main query builders ───────────────────────────────────────────────────── + +/** + * Builds the SurrealQL SELECT query for a given model metadata + query params. + * This is the extracted, pure-function form of `Ad4mModel.queryToSurrealQL`. + */ +export function buildSurrealQuery( + metadata: ModelMetadata, + query: Query, +): string { + const { where, parent: linkedFrom } = query as Query & { + parent?: ParentQueryByPredicate; + }; + + const graphTraversalFilters: string[] = []; + for (const [, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.required) { + if (propMeta.flag && propMeta.initial) { + graphTraversalFilters.push( + `count(->link[WHERE predicate = '${escapeSurrealString(propMeta.predicate)}' AND out.uri = '${escapeSurrealString(propMeta.initial)}']) > 0`, + ); + } else { + graphTraversalFilters.push( + `count(->link[WHERE predicate = '${escapeSurrealString(propMeta.predicate)}']) > 0`, + ); + } + } + } + + if (graphTraversalFilters.length === 0) { + for (const [, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.initial) { + if (propMeta.flag) { + graphTraversalFilters.push( + `count(->link[WHERE predicate = '${escapeSurrealString(propMeta.predicate)}' AND out.uri = '${escapeSurrealString(propMeta.initial)}']) > 0`, + ); + } else { + graphTraversalFilters.push( + `count(->link[WHERE predicate = '${escapeSurrealString(propMeta.predicate)}']) > 0`, + ); + } + break; + } + } + } + + // Last-resort fallback: require at least one link with any of the model's + // own declared predicates (properties OR relations). This prevents matching + // SDNA registration nodes (which use sh://, ad4m://, rdf:// predicates) for + // models that have no @Flag, no required, and no initial-valued properties. + if (graphTraversalFilters.length === 0) { + const allPredicates = [ + ...Object.values(metadata.properties).map((p) => p.predicate), + ...Object.values(metadata.relations).map((r) => r.predicate), + ].filter(Boolean); + + if (allPredicates.length > 0) { + const predicateConditions = allPredicates + .map( + (p) => + `count(->link[WHERE predicate = '${escapeSurrealString(p)}']) > 0`, + ) + .join(" OR "); + graphTraversalFilters.push(`(${predicateConditions})`); + } + } + + const userWhereClause = buildGraphTraversalWhereClause(metadata, where); + + const whereConditions: string[] = [ + ...graphTraversalFilters, + ...(userWhereClause ? [userWhereClause] : []), + ...(linkedFrom + ? [ + `count(<-link[WHERE predicate = '${escapeSurrealString(linkedFrom.predicate)}' AND in.uri = '${escapeSurrealString(linkedFrom.id)}']) > 0`, + ] + : []), + ]; + + // Ensure we always have at least one condition to produce valid SurrealQL + const whereClause = + whereConditions.length > 0 + ? whereConditions.join(" AND ") + : "count(->link) > 0"; + + return ` +SELECT + id AS source, + uri AS source_uri, + (SELECT predicate, out.uri AS target, author, timestamp FROM link WHERE in = $parent.id ORDER BY timestamp ASC) AS links +FROM node +WHERE ${whereClause} + `.trim(); +} + +/** + * Builds a count query (same as `buildSurrealQuery` but without LIMIT/OFFSET). + */ +export function buildSurrealCountQuery( + metadata: ModelMetadata, + query: Query, +): string { + const countQuery = { ...query }; + delete countQuery.limit; + delete countQuery.offset; + return buildSurrealQuery(metadata, countQuery); +} + +// ── Post-query filtering ──────────────────────────────────────────────────── + +/** + * Checks whether a single value satisfies a WhereCondition. + * Used for post-query JavaScript filtering of operators that SurrealDB + * cannot evaluate reliably (gt/gte/lt/lte/between/contains on literals). + */ +export function matchesCondition( + value: any, + condition: WhereCondition, +): boolean { + if (Array.isArray(condition)) { + return (condition as any[]).includes(value); + } + + if (typeof condition === "object" && condition !== null) { + const ops = condition as any; + + let allMet = true; + if (ops.not !== undefined) + allMet = + allMet && + (Array.isArray(ops.not) + ? !(ops.not as any[]).includes(value) + : value !== ops.not); + if ( + ops.between !== undefined && + Array.isArray(ops.between) && + ops.between.length === 2 + ) + allMet = allMet && value >= ops.between[0] && value <= ops.between[1]; + if (ops.gt !== undefined) allMet = allMet && value > ops.gt; + if (ops.gte !== undefined) allMet = allMet && value >= ops.gte; + if (ops.lt !== undefined) allMet = allMet && value < ops.lt; + if (ops.lte !== undefined) allMet = allMet && value <= ops.lte; + if (ops.contains !== undefined) { + if (typeof value === "string") { + allMet = allMet && value.includes(String(ops.contains)); + } else if (Array.isArray(value)) { + allMet = allMet && value.includes(ops.contains); + } else { + allMet = false; + } + } + return allMet; + } + + return value === condition; +} diff --git a/core/src/model/schema/fromJSONSchema.ts b/core/src/model/schema/fromJSONSchema.ts new file mode 100644 index 000000000..47d8acd3d --- /dev/null +++ b/core/src/model/schema/fromJSONSchema.ts @@ -0,0 +1,411 @@ +/** + * Dynamic model creation from JSON Schema. + * + * Extracted from Ad4mModel.ts so it can be tested and reused independently. + * The main entry point — `createModelFromJSONSchema` — takes the `Ad4mModel` + * class as `BaseClass` to avoid a circular import between this module and the + * class file. + */ + +import { + Model, + PropertyOptions, + propertyRegistry, + relationRegistry, +} from "../decorators"; +import { capitalize, propertyNameToSetterName } from "../util"; + +// ── JSON Schema type definitions ──────────────────────────────────────────── + +export interface JSONSchemaProperty { + type: string | string[]; + items?: JSONSchemaProperty; + properties?: { [key: string]: JSONSchemaProperty }; + required?: string[]; + "x-ad4m"?: { + through?: string; + resolveLanguage?: string; + local?: boolean; + readOnly?: boolean; + initial?: string; + }; +} + +export interface JSONSchema { + $schema?: string; + title?: string; + $id?: string; + type?: string; + properties?: { [key: string]: JSONSchemaProperty }; + required?: string[]; + "x-ad4m"?: { + namespace?: string; + className?: string; + }; +} + +export interface JSONSchemaToModelOptions { + name: string; + namespace?: string; + predicateTemplate?: string; + predicateGenerator?: (title: string, property: string) => string; + propertyMapping?: Record; + resolveLanguage?: string; + local?: boolean; + propertyOptions?: Record>; +} + +// ── Internal schema helpers ───────────────────────────────────────────────── + +export function normalizeNamespaceString(namespace: string): string { + if (!namespace) return ""; + if (namespace.includes("://")) { + const [scheme, rest] = namespace.split("://"); + const path = (rest || "").replace(/\/+$/, ""); + return `${scheme}://${path}`; + } else { + return namespace.replace(/\/+$/, ""); + } +} + +export function normalizeSchemaType( + type?: string | string[], +): string | undefined { + if (!type) return undefined; + if (typeof type === "string") return type; + if (Array.isArray(type) && type.length > 0) { + const nonNull = type.find((t) => t !== "null"); + return nonNull || type[0]; + } + return undefined; +} + +export function isSchemaType( + schema: JSONSchemaProperty, + expectedType: string, +): boolean { + return normalizeSchemaType(schema.type) === expectedType; +} + +export function isArrayType(schema: JSONSchemaProperty): boolean { + return isSchemaType(schema, "array"); +} + +export function isObjectType(schema: JSONSchemaProperty): boolean { + return isSchemaType(schema, "object"); +} + +export function isNumericType(schema: JSONSchemaProperty): boolean { + const normalized = normalizeSchemaType(schema.type); + return normalized === "number" || normalized === "integer"; +} + +// ── Predicate + namespace resolution ─────────────────────────────────────── + +export function determineNamespace( + schema: JSONSchema, + options: JSONSchemaToModelOptions, +): string { + if (options.namespace) return options.namespace; + if (schema["x-ad4m"]?.namespace) return schema["x-ad4m"].namespace; + if (schema.title) return `${schema.title.toLowerCase()}://`; + + if (schema.$id) { + try { + const url = new URL(schema.$id); + const pathParts = url.pathname.split("/").filter((p) => p); + if (pathParts.length > 0) { + const lastPart = pathParts[pathParts.length - 1]; + const baseName = lastPart + .replace(/\.schema\.json$/, "") + .replace(/\.json$/, ""); + return `${baseName.toLowerCase()}://`; + } + } catch { + // not a valid URL — fall through to error + } + } + + throw new Error( + `Cannot infer namespace for JSON Schema. Please provide one of:\n` + + ` - options.namespace\n` + + ` - schema["x-ad4m"].namespace\n` + + ` - schema.title\n` + + ` - valid schema.$id`, + ); +} + +export function determinePredicate( + schema: JSONSchema, + propertyName: string, + propertySchema: JSONSchemaProperty, + namespace: string, + options: JSONSchemaToModelOptions, +): string { + if (options.propertyMapping?.[propertyName]) { + return options.propertyMapping[propertyName]; + } + if (propertySchema["x-ad4m"]?.through) { + return propertySchema["x-ad4m"].through; + } + if (options.predicateTemplate) { + const normalizedNs = normalizeNamespaceString(namespace); + const [scheme, rest] = normalizedNs.includes("://") + ? normalizedNs.split("://") + : ["", normalizedNs]; + const nsNoScheme = rest || ""; + return options.predicateTemplate + .replace("${namespace}", nsNoScheme) + .replace("${scheme}", scheme) + .replace("${ns}", nsNoScheme) + .replace("${title}", schema.title || "") + .replace("${property}", propertyName); + } + if (options.predicateGenerator) { + return options.predicateGenerator(schema.title || "", propertyName); + } + const normalizedNs = normalizeNamespaceString(namespace); + if (normalizedNs.includes("://")) { + return `${normalizedNs}${propertyName}`; + } else { + return `${normalizedNs}://${propertyName}`; + } +} + +export function getPropertyOption( + propertyName: string, + propertySchema: JSONSchemaProperty, + options: JSONSchemaToModelOptions, + optionName: keyof PropertyOptions, + defaultValue?: any, +): any { + if (options.propertyOptions?.[propertyName]?.[optionName] !== undefined) { + return options.propertyOptions[propertyName][optionName]; + } + if ( + propertySchema["x-ad4m"]?.[ + optionName as keyof JSONSchemaProperty["x-ad4m"] + ] !== undefined + ) { + return propertySchema["x-ad4m"][ + optionName as keyof JSONSchemaProperty["x-ad4m"] + ]; + } + if (options[optionName as keyof JSONSchemaToModelOptions] !== undefined) { + return options[optionName as keyof JSONSchemaToModelOptions]; + } + return defaultValue; +} + +export function getDefaultValueForType(type?: string): any { + switch (type) { + case "string": + return ""; + case "number": + return 0; + case "integer": + return 0; + case "boolean": + return false; + case "array": + return []; + case "object": + return {}; + default: + return ""; + } +} + +// ── Main factory ──────────────────────────────────────────────────────────── + +/** + * Creates an Ad4mModel subclass dynamically from a JSON Schema definition. + * + * Takes `BaseClass` as a parameter (rather than importing `Ad4mModel` directly) + * to avoid a circular dependency between this module and `Ad4mModel.ts`. + * + * `Ad4mModel.fromJSONSchema()` is a thin wrapper that calls this with `this`. + */ +export function createModelFromJSONSchema( + BaseClass: any, + schema: JSONSchema, + options: JSONSchemaToModelOptions, +): any { + if ( + schema?.properties && + Object.prototype.hasOwnProperty.call(schema.properties, "author") + ) { + throw new Error( + 'JSON Schema must not define a top-level "author" property because Ad4mModel already exposes it. Please rename the property (e.g., "writer").', + ); + } + + const namespace = determineNamespace(schema, options); + const DynamicModelClass = class extends BaseClass {}; + + if (!options.name || options.name.trim() === "") { + throw new Error("options.name is required and cannot be empty"); + } + (DynamicModelClass as any).className = options.name; + (DynamicModelClass.prototype as any).className = options.name; + + const properties: any = {}; + const relations: any = {}; + + if (schema.properties) { + for (const [propertyName, propertySchema] of Object.entries( + schema.properties, + )) { + const predicate = determinePredicate( + schema, + propertyName, + propertySchema, + namespace, + options, + ); + const isRequired = schema.required?.includes(propertyName) || false; + const propertyType = normalizeSchemaType(propertySchema.type); + const isArray = isArrayType(propertySchema); + + if (isArray) { + relations[propertyName] = { + through: predicate, + local: getPropertyOption( + propertyName, + propertySchema, + options, + "local", + ), + }; + + Object.defineProperty(DynamicModelClass.prototype, propertyName, { + configurable: true, + writable: true, + value: [], + }); + + const adderName = `add${capitalize(propertyName)}`; + const removerName = `remove${capitalize(propertyName)}`; + const setterName = `set${capitalize(propertyName)}`; + + (DynamicModelClass.prototype as any)[adderName] = function () {}; + (DynamicModelClass.prototype as any)[removerName] = function () {}; + (DynamicModelClass.prototype as any)[setterName] = function () {}; + } else { + let resolveLanguage = getPropertyOption( + propertyName, + propertySchema, + options, + "resolveLanguage", + ); + if (!resolveLanguage && options.resolveLanguage) { + resolveLanguage = options.resolveLanguage; + } + const local = getPropertyOption( + propertyName, + propertySchema, + options, + "local", + ); + const readOnly = getPropertyOption( + propertyName, + propertySchema, + options, + "readOnly", + ); + let initial = getPropertyOption( + propertyName, + propertySchema, + options, + "initial", + ); + + if (isObjectType(propertySchema) && !resolveLanguage) { + resolveLanguage = "literal"; + console.warn( + `Property "${propertyName}" is an object type. It will be stored as JSON. Consider flattening complex objects for better semantic querying.`, + ); + } + + // No auto-assignment for numeric types — resolveLanguage === undefined + // is now the implicit 'literal' default throughout the model layer. + + if (isRequired && !initial) { + if (isObjectType(propertySchema)) { + initial = "literal://json:{}"; + } else { + initial = "ad4m://undefined"; + } + } + + properties[propertyName] = { + through: predicate, + required: isRequired, + ...(readOnly && { readOnly }), + ...(resolveLanguage && { resolveLanguage }), + ...(local !== undefined && { local }), + ...(initial && { initial }), + }; + + Object.defineProperty(DynamicModelClass.prototype, propertyName, { + configurable: true, + writable: true, + value: getDefaultValueForType(propertyType), + }); + + if (!readOnly) { + const setterName = propertyNameToSetterName(propertyName); + (DynamicModelClass.prototype as any)[setterName] = function () {}; + } + } + } + } + + // Ensure at least one property has an initial value (needed for a valid SDNA constructor) + const hasPropertyWithInitial = Object.values(properties).some( + (prop: any) => prop.initial, + ); + + if (!hasPropertyWithInitial) { + const typeProperty = `ad4m://type`; + let typeValue: string; + if (namespace.includes("://")) { + const [scheme, rest] = namespace.split("://"); + const path = (rest || "").replace(/\/+$/, ""); + typeValue = path + ? `${scheme}://${path}/instance` + : `${scheme}://instance`; + } else { + typeValue = `${namespace.replace(/\/+$/, "")}/instance`; + } + + properties["__ad4m_type"] = { + through: typeProperty, + required: true, + readOnly: true, + initial: typeValue, + flag: true, + }; + + Object.defineProperty(DynamicModelClass.prototype, "__ad4m_type", { + configurable: true, + writable: false, + value: typeValue, + }); + + console.warn( + `No properties with initial values found. Added automatic type flag: ${typeProperty} = ${typeValue}`, + ); + } + + propertyRegistry.set(DynamicModelClass, properties); + relationRegistry.set(DynamicModelClass, relations); + + (DynamicModelClass.prototype as any).__jsonSchema = schema; + (DynamicModelClass.prototype as any).__jsonSchemaOptions = options; + + const ModelDecorator = Model({ name: options.name }); + ModelDecorator(DynamicModelClass); + + return DynamicModelClass; +} diff --git a/core/src/model/schema/metadata.ts b/core/src/model/schema/metadata.ts new file mode 100644 index 000000000..8895a9b15 --- /dev/null +++ b/core/src/model/schema/metadata.ts @@ -0,0 +1,148 @@ +/** + * Metadata extraction helper for Ad4mModel. + * + * - `getModelMetadata(ctor)` — builds a {@link ModelMetadata} descriptor from + * the decorator registries, or falls back to the attached JSON Schema. + */ + +import { getPropertiesMetadata, getRelationsMetadata } from "../decorators"; +import type { PropertyOptions, RelationOptions } from "../decorators"; +import { + isArrayType, + determinePredicate, + determineNamespace, +} from "./fromJSONSchema"; +import type { JSONSchemaProperty } from "./fromJSONSchema"; +import type { + ModelMetadata, + PropertyMetadata, + RelationMetadata, +} from "../types"; + +// ───────────────────────────────────────────────────────────────────────────── +// getModelMetadataForClass +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Builds a {@link ModelMetadata} descriptor for `ctor` from its decorator + * registries, falling back to the attached JSON Schema if no decorators are + * found. + * + * The matching `Ad4mModel.getModelMetadata()` static method delegates here. + */ +export function getModelMetadata(ctor: any): ModelMetadata { + const prototype = ctor.prototype as any; + + if (!prototype.className || prototype.className === "Ad4mModel") { + throw new Error("Model class must be decorated with @Model"); + } + + const className: string = prototype.className; + + // ── Extract from decorator registries ────────────────────────────────────── + const propertiesMetadata: Record = {}; + const prototypeProperties = getPropertiesMetadata(ctor); + + for (const [propertyName, opts] of Object.entries(prototypeProperties)) { + const options = opts as PropertyOptions & { + required?: boolean; + flag?: boolean; + }; + propertiesMetadata[propertyName] = { + name: propertyName, + predicate: options.through || "", + required: options.required || false, + ...(options.readOnly !== undefined && { readOnly: options.readOnly }), + ...(options.initial !== undefined && { initial: options.initial }), + ...(options.resolveLanguage !== undefined && { + resolveLanguage: options.resolveLanguage, + }), + ...(options.getter !== undefined && { getter: options.getter }), + ...(options.local !== undefined && { local: options.local }), + ...(options.transform !== undefined && { transform: options.transform }), + ...(options.flag !== undefined && { flag: options.flag }), + }; + } + + const relationsMetadata: Record = {}; + const prototypeRelations = getRelationsMetadata(ctor); + + for (const [relationName, opts] of Object.entries(prototypeRelations)) { + const options = opts as RelationOptions; + relationsMetadata[relationName] = { + name: relationName, + predicate: options.through || "", + ...(options.local !== undefined && { local: options.local }), + ...(options.getter !== undefined && { getter: options.getter }), + ...((opts as any).direction !== undefined && { + direction: (opts as any).direction, + }), + ...((opts as any).maxCount !== undefined && { + maxCount: (opts as any).maxCount, + }), + ...((opts as any).relatedModel !== undefined && { + relatedModel: (opts as any).relatedModel, + }), + }; + } + + // ── Fallback: derive from attached JSON Schema if registries are empty ────── + const hasMetadata = + Object.keys(propertiesMetadata).length > 0 || + Object.keys(relationsMetadata).length > 0; + + if (!hasMetadata && prototype.__jsonSchema) { + const schema = prototype.__jsonSchema; + const schemaOptions = prototype.__jsonSchemaOptions || {}; + + if (schema.properties) { + for (const [propertyName, propertySchema] of Object.entries( + schema.properties, + )) { + const isArray = isArrayType(propertySchema as JSONSchemaProperty); + const predicate = determinePredicate( + schema, + propertyName, + propertySchema as JSONSchemaProperty, + determineNamespace(schema, schemaOptions), + schemaOptions, + ); + + if (isArray) { + relationsMetadata[propertyName] = { + name: propertyName, + predicate, + ...((propertySchema as any)["x-ad4m"]?.local !== undefined && { + local: (propertySchema as any)["x-ad4m"].local, + }), + }; + } else { + propertiesMetadata[propertyName] = { + name: propertyName, + predicate, + required: schema.required?.includes(propertyName) || false, + ...((propertySchema as any)["x-ad4m"]?.readOnly && { + readOnly: true, + }), + ...((propertySchema as any)["x-ad4m"]?.resolveLanguage && { + resolveLanguage: (propertySchema as any)["x-ad4m"] + .resolveLanguage, + }), + ...((propertySchema as any)["x-ad4m"]?.initial && { + initial: (propertySchema as any)["x-ad4m"].initial, + }), + ...((propertySchema as any)["x-ad4m"]?.local !== undefined && { + local: (propertySchema as any)["x-ad4m"].local, + }), + }; + } + } + } + } + + return { + className, + properties: propertiesMetadata, + relations: relationsMetadata, + }; +} diff --git a/core/src/model/subscription.ts b/core/src/model/subscription.ts new file mode 100644 index 000000000..1ceafd364 --- /dev/null +++ b/core/src/model/subscription.ts @@ -0,0 +1,307 @@ +/** + * Live subscription implementation for Ad4mModel. + * + * Identical queries on the same perspective share a single `findAll()` call + * and a single pair of link listeners via an internal registry — multiple + * components subscribing to the same data cooperate rather than each running + * independent queries. + * + * Results are fingerprinted before broadcasting: callbacks only fire when + * the result set has actually changed. + */ + +import type { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; +import type { LinkExpression } from "../links/Links"; +import type { + Query, + ModelMetadata, + Subscription, + SubscribeOptions, +} from "./types"; +import { instanceToSerializable } from "./decorators"; +import { normalizeParentQuery } from "./parentUtils"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Stable JSON key for a Query — sorts object keys so that equivalent queries + * produced from different code paths hash to the same string. + */ +function stableQueryKey(query: Query): string { + if (Object.keys(query).length === 0) return "{}"; + const sorted: Record = {}; + for (const k of Object.keys(query).sort()) { + sorted[k] = (query as any)[k]; + } + return JSON.stringify(sorted); +} + +/** + * Stable fingerprint for an array of model instances. + * + * Sorted by `id` so result ordering doesn't cause false positives. + * `id` is a prototype getter and therefore not serialised by + * `JSON.stringify` automatically — it is extracted explicitly. + * + * Delegates to `instanceToSerializable` (from decorators.ts) which strips + * all non-decorator-registered fields — including the runtime-enumerable + * TypeScript `private _perspective` that would otherwise cause + * `JSON.stringify` to throw a circular-reference error via + * PerspectiveProxy → Apollo InMemoryCache. + */ +function stableFingerprint(results: any[]): string { + const sorted = [...results].sort((a, b) => { + const aId: string = a.id ?? ""; + const bId: string = b.id ?? ""; + return aId < bId ? -1 : aId > bId ? 1 : 0; + }); + return JSON.stringify(sorted.map(instanceToSerializable)); +} + +// ─── Subscription registry ──────────────────────────────────────────────────── +// +// Key: `${modelClassName}:${stableQueryKey(query)}` +// +// WeakMap: when a PerspectiveProxy is garbage-collected its inner map is +// released automatically — no manual cleanup required. + +interface ListenerRecord { + callback: (results: any[]) => void; + onError: ((err: Error) => void) | undefined; + /** Allows unsubscribe() to surface the last error on the Subscription handle. */ + setLastError: (err: Error) => void; +} + +interface SharedEntry { + listeners: Map; + /** Results from the last successful findAll(). null until first run. */ + lastResults: any[] | null; + lastFingerprint: string | null; + debounceTimer: ReturnType | null; + /** False once the last listener unsubscribes — prevents stale async callbacks. */ + active: boolean; + /** Removes link-added/link-removed listeners from the perspective. */ + detach(): void; +} + +const registry = new WeakMap>(); + +function getOrCreateSharedEntry( + findAll: (perspective: PerspectiveProxy, query?: Query) => Promise, + perspective: PerspectiveProxy, + query: Query, + metadata: ModelMetadata, + debounceMs: number, + perspEntries: Map, + key: string, +): SharedEntry { + const existing = perspEntries.get(key); + if (existing) return existing; + + // Minimum coalesce window — even without a user-configured debounce, a + // single save() emits several link events; this collapses them into one + // findAll() call. Not a timing workaround — SurrealDB is guaranteed + // committed before link-added events are published. + const SETTLE_MS = 50; + const effectiveDebounce = Math.max(debounceMs, SETTLE_MS); + + const watchedPredicates = new Set(); + for (const prop of Object.values(metadata.properties)) { + if (prop.predicate) watchedPredicates.add(prop.predicate); + } + for (const rel of Object.values(metadata.relations)) { + if (rel.predicate) watchedPredicates.add(rel.predicate); + } + // If the query is scoped to a parent via parent query, also watch the + // parent-relationship predicate so that adding/removing a child link + // triggers a live re-query even though it's not in the child model's schema. + if (query.parent && 'predicate' in query.parent) { + watchedPredicates.add(query.parent.predicate); + } + + const entry: SharedEntry = { + listeners: new Map(), + lastResults: null, + lastFingerprint: null, + debounceTimer: null, + active: true, + detach: () => {}, + }; + + const broadcast = (results: any[]) => { + for (const { + callback, + onError, + setLastError, + } of entry.listeners.values()) { + try { + callback(results); + } catch (err) { + setLastError(err as Error); + (onError ?? console.error)(err as Error); + } + } + }; + + const notifyError = (err: Error) => { + for (const { onError, setLastError } of entry.listeners.values()) { + setLastError(err); + (onError ?? console.error)(err); + } + }; + + const rerun = async () => { + if (!entry.active) return; + try { + const results = await findAll(perspective, query); + if (!entry.active) return; + + // Only broadcast if results actually changed. + const fingerprint = stableFingerprint(results); + if (fingerprint === entry.lastFingerprint) return; + entry.lastFingerprint = fingerprint; + entry.lastResults = results; + + broadcast(results); + } catch (err) { + notifyError(err as Error); + } + }; + + const scheduleRerun = () => { + if (entry.debounceTimer !== null) clearTimeout(entry.debounceTimer); + entry.debounceTimer = setTimeout(rerun, effectiveDebounce); + }; + + // LinkCallback must return null (see PerspectiveClient.ts type definition) + const linkChangedCb = (link: LinkExpression): null => { + if (watchedPredicates.has(link.data?.predicate ?? "")) scheduleRerun(); + return null; + }; + + perspective.addListener("link-added", linkChangedCb); + perspective.addListener("link-removed", linkChangedCb); + entry.detach = () => { + // Fire-and-forget — removeListener is async but just does an array splice. + // entry.active = false ensures no further callbacks fire regardless. + perspective.removeListener("link-added", linkChangedCb); + perspective.removeListener("link-removed", linkChangedCb); + }; + + // Fire immediately with initial results. + rerun(); + + perspEntries.set(key, entry); + return entry; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Creates a live subscription for a model query. + * + * Immediately invokes `callback` with the initial query results, then + * re-invokes it whenever a relevant link is added to or removed from the + * perspective. + * + * Multiple callers subscribing to the same model + query on the same + * perspective share a single `findAll()` execution and a single pair of link + * listeners via an internal registry. Callbacks are only fired when the + * result set has actually changed. + * + * @param findAll - The model's `findAll` static method + * @param getMetadata - The model's `getModelMetadata` static method + * @param perspective - The perspective to watch + * @param options - Query parameters + delivery options (debounce, onError) + * @param callback - Invoked with fresh results on every relevant change + * @returns A `Subscription` handle with `unsubscribe()` and `lastError` + * + * @example + * ```typescript + * const sub = createSubscription( + * (p, q) => Post.findAll(p, q), + * () => Post.getModelMetadata(), + * perspective, + * { where: { published: true }, debounce: 200 }, + * (posts) => console.log("Posts updated:", posts), + * ); + * + * // Later: + * sub.unsubscribe(); + * ``` + */ +export function createSubscription( + findAll: (perspective: PerspectiveProxy, query?: Query) => Promise, + getMetadata: () => ModelMetadata, + perspective: PerspectiveProxy, + options: SubscribeOptions, + callback: (results: T[]) => void, + ctor?: new (...args: any[]) => any, +): Subscription { + const { debounce: debounceMs = 0, onError, ...queryOptions } = options; + // Normalise model-backed parent query to { id, predicate } before key + // computation so the shared-entry registry and predicate watcher both + // operate on the resolved string form. + const rawParent = (queryOptions as Query).parent; + const resolvedOptions = rawParent + ? { ...queryOptions, parent: normalizeParentQuery(rawParent, ctor) } + : queryOptions; + const query: Query = resolvedOptions as Query; + const metadata = getMetadata(); + const key = `${metadata.className}:${stableQueryKey(query)}`; + + let perspEntries = registry.get(perspective); + if (!perspEntries) { + perspEntries = new Map(); + registry.set(perspective, perspEntries); + } + + const entry = getOrCreateSharedEntry( + findAll as (p: PerspectiveProxy, q?: Query) => Promise, + perspective, + query, + metadata, + debounceMs, + perspEntries, + key, + ); + + const listenerId = Symbol(); + let lastError: Error | null = null; + + entry.listeners.set(listenerId, { + callback: callback as (results: any[]) => void, + onError, + setLastError: (err) => { + lastError = err; + }, + }); + + // Late subscriber: if the shared entry already ran at least once, fire + // immediately with cached results — avoids an extra findAll() round-trip. + if (entry.lastResults !== null) { + const cached = entry.lastResults as T[]; + Promise.resolve().then(() => { + if (entry.listeners.has(listenerId)) callback(cached); + }); + } + + return { + get lastError(): Error | null { + return lastError; + }, + unsubscribe() { + entry.listeners.delete(listenerId); + if (entry.listeners.size === 0) { + // Last subscriber — tear down the shared entry entirely. + entry.active = false; + if (entry.debounceTimer !== null) { + clearTimeout(entry.debounceTimer); + entry.debounceTimer = null; + } + entry.detach(); + perspEntries!.delete(key); + } + }, + }; +} diff --git a/core/src/model/transaction.ts b/core/src/model/transaction.ts new file mode 100644 index 000000000..29f05b976 --- /dev/null +++ b/core/src/model/transaction.ts @@ -0,0 +1,60 @@ +/** Atomic batch-transaction helper — see {@link runTransaction}. */ + +import type { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; + +// ───────────────────────────────────────────────────────────────────────────── +// TransactionContext +// ───────────────────────────────────────────────────────────────────────────── + +/** + * An open batch transaction on a perspective. + * + * Obtained from the callback argument of {@link runTransaction}. Pass + * `tx.batchId` to `save()`, `delete()` etc. to enlist those operations in the + * transaction. Commit and abort are handled automatically by `runTransaction`. + */ +export interface TransactionContext { + /** The underlying batch ID — pass to save/delete/add/remove calls. */ + readonly batchId: string; + /** The perspective this transaction is open on. */ + readonly perspective: PerspectiveProxy; +} + +// ───────────────────────────────────────────────────────────────────────────── +// runTransaction +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Commits on success, aborts (discards batch) and re-throws on failure. + * + * @example + * ```typescript + * await Ad4mModel.transaction(perspective, async (tx) => { + * await post.save(tx.batchId); + * await comment.save(tx.batchId); + * }); + * ``` + */ +export async function runTransaction( + perspective: PerspectiveProxy, + callback: (tx: TransactionContext) => Promise, +): Promise { + const batchId = await perspective.createBatch(); + const tx: TransactionContext = { batchId, perspective }; + + try { + const result = await callback(tx); + await perspective.commitBatch(batchId); + return result; + } catch (err) { + // PerspectiveProxy has no abortBatch — the uncommitted batch will be + // discarded by the runtime on its next GC cycle. Log at debug level + // only: the re-thrown error already carries all actionable information, + // and logging at warn would spam the console for intentional rollbacks. + console.debug( + `[Ad4mModel.transaction] callback threw — batch ${batchId} was NOT committed and will be discarded.`, + err, + ); + throw err; + } +} diff --git a/core/src/model/types.ts b/core/src/model/types.ts new file mode 100644 index 000000000..f01ca032f --- /dev/null +++ b/core/src/model/types.ts @@ -0,0 +1,207 @@ +/** + * Public types for the AD4M model layer. + * + * Extracted from Ad4mModel.ts so they can be imported by query builders, + * hydration helpers, and external consumers without pulling in the full + * Ad4mModel class. + */ + +// ── Where / Query ────────────────────────────────────────────────────────── + +type WhereOps = { + not: string | number | boolean | string[] | number[]; + between: [number, number]; + lt: number; // less than + lte: number; // less than or equal to + gt: number; // greater than + gte: number; // greater than or equal to + contains: string | number; // substring/element check +}; + +export type WhereCondition = + | string + | number + | boolean + | string[] + | number[] + | { [K in keyof WhereOps]?: WhereOps[K] }; + +export type Where = { [propertyName: string]: WhereCondition }; +export type Order = { [propertyName: string]: "ASC" | "DESC" }; + +/** + * Prisma-style eager-loading map. + * + * Key = relation field name on the model. + * Value = `true` (hydrate all with no filter) or a `Query` to + * filter / order / limit the nested set. + * + * @example + * ```typescript + * Recipe.findAll(perspective, { + * include: { + * comments: true, + * tags: { where: { active: true }, order: { name: 'ASC' }, limit: 5 }, + * }, + * }); + * ``` + */ +export type IncludeMap = { [relationName: string]: true | Query }; + +// ── ParentQuery ───────────────────────────────────────────────────────────── + +/** + * Minimal interface a parent model class must satisfy for the model-backed + * `parent` query form. Avoids a circular import between types.ts and Ad4mModel.ts. + */ +type ParentQueryParentCtor = { getModelMetadata(): ModelMetadata }; + +/** Raw predicate form — use for ad-hoc or cross-package predicates. */ +export type ParentQueryByPredicate = { id: string; predicate: string }; + +/** + * Model-backed form — DRY alternative that resolves the predicate from the + * parent model's `@HasMany` decorator metadata at query time. + */ +export type ParentQueryByModel = { + id: string; + model: ParentQueryParentCtor; + /** The `@HasMany` field name on `model`. Optional when unambiguous. */ + field?: string; +}; + +/** Union of the two `parent` query forms accepted by {@link Query}. */ +export type ParentQuery = ParentQueryByPredicate | ParentQueryByModel; + +export type Query = { + properties?: string[]; + /** Eagerly hydrate relations. Key = field name; value = `true` or a sub-`Query`. See {@link IncludeMap}. */ + include?: IncludeMap; + where?: Where; + order?: Order; + offset?: number; + limit?: number; + count?: boolean; + /** + * When provided, restricts results to instances reachable from the given + * parent node via the given predicate. + * + * The subscription layer also watches this predicate so that adding or + * removing a child link triggers a live re-query. + * + * Two equivalent forms are accepted: + * - Raw: `{ id, predicate }` — use for ad-hoc / foreign predicates. + * - Model-backed: `{ id, model, field? }` — DRY, refactor-safe; the + * predicate is resolved automatically from the parent model's `@HasMany` + * decorator metadata. `field` is the `@HasMany` field name; when the + * parent has exactly one `@HasMany` pointing to the child type it may be + * omitted (but is recommended for clarity). + * + * @example + * // Raw form (still valid for foreign predicates) + * parent: { id: parentId, predicate: 'custom://my_pred' } + * + * // Model-backed form (preferred for schema-declared relations) + * parent: { id: poll.id, model: Poll, field: 'answers' } + */ + parent?: ParentQuery; +}; + +/** + * Extends `Query` (minus `count`) with subscription delivery options. + * Pass to {@link Ad4mModel.subscribe} or the builder's `.subscribe()`. + */ +export type SubscribeOptions = Omit & { + /** + * Debounce delay in milliseconds. Multiple link changes within this window + * trigger only one re-query. Default: `0` (no debouncing). + */ + debounce?: number; + /** + * Called when the re-query or the callback throws. + * Defaults to `console.error` so failures are always visible without + * requiring every caller to handle them. + */ + onError?: (err: Error) => void; +}; + +/** + * Handle returned by `Ad4mModel.subscribe()`. Call `unsubscribe()` in cleanup. + * `lastError` holds the most recent unhandled error, or `null`. + */ +export type Subscription = { + unsubscribe(): void; + /** The most recent unhandled error from re-query or callback, or `null`. */ + readonly lastError: Error | null; +}; + +// ── Result shapes ────────────────────────────────────────────────────────── + +export type AllInstancesResult = any; +export type ResultsWithTotalCount = { results: T[]; totalCount?: number }; +export type PaginationResult = { + results: T[]; + totalCount?: number; + pageSize: number; + pageNumber: number; +}; + +// ── Model metadata ───────────────────────────────────────────────────────── + +/** + * Metadata for a single property extracted from decorators. + */ +export interface PropertyMetadata { + /** The property name */ + name: string; + /** The predicate URI (through value) */ + predicate: string; + /** Whether the property is required */ + required: boolean; + /** Whether the property is read-only (default: false, i.e. writable) */ + readOnly?: boolean; + /** Initial value if specified */ + initial?: string; + /** Language for resolution (e.g., "literal") */ + resolveLanguage?: string; + /** Custom SurrealQL getter code */ + getter?: string; + /** Whether stored locally only */ + local?: boolean; + /** Transform function */ + transform?: (value: any) => any; + /** Whether this is a flag property */ + flag?: boolean; +} + +/** + * Metadata for a single relation extracted from decorators. + */ +export interface RelationMetadata { + /** The relation name */ + name: string; + /** The predicate URI (through value) */ + predicate: string; + /** Custom SurrealQL getter code */ + getter?: string; + /** Whether stored locally only */ + local?: boolean; + /** Traversal direction — "forward" (default) for @HasMany/@HasOne, "reverse" for @BelongsToOne/@BelongsToMany */ + direction?: "forward" | "reverse"; + /** Maximum number of results (1 for @BelongsToOne / @HasOne) */ + maxCount?: number; + /** Model factory for eager hydration — set when decorator is called with () => ModelClass */ + relatedModel?: () => any; +} + +/** + * Complete model metadata extracted from decorators. + */ +export interface ModelMetadata { + /** The model class name from @ModelOptions */ + className: string; + /** Map of property name to metadata */ + properties: Record; + /** Map of relation name to metadata */ + relations: Record; +} diff --git a/core/src/model/util.ts b/core/src/model/util.ts index 9719f594c..fcc2fb39e 100644 --- a/core/src/model/util.ts +++ b/core/src/model/util.ts @@ -1,88 +1,87 @@ export function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); + return str.charAt(0).toUpperCase() + str.slice(1); } // e.g. "name" -> "setName" export function propertyNameToSetterName(property: string): string { - return `set${capitalize(property)}` + return `set${capitalize(property)}`; } // e.g. "setName" -> "name" export function setterNameToPropertyName(setter: string): string { - return setter.replace("set", "").replace(/^[A-Z]/, (m) => m.toLowerCase()) + return setter.replace("set", "").replace(/^[A-Z]/, (m) => m.toLowerCase()); } export function singularToPlural(singular: string): string { - if(singular.endsWith("y")) { - return singular.slice(0, -1) + "ies" - } else { - return singular + "s" - } + if (singular.endsWith("y")) { + return singular.slice(0, -1) + "ies"; + } else { + return singular + "s"; + } } export function pluralToSingular(plural: string): string { - if(plural.endsWith("ies")) { - return plural.slice(0, -3) + "y" - } else if(plural.endsWith("s")) { - return plural.slice(0, -1) - } else { - return plural - } + if (plural.endsWith("ies")) { + return plural.slice(0, -3) + "y"; + } else if (plural.endsWith("s")) { + return plural.slice(0, -1); + } else { + return plural; + } } // e.g. "comments" -> "addComment" export function collectionToAdderName(collection: string): string { - return `add${capitalize(pluralToSingular(collection))}` + return `add${capitalize(pluralToSingular(collection))}`; } // e.g. "addComments" -> "comments" export function collectionAdderToName(adderName: string): string { - // Extract the collection name after "add" and lowercase first char - // The method name already has the plural collection name (e.g., "addComments") - let collectionName = adderName.substring(3) - return collectionName.charAt(0).toLowerCase() + collectionName.slice(1) + // Extract the collection name after "add" and lowercase first char + // The method name already has the plural collection name (e.g., "addComments") + let collectionName = adderName.substring(3); + return collectionName.charAt(0).toLowerCase() + collectionName.slice(1); } // e.g. "comments" -> "removeComment" export function collectionToRemoverName(collection: string): string { - return `remove${capitalize(pluralToSingular(collection))}` + return `remove${capitalize(pluralToSingular(collection))}`; } -// e.g. "removeComments" -> "comments" +// e.g. "removeComments" -> "comments" export function collectionRemoverToName(removerName: string): string { - // Extract the collection name after "remove" and lowercase first char - // The method name already has the plural collection name (e.g., "removeComments") - let collectionName = removerName.substring(6) - return collectionName.charAt(0).toLowerCase() + collectionName.slice(1) + // Extract the collection name after "remove" and lowercase first char + // The method name already has the plural collection name (e.g., "removeComments") + let collectionName = removerName.substring(6); + return collectionName.charAt(0).toLowerCase() + collectionName.slice(1); } export function collectionSetterToName(setterName: string): string { - // Extract the collection name after "setCollection" and lowercase first char - // The method name already has the plural collection name (e.g., "setCollectionComments") - let collectionName = setterName.substring(13) - return collectionName.charAt(0).toLowerCase() + collectionName.slice(1) + // Extract the collection name after "set" and lowercase first char + // The method name has the plural collection name (e.g., "setComments") + let collectionName = setterName.substring(3); + return collectionName.charAt(0).toLowerCase() + collectionName.slice(1); } -// e.g. "comments" -> "addComment" +// e.g. "comments" -> "setComment" export function collectionToSetterName(collection: string): string { - return `setCollection${capitalize(pluralToSingular(collection))}` + return `set${capitalize(pluralToSingular(collection))}`; } - export function stringifyObjectLiteral(obj) { - if(Array.isArray(obj)) { - //@ts-ignore - return `[${obj.map(stringifyObjectLiteral).join(", ")}]` - } - - const keys = Object.keys(obj); - const stringifiedPairs = []; - - for (const key of keys) { - const valueString = JSON.stringify(obj[key]); - const keyValuePairString = `${key}: ${valueString}`; - stringifiedPairs.push(keyValuePairString); - } + if (Array.isArray(obj)) { + //@ts-ignore + return `[${obj.map(stringifyObjectLiteral).join(", ")}]`; + } + + const keys = Object.keys(obj); + const stringifiedPairs = []; - return `{${stringifiedPairs.join(', ')}}`; - } \ No newline at end of file + for (const key of keys) { + const valueString = JSON.stringify(obj[key]); + const keyValuePairString = `${key}: ${valueString}`; + stringifiedPairs.push(keyValuePairString); + } + + return `{${stringifiedPairs.join(", ")}}`; +} diff --git a/core/src/neighbourhood/NeighbourhoodClient.ts b/core/src/neighbourhood/NeighbourhoodClient.ts index b70dd8f0c..ddc77d0b8 100644 --- a/core/src/neighbourhood/NeighbourhoodClient.ts +++ b/core/src/neighbourhood/NeighbourhoodClient.ts @@ -1,295 +1,421 @@ -import { ApolloClient, gql, FetchResult } from "@apollo/client/core" -import { Address } from "../Address" -import { DID } from "../DID" -import { OnlineAgent, TelepresenceSignalCallback } from "../language/Language" -import { Perspective, PerspectiveUnsignedInput } from "../perspectives/Perspective" -import { PerspectiveHandle } from "../perspectives/PerspectiveHandle" -import unwrapApolloResult from "../unwrapApolloResult" -import { NeighbourhoodProxy } from "./NeighbourhoodProxy" +import { ApolloClient, gql, FetchResult } from "@apollo/client/core"; +import { Address } from "../Address"; +import { DID } from "../DID"; +import { OnlineAgent, TelepresenceSignalCallback } from "../language/Language"; +import { + Perspective, + PerspectiveUnsignedInput, +} from "../perspectives/Perspective"; +import { PerspectiveHandle } from "../perspectives/PerspectiveHandle"; +import unwrapApolloResult from "../unwrapApolloResult"; +import { isSocketCloseError } from "../utils"; export class NeighbourhoodClient { - #apolloClient: ApolloClient - #signalHandlers: Map = new Map() + private _apolloClient: ApolloClient; + private _signalHandlers: Map = + new Map(); + private _signalSubscriptions: Map = + new Map(); - constructor(client: ApolloClient) { - this.#apolloClient = client - } + constructor(client: ApolloClient) { + this._apolloClient = client; + } - async publishFromPerspective( - perspectiveUUID: string, - linkLanguage: Address, - meta: Perspective - ): Promise { - const { neighbourhoodPublishFromPerspective } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodPublishFromPerspective( - $linkLanguage: String!, - $meta: PerspectiveInput!, - $perspectiveUUID: String! - ) { - neighbourhoodPublishFromPerspective( - linkLanguage: $linkLanguage, - meta: $meta, - perspectiveUUID: $perspectiveUUID - ) - }`, - variables: { perspectiveUUID, linkLanguage, meta: meta} - })) - return neighbourhoodPublishFromPerspective - } + async publishFromPerspective( + perspectiveUUID: string, + linkLanguage: Address, + meta: Perspective, + ): Promise { + const { neighbourhoodPublishFromPerspective } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodPublishFromPerspective( + $linkLanguage: String! + $meta: PerspectiveInput! + $perspectiveUUID: String! + ) { + neighbourhoodPublishFromPerspective( + linkLanguage: $linkLanguage + meta: $meta + perspectiveUUID: $perspectiveUUID + ) + } + `, + variables: { perspectiveUUID, linkLanguage, meta: meta }, + }), + ); + return neighbourhoodPublishFromPerspective; + } - async joinFromUrl(url: string): Promise { - const { neighbourhoodJoinFromUrl } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodJoinFromUrl($url: String!) { - neighbourhoodJoinFromUrl(url: $url) { - uuid - name - sharedUrl - state - neighbourhood { - data { - linkLanguage - meta { - links - { - author - timestamp - data { source, predicate, target } - proof { valid, invalid, signature, key } - } - } - } - author + async joinFromUrl(url: string): Promise { + const { neighbourhoodJoinFromUrl } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodJoinFromUrl($url: String!) { + neighbourhoodJoinFromUrl(url: $url) { + uuid + name + sharedUrl + state + neighbourhood { + data { + linkLanguage + meta { + links { + author + timestamp + data { + source + predicate + target + } + proof { + valid + invalid + signature + key + } } + } } - }`, - variables: { url } - })) - return neighbourhoodJoinFromUrl - } + author + } + } + } + `, + variables: { url }, + }), + ); + return neighbourhoodJoinFromUrl; + } - async otherAgents(perspectiveUUID: string): Promise { - const { neighbourhoodOtherAgents } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query neighbourhoodOtherAgents($perspectiveUUID: String!) { - neighbourhoodOtherAgents(perspectiveUUID: $perspectiveUUID) - }`, - variables: { perspectiveUUID } - })) - return neighbourhoodOtherAgents - } + async otherAgents(perspectiveUUID: string): Promise { + const { neighbourhoodOtherAgents } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query neighbourhoodOtherAgents($perspectiveUUID: String!) { + neighbourhoodOtherAgents(perspectiveUUID: $perspectiveUUID) + } + `, + variables: { perspectiveUUID }, + }), + ); + return neighbourhoodOtherAgents; + } - async hasTelepresenceAdapter(perspectiveUUID: string): Promise { - const { neighbourhoodHasTelepresenceAdapter } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query neighbourhoodHasTelepresenceAdapter($perspectiveUUID: String!) { - neighbourhoodHasTelepresenceAdapter(perspectiveUUID: $perspectiveUUID) - }`, - variables: { perspectiveUUID } - })) - return neighbourhoodHasTelepresenceAdapter - } + async hasTelepresenceAdapter(perspectiveUUID: string): Promise { + const { neighbourhoodHasTelepresenceAdapter } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query neighbourhoodHasTelepresenceAdapter($perspectiveUUID: String!) { + neighbourhoodHasTelepresenceAdapter( + perspectiveUUID: $perspectiveUUID + ) + } + `, + variables: { perspectiveUUID }, + }), + ); + return neighbourhoodHasTelepresenceAdapter; + } - async onlineAgents(perspectiveUUID: string): Promise { - const { neighbourhoodOnlineAgents } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query neighbourhoodOnlineAgents($perspectiveUUID: String!) { - neighbourhoodOnlineAgents(perspectiveUUID: $perspectiveUUID) { - did - status { - author - timestamp - data { - links { - author - timestamp - data { source, predicate, target } - proof { valid, invalid, signature, key } - } - } - proof { valid, invalid, signature, key } + async onlineAgents(perspectiveUUID: string): Promise { + const { neighbourhoodOnlineAgents } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query neighbourhoodOnlineAgents($perspectiveUUID: String!) { + neighbourhoodOnlineAgents(perspectiveUUID: $perspectiveUUID) { + did + status { + author + timestamp + data { + links { + author + timestamp + data { + source + predicate + target + } + proof { + valid + invalid + signature + key } + } } - }`, - variables: { perspectiveUUID } - })) - return neighbourhoodOnlineAgents - } - - async setOnlineStatus(perspectiveUUID: string, status: Perspective): Promise { - const { neighbourhoodSetOnlineStatus } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodSetOnlineStatus( - $perspectiveUUID: String!, - $status: PerspectiveInput! - ) { - neighbourhoodSetOnlineStatus( - perspectiveUUID: $perspectiveUUID, - status: $status - ) - }`, - variables: { perspectiveUUID, status } - })) - - return neighbourhoodSetOnlineStatus - } - - async setOnlineStatusU(perspectiveUUID: string, status: PerspectiveUnsignedInput): Promise { - const { neighbourhoodSetOnlineStatusU } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodSetOnlineStatusU( - $perspectiveUUID: String!, - $status: PerspectiveUnsignedInput! - ) { - neighbourhoodSetOnlineStatusU( - perspectiveUUID: $perspectiveUUID, - status: $status - ) - }`, - variables: { perspectiveUUID, status } - })) - - return neighbourhoodSetOnlineStatusU - } - - async sendSignal(perspectiveUUID: string, remoteAgentDid: string, payload: Perspective): Promise { - const { neighbourhoodSendSignal } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodSendSignal( - $perspectiveUUID: String!, - $remoteAgentDid: String!, - $payload: PerspectiveInput! - ) { - neighbourhoodSendSignal( - perspectiveUUID: $perspectiveUUID, - remoteAgentDid: $remoteAgentDid, - payload: $payload - ) - }`, - variables: { perspectiveUUID, remoteAgentDid, payload } - })) - - return neighbourhoodSendSignal - } + proof { + valid + invalid + signature + key + } + } + } + } + `, + variables: { perspectiveUUID }, + }), + ); + return neighbourhoodOnlineAgents; + } - async sendSignalU(perspectiveUUID: string, remoteAgentDid: string, payload: PerspectiveUnsignedInput): Promise { - const { neighbourhoodSendSignalU } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodSendSignalU( - $perspectiveUUID: String!, - $remoteAgentDid: String!, - $payload: PerspectiveUnsignedInput! - ) { - neighbourhoodSendSignalU( - perspectiveUUID: $perspectiveUUID, - remoteAgentDid: $remoteAgentDid, - payload: $payload - ) - }`, - variables: { perspectiveUUID, remoteAgentDid, payload } - })) + async setOnlineStatus( + perspectiveUUID: string, + status: Perspective, + ): Promise { + const { neighbourhoodSetOnlineStatus } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodSetOnlineStatus( + $perspectiveUUID: String! + $status: PerspectiveInput! + ) { + neighbourhoodSetOnlineStatus( + perspectiveUUID: $perspectiveUUID + status: $status + ) + } + `, + variables: { perspectiveUUID, status }, + }), + ); + return neighbourhoodSetOnlineStatus; + } - return neighbourhoodSendSignalU - } + async setOnlineStatusU( + perspectiveUUID: string, + status: PerspectiveUnsignedInput, + ): Promise { + const { neighbourhoodSetOnlineStatusU } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodSetOnlineStatusU( + $perspectiveUUID: String! + $status: PerspectiveUnsignedInput! + ) { + neighbourhoodSetOnlineStatusU( + perspectiveUUID: $perspectiveUUID + status: $status + ) + } + `, + variables: { perspectiveUUID, status }, + }), + ); + return neighbourhoodSetOnlineStatusU; + } - async sendBroadcast(perspectiveUUID: string, payload: Perspective, loopback: boolean = false): Promise { - const { neighbourhoodSendBroadcast } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodSendBroadcast( - $perspectiveUUID: String!, - $payload: PerspectiveInput!, - $loopback: Boolean - ) { - neighbourhoodSendBroadcast( - perspectiveUUID: $perspectiveUUID, - payload: $payload, - loopback: $loopback - ) - }`, - variables: { perspectiveUUID, payload, loopback } - })) + async sendSignal( + perspectiveUUID: string, + remoteAgentDid: string, + payload: Perspective, + ): Promise { + const { neighbourhoodSendSignal } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodSendSignal( + $perspectiveUUID: String! + $remoteAgentDid: String! + $payload: PerspectiveInput! + ) { + neighbourhoodSendSignal( + perspectiveUUID: $perspectiveUUID + remoteAgentDid: $remoteAgentDid + payload: $payload + ) + } + `, + variables: { perspectiveUUID, remoteAgentDid, payload }, + }), + ); + return neighbourhoodSendSignal; + } - return neighbourhoodSendBroadcast - } + async sendSignalU( + perspectiveUUID: string, + remoteAgentDid: string, + payload: PerspectiveUnsignedInput, + ): Promise { + const { neighbourhoodSendSignalU } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodSendSignalU( + $perspectiveUUID: String! + $remoteAgentDid: String! + $payload: PerspectiveUnsignedInput! + ) { + neighbourhoodSendSignalU( + perspectiveUUID: $perspectiveUUID + remoteAgentDid: $remoteAgentDid + payload: $payload + ) + } + `, + variables: { perspectiveUUID, remoteAgentDid, payload }, + }), + ); + return neighbourhoodSendSignalU; + } - async sendBroadcastU(perspectiveUUID: string, payload: PerspectiveUnsignedInput, loopback: boolean = false): Promise { - const { neighbourhoodSendBroadcastU } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation neighbourhoodSendBroadcastU( - $perspectiveUUID: String!, - $payload: PerspectiveUnsignedInput!, - $loopback: Boolean - ) { - neighbourhoodSendBroadcastU( - perspectiveUUID: $perspectiveUUID, - payload: $payload, - loopback: $loopback - ) - }`, - variables: { perspectiveUUID, payload, loopback } - })) + async sendBroadcast( + perspectiveUUID: string, + payload: Perspective, + loopback: boolean = false, + ): Promise { + const { neighbourhoodSendBroadcast } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodSendBroadcast( + $perspectiveUUID: String! + $payload: PerspectiveInput! + $loopback: Boolean + ) { + neighbourhoodSendBroadcast( + perspectiveUUID: $perspectiveUUID + payload: $payload + loopback: $loopback + ) + } + `, + variables: { perspectiveUUID, payload, loopback }, + }), + ); + return neighbourhoodSendBroadcast; + } - return neighbourhoodSendBroadcastU - } + async sendBroadcastU( + perspectiveUUID: string, + payload: PerspectiveUnsignedInput, + loopback: boolean = false, + ): Promise { + const { neighbourhoodSendBroadcastU } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation neighbourhoodSendBroadcastU( + $perspectiveUUID: String! + $payload: PerspectiveUnsignedInput! + $loopback: Boolean + ) { + neighbourhoodSendBroadcastU( + perspectiveUUID: $perspectiveUUID + payload: $payload + loopback: $loopback + ) + } + `, + variables: { perspectiveUUID, payload, loopback }, + }), + ); + return neighbourhoodSendBroadcastU; + } - dispatchSignal(perspectiveUUID:string, signal: any) { - const handlers = this.#signalHandlers.get(perspectiveUUID) - if (handlers) { - for (const handler of handlers) { - try { - handler(signal) - } catch(e) { - console.error("Error in signal handler:", e) - } - } + dispatchSignal(perspectiveUUID: string, signal: any) { + const handlers = this._signalHandlers.get(perspectiveUUID); + if (handlers) { + for (const handler of handlers) { + try { + handler(signal); + } catch (e) { + console.error("Error in signal handler:", e); } + } } + } - async subscribeToSignals(perspectiveUUID: string): Promise { - const that = this - this.#apolloClient.subscribe({ - query: gql`subscription neighbourhoodSignal($perspectiveUUID: String!) { - neighbourhoodSignal(perspectiveUUID: $perspectiveUUID) { - author - timestamp - data { - links - { - author - timestamp - data { source, predicate, target } - proof { valid, invalid, signature, key } - } - } - proof { valid, invalid, signature, key } + async subscribeToSignals(perspectiveUUID: string): Promise { + const that = this; + const sub = this._apolloClient + .subscribe({ + query: gql` + subscription neighbourhoodSignal($perspectiveUUID: String!) { + neighbourhoodSignal(perspectiveUUID: $perspectiveUUID) { + author + timestamp + data { + links { + author + timestamp + data { + source + predicate + target + } + proof { + valid + invalid + signature + key + } } - }`, - variables: { perspectiveUUID } - }).subscribe({ - next: (result: FetchResult) => { - try { - const { neighbourhoodSignal } = unwrapApolloResult(result) - that.dispatchSignal(perspectiveUUID, neighbourhoodSignal) - } catch(e) { - console.error("Error in signal subscription:", e) - } - }, - error: (err: any) => { - console.error("Signal subscription error for perspective", perspectiveUUID, err) + } + proof { + valid + invalid + signature + key + } } - }) - } + } + `, + variables: { perspectiveUUID }, + }) + .subscribe({ + next: (result: FetchResult) => { + try { + const { neighbourhoodSignal } = unwrapApolloResult(result); + that.dispatchSignal(perspectiveUUID, neighbourhoodSignal); + } catch (e) { + console.error("Error in signal subscription:", e); + } + }, + error: (e) => { + if (!isSocketCloseError(e)) + console.error("neighbourhoodSignal subscription error:", e); + }, + }); + this._signalSubscriptions.set(perspectiveUUID, sub); + } - async addSignalHandler(perspectiveUUID: string, handler: TelepresenceSignalCallback): Promise { - let handlersForPerspective = this.#signalHandlers.get(perspectiveUUID) - if (!handlersForPerspective) { - handlersForPerspective = [] - this.#signalHandlers.set(perspectiveUUID, handlersForPerspective) - // Push handler BEFORE subscribing so it's available when signals arrive - handlersForPerspective.push(handler) - await this.subscribeToSignals(perspectiveUUID) - } else { - handlersForPerspective.push(handler) - } + async addSignalHandler( + perspectiveUUID: string, + handler: TelepresenceSignalCallback, + ): Promise { + let handlersForPerspective = this._signalHandlers.get(perspectiveUUID); + if (!handlersForPerspective) { + handlersForPerspective = []; + this._signalHandlers.set(perspectiveUUID, handlersForPerspective); + // Push handler BEFORE subscribing so it's available when signals arrive + handlersForPerspective.push(handler); + await this.subscribeToSignals(perspectiveUUID); + } else { + handlersForPerspective.push(handler); } + } - removeSignalHandler(perspectiveUUID: string, handler: TelepresenceSignalCallback): void { - const handlersForPerspective = this.#signalHandlers.get(perspectiveUUID) - if (handlersForPerspective) { - const index = handlersForPerspective.indexOf(handler) - if (index > -1) { - handlersForPerspective.splice(index, 1) - } - } + removeSignalHandler( + perspectiveUUID: string, + handler: TelepresenceSignalCallback, + ): void { + const handlersForPerspective = this._signalHandlers.get(perspectiveUUID); + if (handlersForPerspective) { + const index = handlersForPerspective.indexOf(handler); + if (index > -1) { + handlersForPerspective.splice(index, 1); + } + // Intentionally keep the Apollo subscription and the handlers-map entry alive + // even when the array becomes empty. This prevents an unnecessary re-subscription + // if a new handler is added shortly after (the empty array makes addSignalHandler + // skip subscribeToSignals). For explicit teardown use unsubscribeFromPerspective(). } + } + + /** Fully tears down the Apollo subscription for a perspective UUID. */ + unsubscribeFromPerspective(perspectiveUUID: string): void { + this._signalSubscriptions.get(perspectiveUUID)?.unsubscribe(); + this._signalSubscriptions.delete(perspectiveUUID); + this._signalHandlers.delete(perspectiveUUID); + } } diff --git a/core/src/neighbourhood/NeighbourhoodProxy.test.ts b/core/src/neighbourhood/NeighbourhoodProxy.test.ts index 304722b34..3a70ca0ff 100644 --- a/core/src/neighbourhood/NeighbourhoodProxy.test.ts +++ b/core/src/neighbourhood/NeighbourhoodProxy.test.ts @@ -19,7 +19,7 @@ describe("NeighbourhoodProxy", () => { const neighbourhoodClient = new NeighbourhoodClient(mockApolloClient); const neighbourhoodProxy = new NeighbourhoodProxy( neighbourhoodClient, - neighbourhoodURI + neighbourhoodURI, ); let callbacks = 0; @@ -47,11 +47,7 @@ describe("NeighbourhoodProxy", () => { const subscriptions = []; const subscribe = (args: { next: (result: any) => void }) => { subscriptions.push(args); - new Promise((resolve) => { - setTimeout(() => { - resolve({}); - }, 10); - }); + return { unsubscribe: () => {} }; }; const mockApolloClient = { @@ -63,7 +59,7 @@ describe("NeighbourhoodProxy", () => { const neighbourhoodClient = new NeighbourhoodClient(mockApolloClient); const neighbourhoodProxy = new NeighbourhoodProxy( neighbourhoodClient, - neighbourhoodURI + neighbourhoodURI, ); let callbacks1 = 0; diff --git a/core/src/neighbourhood/NeighbourhoodProxy.ts b/core/src/neighbourhood/NeighbourhoodProxy.ts index 042120edd..8a41e2eac 100644 --- a/core/src/neighbourhood/NeighbourhoodProxy.ts +++ b/core/src/neighbourhood/NeighbourhoodProxy.ts @@ -1,58 +1,76 @@ import { DID } from "../DID"; import { OnlineAgent } from "../language/Language"; -import { Perspective, PerspectiveExpression, PerspectiveUnsignedInput } from "../perspectives/Perspective"; +import { + Perspective, + PerspectiveExpression, + PerspectiveUnsignedInput, +} from "../perspectives/Perspective"; import { NeighbourhoodClient } from "./NeighbourhoodClient"; export class NeighbourhoodProxy { - #client: NeighbourhoodClient - #pID: string + private _client: NeighbourhoodClient; + private _pID: string; - constructor(client: NeighbourhoodClient, pID: string) { - this.#client = client - this.#pID = pID - } + constructor(client: NeighbourhoodClient, pID: string) { + this._client = client; + this._pID = pID; + } - async otherAgents(): Promise { - return await this.#client.otherAgents(this.#pID) - } + async otherAgents(): Promise { + return await this._client.otherAgents(this._pID); + } - async hasTelepresenceAdapter(): Promise { - return await this.#client.hasTelepresenceAdapter(this.#pID) - } + async hasTelepresenceAdapter(): Promise { + return await this._client.hasTelepresenceAdapter(this._pID); + } - async onlineAgents(): Promise { - return await this.#client.onlineAgents(this.#pID) - } + async onlineAgents(): Promise { + return await this._client.onlineAgents(this._pID); + } - async setOnlineStatus(status: Perspective): Promise { - return await this.#client.setOnlineStatus(this.#pID, status) - } + async setOnlineStatus(status: Perspective): Promise { + return await this._client.setOnlineStatus(this._pID, status); + } - async setOnlineStatusU(status: PerspectiveUnsignedInput): Promise { - return await this.#client.setOnlineStatusU(this.#pID, status) - } + async setOnlineStatusU(status: PerspectiveUnsignedInput): Promise { + return await this._client.setOnlineStatusU(this._pID, status); + } - async sendSignal(remoteAgentDid: string, payload: Perspective): Promise { - return await this.#client.sendSignal(this.#pID, remoteAgentDid, payload) - } + async sendSignal( + remoteAgentDid: string, + payload: Perspective, + ): Promise { + return await this._client.sendSignal(this._pID, remoteAgentDid, payload); + } - async sendSignalU(remoteAgentDid: string, payload: PerspectiveUnsignedInput): Promise { - return await this.#client.sendSignalU(this.#pID, remoteAgentDid, payload) - } + async sendSignalU( + remoteAgentDid: string, + payload: PerspectiveUnsignedInput, + ): Promise { + return await this._client.sendSignalU(this._pID, remoteAgentDid, payload); + } - async sendBroadcast(payload: Perspective, loopback: boolean = false): Promise { - return await this.#client.sendBroadcast(this.#pID, payload, loopback) - } + async sendBroadcast( + payload: Perspective, + loopback: boolean = false, + ): Promise { + return await this._client.sendBroadcast(this._pID, payload, loopback); + } - async sendBroadcastU(payload: PerspectiveUnsignedInput, loopback: boolean = false): Promise { - return await this.#client.sendBroadcastU(this.#pID, payload, loopback) - } + async sendBroadcastU( + payload: PerspectiveUnsignedInput, + loopback: boolean = false, + ): Promise { + return await this._client.sendBroadcastU(this._pID, payload, loopback); + } - async addSignalHandler(handler: (payload: PerspectiveExpression) => void): Promise { - await this.#client.addSignalHandler(this.#pID, handler) - } + async addSignalHandler( + handler: (payload: PerspectiveExpression) => void, + ): Promise { + await this._client.addSignalHandler(this._pID, handler); + } - removeSignalHandler(handler: (payload: PerspectiveExpression) => void) { - this.#client.removeSignalHandler(this.#pID, handler) - } + removeSignalHandler(handler: (payload: PerspectiveExpression) => void) { + this._client.removeSignalHandler(this._pID, handler); + } } diff --git a/core/src/perspectives/PerspectiveClient.ts b/core/src/perspectives/PerspectiveClient.ts index b21ecf03a..531ae58b8 100644 --- a/core/src/perspectives/PerspectiveClient.ts +++ b/core/src/perspectives/PerspectiveClient.ts @@ -1,16 +1,24 @@ import { ApolloClient, gql } from "@apollo/client/core"; import { ExpressionRendered } from "../expression/Expression"; import { ExpressionClient } from "../expression/ExpressionClient"; -import { Link, LinkExpressionInput, LinkExpression, LinkInput, LinkMutations, LinkExpressionMutations } from "../links/Links"; +import { + Link, + LinkExpressionInput, + LinkExpression, + LinkInput, + LinkMutations, + LinkExpressionMutations, +} from "../links/Links"; import { NeighbourhoodClient } from "../neighbourhood/NeighbourhoodClient"; import { NeighbourhoodProxy } from "../neighbourhood/NeighbourhoodProxy"; import unwrapApolloResult from "../unwrapApolloResult"; import { LinkQuery } from "./LinkQuery"; import { Perspective } from "./Perspective"; import { PerspectiveHandle, PerspectiveState } from "./PerspectiveHandle"; -import { LinkStatus, PerspectiveProxy } from './PerspectiveProxy'; +import { LinkStatus, PerspectiveProxy } from "./PerspectiveProxy"; import { AIClient } from "../ai/AIClient"; import { AllInstancesResult } from "../model/Ad4mModel"; +import { isSocketCloseError } from "../utils"; const LINK_EXPRESSION_FIELDS = ` author @@ -18,7 +26,7 @@ timestamp status data { source, predicate, target } proof { valid, invalid, signature, key } -` +`; const PERSPECTIVE_HANDLE_FIELDS = ` uuid @@ -41,343 +49,393 @@ neighbourhood { } author } -` +`; -export type PerspectiveHandleCallback = (perspective: PerspectiveHandle) => null -export type UuidCallback = (uuid: string) => null -export type LinkCallback = (link: LinkExpression) => null -export type SyncStateChangeCallback = (state: PerspectiveState) => null +export type PerspectiveHandleCallback = ( + perspective: PerspectiveHandle, +) => null; +export type UuidCallback = (uuid: string) => null; +export type LinkCallback = (link: LinkExpression) => null; +export type SyncStateChangeCallback = (state: PerspectiveState) => null; export class PerspectiveClient { - #apolloClient: ApolloClient - #perspectiveAddedCallbacks: PerspectiveHandleCallback[] - #perspectiveUpdatedCallbacks: PerspectiveHandleCallback[] - #perspectiveRemovedCallbacks: UuidCallback[] - #perspectiveSyncStateChangeCallbacks: SyncStateChangeCallback[] - #expressionClient?: ExpressionClient - #neighbourhoodClient?: NeighbourhoodClient - #aiClient?: AIClient - - constructor(client: ApolloClient, subscribe: boolean = true) { - this.#apolloClient = client - this.#perspectiveAddedCallbacks = [] - this.#perspectiveUpdatedCallbacks = [] - this.#perspectiveRemovedCallbacks = [] - this.#perspectiveSyncStateChangeCallbacks = [] - - if(subscribe) { - this.subscribePerspectiveAdded() - this.subscribePerspectiveUpdated() - this.subscribePerspectiveRemoved() - } - } - - setExpressionClient(client: ExpressionClient) { - this.#expressionClient = client - } - - setNeighbourhoodClient(client: NeighbourhoodClient) { - this.#neighbourhoodClient = client - } - - setAIClient(client: AIClient) { - this.#aiClient = client - } - - get aiClient(): AIClient { - return this.#aiClient - } - - async all(): Promise { - const { perspectives } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query perspectives { + private _apolloClient: ApolloClient; + private _perspectiveAddedCallbacks: PerspectiveHandleCallback[]; + private _perspectiveUpdatedCallbacks: PerspectiveHandleCallback[]; + private _perspectiveRemovedCallbacks: UuidCallback[]; + private _perspectiveSyncStateChangeCallbacks: SyncStateChangeCallback[]; + private _expressionClient?: ExpressionClient; + private _neighbourhoodClient?: NeighbourhoodClient; + private _aiClient?: AIClient; + + constructor(client: ApolloClient, subscribe: boolean = true) { + this._apolloClient = client; + this._perspectiveAddedCallbacks = []; + this._perspectiveUpdatedCallbacks = []; + this._perspectiveRemovedCallbacks = []; + this._perspectiveSyncStateChangeCallbacks = []; + + if (subscribe) { + this.subscribePerspectiveAdded(); + this.subscribePerspectiveUpdated(); + this.subscribePerspectiveRemoved(); + } + } + + setExpressionClient(client: ExpressionClient) { + this._expressionClient = client; + } + + setNeighbourhoodClient(client: NeighbourhoodClient) { + this._neighbourhoodClient = client; + } + + setAIClient(client: AIClient) { + this._aiClient = client; + } + + get aiClient(): AIClient { + return this._aiClient; + } + + async all(): Promise { + const { perspectives } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query perspectives { perspectives { ${PERSPECTIVE_HANDLE_FIELDS} } - }` - })) - return perspectives.map(handle => new PerspectiveProxy(handle, this)) - } - - async byUUID(uuid: string): Promise { - const { perspective } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query perspective($uuid: String!) { + }`, + }), + ); + return perspectives.map((handle) => new PerspectiveProxy(handle, this)); + } + + async byUUID(uuid: string): Promise { + const { perspective } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query perspective($uuid: String!) { perspective(uuid: $uuid) { ${PERSPECTIVE_HANDLE_FIELDS} } }`, - variables: { uuid } - })) - if(!perspective) return null - return new PerspectiveProxy(perspective, this) - } - - async snapshotByUUID(uuid: string): Promise { - const { perspectiveSnapshot } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query perspectiveSnapshot($uuid: String!) { + variables: { uuid }, + }), + ); + if (!perspective) return null; + return new PerspectiveProxy(perspective, this); + } + + async snapshotByUUID(uuid: string): Promise { + const { perspectiveSnapshot } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query perspectiveSnapshot($uuid: String!) { perspectiveSnapshot(uuid: $uuid) { links { ${LINK_EXPRESSION_FIELDS} } } }`, - variables: { uuid } - })) - return perspectiveSnapshot - } - async publishSnapshotByUUID(uuid: string): Promise { - const { perspectivePublishSnapshot } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectivePublishSnapshot($uuid: String!) { - perspectivePublishSnapshot(uuid: $uuid) - }`, - variables: { uuid } - })) - return perspectivePublishSnapshot - } - - async queryLinks(uuid: string, query: LinkQuery): Promise { - const { perspectiveQueryLinks } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query perspectiveQueryLinks($uuid: String!, $query: LinkQuery!) { + variables: { uuid }, + }), + ); + return perspectiveSnapshot; + } + async publishSnapshotByUUID(uuid: string): Promise { + const { perspectivePublishSnapshot } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectivePublishSnapshot($uuid: String!) { + perspectivePublishSnapshot(uuid: $uuid) + } + `, + variables: { uuid }, + }), + ); + return perspectivePublishSnapshot; + } + + async queryLinks(uuid: string, query: LinkQuery): Promise { + const { perspectiveQueryLinks } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query perspectiveQueryLinks($uuid: String!, $query: LinkQuery!) { perspectiveQueryLinks(query: $query, uuid: $uuid) { ${LINK_EXPRESSION_FIELDS} } }`, - variables: { uuid, query } - })) - return perspectiveQueryLinks - } - - async queryProlog(uuid: string, query: string): Promise { - const { perspectiveQueryProlog } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query perspectiveQueryProlog($uuid: String!, $query: String!) { - perspectiveQueryProlog(uuid: $uuid, query: $query) - }`, - variables: { uuid, query } - })) - - return JSON.parse(perspectiveQueryProlog) - } - - /** - * Executes a read-only SurrealQL query against a perspective's link cache. - * - * Security: Only SELECT, RETURN, and other read-only queries are permitted. - * Mutating operations (DELETE, UPDATE, INSERT, etc.) are blocked. - * - * Note: GraphQL field name is "perspectiveQuerySurrealDb" (lowercase "b" in "Db") - * as generated from Rust method "perspective_query_surreal_db" - */ - async querySurrealDB(uuid: string, query: string): Promise { - const { perspectiveQuerySurrealDb } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query perspectiveQuerySurrealDb($uuid: String!, $query: String!) { - perspectiveQuerySurrealDb(uuid: $uuid, query: $query) - }`, - variables: { uuid, query } - })) - - return JSON.parse(perspectiveQuerySurrealDb) - } - - async subscribeQuery(uuid: string, query: string): Promise<{ subscriptionId: string, result: AllInstancesResult, isInit?: boolean }> { - const { perspectiveSubscribeQuery } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveSubscribeQuery($uuid: String!, $query: String!) { - perspectiveSubscribeQuery(uuid: $uuid, query: $query) { - subscriptionId - result - } - }`, - variables: { uuid, query } - })) - const { subscriptionId, result } = perspectiveSubscribeQuery - let finalResult = result; - let isInit = false; - if(finalResult.startsWith("#init#")) { - finalResult = finalResult.substring(6) - isInit = true; - } - try { - finalResult = JSON.parse(finalResult) - } catch (e) { - console.error('Error parsing perspectiveSubscribeQuery result:', e) - } - return { subscriptionId, result: finalResult, isInit } - } - - async perspectiveSubscribeSurrealQuery(uuid: string, query: string): Promise<{ subscriptionId: string, result: AllInstancesResult, isInit?: boolean }> { - const { perspectiveSubscribeSurrealQuery } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveSubscribeSurrealQuery($uuid: String!, $query: String!) { - perspectiveSubscribeSurrealQuery(uuid: $uuid, query: $query) { - subscriptionId - result - } - }`, - variables: { uuid, query } - })) - const { subscriptionId, result } = perspectiveSubscribeSurrealQuery - let finalResult = result; - let isInit = false; - if(finalResult.startsWith("#init#")) { - finalResult = finalResult.substring(6) - isInit = true; - } - try { - finalResult = JSON.parse(finalResult) - } catch (e) { - console.error('Error parsing perspectiveSubscribeSurrealQuery result:', e) - } - return { subscriptionId, result: finalResult, isInit } - } - - async perspectiveKeepAliveSurrealQuery(uuid: string, subscriptionId: string): Promise { - const { perspectiveKeepAliveSurrealQuery } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveKeepAliveSurrealQuery($uuid: String!, $subscriptionId: String!) { - perspectiveKeepAliveSurrealQuery(uuid: $uuid, subscriptionId: $subscriptionId) - }`, - variables: { uuid, subscriptionId } - })) - - return perspectiveKeepAliveSurrealQuery - } - - async perspectiveDisposeSurrealQuerySubscription(uuid: string, subscriptionId: string): Promise { - const { perspectiveDisposeSurrealQuerySubscription } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveDisposeSurrealQuerySubscription($uuid: String!, $subscriptionId: String!) { - perspectiveDisposeSurrealQuerySubscription(uuid: $uuid, subscriptionId: $subscriptionId) - }`, - variables: { uuid, subscriptionId } - })) - - return perspectiveDisposeSurrealQuerySubscription - } - - subscribeToQueryUpdates(subscriptionId: string, onData: (result: AllInstancesResult) => void): () => void { - const subscription = this.#apolloClient.subscribe({ - query: gql` - subscription perspectiveQuerySubscription($subscriptionId: String!) { - perspectiveQuerySubscription(subscriptionId: $subscriptionId) - } - `, - variables: { - subscriptionId + variables: { uuid, query }, + }), + ); + return perspectiveQueryLinks; + } + + async queryProlog(uuid: string, query: string): Promise { + const { perspectiveQueryProlog } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query perspectiveQueryProlog($uuid: String!, $query: String!) { + perspectiveQueryProlog(uuid: $uuid, query: $query) + } + `, + variables: { uuid, query }, + }), + ); + + return JSON.parse(perspectiveQueryProlog); + } + + /** + * Executes a read-only SurrealQL query against a perspective's link cache. + * + * Security: Only SELECT, RETURN, and other read-only queries are permitted. + * Mutating operations (DELETE, UPDATE, INSERT, etc.) are blocked. + * + * Note: GraphQL field name is "perspectiveQuerySurrealDb" (lowercase "b" in "Db") + * as generated from Rust method "perspective_query_surreal_db" + */ + async querySurrealDB(uuid: string, query: string): Promise { + const { perspectiveQuerySurrealDb } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query perspectiveQuerySurrealDb($uuid: String!, $query: String!) { + perspectiveQuerySurrealDb(uuid: $uuid, query: $query) + } + `, + variables: { uuid, query }, + }), + ); + + return JSON.parse(perspectiveQuerySurrealDb); + } + + async subscribeQuery( + uuid: string, + query: string, + ): Promise<{ + subscriptionId: string; + result: AllInstancesResult; + isInit?: boolean; + }> { + const { perspectiveSubscribeQuery } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveSubscribeQuery($uuid: String!, $query: String!) { + perspectiveSubscribeQuery(uuid: $uuid, query: $query) { + subscriptionId + result } - }).subscribe({ - next: (result) => { - if (result.data && result.data.perspectiveQuerySubscription) { - let finalResult = result.data.perspectiveQuerySubscription; - let isInit = false; - if(finalResult.startsWith("#init#")) { - finalResult = finalResult.substring(6) - isInit = true; - } - try { - finalResult = JSON.parse(finalResult) - if(isInit && typeof finalResult === 'object') { - finalResult.isInit = true; - } - } catch (e) { - console.error('Error parsing perspectiveQuerySubscription:', e) - } - onData(finalResult); - } - }, - error: (e) => console.error('Error in query subscription:', e) - }); - - return () => subscription.unsubscribe(); - } - - async keepAliveQuery(uuid: string, subscriptionId: string): Promise { - const { perspectiveKeepAliveQuery } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveKeepAliveQuery($uuid: String!, $subscriptionId: String!) { - perspectiveKeepAliveQuery(uuid: $uuid, subscriptionId: $subscriptionId) - }`, - variables: { uuid, subscriptionId } - })) - - return perspectiveKeepAliveQuery - } - - async disposeQuerySubscription(uuid: string, subscriptionId: string): Promise { - const { perspectiveDisposeQuerySubscription } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveDisposeQuerySubscription($uuid: String!, $subscriptionId: String!) { - perspectiveDisposeQuerySubscription(uuid: $uuid, subscriptionId: $subscriptionId) - }`, - variables: { uuid, subscriptionId } - })) - - return perspectiveDisposeQuerySubscription - } - - async add(name: string): Promise { - const { perspectiveAdd } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveAdd($name: String!) { + } + `, + variables: { uuid, query }, + }), + ); + const { subscriptionId, result } = perspectiveSubscribeQuery; + let finalResult = result; + let isInit = false; + if (finalResult.startsWith("#init#")) { + finalResult = finalResult.substring(6); + isInit = true; + } + try { + finalResult = JSON.parse(finalResult); + } catch (e) { + console.error("Error parsing perspectiveSubscribeQuery result:", e); + } + return { subscriptionId, result: finalResult, isInit }; + } + + subscribeToQueryUpdates( + subscriptionId: string, + onData: (result: AllInstancesResult) => void, + ): () => void { + const subscription = this._apolloClient + .subscribe({ + query: gql` + subscription perspectiveQuerySubscription($subscriptionId: String!) { + perspectiveQuerySubscription(subscriptionId: $subscriptionId) + } + `, + variables: { + subscriptionId, + }, + }) + .subscribe({ + next: (result) => { + if (result.data && result.data.perspectiveQuerySubscription) { + let finalResult = result.data.perspectiveQuerySubscription; + let isInit = false; + if (finalResult.startsWith("#init#")) { + finalResult = finalResult.substring(6); + isInit = true; + } + try { + finalResult = JSON.parse(finalResult); + if (isInit && typeof finalResult === "object") { + finalResult.isInit = true; + } + } catch (e) { + console.error("Error parsing perspectiveQuerySubscription:", e); + } + onData(finalResult); + } + }, + error: (e) => { + if (!isSocketCloseError(e)) + console.error("Error in query subscription:", e); + }, + }); + + return () => subscription.unsubscribe(); + } + + async keepAliveQuery(uuid: string, subscriptionId: string): Promise { + const { perspectiveKeepAliveQuery } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveKeepAliveQuery( + $uuid: String! + $subscriptionId: String! + ) { + perspectiveKeepAliveQuery( + uuid: $uuid + subscriptionId: $subscriptionId + ) + } + `, + variables: { uuid, subscriptionId }, + }), + ); + + return perspectiveKeepAliveQuery; + } + + async disposeQuerySubscription( + uuid: string, + subscriptionId: string, + ): Promise { + const { perspectiveDisposeQuerySubscription } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveDisposeQuerySubscription( + $uuid: String! + $subscriptionId: String! + ) { + perspectiveDisposeQuerySubscription( + uuid: $uuid + subscriptionId: $subscriptionId + ) + } + `, + variables: { uuid, subscriptionId }, + }), + ); + + return perspectiveDisposeQuerySubscription; + } + + async add(name: string): Promise { + const { perspectiveAdd } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveAdd($name: String!) { perspectiveAdd(name: $name) { ${PERSPECTIVE_HANDLE_FIELDS} } }`, - variables: { name } - })) - return new PerspectiveProxy(perspectiveAdd, this) - } - - async update(uuid: string, name: string): Promise { - const { perspectiveUpdate } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveUpdate($uuid: String!, $name: String!) { + variables: { name }, + }), + ); + return new PerspectiveProxy(perspectiveAdd, this); + } + + async update(uuid: string, name: string): Promise { + const { perspectiveUpdate } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveUpdate($uuid: String!, $name: String!) { perspectiveUpdate(uuid: $uuid, name: $name) { ${PERSPECTIVE_HANDLE_FIELDS} } }`, - variables: { uuid, name } - })) - return new PerspectiveProxy(perspectiveUpdate, this) - } - - async remove(uuid: string): Promise<{perspectiveRemove: boolean}> { - return unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveRemove($uuid: String!) { - perspectiveRemove(uuid: $uuid) - }`, - variables: { uuid } - })) - } - - async addLink(uuid: string, link: Link, status: LinkStatus = 'shared', batchId?: string): Promise { - const { perspectiveAddLink } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveAddLink($uuid: String!, $link: LinkInput!, $status: String!, $batchId: String) { + variables: { uuid, name }, + }), + ); + return new PerspectiveProxy(perspectiveUpdate, this); + } + + async remove(uuid: string): Promise<{ perspectiveRemove: boolean }> { + return unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveRemove($uuid: String!) { + perspectiveRemove(uuid: $uuid) + } + `, + variables: { uuid }, + }), + ); + } + + async addLink( + uuid: string, + link: Link, + status: LinkStatus = "shared", + batchId?: string, + ): Promise { + const { perspectiveAddLink } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveAddLink($uuid: String!, $link: LinkInput!, $status: String!, $batchId: String) { perspectiveAddLink(uuid: $uuid, link: $link, status: $status, batchId: $batchId) { ${LINK_EXPRESSION_FIELDS} } }`, - variables: { uuid, link, status, batchId } - })) - return perspectiveAddLink - } - - async addLinks(uuid: string, links: Link[], status: LinkStatus = 'shared', batchId?: string): Promise { - const { perspectiveAddLinks } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveAddLinks($uuid: String!, $links: [LinkInput!]!, $status: String!, $batchId: String) { + variables: { uuid, link, status, batchId }, + }), + ); + return perspectiveAddLink; + } + + async addLinks( + uuid: string, + links: Link[], + status: LinkStatus = "shared", + batchId?: string, + ): Promise { + const { perspectiveAddLinks } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveAddLinks($uuid: String!, $links: [LinkInput!]!, $status: String!, $batchId: String) { perspectiveAddLinks(uuid: $uuid, links: $links, status: $status, batchId: $batchId) { ${LINK_EXPRESSION_FIELDS} } }`, - variables: { uuid, links, status, batchId } - })) - return perspectiveAddLinks - } - - async removeLinks(uuid: string, links: LinkExpressionInput[], batchId?: string): Promise { - const { perspectiveRemoveLinks } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveRemoveLinks($uuid: String!, $links: [LinkExpressionInput!]!, $batchId: String) { + variables: { uuid, links, status, batchId }, + }), + ); + return perspectiveAddLinks; + } + + async removeLinks( + uuid: string, + links: LinkExpressionInput[], + batchId?: string, + ): Promise { + const { perspectiveRemoveLinks } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveRemoveLinks($uuid: String!, $links: [LinkExpressionInput!]!, $batchId: String) { perspectiveRemoveLinks(uuid: $uuid, links: $links, batchId: $batchId) { ${LINK_EXPRESSION_FIELDS} } }`, - variables: { uuid, links, batchId } - })) - return perspectiveRemoveLinks - } - - async linkMutations(uuid: string, mutations: LinkMutations, status?: LinkStatus): Promise { - const { perspectiveLinkMutations } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveLinkMutations($uuid: String!, $mutations: LinkMutations!, $status: String){ + variables: { uuid, links, batchId }, + }), + ); + return perspectiveRemoveLinks; + } + + async linkMutations( + uuid: string, + mutations: LinkMutations, + status?: LinkStatus, + ): Promise { + const { perspectiveLinkMutations } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveLinkMutations($uuid: String!, $mutations: LinkMutations!, $status: String){ perspectiveLinkMutations(mutations: $mutations, uuid: $uuid, status: $status) { additions { ${LINK_EXPRESSION_FIELDS} @@ -387,223 +445,391 @@ export class PerspectiveClient { } } }`, - variables: { uuid, mutations, status } - })) - return perspectiveLinkMutations - } - - async addLinkExpression(uuid: string, link: LinkExpression, status: LinkStatus = 'shared', batchId?: string): Promise { - const { perspectiveAddLinkExpression } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveAddLinkExpression($uuid: String!, $link: LinkExpressionInput!, $status: String!, $batchId: String) { + variables: { uuid, mutations, status }, + }), + ); + return perspectiveLinkMutations; + } + + async addLinkExpression( + uuid: string, + link: LinkExpression, + status: LinkStatus = "shared", + batchId?: string, + ): Promise { + const { perspectiveAddLinkExpression } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveAddLinkExpression($uuid: String!, $link: LinkExpressionInput!, $status: String!, $batchId: String) { perspectiveAddLinkExpression(uuid: $uuid, link: $link, status: $status, batchId: $batchId) { ${LINK_EXPRESSION_FIELDS} } }`, - variables: { uuid, link, status, batchId } - })) - return perspectiveAddLinkExpression - } - - async updateLink(uuid: string, oldLink: LinkExpressionInput, newLink: Link, batchId?: string): Promise { - const { perspectiveUpdateLink } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveUpdateLink($uuid: String!, $oldLink: LinkExpressionInput!, $newLink: LinkInput!, $batchId: String) { + variables: { uuid, link, status, batchId }, + }), + ); + return perspectiveAddLinkExpression; + } + + async updateLink( + uuid: string, + oldLink: LinkExpressionInput, + newLink: Link, + batchId?: string, + ): Promise { + const { perspectiveUpdateLink } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveUpdateLink($uuid: String!, $oldLink: LinkExpressionInput!, $newLink: LinkInput!, $batchId: String) { perspectiveUpdateLink(uuid: $uuid, oldLink: $oldLink, newLink: $newLink, batchId: $batchId) { ${LINK_EXPRESSION_FIELDS} } }`, - variables: { uuid, oldLink, newLink, batchId } - })) - return perspectiveUpdateLink - } - - async removeLink(uuid: string, link: LinkExpressionInput, batchId?: string): Promise { - delete link.__typename - delete link.data.__typename - delete link.proof.__typename - delete link.status - const { perspectiveRemoveLink } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveRemoveLink($link: LinkExpressionInput!, $uuid: String!, $batchId: String) { - perspectiveRemoveLink(link: $link, uuid: $uuid, batchId: $batchId) - }`, - variables: { uuid, link, batchId } - })) - return perspectiveRemoveLink - } - - /** - * Adds Social DNA code to a perspective. - * - * Preferred usage: pass shaclJson (from SHACLShape.toJSON()) as the primary schema definition. - * The sdnaCode parameter is kept for backward compatibility but SHACL is the source of truth - * for all SDNA operations. Prolog engines remain available for complex queries. - * - * @param sdnaCode - Legacy Prolog code (pass empty string when using shaclJson) - * @param shaclJson - SHACL JSON string from SHACLShape.toJSON() (recommended) - */ - async addSdna(uuid: string, name: string, sdnaCode: string | undefined, sdnaType: "subject_class" | "flow" | "custom", shaclJson?: string): Promise { - return unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveAddSdna($uuid: String!, $name: String!, $sdnaCode: String, $sdnaType: String!, $shaclJson: String) { - perspectiveAddSdna(uuid: $uuid, name: $name, sdnaCode: $sdnaCode, sdnaType: $sdnaType, shaclJson: $shaclJson) - }`, - variables: { uuid, name, sdnaCode: sdnaCode || "", sdnaType, shaclJson } - })).perspectiveAddSdna - } - - async executeCommands(uuid: string, commands: string, expression: string, parameters: string, batchId?: string): Promise { - return unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveExecuteCommands($uuid: String!, $commands: String!, $expression: String!, $parameters: String, $batchId: String) { - perspectiveExecuteCommands(uuid: $uuid, commands: $commands, expression: $expression, parameters: $parameters, batchId: $batchId) - }`, - variables: { uuid, commands, expression, parameters, batchId } - })).perspectiveExecuteCommands - } - - async createSubject(uuid: string, subjectClass: string, expressionAddress: string, initialValues?: string, batchId?: string): Promise { - return unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveCreateSubject($uuid: String!, $subjectClass: String!, $expressionAddress: String!, $initialValues: String, $batchId: String) { - perspectiveCreateSubject(uuid: $uuid, subjectClass: $subjectClass, expressionAddress: $expressionAddress, initialValues: $initialValues, batchId: $batchId) - }`, - variables: { uuid, subjectClass, expressionAddress, initialValues, batchId } - })).perspectiveCreateSubject - } - - async getSubjectData(uuid: string, subjectClass: string, expressionAddress: string): Promise { - return unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveGetSubjectData($uuid: String!, $subjectClass: String!, $expressionAddress: String!) { - perspectiveGetSubjectData(uuid: $uuid, subjectClass: $subjectClass, expressionAddress: $expressionAddress) - }`, - variables: { uuid, subjectClass, expressionAddress } - })).perspectiveGetSubjectData - } - - // ExpressionClient functions, needed for Subjects: - async getExpression(expressionURI: string): Promise { - return await this.#expressionClient.get(expressionURI) - } - - async createExpression(content: any, languageAddress: string): Promise { - return await this.#expressionClient.create(content, languageAddress) - } - - // Subscriptions: - addPerspectiveAddedListener(cb: PerspectiveHandleCallback) { - this.#perspectiveAddedCallbacks.push(cb) - } - - subscribePerspectiveAdded() { - this.#apolloClient.subscribe({ - query: gql` subscription { + variables: { uuid, oldLink, newLink, batchId }, + }), + ); + return perspectiveUpdateLink; + } + + async removeLink( + uuid: string, + link: LinkExpressionInput, + batchId?: string, + ): Promise { + delete link.__typename; + delete link.data.__typename; + delete link.proof.__typename; + delete link.status; + const { perspectiveRemoveLink } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveRemoveLink( + $link: LinkExpressionInput! + $uuid: String! + $batchId: String + ) { + perspectiveRemoveLink(link: $link, uuid: $uuid, batchId: $batchId) + } + `, + variables: { uuid, link, batchId }, + }), + ); + return perspectiveRemoveLink; + } + + /** + * Adds Social DNA code to a perspective. + * + * Preferred usage: pass shaclJson (from SHACLShape.toJSON()) as the primary schema definition. + * The sdnaCode parameter is kept for backward compatibility but SHACL is the source of truth + * for all SDNA operations. Prolog engines remain available for complex queries. + * + * @param sdnaCode - Legacy Prolog code (pass empty string when using shaclJson) + * @param shaclJson - SHACL JSON string from SHACLShape.toJSON() (recommended) + */ + async addSdna( + uuid: string, + name: string, + sdnaCode: string | undefined, + sdnaType: "subject_class" | "flow" | "custom", + shaclJson?: string, + ): Promise { + return unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveAddSdna( + $uuid: String! + $name: String! + $sdnaCode: String + $sdnaType: String! + $shaclJson: String + ) { + perspectiveAddSdna( + uuid: $uuid + name: $name + sdnaCode: $sdnaCode + sdnaType: $sdnaType + shaclJson: $shaclJson + ) + } + `, + variables: { + uuid, + name, + sdnaCode: sdnaCode || "", + sdnaType, + shaclJson, + }, + }), + ).perspectiveAddSdna; + } + + async executeCommands( + uuid: string, + commands: string, + expression: string, + parameters: string, + batchId?: string, + ): Promise { + return unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveExecuteCommands( + $uuid: String! + $commands: String! + $expression: String! + $parameters: String + $batchId: String + ) { + perspectiveExecuteCommands( + uuid: $uuid + commands: $commands + expression: $expression + parameters: $parameters + batchId: $batchId + ) + } + `, + variables: { uuid, commands, expression, parameters, batchId }, + }), + ).perspectiveExecuteCommands; + } + + async createSubject( + uuid: string, + subjectClass: string, + expressionAddress: string, + initialValues?: string, + batchId?: string, + ): Promise { + return unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveCreateSubject( + $uuid: String! + $subjectClass: String! + $expressionAddress: String! + $initialValues: String + $batchId: String + ) { + perspectiveCreateSubject( + uuid: $uuid + subjectClass: $subjectClass + expressionAddress: $expressionAddress + initialValues: $initialValues + batchId: $batchId + ) + } + `, + variables: { + uuid, + subjectClass, + expressionAddress, + initialValues, + batchId, + }, + }), + ).perspectiveCreateSubject; + } + + async getSubjectData( + uuid: string, + subjectClass: string, + expressionAddress: string, + ): Promise { + return unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveGetSubjectData( + $uuid: String! + $subjectClass: String! + $expressionAddress: String! + ) { + perspectiveGetSubjectData( + uuid: $uuid + subjectClass: $subjectClass + expressionAddress: $expressionAddress + ) + } + `, + variables: { uuid, subjectClass, expressionAddress }, + }), + ).perspectiveGetSubjectData; + } + + // ExpressionClient functions, needed for Subjects: + async getExpression(expressionURI: string): Promise { + return await this._expressionClient.get(expressionURI); + } + + async createExpression( + content: any, + languageAddress: string, + ): Promise { + return await this._expressionClient.create(content, languageAddress); + } + + // Subscriptions: + addPerspectiveAddedListener(cb: PerspectiveHandleCallback) { + this._perspectiveAddedCallbacks.push(cb); + } + + subscribePerspectiveAdded() { + this._apolloClient + .subscribe({ + query: gql` subscription { perspectiveAdded { ${PERSPECTIVE_HANDLE_FIELDS} } } - `}).subscribe({ - next: result => { - this.#perspectiveAddedCallbacks.forEach(cb => { - cb(result.data.perspectiveAdded) - }) - }, - error: (e) => console.error(e) - }) - } - - addPerspectiveUpdatedListener(cb: PerspectiveHandleCallback) { - this.#perspectiveUpdatedCallbacks.push(cb) - } - - subscribePerspectiveUpdated() { - this.#apolloClient.subscribe({ - query: gql` subscription { + `, + }) + .subscribe({ + next: (result) => { + this._perspectiveAddedCallbacks.forEach((cb) => { + cb(result.data.perspectiveAdded); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + } + + addPerspectiveUpdatedListener(cb: PerspectiveHandleCallback) { + this._perspectiveUpdatedCallbacks.push(cb); + } + + subscribePerspectiveUpdated() { + this._apolloClient + .subscribe({ + query: gql` subscription { perspectiveUpdated { ${PERSPECTIVE_HANDLE_FIELDS} } } - `}).subscribe({ - next: result => { - this.#perspectiveUpdatedCallbacks.forEach(cb => { - cb(result.data.perspectiveUpdated) - }) - }, - error: (e) => console.error(e) - }) - } - - addPerspectiveSyncedListener(cb: SyncStateChangeCallback) { - this.#perspectiveSyncStateChangeCallbacks.push(cb) - } - - async addPerspectiveSyncStateChangeListener(uuid: String, cb: SyncStateChangeCallback[]): Promise { - this.#apolloClient.subscribe({ - query: gql` subscription { + `, + }) + .subscribe({ + next: (result) => { + this._perspectiveUpdatedCallbacks.forEach((cb) => { + cb(result.data.perspectiveUpdated); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + } + + addPerspectiveSyncedListener(cb: SyncStateChangeCallback) { + this._perspectiveSyncStateChangeCallbacks.push(cb); + } + + async addPerspectiveSyncStateChangeListener( + uuid: String, + cb: SyncStateChangeCallback[], + ): Promise { + this._apolloClient + .subscribe({ + query: gql` subscription { perspectiveSyncStateChange(uuid: "${uuid}") } - `}).subscribe({ - next: result => { - cb.forEach(c => { - c(result.data.perspectiveSyncStateChange) - }) - }, - error: (e) => console.error(e) - }) - - await new Promise(resolve => setTimeout(resolve, 500)) - } - - addPerspectiveRemovedListener(cb: UuidCallback) { - this.#perspectiveRemovedCallbacks.push(cb) - } - - subscribePerspectiveRemoved() { - this.#apolloClient.subscribe({ - query: gql` subscription { - perspectiveRemoved - } - `}).subscribe({ - next: result => { - this.#perspectiveRemovedCallbacks.forEach(cb => { - cb(result.data.perspectiveRemoved) - }) - }, - error: (e) => console.error(e) - }) - } - - async addPerspectiveLinkAddedListener(uuid: String, cb: LinkCallback[]): Promise { - this.#apolloClient.subscribe({ - query: gql` subscription { + `, + }) + .subscribe({ + next: (result) => { + cb.forEach((c) => { + c(result.data.perspectiveSyncStateChange); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + addPerspectiveRemovedListener(cb: UuidCallback) { + this._perspectiveRemovedCallbacks.push(cb); + } + + subscribePerspectiveRemoved() { + this._apolloClient + .subscribe({ + query: gql` + subscription { + perspectiveRemoved + } + `, + }) + .subscribe({ + next: (result) => { + this._perspectiveRemovedCallbacks.forEach((cb) => { + cb(result.data.perspectiveRemoved); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + } + + async addPerspectiveLinkAddedListener( + uuid: String, + cb: LinkCallback[], + ): Promise { + this._apolloClient + .subscribe({ + query: gql` subscription { perspectiveLinkAdded(uuid: "${uuid}") { ${LINK_EXPRESSION_FIELDS} } } - `}).subscribe({ - next: result => { - cb.forEach(c => { - c(result.data.perspectiveLinkAdded) - }) - }, - error: (e) => console.error(e) - }) - - await new Promise(resolve => setTimeout(resolve, 500)) - } - - async addPerspectiveLinkRemovedListener(uuid: String, cb: LinkCallback[]): Promise { - this.#apolloClient.subscribe({ - query: gql` subscription { + `, + }) + .subscribe({ + next: (result) => { + cb.forEach((c) => { + c(result.data.perspectiveLinkAdded); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + async addPerspectiveLinkRemovedListener( + uuid: String, + cb: LinkCallback[], + ): Promise { + this._apolloClient + .subscribe({ + query: gql` subscription { perspectiveLinkRemoved(uuid: "${uuid}") { ${LINK_EXPRESSION_FIELDS} } } - `}).subscribe({ - next: result => { - cb.forEach(c => { - if (!result.data.perspectiveLinkRemoved.status) { - delete result.data.perspectiveLinkRemoved.status - } - c(result.data.perspectiveLinkRemoved) - }) - }, - error: (e) => console.error(e) - }) - - await new Promise(resolve => setTimeout(resolve, 500)) - } - - async addPerspectiveLinkUpdatedListener(uuid: String, cb: LinkCallback[]): Promise { - this.#apolloClient.subscribe({ - query: gql` subscription { + `, + }) + .subscribe({ + next: (result) => { + cb.forEach((c) => { + if (!result.data.perspectiveLinkRemoved.status) { + delete result.data.perspectiveLinkRemoved.status; + } + c(result.data.perspectiveLinkRemoved); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + async addPerspectiveLinkUpdatedListener( + uuid: String, + cb: LinkCallback[], + ): Promise { + this._apolloClient + .subscribe({ + query: gql` subscription { perspectiveLinkUpdated(uuid: "${uuid}") { oldLink { ${LINK_EXPRESSION_FIELDS} @@ -613,41 +839,53 @@ export class PerspectiveClient { } } } - `}).subscribe({ - next: result => { - cb.forEach(c => { - if (!result.data.perspectiveLinkUpdated.newLink.status) { - delete result.data.perspectiveLinkUpdated.newLink.status - } - if (!result.data.perspectiveLinkUpdated.oldLink.status) { - delete result.data.perspectiveLinkUpdated.oldLink.status - } - c(result.data.perspectiveLinkUpdated) - }) - }, - error: (e) => console.error(e) - }) - - await new Promise(resolve => setTimeout(resolve, 500)) - } - - getNeighbourhoodProxy(uuid: string): NeighbourhoodProxy { - return new NeighbourhoodProxy(this.#neighbourhoodClient, uuid) - } - - async createBatch(uuid: string): Promise { - const { perspectiveCreateBatch } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveCreateBatch($uuid: String!) { - perspectiveCreateBatch(uuid: $uuid) - }`, - variables: { uuid } - })) - return perspectiveCreateBatch - } - - async commitBatch(uuid: string, batchId: string): Promise { - const { perspectiveCommitBatch } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveCommitBatch($uuid: String!, $batchId: String!) { + `, + }) + .subscribe({ + next: (result) => { + cb.forEach((c) => { + if (!result.data.perspectiveLinkUpdated.newLink.status) { + delete result.data.perspectiveLinkUpdated.newLink.status; + } + if (!result.data.perspectiveLinkUpdated.oldLink.status) { + delete result.data.perspectiveLinkUpdated.oldLink.status; + } + c(result.data.perspectiveLinkUpdated); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + getNeighbourhoodProxy(uuid: string): NeighbourhoodProxy { + return new NeighbourhoodProxy(this._neighbourhoodClient, uuid); + } + + async createBatch(uuid: string): Promise { + const { perspectiveCreateBatch } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation perspectiveCreateBatch($uuid: String!) { + perspectiveCreateBatch(uuid: $uuid) + } + `, + variables: { uuid }, + }), + ); + return perspectiveCreateBatch; + } + + async commitBatch( + uuid: string, + batchId: string, + ): Promise { + const { perspectiveCommitBatch } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql`mutation perspectiveCommitBatch($uuid: String!, $batchId: String!) { perspectiveCommitBatch(uuid: $uuid, batchId: $batchId) { additions { ${LINK_EXPRESSION_FIELDS} @@ -657,8 +895,9 @@ export class PerspectiveClient { } } }`, - variables: { uuid, batchId } - })) - return perspectiveCommitBatch - } -} \ No newline at end of file + variables: { uuid, batchId }, + }), + ); + return perspectiveCommitBatch; + } +} diff --git a/core/src/perspectives/PerspectiveProxy.ts b/core/src/perspectives/PerspectiveProxy.ts index 30183e6c5..00dab11e7 100644 --- a/core/src/perspectives/PerspectiveProxy.ts +++ b/core/src/perspectives/PerspectiveProxy.ts @@ -1,30 +1,49 @@ -import { LinkCallback, PerspectiveClient, SyncStateChangeCallback } from "./PerspectiveClient"; -import { Link, LinkExpression, LinkExpressionInput, LinkExpressionMutations, LinkMutations } from "../links/Links"; +import { + LinkCallback, + PerspectiveClient, + SyncStateChangeCallback, +} from "./PerspectiveClient"; +import { + Link, + LinkExpression, + LinkExpressionInput, + LinkExpressionMutations, + LinkMutations, +} from "../links/Links"; import { LinkQuery } from "./LinkQuery"; -import { PerspectiveHandle, PerspectiveState } from './PerspectiveHandle' +import { PerspectiveHandle, PerspectiveState } from "./PerspectiveHandle"; import { Perspective } from "./Perspective"; import { Literal } from "../Literal"; -import { Subject } from "../model/Subject"; import { ExpressionRendered } from "../expression/Expression"; +import { + collectionAdderToName, + collectionRemoverToName, + collectionSetterToName, + collectionToAdderName, + collectionToRemoverName, + collectionToSetterName, + propertyNameToSetterName, +} from "../model/util"; +import { + getPropertiesMetadata, + getRelationsMetadata, +} from "../model/decorators"; import { NeighbourhoodProxy } from "../neighbourhood/NeighbourhoodProxy"; import { NeighbourhoodExpression } from "../neighbourhood/Neighbourhood"; +import { LinkPattern } from "../shacl/SHACLFlow"; import { AIClient } from "../ai/AIClient"; -import { PERSPECTIVE_QUERY_SUBSCRIPTION } from "./PerspectiveResolver"; -import { gql } from "@apollo/client/core"; import { AllInstancesResult } from "../model/Ad4mModel"; import { escapeSurrealString } from "../utils"; -import { SHACLShape } from "../shacl/SHACLShape"; -import { SHACLFlow, LinkPattern } from "../shacl/SHACLFlow"; type QueryCallback = (result: AllInstancesResult) => void; // Generic subscription interface that matches Apollo's Subscription interface Unsubscribable { - unsubscribe(): void; + unsubscribe(): void; } /** Proxy object for a subscribed Prolog query that provides real-time updates - * + * * This class handles: * - Keeping the subscription alive by sending periodic keepalive signals * - Managing callbacks for result updates @@ -32,304 +51,311 @@ interface Unsubscribable { * - Maintaining the latest query result * - Ensuring subscription is fully initialized before allowing access * - Cleaning up resources when disposed - * + * * The subscription will remain active as long as keepalive signals are sent. * Make sure to call dispose() when you're done with the subscription to clean up * resources, stop keepalive signals, and notify the backend to remove the subscription. - * + * * The subscription goes through an initialization process where it waits for the first - * result to come through the subscription channel. You can await the `initialized` + * result to come through the subscription channel. You can await the `initialized` * promise to ensure the subscription is ready. The initialization will timeout after * 30 seconds if no result is received. - * + * * Example usage: * ```typescript * const subscription = await perspective.subscribeInfer("my_query(X)"); * // At this point the subscription is already initialized since subscribeInfer waits - * + * * // Set up callback for future updates * const removeCallback = subscription.onResult(result => { * console.log("New result:", result); * }); - * + * * // Later: clean up subscription and notify backend * subscription.dispose(); * ``` */ export class QuerySubscriptionProxy { - #uuid: string; - #subscriptionId: string; - #client: PerspectiveClient; - #callbacks: Set; - #keepaliveTimer: number; - #unsubscribe?: () => void; - #latestResult: AllInstancesResult|null; - #disposed: boolean = false; - #initialized: Promise; - #initResolve?: (value: boolean) => void; - #initReject?: (reason?: any) => void; - #initTimeoutId?: NodeJS.Timeout; - #query: string; - isSurrealDB: boolean = false; - - /** Creates a new query subscription - * @param uuid - The UUID of the perspective - * @param query - The Prolog query to subscribe to - * @param client - The PerspectiveClient instance to use for communication - */ - constructor(uuid: string, query: string, client: PerspectiveClient) { - this.#uuid = uuid; - this.#query = query; - this.#client = client; - this.#callbacks = new Set(); - this.#latestResult = null; - - // Create the promise once and store its resolve/reject - this.#initialized = new Promise((resolve, reject) => { - this.#initResolve = resolve; - this.#initReject = reject; + private _uuid: string; + private _subscriptionId: string; + private _client: PerspectiveClient; + private _callbacks: Set; + private _keepaliveTimer: number; + private _unsubscribe?: () => void; + private _latestResult: AllInstancesResult | null; + private _disposed: boolean = false; + private _initialized: Promise; + private _initResolve?: (value: boolean) => void; + private _initReject?: (reason?: any) => void; + private _initTimeoutId?: NodeJS.Timeout; + private _query: string; + + /** Creates a new query subscription + * @param uuid - The UUID of the perspective + * @param query - The Prolog query to subscribe to + * @param client - The PerspectiveClient instance to use for communication + */ + constructor(uuid: string, query: string, client: PerspectiveClient) { + // Prevent Vue / Proxy-based reactivity systems from wrapping this instance. + // QuerySubscriptionProxy manages its own internal state via #private fields + // and callbacks; wrapping it in a Proxy breaks WeakMap-based private field access. + this._uuid = uuid; + this._query = query; + this._client = client; + this._callbacks = new Set(); + this._latestResult = null; + + // Create the promise once and store its resolve/reject + this._initialized = new Promise((resolve, reject) => { + this._initResolve = resolve; + this._initReject = reject; + }); + } + + async subscribe() { + // Clean up previous subscription attempt if retrying + if (this._unsubscribe) { + this._unsubscribe(); + this._unsubscribe = undefined; + } + + // Clear any existing timeout + if (this._initTimeoutId) { + clearTimeout(this._initTimeoutId); + this._initTimeoutId = undefined; + } + + // Clear any existing keepalive timer to prevent accumulation + if (this._keepaliveTimer) { + clearTimeout(this._keepaliveTimer); + this._keepaliveTimer = undefined; + } + + try { + // Initialize the query subscription + let initialResult; + initialResult = await this._client.subscribeQuery( + this._uuid, + this._query, + ); + this._subscriptionId = initialResult.subscriptionId; + + // Process the initial result immediately for fast UX + if (initialResult.result) { + this._latestResult = initialResult.result; + this._notifyCallbacks(initialResult.result); + } else { + console.warn("⚠️ No initial result returned from subscribeQuery!"); + } + + // Set up timeout for retry + this._initTimeoutId = setTimeout(() => { + console.error( + "Subscription initialization timed out after 30 seconds. Resubscribing...", + ); + // Recursively retry subscription, catching any errors + this.subscribe().catch((error) => { + console.error( + "Error during subscription retry after timeout:", + error, + ); }); - } - - async subscribe() { - // Clean up previous subscription attempt if retrying - if (this.#unsubscribe) { - this.#unsubscribe(); - this.#unsubscribe = undefined; - } - - // Clear any existing timeout - if (this.#initTimeoutId) { - clearTimeout(this.#initTimeoutId); - this.#initTimeoutId = undefined; - } - - // Clear any existing keepalive timer to prevent accumulation - if (this.#keepaliveTimer) { - clearTimeout(this.#keepaliveTimer); - this.#keepaliveTimer = undefined; - } - + }, 30000); + + // Subscribe to query updates + this._unsubscribe = this._client.subscribeToQueryUpdates( + this._subscriptionId, + (updateResult) => { + // Clear timeout on first message + if (this._initTimeoutId) { + clearTimeout(this._initTimeoutId); + this._initTimeoutId = undefined; + } + + // Resolve the initialization promise (only resolves once) + if (this._initResolve) { + this._initResolve(true); + this._initResolve = undefined; // Prevent double-resolve + this._initReject = undefined; + } + + // Skip duplicate init messages + if (updateResult.isInit && this._latestResult) return; + + this._latestResult = updateResult; + this._notifyCallbacks(updateResult); + }, + ); + } catch (error) { + console.error("Error setting up subscription:", error); + + // Reject the promise if this is the first attempt + if (this._initReject) { + this._initReject(error); + this._initResolve = undefined; + this._initReject = undefined; + } + + throw error; // Re-throw so caller knows it failed + } + + // Start keepalive loop using platform-agnostic setTimeout + const keepaliveLoop = async () => { + if (this._disposed) return; + + try { + await this._client.keepAliveQuery(this._uuid, this._subscriptionId); + } catch (e) { + console.error("Error in keepalive:", e); + // try to reinitialize the subscription + console.log("Reinitializing subscription for query:", this._query); try { - // Initialize the query subscription - let initialResult; - if (this.isSurrealDB) { - initialResult = await this.#client.perspectiveSubscribeSurrealQuery(this.#uuid, this.#query); - } else { - initialResult = await this.#client.subscribeQuery(this.#uuid, this.#query); - } - this.#subscriptionId = initialResult.subscriptionId; - - // Process the initial result immediately for fast UX - if (initialResult.result) { - this.#latestResult = initialResult.result; - this.#notifyCallbacks(initialResult.result); - } else { - console.warn('⚠️ No initial result returned from subscribeQuery!'); - } - - // Set up timeout for retry - this.#initTimeoutId = setTimeout(() => { - console.error('Subscription initialization timed out after 30 seconds. Resubscribing...'); - // Recursively retry subscription, catching any errors - this.subscribe().catch(error => { - console.error('Error during subscription retry after timeout:', error); - }); - }, 30000); - - // Subscribe to query updates - this.#unsubscribe = this.#client.subscribeToQueryUpdates( - this.#subscriptionId, - (updateResult) => { - // Clear timeout on first message - if (this.#initTimeoutId) { - clearTimeout(this.#initTimeoutId); - this.#initTimeoutId = undefined; - } - - // Resolve the initialization promise (only resolves once) - if (this.#initResolve) { - this.#initResolve(true); - this.#initResolve = undefined; // Prevent double-resolve - this.#initReject = undefined; - } - - // Skip duplicate init messages - if (updateResult.isInit && this.#latestResult) return; - - this.#latestResult = updateResult; - this.#notifyCallbacks(updateResult); - } - ); - } catch (error) { - console.error('Error setting up subscription:', error); - - // Reject the promise if this is the first attempt - if (this.#initReject) { - this.#initReject(error); - this.#initResolve = undefined; - this.#initReject = undefined; - } - - throw error; // Re-throw so caller knows it failed - } - - // Start keepalive loop using platform-agnostic setTimeout - const keepaliveLoop = async () => { - if (this.#disposed) return; - - try { - if (this.isSurrealDB) { - await this.#client.perspectiveKeepAliveSurrealQuery(this.#uuid, this.#subscriptionId); - } else { - await this.#client.keepAliveQuery(this.#uuid, this.#subscriptionId); - } - } catch (e) { - console.error('Error in keepalive:', e); - // try to reinitialize the subscription - console.log('Reinitializing subscription for query:', this.#query); - try { - await this.subscribe(); - console.log('Subscription reinitialized'); - } catch (resubscribeError) { - console.error('Error during resubscription from keepalive:', resubscribeError); - // Don't schedule another keepalive on resubscribe failure - return; - } - } - - // Schedule next keepalive if not disposed - if (!this.#disposed) { - this.#keepaliveTimer = setTimeout(keepaliveLoop, 30000) as unknown as number; - } - }; - - // Start the first keepalive loop - this.#keepaliveTimer = setTimeout(keepaliveLoop, 30000) as unknown as number; - } - - /** Get the subscription ID for this query subscription - * - * This is a unique identifier assigned when the subscription was created. - * It can be used to reference this specific subscription, for example when - * sending keepalive signals. - * - * @returns The subscription ID string - */ - get id(): string { - return this.#subscriptionId; - } - -/** Promise that resolves when the subscription has received its first result - * through the subscription channel. This ensures the subscription is fully - * set up before allowing access to results or updates. - * - * If no result is received within 30 seconds, the subscription will automatically - * retry. The promise will remain pending until a subscription message successfully - * arrives, or until a fatal error occurs during subscription setup. - * - * Note: You typically don't need to await this directly since the subscription - * creation methods (like subscribeInfer) already wait for initialization. - */ - get initialized(): Promise { - return this.#initialized; - } - - /** Get the latest query result - * - * This returns the most recent result from the query, which could be either: - * - The initial result from when the subscription was created - * - The latest update received through the subscription - * - * @returns The latest query result as a string (usually a JSON array of bindings) - */ - get result(): AllInstancesResult { - return this.#latestResult; - } - - /** Add a callback that will be called whenever new results arrive - * - * The callback will be called immediately with the current result, - * and then again each time the query results change. - * - * @param callback - Function that takes a result string and processes it - * @returns A function that can be called to remove this callback - * - * Example: - * ```typescript - * const removeCallback = subscription.onResult(result => { - * const bindings = JSON.parse(result); - * console.log("New bindings:", bindings); - * }); - * - * // Later: stop receiving updates - * removeCallback(); - * ``` - */ - onResult(callback: QueryCallback): () => void { - this.#callbacks.add(callback); - return () => this.#callbacks.delete(callback); - } - - /** Internal method to notify all callbacks of a new result */ - #notifyCallbacks(result: AllInstancesResult) { - for (const callback of this.#callbacks) { - try { - callback(result); - } catch (e) { - console.error('Error in query subscription callback:', e); - } - } - } - - /** Clean up the subscription and stop keepalive signals - * - * This method: - * 1. Stops the keepalive timer - * 2. Unsubscribes from GraphQL subscription updates - * 3. Clears all registered callbacks - * 4. Cleans up any pending initialization timeout - * - * After calling this method, the subscription is no longer active and - * will not receive any more updates. The instance should be discarded. - */ - dispose() { - this.#disposed = true; - clearTimeout(this.#keepaliveTimer); - if (this.#unsubscribe) { - this.#unsubscribe(); - } - this.#callbacks.clear(); - if (this.#initTimeoutId) { - clearTimeout(this.#initTimeoutId); - this.#initTimeoutId = undefined; + await this.subscribe(); + console.log("Subscription reinitialized"); + } catch (resubscribeError) { + console.error( + "Error during resubscription from keepalive:", + resubscribeError, + ); + // Don't schedule another keepalive on resubscribe failure + return; } - - // Tell the backend to dispose of the subscription - if (this.#subscriptionId) { - if (this.isSurrealDB) { - this.#client.perspectiveDisposeSurrealQuerySubscription(this.#uuid, this.#subscriptionId) - .catch(e => console.error('Error disposing surreal query subscription:', e)); - } else { - this.#client.disposeQuerySubscription(this.#uuid, this.#subscriptionId) - .catch(e => console.error('Error disposing query subscription:', e)); - } - } - } + } + + // Schedule next keepalive if not disposed + if (!this._disposed) { + this._keepaliveTimer = setTimeout( + keepaliveLoop, + 30000, + ) as unknown as number; + } + }; + + // Start the first keepalive loop + this._keepaliveTimer = setTimeout( + keepaliveLoop, + 30000, + ) as unknown as number; + } + + /** Get the subscription ID for this query subscription + * + * This is a unique identifier assigned when the subscription was created. + * It can be used to reference this specific subscription, for example when + * sending keepalive signals. + * + * @returns The subscription ID string + */ + get id(): string { + return this._subscriptionId; + } + + /** Promise that resolves when the subscription has received its first result + * through the subscription channel. This ensures the subscription is fully + * set up before allowing access to results or updates. + * + * If no result is received within 30 seconds, the subscription will automatically + * retry. The promise will remain pending until a subscription message successfully + * arrives, or until a fatal error occurs during subscription setup. + * + * Note: You typically don't need to await this directly since the subscription + * creation methods (like subscribeInfer) already wait for initialization. + */ + get initialized(): Promise { + return this._initialized; + } + + /** Get the latest query result + * + * This returns the most recent result from the query, which could be either: + * - The initial result from when the subscription was created + * - The latest update received through the subscription + * + * @returns The latest query result as a string (usually a JSON array of bindings) + */ + get result(): AllInstancesResult { + return this._latestResult; + } + + /** Add a callback that will be called whenever new results arrive + * + * The callback will be called immediately with the current result, + * and then again each time the query results change. + * + * @param callback - Function that takes a result string and processes it + * @returns A function that can be called to remove this callback + * + * Example: + * ```typescript + * const removeCallback = subscription.onResult(result => { + * const bindings = JSON.parse(result); + * console.log("New bindings:", bindings); + * }); + * + * // Later: stop receiving updates + * removeCallback(); + * ``` + */ + onResult(callback: QueryCallback): () => void { + this._callbacks.add(callback); + return () => this._callbacks.delete(callback); + } + + /** Internal method to notify all callbacks of a new result */ + private _notifyCallbacks(result: AllInstancesResult) { + for (const callback of this._callbacks) { + try { + callback(result); + } catch (e) { + console.error("Error in query subscription callback:", e); + } + } + } + + /** Clean up the subscription and stop keepalive signals + * + * This method: + * 1. Stops the keepalive timer + * 2. Unsubscribes from GraphQL subscription updates + * 3. Clears all registered callbacks + * 4. Cleans up any pending initialization timeout + * + * After calling this method, the subscription is no longer active and + * will not receive any more updates. The instance should be discarded. + */ + dispose() { + this._disposed = true; + clearTimeout(this._keepaliveTimer); + if (this._unsubscribe) { + this._unsubscribe(); + } + this._callbacks.clear(); + if (this._initTimeoutId) { + clearTimeout(this._initTimeoutId); + this._initTimeoutId = undefined; + } + + // Tell the backend to dispose of the subscription + if (this._subscriptionId) { + this._client + .disposeQuerySubscription(this._uuid, this._subscriptionId) + .catch((e) => console.error("Error disposing query subscription:", e)); + } + } } -type PerspectiveListenerTypes = "link-added" | "link-removed" | "link-updated" +type PerspectiveListenerTypes = "link-added" | "link-removed" | "link-updated"; -export type LinkStatus = "shared" | "local" +export type LinkStatus = "shared" | "local"; interface Parameter { - name: string - value: string + name: string; + value: string; } /** * PerspectiveProxy provides a high-level interface for working with AD4M Perspectives - agent-centric semantic graphs * that store and organize links between expressions. - * + * * A Perspective is fundamentally a collection of links (subject-predicate-object triples) that represent an agent's view * of their digital world. Through PerspectiveProxy, you can: * - Add, remove, and query links @@ -337,8 +363,8 @@ interface Parameter { * - Subscribe to real-time updates * - Share perspectives as Neighbourhoods * - Execute Prolog queries for complex graph patterns - * - * + * + * * @example * ```typescript * // Create and work with links @@ -348,1852 +374,2633 @@ interface Parameter { * predicate: "knows", * target: "did:key:bob" * }); - * + * * // Query links * const friends = await perspective.get({ * source: "did:key:alice", * predicate: "knows" * }); - * + * * // Use Social DNA * await perspective.addSdna(todoClass, "subject_class"); * const todo = await perspective.createSubject("Todo", "expression://123"); - * + * * // Subscribe to changes * perspective.addListener("link-added", (link) => { * console.log("New link added:", link); * }); * ``` */ -export class PerspectiveProxy { - /** Unique identifier of this perspective */ - uuid: string; - - /** Human-readable name of this perspective */ - name: string; - - /** If this perspective is shared as a Neighbourhood, this is its URL */ - sharedUrl: string|null; - - /** If this perspective is shared, this contains the Neighbourhood metadata */ - neighbourhood: NeighbourhoodExpression|null; - - /** Current sync state if this perspective is shared */ - state: PerspectiveState|null; - - /** List of owners of this perspective */ - owners?: string[] - - #handle: PerspectiveHandle - #client: PerspectiveClient - #perspectiveLinkAddedCallbacks: LinkCallback[] - #perspectiveLinkRemovedCallbacks: LinkCallback[] - #perspectiveLinkUpdatedCallbacks: LinkCallback[] - #perspectiveSyncStateChangeCallbacks: SyncStateChangeCallback[] - - /** - * Creates a new PerspectiveProxy instance. - * Note: Don't create this directly, use ad4m.perspective.add() instead. - */ - constructor(handle: PerspectiveHandle, ad4m: PerspectiveClient) { - this.#perspectiveLinkAddedCallbacks = [] - this.#perspectiveLinkRemovedCallbacks = [] - this.#perspectiveLinkUpdatedCallbacks = [] - this.#perspectiveSyncStateChangeCallbacks = [] - this.#handle = handle - this.#client = ad4m - this.uuid = this.#handle.uuid; - this.name = this.#handle.name; - this.owners = this.#handle.owners; - this.sharedUrl = this.#handle.sharedUrl; - this.neighbourhood = this.#handle.neighbourhood; - this.state = this.#handle.state; - this.#client.addPerspectiveLinkAddedListener(this.#handle.uuid, this.#perspectiveLinkAddedCallbacks) - this.#client.addPerspectiveLinkRemovedListener(this.#handle.uuid, this.#perspectiveLinkRemovedCallbacks) - this.#client.addPerspectiveLinkUpdatedListener(this.#handle.uuid, this.#perspectiveLinkUpdatedCallbacks) - this.#client.addPerspectiveSyncStateChangeListener(this.#handle.uuid, this.#perspectiveSyncStateChangeCallbacks) - } - - /** - * Escapes special regex characters in a string to prevent ReDoS attacks - * and regex injection when building dynamic regular expressions. - * - * @param str - The string to escape - * @returns The escaped string safe for use in RegExp constructor - * - * @private - */ - private escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - /** - * Executes a set of actions on an expression with optional parameters. - * Used internally by Social DNA flows and subject class operations. - * - * Actions are specified as an array of commands that modify links in the perspective. - * Each action is an object with the following format: - * ```typescript - * { - * action: "addLink" | "removeLink" | "setSingleTarget" | "collectionSetter", - * source: string, // Usually "this" to reference the current expression - * predicate: string, // The predicate URI - * target: string // The target value or "value" for parameters - * } - * ``` - * - * Available commands: - * - `addLink`: Creates a new link - * - `removeLink`: Removes an existing link - * - `setSingleTarget`: Removes all existing links with the same source/predicate and adds a new one - * - `collectionSetter`: Special command for setting collection properties - * - * When used with parameters, the special value "value" in the target field will be - * replaced with the actual parameter value. - * - * @example - * ```typescript - * // Add a state link and remove an old one - * await perspective.executeAction([ - * { - * action: "addLink", - * source: "this", - * predicate: "todo://state", - * target: "todo://doing" - * }, - * { - * action: "removeLink", - * source: "this", - * predicate: "todo://state", - * target: "todo://ready" - * } - * ], "expression://123"); - * - * // Set a property using a parameter - * await perspective.executeAction([ - * { - * action: "setSingleTarget", - * source: "this", - * predicate: "todo://title", - * target: "value" - * } - * ], "expression://123", [ - * { name: "title", value: "New Title" } - * ]); - * ``` - * - * @param actions - Array of action objects to execute - * @param expression - Target expression address (replaces "this" in actions) - * @param parameters - Optional parameters that replace "value" in actions - * @param batchId - Optional batch ID to group this operation with others - */ - async executeAction(actions, expression, parameters: Parameter[], batchId?: string) { - return await this.#client.executeCommands(this.#handle.uuid, JSON.stringify(actions), expression, JSON.stringify(parameters), batchId) - } - - /** - * Retrieves links from the perspective that match the given query. - * - * @param query - Query parameters to filter links - * @returns Array of matching LinkExpressions - * - * @example - * ```typescript - * // Get all links where Alice knows someone - * const links = await perspective.get({ - * source: "did:key:alice", - * predicate: "knows" - * }); - * - * // Get all comments on a post - * const comments = await perspective.get({ - * source: "post://123", - * predicate: "comment" - * }); - * ``` - */ - async get(query: LinkQuery): Promise { - return await this.#client.queryLinks(this.#handle.uuid, query) - } - - /** - * Executes a Prolog query against the perspective's knowledge base. - * This is a powerful way to find complex patterns in the graph. - * - * @param query - Prolog query string - * @returns Query results or false if no results - * - * @example - * ```typescript - * // Find friends of friends - * const results = await perspective.infer(` - * triple(A, "knows", B), - * triple(B, "knows", C), - * A \= C - * `); - * - * // Find all active todos - * const todos = await perspective.infer(` - * instance(Todo, "Todo"), - * property_getter("Todo", Todo, "state", "active") - * `); - * ``` - */ - async infer(query: string): Promise { - return await this.#client.queryProlog(this.#handle.uuid, query) - } - - /** - * Executes a SurrealQL query against the perspective's link cache. - * This allows powerful SQL-like queries on the link data stored in SurrealDB. - * - * **Security Note:** Only read-only queries (SELECT, RETURN, etc.) are permitted. - * Mutating operations (DELETE, UPDATE, INSERT, CREATE, DROP, DEFINE, etc.) are - * blocked for security reasons. Use the perspective's add/remove methods to modify links. - * - * @param query - SurrealQL query string (read-only operations only) - * @returns Query results as parsed JSON - * - * @example - * ```typescript - * // Get all links - * const links = await perspective.querySurrealDB('SELECT * FROM link'); - * - * // Filter links by predicate - * const follows = await perspective.querySurrealDB( - * "SELECT * FROM link WHERE predicate = 'follows'" - * ); - * - * // Complex aggregation query - * const stats = await perspective.querySurrealDB( - * "SELECT predicate, count() as total FROM link GROUP BY predicate" - * ); - * ``` - */ - async querySurrealDB(query: string): Promise { - return await this.#client.querySurrealDB(this.#handle.uuid, query) - } - - /** - * Adds a new link to the perspective. - * - * @param link - The link to add - * @param status - Whether the link should be shared in a Neighbourhood - * @param batchId - Optional batch ID to group this operation with others - * @returns The created LinkExpression - * - * @example - * ```typescript - * // Add a simple relationship - * await perspective.add({ - * source: "did:key:alice", - * predicate: "follows", - * target: "did:key:bob" - * }); - * - * // Add a local-only link - * await perspective.add({ - * source: "note://123", - * predicate: "tag", - * target: "private" - * }, "local"); - * ``` - */ - async add(link: Link, status: LinkStatus = 'shared', batchId?: string): Promise { - return await this.#client.addLink(this.#handle.uuid, link, status, batchId) - } - - /** - * Adds multiple links to the perspective in a single operation. - * More efficient than adding links one by one. - * - * @param links - Array of links to add - * @param status - Whether the links should be shared - * @param batchId - Optional batch ID to group this operation with others - * @returns Array of created LinkExpressions - */ - async addLinks(links: Link[], status: LinkStatus = 'shared', batchId?: string): Promise { - return await this.#client.addLinks(this.#handle.uuid, links, status, batchId) - } - - /** - * Removes multiple links from the perspective. - * - * @param links - Array of links to remove - * @param batchId - Optional batch ID to group this operation with others - * @returns Array of removed LinkExpressions - */ - async removeLinks(links: LinkExpressionInput[], batchId?: string): Promise { - return await this.#client.removeLinks(this.#handle.uuid, links, batchId) - } - - /** - * Applies a set of link mutations (adds and removes) in a single operation. - * Useful for atomic updates to the perspective. - * - * @param mutations - Object containing links to add and remove - * @param status - Whether new links should be shared - * @returns Object containing results of the mutations - */ - async linkMutations(mutations: LinkMutations, status: LinkStatus = 'shared'): Promise { - return await this.#client.linkMutations(this.#handle.uuid, mutations, status) - } - - /** - * Adds a pre-signed LinkExpression to the perspective. - * - * @param link - The signed LinkExpression to add - * @param status - Whether the link should be shared - * @param batchId - Optional batch ID to group this operation with others - * @returns The added LinkExpression - */ - async addLinkExpression(link: LinkExpression, status: LinkStatus = 'shared', batchId?: string): Promise { - return await this.#client.addLinkExpression(this.#handle.uuid, link, status, batchId) - } - - /** - * Updates an existing link with new data. - * - * @param oldLink - The existing link to update - * @param newLink - The new link data - * @param batchId - Optional batch ID to group this operation with others - */ - async update(oldLink: LinkExpressionInput, newLink: Link, batchId?: string): Promise { - return await this.#client.updateLink(this.#handle.uuid, oldLink, newLink, batchId) - } - - /** - * Removes a link from the perspective. - * - * @param link - The link to remove - * @param batchId - Optional batch ID to group this operation with others - */ - async remove(link: LinkExpressionInput, batchId?: string): Promise { - return await this.#client.removeLink(this.#handle.uuid, link, batchId) - } - - /** Creates a new batch for grouping operations */ - async createBatch(): Promise { - return await this.#client.createBatch(this.#handle.uuid) - } - - /** Commits a batch of operations */ - async commitBatch(batchId: string): Promise { - return await this.#client.commitBatch(this.#handle.uuid, batchId) - - - } - /** - * Retrieves and renders an Expression referenced in this perspective. - * - * @param expressionURI - URI of the Expression to retrieve - * @returns The rendered Expression - */ - async getExpression(expressionURI: string): Promise { - return await this.#client.getExpression(expressionURI) - } - - /** - * Creates a new Expression in the specified Language. - * - * @param content - Content for the new Expression - * @param languageAddress - Address of the Language to use - * @returns URI of the created Expression - */ - async createExpression(content: any, languageAddress: string): Promise { - return await this.#client.createExpression(content, languageAddress) - } - - /** - * Subscribes to link changes in the perspective. - * - * @param type - Type of change to listen for - * @param cb - Callback function - * - * @example - * ```typescript - * // Listen for new links - * perspective.addListener("link-added", (link) => { - * console.log("New link:", link); - * }); - * - * // Listen for removed links - * perspective.addListener("link-removed", (link) => { - * console.log("Link removed:", link); - * }); - * ``` - */ - async addListener(type: PerspectiveListenerTypes, cb: LinkCallback) { - if (type === 'link-added') { - this.#perspectiveLinkAddedCallbacks.push(cb); - } else if (type === 'link-removed') { - this.#perspectiveLinkRemovedCallbacks.push(cb); - } else if (type === 'link-updated') { - this.#perspectiveLinkUpdatedCallbacks.push(cb); - } - } - - /** - * Subscribes to sync state changes if this perspective is shared. - * - * @param cb - Callback function - * - * @example - * ```typescript - * perspective.addSyncStateChangeListener((state) => { - * console.log("Sync state:", state); - * }); - * ``` - */ - async addSyncStateChangeListener(cb: SyncStateChangeCallback) { - this.#perspectiveSyncStateChangeCallbacks.push(cb) - } - - /** - * Unsubscribes from link changes. - * - * @param type - Type of change to stop listening for - * @param cb - The callback function to remove - */ - async removeListener(type: PerspectiveListenerTypes, cb: LinkCallback) { - if (type === 'link-added') { - const index = this.#perspectiveLinkAddedCallbacks.indexOf(cb); - - this.#perspectiveLinkAddedCallbacks.splice(index, 1); - } else if (type === 'link-removed') { - const index = this.#perspectiveLinkRemovedCallbacks.indexOf(cb); - - this.#perspectiveLinkRemovedCallbacks.splice(index, 1); - } else if (type === 'link-updated') { - const index = this.#perspectiveLinkUpdatedCallbacks.indexOf(cb); - - this.#perspectiveLinkUpdatedCallbacks.splice(index, 1); - } - } - - /** - * Creates a snapshot of the current perspective state. - * Useful for backup or sharing. - * - * @returns Perspective object containing all links - */ - async snapshot(): Promise { - return this.#client.snapshotByUUID(this.#handle.uuid) - } - - /** - * Loads a perspective snapshot, replacing current content. - * - * @param snapshot - Perspective snapshot to load - */ - async loadSnapshot(snapshot: Perspective) { - //Clean the input data from __typename - const cleanedSnapshot = JSON.parse(JSON.stringify(snapshot)); - delete cleanedSnapshot.__typename; - cleanedSnapshot.links.forEach(link => { - delete link.data.__typename; - }); - - for(const link of cleanedSnapshot.links) { - await this.addLinkExpression(link) - } - } - /** - * Gets a single target value matching a query. - * Useful when you expect only one result. - * - * @param query - Query to find the target - * @returns Target value or void if not found - * - * @example - * ```typescript - * // Get a user's name - * const name = await perspective.getSingleTarget({ - * source: "did:key:alice", - * predicate: "name" - * }); - * ``` - */ - async getSingleTarget(query: LinkQuery): Promise { - delete query.target - const foundLinks = await this.get(query) - if(foundLinks.length) - return foundLinks[0].data.target - else - return null - } - - /** - * Sets a single target value, removing any existing targets. - * - * @param link - Link defining the new target - * @param status - Whether the link should be shared - * - * @example - * ```typescript - * // Set a user's status - * await perspective.setSingleTarget({ - * source: "did:key:alice", - * predicate: "status", - * target: "online" - * }); - * ``` - */ - async setSingleTarget(link: Link, status: LinkStatus = 'shared') { - const query = new LinkQuery({source: link.source, predicate: link.predicate}) - const foundLinks = await this.get(query) - const removals = []; - for(const l of foundLinks){ - delete l.__typename - delete l.data.__typename - delete l.proof.__typename - removals.push(l); - } - const additions = [link]; - - await this.linkMutations({additions, removals}, status) - } - - /** Returns all the Social DNA flows defined in this perspective */ - async sdnaFlows(): Promise { - // Query for all flow registration links - const flowLinks = await this.get(new LinkQuery({ - source: "ad4m://self", - predicate: "ad4m://has_flow" - })); - return flowLinks.map(l => { - try { - return Literal.fromUrl(l.data.target).get() as string; - } catch { - return l.data.target; - } - }); - } - - /** Returns all Social DNA flows that can be started from the given expression */ - async availableFlows(exprAddr: string): Promise { - const allFlowNames = await this.sdnaFlows(); - const available: string[] = []; - for (const name of allFlowNames) { - const flow = await this.getFlow(name); - if (!flow) continue; - if (flow.flowable === "any") { - available.push(name); - } else { - // Check if the expression matches the flowable link pattern - const pattern = flow.flowable as LinkPattern; - const source = pattern.source || exprAddr; - const links = await this.get(new LinkQuery({ - source, - predicate: pattern.predicate, - target: pattern.target - })); - if (links.length > 0) { - available.push(name); - } - } - } - return available; - } - - /** Starts the Social DNA flow @param flowName on the expression @param exprAddr */ - async startFlow(flowName: string, exprAddr: string) { - const flow = await this.getFlow(flowName); - if (!flow) throw `Flow "${flowName}" not found`; - if (flow.startAction.length === 0) throw `Flow "${flowName}" has no start action`; - await this.executeAction(flow.startAction, exprAddr, undefined) - } - - /** Returns all expressions in the given state of given Social DNA flow */ - async expressionsInFlowState(flowName: string, flowState: number): Promise { - const flow = await this.getFlow(flowName); - if (!flow) return []; - // Find the state with the matching value - const state = flow.states.find(s => s.value === flowState); - if (!state) return []; - // Query for expressions matching this state's check pattern - const pattern = state.stateCheck; - const links = await this.get(new LinkQuery({ - predicate: pattern.predicate, - target: pattern.target - })); - // Return the sources (expression addresses) - use source if pattern has no explicit source - return links.map(l => pattern.source ? l.data.target : l.data.source); - } - - /** Returns the given expression's flow state with regard to given Social DNA flow */ - async flowState(flowName: string, exprAddr: string): Promise { - const flow = await this.getFlow(flowName); - if (!flow) throw `Flow "${flowName}" not found`; - // Check each state to find which one the expression is in - for (const state of flow.states) { - const pattern = state.stateCheck; - const source = pattern.source || exprAddr; - const links = await this.get(new LinkQuery({ - source, - predicate: pattern.predicate, - target: pattern.target - })); - if (links.length > 0) return state.value; - } - throw `Expression "${exprAddr}" is not in any state of flow "${flowName}"`; - } - - /** Returns available action names, with regard to Social DNA flow and expression's flow state */ - async flowActions(flowName: string, exprAddr: string): Promise { - const flow = await this.getFlow(flowName); - if (!flow) return []; - // Determine current state - let currentStateName: string | null = null; - for (const state of flow.states) { - const pattern = state.stateCheck; - const source = pattern.source || exprAddr; - const links = await this.get(new LinkQuery({ - source, - predicate: pattern.predicate, - target: pattern.target - })); - if (links.length > 0) { - currentStateName = state.name; - break; - } - } - if (!currentStateName) return []; - // Return transitions available from current state - return flow.transitions - .filter(t => t.fromState === currentStateName) - .map(t => t.actionName); - } - - /** Runs given Social DNA flow action */ - async runFlowAction(flowName: string, exprAddr: string, actionName: string) { - const flow = await this.getFlow(flowName); - if (!flow) throw `Flow "${flowName}" not found`; - const transition = flow.transitions.find(t => t.actionName === actionName); - if (!transition) throw `Action "${actionName}" not found in flow "${flowName}"`; - await this.executeAction(transition.actions, exprAddr, undefined) - } - - /** Returns the perspective's Social DNA code - * This will return all SDNA code elements in an array. - */ - async getSdna(): Promise { - // First, find all the name literals that are linked from ad4m://self with SDNA predicates - const sdnaPredicates = [ - "ad4m://has_subject_class", - "ad4m://has_flow", - "ad4m://has_custom_sdna" - ]; - - const allSdnaCode: string[] = []; - - for (const predicate of sdnaPredicates) { - // Find name literals linked from ad4m://self with this predicate - const nameLinks = await this.get(new LinkQuery({ - source: "ad4m://self", - predicate: predicate - })); - - // For each name literal found, get the actual SDNA code - for (const nameLink of nameLinks) { - const nameLiteral = nameLink.data.target; - - // Now find the SDNA code linked from this name with predicate "ad4m://sdna" - const sdnaLinks = await this.get(new LinkQuery({ - source: nameLiteral, - predicate: "ad4m://sdna" - })); - - // Extract the SDNA code from each link - for (const sdnaLink of sdnaLinks) { - const code = Literal.fromUrl(sdnaLink.data.target).get(); - if (typeof code === 'string') { - allSdnaCode.push(code); - } - } - } - } - - return allSdnaCode; - } - - /** Returns the Social DNA code for a specific class - * This will return the SDNA code for the specified class, or null if not found. - */ - async getSdnaForClass(className: string): Promise { - // First, find the name literal for this class that is linked from ad4m://self - const nameLiteral = Literal.from(className); - - const links = await this.get(new LinkQuery({ - source: "ad4m://self", - target: nameLiteral.toUrl(), - predicate: "ad4m://has_subject_class" - })); - - if (links.length === 0) { - return null; - } - - // Now find the SDNA code linked from this name with predicate "ad4m://sdna" - const sdnaLinks = await this.get(new LinkQuery({ - source: nameLiteral.toUrl(), - predicate: "ad4m://sdna" - })); - - if (sdnaLinks.length === 0) { - return null; - } - - // Extract the SDNA code from the first link - const code = Literal.fromUrl(sdnaLinks[0].data.target).get(); - return typeof code === 'string' ? code : null; - } - - /** - * Adds Social DNA code to the perspective. - * - * **Recommended:** Use {@link addShacl} instead, which accepts the `SHACLShape` type directly. - * This method is primarily for the GraphQL layer and legacy Prolog code. - * - * @param name - Unique name for this SDNA definition - * @param sdnaCode - Prolog SDNA code (legacy, can be empty string if shaclJson provided) - * @param sdnaType - Type of SDNA: "subject_class", "flow", or "custom" - * @param shaclJson - SHACL JSON string (use addShacl() for type-safe alternative) - * - * @example - * // Recommended: Use addShacl() with SHACLShape type - * const shape = new SHACLShape('recipe://Recipe'); - * shape.addProperty({ name: 'title', path: 'recipe://title', datatype: 'xsd:string' }); - * await perspective.addShacl('Recipe', shape); - * - * // Legacy: Prolog code is auto-converted to SHACL - * await perspective.addSdna('Recipe', prologCode, 'subject_class'); - */ - async addSdna(name: string, sdnaCode: string, sdnaType: "subject_class" | "flow" | "custom", shaclJson?: string) { - return this.#client.addSdna(this.#handle.uuid, name, sdnaCode, sdnaType, shaclJson) - } - - /** - * **Recommended way to add SDNA schemas.** - * - * Store a SHACL shape in this Perspective using the type-safe `SHACLShape` class. - * The shape is serialized as RDF triples (links) for native AD4M storage and querying. - * - * @param name - Unique name for this schema (e.g., 'Recipe', 'Task') - * @param shape - SHACLShape instance defining the schema - * - * @example - * import { SHACLShape } from '@coasys/ad4m'; - * - * const shape = new SHACLShape('recipe://Recipe'); - * shape.addProperty({ - * name: 'title', - * path: 'recipe://title', - * datatype: 'xsd:string', - * minCount: 1 - * }); - * shape.addProperty({ - * name: 'ingredients', - * path: 'recipe://has_ingredient', - * // No maxCount = collection - * }); - * - * await perspective.addShacl('Recipe', shape); - */ - async addShacl(name: string, shape: SHACLShape): Promise { - // Serialize shape to links - const shapeLinks = shape.toLinks(); - - // Create name -> shape mapping links - const nameMapping = Literal.fromUrl(`literal://string:shacl://${name}`); - const allLinks: Link[] = [ - ...shapeLinks.map(l => new Link({ - source: l.source, - predicate: l.predicate, - target: l.target - })), - new Link({ - source: "ad4m://self", - predicate: "ad4m://has_shacl", - target: nameMapping.toUrl() - }), - new Link({ - source: nameMapping.toUrl(), - predicate: "ad4m://shacl_shape_uri", - target: shape.nodeShapeUri - }) - ]; - - // Batch add all links at once - await this.addLinks(allLinks); - } - - /** - * Retrieve a SHACL shape by name from this Perspective - */ - async getShacl(name: string): Promise { - // Find the shape URI from the name mapping - const nameMapping = Literal.fromUrl(`literal://string:shacl://${name}`); - const shapeUriLinks = await this.get(new LinkQuery({ - source: nameMapping.toUrl(), - predicate: "ad4m://shacl_shape_uri" - })); - - if (shapeUriLinks.length === 0) { - return null; - } - - const shapeUri = shapeUriLinks[0].data.target; - const escapedShapeUri = escapeSurrealString(shapeUri); - - // First get property shape URIs so we can query everything in one go - const propertyLinks = await this.get(new LinkQuery({ - source: shapeUri, - predicate: "sh://property" - })); - - // Build a single surreal query that fetches all relevant links - const sourceUris = [shapeUri, ...propertyLinks.map(l => l.data.target)]; - const escapedSources = sourceUris.map(u => `'${escapeSurrealString(u)}'`).join(', '); - - const query = `SELECT in.uri AS source, predicate, out.uri AS target FROM link WHERE in.uri IN [${escapedSources}]`; - const result = await this.querySurrealDB(query); - - const shapeLinks = (result || []).map((r: any) => ({ - source: r.source, - predicate: r.predicate, - target: r.target - })); - - return SHACLShape.fromLinks(shapeLinks, shapeUri); - } - - /** - * Get all SHACL shapes stored in this Perspective - */ - async getAllShacl(): Promise> { - const nameLinks = await this.get(new LinkQuery({ - source: "ad4m://self", - predicate: "ad4m://has_shacl" - })); - - const shapes = []; - for (const nameLink of nameLinks) { - const nameUrl = nameLink.data.target; - const name = Literal.fromUrl(nameUrl).get() as string; - const shapeName = name.replace('shacl://', ''); - - const shape = await this.getShacl(shapeName); - if (shape) { - shapes.push({ name: shapeName, shape }); - } - } - - return shapes; - } - - /** - * **Recommended way to add Flow definitions.** - * - * Store a SHACL Flow (state machine) in this Perspective using the type-safe `SHACLFlow` class. - * The flow is serialized as RDF triples (links) for native AD4M storage and querying. - * - * @param name - Flow name (e.g., 'TODO', 'Approval') - * @param flow - SHACLFlow instance defining the state machine - * - * @example - * ```typescript - * import { SHACLFlow } from '@coasys/ad4m'; - * - * const todoFlow = new SHACLFlow('TODO', 'todo://'); - * todoFlow.flowable = 'any'; - * - * // Define states - * todoFlow.addState({ name: 'ready', value: 0, stateCheck: { predicate: 'todo://state', target: 'todo://ready' }}); - * todoFlow.addState({ name: 'done', value: 1, stateCheck: { predicate: 'todo://state', target: 'todo://done' }}); - * - * // Define start action - * todoFlow.startAction = [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' }]; - * - * // Define transitions - * todoFlow.addTransition({ - * actionName: 'Complete', - * fromState: 'ready', - * toState: 'done', - * actions: [ - * { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, - * { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } - * ] - * }); - * - * await perspective.addFlow('TODO', todoFlow); - * ``` - */ - async addFlow(name: string, flow: SHACLFlow): Promise { - // Serialize flow to links - const flowLinks = flow.toLinks(); - - // Create registration and mapping links - const flowNameLiteral = Literal.from(name).toUrl(); - const allLinks: Link[] = [ - ...flowLinks.map(l => new Link({ - source: l.source, - predicate: l.predicate, - target: l.target - })), - new Link({ - source: "ad4m://self", - predicate: "ad4m://has_flow", - target: flowNameLiteral - }), - new Link({ - source: flowNameLiteral, - predicate: "ad4m://flow_uri", - target: flow.flowUri - }) - ]; - - // Batch add all links at once - await this.addLinks(allLinks); - } - - /** - * Retrieve a Flow definition by name from this Perspective - * - * @param name - Flow name to retrieve - * @returns The SHACLFlow or null if not found - */ - async getFlow(name: string): Promise { - const flowNameLiteral = Literal.from(name).toUrl(); - - // Find flow URI from name mapping - const flowUriLinks = await this.get(new LinkQuery({ - source: flowNameLiteral, - predicate: "ad4m://flow_uri" - })); - - if (flowUriLinks.length === 0) { - return null; +/** Local (unexported) helper – was previously `core/src/model/Subject.ts` */ +class Subject { + private _baseExpression: string; + private _subjectClassName: string; + private _perspective: PerspectiveProxy; + + constructor( + perspective: PerspectiveProxy, + baseExpression: string, + subjectClassName: string, + ) { + this._baseExpression = baseExpression; + this._subjectClassName = subjectClassName; + this._perspective = perspective; + } + + get baseExpression() { + return this._baseExpression; + } + + async init() { + let isInstance = await this._perspective.isSubjectInstance( + this._baseExpression, + this._subjectClassName, + ); + if (!isInstance) { + throw `Not a valid subject instance of ${this._subjectClassName} for ${this._baseExpression}`; + } + let results = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), property(C, Property)`, + ); + let properties = results.map((result) => result.Property); + for (let p of properties) { + await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), property_resolve(C, "${p}")`, + ); + Object.defineProperty(this, p, { + configurable: true, + get: async () => { + try { + return await this._perspective.getPropertyValueViaSurreal( + this._baseExpression, + this._subjectClassName, + p, + ); + } catch (err) { + console.warn(`Failed to get property ${p} via SurrealDB:`, err); + return undefined; + } + }, + }); + } + const setters = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), property_setter(C, Property, Setter)`, + ); + for (let setter of setters ? setters : []) { + if (setter) { + const property = setter.Property; + const actions = eval(setter.Setter); + const resolveLanguageResults = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), property_resolve_language(C, "${property}", Language)`, + ); + let resolveLanguage; + if (resolveLanguageResults && resolveLanguageResults.length > 0) { + resolveLanguage = resolveLanguageResults[0].Language; } - - const flowUri = flowUriLinks[0].data.target; - const escapedFlowUri = escapeSurrealString(flowUri); - - // Compute alternate prefix for state/transition URIs - const alternatePrefix = flowUri.endsWith('Flow') - ? flowUri.slice(0, -4) + '.' - : flowUri + '.'; - const escapedAltPrefix = escapeSurrealString(alternatePrefix); - - // Single surreal query to get all flow-related links - const query = `SELECT in.uri AS source, predicate, out.uri AS target FROM link WHERE in.uri = '${escapedFlowUri}' OR string::starts_with(in.uri, '${escapedAltPrefix}')`; - const result = await this.querySurrealDB(query); - - const flowLinks = (result || []).map((r: any) => ({ - source: r.source, - predicate: r.predicate, - target: r.target - })); - - return SHACLFlow.fromLinks(flowLinks, flowUri); - } - - /** Returns all the Subject classes defined in this perspectives SDNA - * - * Uses SHACL-based lookup (Prolog-free implementation). - */ - async subjectClasses(): Promise { - try { - // Query SHACL class links directly — no need for a separate GraphQL endpoint - const classLinks = await this.get(new LinkQuery({ - predicate: "rdf://type", - target: "ad4m://SubjectClass" - })); - const classNames = classLinks - .map(l => { - const source = l.data.source; - // Extract class name from URI like "recipe://Recipe" or "flux://Channel" - const parts = source.split("://"); - const lastPart = parts[parts.length - 1]; - return lastPart.split('/').pop() || ''; - }) - .filter(name => name.length > 0); - // Deduplicate - return [...new Set(classNames)]; - } catch (e) { - console.warn('subjectClasses: SHACL lookup failed:', e); + this[propertyNameToSetterName(property)] = async (value: any) => { + if (resolveLanguage) { + value = await this._perspective.createExpression( + value, + resolveLanguage, + ); + } + await this._perspective.executeAction(actions, this._baseExpression, [ + { name: "value", value }, + ]); + }; + } + } + let results2 = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), collection(C, Collection)`, + ); + if (!results2) results2 = []; + let collections = results2.map((result) => result.Collection); + for (let c of collections) { + Object.defineProperty(this, c, { + configurable: true, + get: async () => { + try { + return await this._perspective.getCollectionValuesViaSurreal( + this._baseExpression, + this._subjectClassName, + c, + ); + } catch (err) { + console.warn(`Failed to get collection ${c} via SurrealDB:`, err); return []; - } - } - - async stringOrTemplateObjectToSubjectClassName(subjectClass: T): Promise { - if(typeof subjectClass === "string") - return subjectClass - else { - let subjectClasses = await this.subjectClassesByTemplate(subjectClass as object) - if(subjectClasses[0]) { - return subjectClasses[0] - } else { - //@ts-ignore - return subjectClass.className - } - } - } - - /** - * Creates a new subject instance of the given subject class - * - * @param subjectClass Either a string with the name of the subject class, or an object - * with the properties of the subject class. - * @param exprAddr The address of the expression to be turned into a subject instance - * @param initialValues Optional initial values for properties. If provided, these will be - * merged with constructor actions for better performance. - * @param batchId Optional batch ID for grouping operations. If provided, returns the expression address - * instead of the subject proxy since the subject won't exist until the batch is committed. - * @returns A proxy object for the created subject, or just the expression address if in batch mode - */ - async createSubject( - subjectClass: T, - exprAddr: string, - initialValues?: Record, - batchId?: B - ): Promise { - let className: string; - - if(typeof subjectClass === "string") { - className = subjectClass; - await this.#client.createSubject( - this.#handle.uuid, - JSON.stringify({ - className, - initialValues - }), - exprAddr, - initialValues ? JSON.stringify(initialValues) : undefined, - batchId + } + }, + }); + } + let adders = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), collection_adder(C, Collection, Adder)`, + ); + if (!adders) adders = []; + for (let adder of adders) { + if (adder) { + const collection = adder.Collection; + const actions = eval(adder.Adder); + this[collectionToAdderName(collection)] = async (value: any) => { + if (Array.isArray(value)) { + await Promise.all( + value.map((v) => + this._perspective.executeAction(actions, this._baseExpression, [ + { name: "value", value: v }, + ]), + ), ); - } else { - const obj = subjectClass as any; - const resolvedClassName = obj.className || obj.constructor?.className || obj.constructor?.prototype?.className - || await this.findClassByProperties(obj); - if (!resolvedClassName) { - throw new Error("Could not resolve subject class name from object. Use a decorated class or pass a className string."); - } - await this.#client.createSubject( - this.#handle.uuid, - JSON.stringify({ - className: resolvedClassName, - initialValues - }), - exprAddr, - initialValues ? JSON.stringify(initialValues) : undefined, - batchId + } else { + await this._perspective.executeAction( + actions, + this._baseExpression, + [{ name: "value", value }], ); - } - - // Skip subject proxy creation when in batch mode since the subject won't exist until batch is committed - if (batchId) { - return exprAddr as B extends undefined ? T : string; - } - - return this.getSubjectProxy(exprAddr, subjectClass) as Promise; - } - - async getSubjectData(subjectClass: T, exprAddr: string): Promise { - if (typeof subjectClass === "string") { - return JSON.parse(await this.#client.getSubjectData(this.#handle.uuid, JSON.stringify({className: subjectClass}), exprAddr)) - } - const obj = subjectClass as any; - const resolvedClassName = obj.className || obj.constructor?.className || obj.constructor?.prototype?.className - || await this.findClassByProperties(obj); - if (!resolvedClassName) { - throw new Error("Could not resolve subject class name from object. Use a decorated class or pass a className string."); - } - return JSON.parse(await this.#client.getSubjectData(this.#handle.uuid, JSON.stringify({className: resolvedClassName}), exprAddr)) - } - - /** - * Gets actions from SHACL links for a given predicate (e.g., ad4m://constructor, ad4m://destructor). - * Returns the parsed action array if found, or null if not found. - */ - private async getActionsFromSHACL(className: string, predicate: string): Promise { - // Use regex to match exact class name followed by "Shape" at end of URI - // This prevents "RecipeShape" from matching "MyRecipeShape" - const escaped = this.escapeRegExp(className); - const shapePattern = new RegExp(`[/:#]${escaped}Shape$`); - const links = await this.get(new LinkQuery({ predicate })); - - for (const link of links) { - if (shapePattern.test(link.data.source)) { - // Parse actions from literal://string:{json} - const prefix = "literal://string:"; - if (link.data.target.startsWith(prefix)) { - const jsonStr = link.data.target.slice(prefix.length); - // Decode URL-encoded JSON if needed, with fallback for raw % characters - let decoded = jsonStr; - try { decoded = decodeURIComponent(jsonStr); } catch {} - try { - return JSON.parse(decoded); - } catch (e) { - console.warn(`Failed to parse SHACL actions JSON for ${className}:`, e); - return null; - } - } - } - } - - return null; + } + }; + } + } + let removers = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), collection_remover(C, Collection, Remover)`, + ); + if (!removers) removers = []; + for (let remover of removers) { + if (remover) { + const collection = remover.Collection; + const actions = eval(remover.Remover); + this[collectionToRemoverName(collection)] = async (value: any) => { + if (Array.isArray(value)) { + await Promise.all( + value.map((v) => + this._perspective.executeAction(actions, this._baseExpression, [ + { name: "value", value: v }, + ]), + ), + ); + } else { + await this._perspective.executeAction( + actions, + this._baseExpression, + [{ name: "value", value }], + ); + } + }; + } + } + let collectionSetters = await this._perspective.infer( + `subject_class("${this._subjectClassName}", C), collection_setter(C, Collection, Setter)`, + ); + if (!collectionSetters) collectionSetters = []; + for (let collectionSetter of collectionSetters) { + if (collectionSetter) { + const collection = collectionSetter.Collection; + const actions = eval(collectionSetter.Setter); + this[collectionToSetterName(collection)] = async (value: any) => { + if (Array.isArray(value)) { + await this._perspective.executeAction( + actions, + this._baseExpression, + value.map((v) => ({ name: "value", value: v })), + ); + } else { + await this._perspective.executeAction( + actions, + this._baseExpression, + [{ name: "value", value }], + ); + } + }; + } } + } +} - /** Removes a subject instance by running its (SDNA defined) destructor, - * which means removing links around the given expression address - * - * @param subjectClass Either a string with the name of the subject class, or an object - * with the properties of the subject class. In the latter case, the first subject class - * that matches the given properties will be used. - * @param exprAddr The address of the expression to be turned into a subject instance - * @param batchId Optional batch ID for grouping operations. If provided, the removal will be part of the batch - * and won't be executed until the batch is committed. - */ - async removeSubject(subjectClass: T, exprAddr: string, batchId?: string) { - let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) - - // Get destructor actions from SHACL links (Prolog-free) - let actions = await this.getActionsFromSHACL(className, "ad4m://destructor"); - - if (!actions) { - throw `No destructor found for subject class: ${className}. Make sure the class was registered with SHACL.`; +export class PerspectiveProxy { + /** Unique identifier of this perspective */ + uuid: string; + + /** Human-readable name of this perspective */ + name: string; + + /** If this perspective is shared as a Neighbourhood, this is its URL */ + sharedUrl: string | null; + + /** If this perspective is shared, this contains the Neighbourhood metadata */ + neighbourhood: NeighbourhoodExpression | null; + + /** Current sync state if this perspective is shared */ + state: PerspectiveState | null; + + /** List of owners of this perspective */ + owners?: string[]; + + private _handle: PerspectiveHandle; + private _client: PerspectiveClient; + private _perspectiveLinkAddedCallbacks: LinkCallback[]; + private _perspectiveLinkRemovedCallbacks: LinkCallback[]; + private _perspectiveLinkUpdatedCallbacks: LinkCallback[]; + private _perspectiveSyncStateChangeCallbacks: SyncStateChangeCallback[]; + + /** + * Creates a new PerspectiveProxy instance. + * Note: Don't create this directly, use ad4m.perspective.add() instead. + */ + constructor(handle: PerspectiveHandle, ad4m: PerspectiveClient) { + // Prevent Vue (and other Proxy-based reactivity systems) from wrapping this + // instance. PerspectiveProxy manages its own internal state via subscriptions + // and #private fields; wrapping it in a Proxy breaks WeakMap-based private + // field access (TypeError: Cannot read private member). + this._perspectiveLinkAddedCallbacks = []; + this._perspectiveLinkRemovedCallbacks = []; + this._perspectiveLinkUpdatedCallbacks = []; + this._perspectiveSyncStateChangeCallbacks = []; + this._handle = handle; + this._client = ad4m; + this.uuid = this._handle.uuid; + this.name = this._handle.name; + this.owners = this._handle.owners; + this.sharedUrl = this._handle.sharedUrl; + this.neighbourhood = this._handle.neighbourhood; + this.state = this._handle.state; + this._client.addPerspectiveLinkAddedListener( + this._handle.uuid, + this._perspectiveLinkAddedCallbacks, + ); + this._client.addPerspectiveLinkRemovedListener( + this._handle.uuid, + this._perspectiveLinkRemovedCallbacks, + ); + this._client.addPerspectiveLinkUpdatedListener( + this._handle.uuid, + this._perspectiveLinkUpdatedCallbacks, + ); + this._client.addPerspectiveSyncStateChangeListener( + this._handle.uuid, + this._perspectiveSyncStateChangeCallbacks, + ); + } + + /** + * Escapes special regex characters in a string to prevent ReDoS attacks + * and regex injection when building dynamic regular expressions. + * + * @param str - The string to escape + * @returns The escaped string safe for use in RegExp constructor + * + * @private + */ + private escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * Executes a set of actions on an expression with optional parameters. + * Used internally by Social DNA flows and subject class operations. + * + * Actions are specified as an array of commands that modify links in the perspective. + * Each action is an object with the following format: + * ```typescript + * { + * action: "addLink" | "removeLink" | "setSingleTarget" | "relationSetter", + * source: string, // Usually "this" to reference the current expression + * predicate: string, // The predicate URI + * target: string // The target value or "value" for parameters + * } + * ``` + * + * Available commands: + * - `addLink`: Creates a new link + * - `removeLink`: Removes an existing link + * - `setSingleTarget`: Removes all existing links with the same source/predicate and adds a new one + * - `relationSetter`: Special command for bulk-setting a relation (clears existing links, then adds new ones) + * + * When used with parameters, the special value "value" in the target field will be + * replaced with the actual parameter value. + * + * @example + * ```typescript + * // Add a state link and remove an old one + * await perspective.executeAction([ + * { + * action: "addLink", + * source: "this", + * predicate: "todo://state", + * target: "todo://doing" + * }, + * { + * action: "removeLink", + * source: "this", + * predicate: "todo://state", + * target: "todo://ready" + * } + * ], "expression://123"); + * + * // Set a property using a parameter + * await perspective.executeAction([ + * { + * action: "setSingleTarget", + * source: "this", + * predicate: "todo://title", + * target: "value" + * } + * ], "expression://123", [ + * { name: "title", value: "New Title" } + * ]); + * ``` + * + * @param actions - Array of action objects to execute + * @param expression - Target expression address (replaces "this" in actions) + * @param parameters - Optional parameters that replace "value" in actions + * @param batchId - Optional batch ID to group this operation with others + */ + async executeAction( + actions, + expression, + parameters: Parameter[], + batchId?: string, + ) { + return await this._client.executeCommands( + this._handle.uuid, + JSON.stringify(actions), + expression, + JSON.stringify(parameters), + batchId, + ); + } + + /** + * Retrieves links from the perspective that match the given query. + * + * @param query - Query parameters to filter links + * @returns Array of matching LinkExpressions + * + * @example + * ```typescript + * // Get all links where Alice knows someone + * const links = await perspective.get({ + * source: "did:key:alice", + * predicate: "knows" + * }); + * + * // Get all comments on a post + * const comments = await perspective.get({ + * source: "post://123", + * predicate: "comment" + * }); + * ``` + */ + async get(query: LinkQuery): Promise { + return await this._client.queryLinks(this._handle.uuid, query); + } + + /** + * Executes a Prolog query against the perspective's knowledge base. + * This is a powerful way to find complex patterns in the graph. + * + * @param query - Prolog query string + * @returns Query results or false if no results + * + * @example + * ```typescript + * // Find friends of friends + * const results = await perspective.infer(` + * triple(A, "knows", B), + * triple(B, "knows", C), + * A \= C + * `); + * + * // Find all active todos + * const todos = await perspective.infer(` + * instance(Todo, "Todo"), + * property_getter("Todo", Todo, "state", "active") + * `); + * ``` + */ + async infer(query: string): Promise { + return await this._client.queryProlog(this._handle.uuid, query); + } + + /** + * Executes a SurrealQL query against the perspective's link cache. + * This allows powerful SQL-like queries on the link data stored in SurrealDB. + * + * **Security Note:** Only read-only queries (SELECT, RETURN, etc.) are permitted. + * Mutating operations (DELETE, UPDATE, INSERT, CREATE, DROP, DEFINE, etc.) are + * blocked for security reasons. Use the perspective's add/remove methods to modify links. + * + * @param query - SurrealQL query string (read-only operations only) + * @returns Query results as parsed JSON + * + * @example + * ```typescript + * // Get all links + * const links = await perspective.querySurrealDB('SELECT * FROM link'); + * + * // Filter links by predicate + * const follows = await perspective.querySurrealDB( + * "SELECT * FROM link WHERE predicate = 'follows'" + * ); + * + * // Complex aggregation query + * const stats = await perspective.querySurrealDB( + * "SELECT predicate, count() as total FROM link GROUP BY predicate" + * ); + * ``` + */ + async querySurrealDB(query: string): Promise { + return await this._client.querySurrealDB(this._handle.uuid, query); + } + + /** + * Adds a new link to the perspective. + * + * @param link - The link to add + * @param status - Whether the link should be shared in a Neighbourhood + * @param batchId - Optional batch ID to group this operation with others + * @returns The created LinkExpression + * + * @example + * ```typescript + * // Add a simple relationship + * await perspective.add({ + * source: "did:key:alice", + * predicate: "follows", + * target: "did:key:bob" + * }); + * + * // Add a local-only link + * await perspective.add({ + * source: "note://123", + * predicate: "tag", + * target: "private" + * }, "local"); + * ``` + */ + async add( + link: Link, + status: LinkStatus = "shared", + batchId?: string, + ): Promise { + return await this._client.addLink(this._handle.uuid, link, status, batchId); + } + + /** + * Adds multiple links to the perspective in a single operation. + * More efficient than adding links one by one. + * + * @param links - Array of links to add + * @param status - Whether the links should be shared + * @param batchId - Optional batch ID to group this operation with others + * @returns Array of created LinkExpressions + */ + async addLinks( + links: Link[], + status: LinkStatus = "shared", + batchId?: string, + ): Promise { + return await this._client.addLinks( + this._handle.uuid, + links, + status, + batchId, + ); + } + + /** + * Removes multiple links from the perspective. + * + * @param links - Array of links to remove + * @param batchId - Optional batch ID to group this operation with others + * @returns Array of removed LinkExpressions + */ + async removeLinks( + links: LinkExpressionInput[], + batchId?: string, + ): Promise { + return await this._client.removeLinks(this._handle.uuid, links, batchId); + } + + /** + * Applies a set of link mutations (adds and removes) in a single operation. + * Useful for atomic updates to the perspective. + * + * @param mutations - Object containing links to add and remove + * @param status - Whether new links should be shared + * @returns Object containing results of the mutations + */ + async linkMutations( + mutations: LinkMutations, + status: LinkStatus = "shared", + ): Promise { + return await this._client.linkMutations( + this._handle.uuid, + mutations, + status, + ); + } + + /** + * Adds a pre-signed LinkExpression to the perspective. + * + * @param link - The signed LinkExpression to add + * @param status - Whether the link should be shared + * @param batchId - Optional batch ID to group this operation with others + * @returns The added LinkExpression + */ + async addLinkExpression( + link: LinkExpression, + status: LinkStatus = "shared", + batchId?: string, + ): Promise { + return await this._client.addLinkExpression( + this._handle.uuid, + link, + status, + batchId, + ); + } + + /** + * Updates an existing link with new data. + * + * @param oldLink - The existing link to update + * @param newLink - The new link data + * @param batchId - Optional batch ID to group this operation with others + */ + async update( + oldLink: LinkExpressionInput, + newLink: Link, + batchId?: string, + ): Promise { + return await this._client.updateLink( + this._handle.uuid, + oldLink, + newLink, + batchId, + ); + } + + /** + * Removes a link from the perspective. + * + * @param link - The link to remove + * @param batchId - Optional batch ID to group this operation with others + */ + async remove(link: LinkExpressionInput, batchId?: string): Promise { + return await this._client.removeLink(this._handle.uuid, link, batchId); + } + + /** Creates a new batch for grouping operations */ + async createBatch(): Promise { + return await this._client.createBatch(this._handle.uuid); + } + + /** Commits a batch of operations */ + async commitBatch(batchId: string): Promise { + return await this._client.commitBatch(this._handle.uuid, batchId); + } + /** + * Retrieves and renders an Expression referenced in this perspective. + * + * @param expressionURI - URI of the Expression to retrieve + * @returns The rendered Expression + */ + async getExpression(expressionURI: string): Promise { + return await this._client.getExpression(expressionURI); + } + + /** + * Creates a new Expression in the specified Language. + * + * @param content - Content for the new Expression + * @param languageAddress - Address of the Language to use + * @returns URI of the created Expression + */ + async createExpression( + content: any, + languageAddress: string, + ): Promise { + return await this._client.createExpression(content, languageAddress); + } + + /** + * Subscribes to link changes in the perspective. + * + * @param type - Type of change to listen for + * @param cb - Callback function + * + * @example + * ```typescript + * // Listen for new links + * perspective.addListener("link-added", (link) => { + * console.log("New link:", link); + * }); + * + * // Listen for removed links + * perspective.addListener("link-removed", (link) => { + * console.log("Link removed:", link); + * }); + * ``` + */ + async addListener(type: PerspectiveListenerTypes, cb: LinkCallback) { + if (type === "link-added") { + this._perspectiveLinkAddedCallbacks.push(cb); + } else if (type === "link-removed") { + this._perspectiveLinkRemovedCallbacks.push(cb); + } else if (type === "link-updated") { + this._perspectiveLinkUpdatedCallbacks.push(cb); + } + } + + /** + * Subscribes to sync state changes if this perspective is shared. + * + * @param cb - Callback function + * + * @example + * ```typescript + * perspective.addSyncStateChangeListener((state) => { + * console.log("Sync state:", state); + * }); + * ``` + */ + async addSyncStateChangeListener(cb: SyncStateChangeCallback) { + this._perspectiveSyncStateChangeCallbacks.push(cb); + } + + /** + * Unsubscribes from link changes. + * + * @param type - Type of change to stop listening for + * @param cb - The callback function to remove + */ + async removeListener(type: PerspectiveListenerTypes, cb: LinkCallback) { + if (type === "link-added") { + const index = this._perspectiveLinkAddedCallbacks.indexOf(cb); + + this._perspectiveLinkAddedCallbacks.splice(index, 1); + } else if (type === "link-removed") { + const index = this._perspectiveLinkRemovedCallbacks.indexOf(cb); + + this._perspectiveLinkRemovedCallbacks.splice(index, 1); + } else if (type === "link-updated") { + const index = this._perspectiveLinkUpdatedCallbacks.indexOf(cb); + + this._perspectiveLinkUpdatedCallbacks.splice(index, 1); + } + } + + /** + * Creates a snapshot of the current perspective state. + * Useful for backup or sharing. + * + * @returns Perspective object containing all links + */ + async snapshot(): Promise { + return this._client.snapshotByUUID(this._handle.uuid); + } + + /** + * Loads a perspective snapshot, replacing current content. + * + * @param snapshot - Perspective snapshot to load + */ + async loadSnapshot(snapshot: Perspective) { + //Clean the input data from __typename + const cleanedSnapshot = JSON.parse(JSON.stringify(snapshot)); + delete cleanedSnapshot.__typename; + cleanedSnapshot.links.forEach((link) => { + delete link.data.__typename; + }); + + for (const link of cleanedSnapshot.links) { + await this.addLinkExpression(link); + } + } + + /** + * Gets a single target value matching a query. + * Useful when you expect only one result. + * + * @param query - Query to find the target + * @returns Target value or void if not found + * + * @example + * ```typescript + * // Get a user's name + * const name = await perspective.getSingleTarget({ + * source: "did:key:alice", + * predicate: "name" + * }); + * ``` + */ + async getSingleTarget(query: LinkQuery): Promise { + delete query.target; + const foundLinks = await this.get(query); + if (foundLinks.length) return foundLinks[0].data.target; + else return null; + } + + /** + * Sets a single target value, removing any existing targets. + * + * @param link - Link defining the new target + * @param status - Whether the link should be shared + * + * @example + * ```typescript + * // Set a user's status + * await perspective.setSingleTarget({ + * source: "did:key:alice", + * predicate: "status", + * target: "online" + * }); + * ``` + */ + async setSingleTarget(link: Link, status: LinkStatus = "shared") { + const query = new LinkQuery({ + source: link.source, + predicate: link.predicate, + }); + const foundLinks = await this.get(query); + const removals = []; + for (const l of foundLinks) { + delete l.__typename; + delete l.data.__typename; + delete l.proof.__typename; + removals.push(l); + } + const additions = [link]; + + await this.linkMutations({ additions, removals }, status); + } + + /** Returns all the Social DNA flows defined in this perspective */ + async sdnaFlows(): Promise { + // Query for all flow registration links + const flowLinks = await this.get( + new LinkQuery({ + source: "ad4m://self", + predicate: "ad4m://has_flow", + }), + ); + return flowLinks.map((l) => { + try { + return Literal.fromUrl(l.data.target).get() as string; + } catch { + return l.data.target; + } + }); + } + + /** Returns all Social DNA flows that can be started from the given expression */ + async availableFlows(exprAddr: string): Promise { + const allFlowNames = await this.sdnaFlows(); + const available: string[] = []; + for (const name of allFlowNames) { + const flow = await this.getFlow(name); + if (!flow) continue; + if (flow.flowable === "any") { + available.push(name); + } else { + // Check if the expression matches the flowable link pattern + const pattern = flow.flowable as LinkPattern; + const source = pattern.source || exprAddr; + const links = await this.get( + new LinkQuery({ + source, + predicate: pattern.predicate, + target: pattern.target, + }), + ); + if (links.length > 0) { + available.push(name); } + } + } + return available; + } + + /** Starts the Social DNA flow @param flowName on the expression @param exprAddr */ + async startFlow(flowName: string, exprAddr: string) { + const flow = await this.getFlow(flowName); + if (!flow) throw `Flow "${flowName}" not found`; + if (flow.startAction.length === 0) + throw `Flow "${flowName}" has no start action`; + await this.executeAction(flow.startAction, exprAddr, undefined); + } + + /** Returns all expressions in the given state of given Social DNA flow */ + async expressionsInFlowState( + flowName: string, + flowState: number, + ): Promise { + const flow = await this.getFlow(flowName); + if (!flow) return []; + // Find the state with the matching value + const state = flow.states.find((s) => s.value === flowState); + if (!state) return []; + // Query for expressions matching this state's check pattern + const pattern = state.stateCheck; + const links = await this.get( + new LinkQuery({ + predicate: pattern.predicate, + target: pattern.target, + }), + ); + // Return the sources (expression addresses) - use source if pattern has no explicit source + return links.map((l) => (pattern.source ? l.data.target : l.data.source)); + } + + /** Returns the given expression's flow state with regard to given Social DNA flow */ + async flowState(flowName: string, exprAddr: string): Promise { + const flow = await this.getFlow(flowName); + if (!flow) throw `Flow "${flowName}" not found`; + // Check each state to find which one the expression is in + for (const state of flow.states) { + const pattern = state.stateCheck; + const source = pattern.source || exprAddr; + const links = await this.get( + new LinkQuery({ + source, + predicate: pattern.predicate, + target: pattern.target, + }), + ); + if (links.length > 0) return state.value; + } + throw `Expression "${exprAddr}" is not in any state of flow "${flowName}"`; + } + + /** Returns available action names, with regard to Social DNA flow and expression's flow state */ + async flowActions(flowName: string, exprAddr: string): Promise { + const flow = await this.getFlow(flowName); + if (!flow) return []; + // Determine current state + let currentStateName: string | null = null; + for (const state of flow.states) { + const pattern = state.stateCheck; + const source = pattern.source || exprAddr; + const links = await this.get( + new LinkQuery({ + source, + predicate: pattern.predicate, + target: pattern.target, + }), + ); + if (links.length > 0) { + currentStateName = state.name; + break; + } + } + if (!currentStateName) return []; + // Return transitions available from current state + return flow.transitions + .filter((t) => t.fromState === currentStateName) + .map((t) => t.actionName); + } + + /** Runs given Social DNA flow action */ + async runFlowAction(flowName: string, exprAddr: string, actionName: string) { + const flow = await this.getFlow(flowName); + if (!flow) throw `Flow "${flowName}" not found`; + const transition = flow.transitions.find( + (t) => t.actionName === actionName, + ); + if (!transition) + throw `Action "${actionName}" not found in flow "${flowName}"`; + await this.executeAction(transition.actions, exprAddr, undefined); + } + + /** Returns the perspective's Social DNA code + * This will return all SDNA code elements in an array. + */ + async getSdna(): Promise { + // First, find all the name literals that are linked from ad4m://self with SDNA predicates + const sdnaPredicates = [ + "ad4m://has_subject_class", + "ad4m://has_flow", + "ad4m://has_custom_sdna", + ]; + + const allSdnaCode: string[] = []; + + for (const predicate of sdnaPredicates) { + // Find name literals linked from ad4m://self with this predicate + const nameLinks = await this.get( + new LinkQuery({ + source: "ad4m://self", + predicate: predicate, + }), + ); + + // For each name literal found, get the actual SDNA code + for (const nameLink of nameLinks) { + const nameLiteral = nameLink.data.target; - await this.executeAction(actions, exprAddr, undefined, batchId) - } - - /** Checks if the given expression is a subject instance of the given subject class - * @param expression The expression to be checked - * @param subjectClass Either a string with the name of the subject class, or an object - * with the properties of the subject class. In the latter case, the first subject class - * that matches the given properties will be used. - */ - async isSubjectInstance(expression: string, subjectClass: T): Promise { - let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) + // Now find the SDNA code linked from this name with predicate "ad4m://sdna" + const sdnaLinks = await this.get( + new LinkQuery({ + source: nameLiteral, + predicate: "ad4m://sdna", + }), + ); - // Get metadata from SHACL links - const metadata = await this.getSubjectClassMetadataFromSDNA(className); - if (!metadata) { - console.warn(`isSubjectInstance: No SHACL metadata found for class ${className}`); - return false; + // Extract the SDNA code from each link + for (const sdnaLink of sdnaLinks) { + const code = Literal.fromUrl(sdnaLink.data.target).get(); + if (typeof code === "string") { + allSdnaCode.push(code); + } } - - // If no required triples, any expression with links is an instance - if (metadata.requiredTriples.length === 0) { - const escapedExpression = escapeSurrealString(expression); - const checkQuery = `SELECT count() AS count FROM link WHERE in.uri = '${escapedExpression}'`; - const result = await this.querySurrealDB(checkQuery); - const count = result[0]?.count ?? 0; - const countValue = typeof count === 'object' && count?.Int !== undefined ? count.Int : count; - return countValue > 0; + } + } + + return allSdnaCode; + } + + /** Returns the Social DNA code for a specific class + * This will return the SDNA code for the specified class, or null if not found. + */ + async getSdnaForClass(className: string): Promise { + // First, find the name literal for this class that is linked from ad4m://self + const nameLiteral = Literal.from(className); + + const links = await this.get( + new LinkQuery({ + source: "ad4m://self", + target: nameLiteral.toUrl(), + predicate: "ad4m://has_subject_class", + }), + ); + + if (links.length === 0) { + return null; + } + + // Now find the SDNA code linked from this name with predicate "ad4m://sdna" + const sdnaLinks = await this.get( + new LinkQuery({ + source: nameLiteral.toUrl(), + predicate: "ad4m://sdna", + }), + ); + + if (sdnaLinks.length === 0) { + return null; + } + + // Extract the SDNA code from the first link + const code = Literal.fromUrl(sdnaLinks[0].data.target).get(); + return typeof code === "string" ? code : null; + } + + /** + * Adds Social DNA code to the perspective. + * + * **Recommended:** Use {@link addShacl} instead, which accepts the `SHACLShape` type directly. + * This method is primarily for the GraphQL layer and legacy Prolog code. + * + * @param name - Unique name for this SDNA definition + * @param sdnaCode - Prolog SDNA code (legacy, can be empty string if shaclJson provided) + * @param sdnaType - Type of SDNA: "subject_class", "flow", or "custom" + * @param shaclJson - SHACL JSON string (use addShacl() for type-safe alternative) + * + * @example + * // Recommended: Use addShacl() with SHACLShape type + * const shape = new SHACLShape('recipe://Recipe'); + * shape.addProperty({ name: 'title', path: 'recipe://title', datatype: 'xsd:string' }); + * await perspective.addShacl('Recipe', shape); + * + * // Legacy: Prolog code is auto-converted to SHACL + * await perspective.addSdna('Recipe', prologCode, 'subject_class'); + */ + async addSdna( + name: string, + sdnaCode: string, + sdnaType: "subject_class" | "flow" | "custom", + shaclJson?: string, + ) { + return this._client.addSdna( + this._handle.uuid, + name, + sdnaCode, + sdnaType, + shaclJson, + ); + } + + /** + * **Recommended way to add SDNA schemas.** + * + * Store a SHACL shape in this Perspective using the type-safe `SHACLShape` class. + * The shape is serialized as RDF triples (links) for native AD4M storage and querying. + * + * @param name - Unique name for this schema (e.g., 'Recipe', 'Task') + * @param shape - SHACLShape instance defining the schema + * + * @example + * import { SHACLShape } from '@coasys/ad4m'; + * + * const shape = new SHACLShape('recipe://Recipe'); + * shape.addProperty({ + * name: 'title', + * path: 'recipe://title', + * datatype: 'xsd:string', + * minCount: 1 + * }); + * shape.addProperty({ + * name: 'ingredients', + * path: 'recipe://has_ingredient', + * // No maxCount = collection + * }); + * + * await perspective.addShacl('Recipe', shape); + */ + async addShacl( + name: string, + shape: import("../shacl/SHACLShape").SHACLShape, + ): Promise { + // Serialize shape to links + const links = shape.toLinks(); + + // Add all links to perspective + for (const link of links) { + await this.add({ + source: link.source, + predicate: link.predicate, + target: link.target, + }); + } + + // Create a name -> shape mapping link for easy retrieval + const nameMapping = Literal.fromUrl(`literal://string:shacl://${name}`); + await this.add({ + source: "ad4m://self", + predicate: "ad4m://has_shacl", + target: nameMapping.toUrl(), + }); + + await this.add({ + source: nameMapping.toUrl(), + predicate: "ad4m://shacl_shape_uri", + target: shape.nodeShapeUri, + }); + } + + /** + * Retrieve a SHACL shape by name from this Perspective + */ + async getShacl( + name: string, + ): Promise { + // Find the shape URI from the name mapping + const nameMapping = Literal.fromUrl(`literal://string:shacl://${name}`); + const shapeUriLinks = await this.get( + new LinkQuery({ + source: nameMapping.toUrl(), + predicate: "ad4m://shacl_shape_uri", + }), + ); + + if (shapeUriLinks.length === 0) { + return null; + } + + const shapeUri = shapeUriLinks[0].data.target; + + // Get all links that are part of this shape + // This includes the shape itself and all its property shapes + const shapeLinks: any[] = []; + + // Get shape type and target class + const shapeTypeLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "rdf://type", + }), + ); + shapeLinks.push(...shapeTypeLinks.map((l) => l.data)); + + const targetClassLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "sh://targetClass", + }), + ); + shapeLinks.push(...targetClassLinks.map((l) => l.data)); + + // Get constructor actions + const constructorLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "ad4m://constructor", + }), + ); + shapeLinks.push(...constructorLinks.map((l) => l.data)); + + // Get destructor actions + const destructorLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "ad4m://destructor", + }), + ); + shapeLinks.push(...destructorLinks.map((l) => l.data)); + + // Get property shapes + const propertyLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "sh://property", + }), + ); + + for (const propLink of propertyLinks) { + shapeLinks.push(propLink.data); + + // Get all links for this property shape (named URI or blank node) + const propShapeId = propLink.data.target; + + // Query targeted predicates for this property shape to avoid loading all links + const expectedPredicates = [ + "sh://path", + "sh://datatype", + "sh://nodeKind", + "sh://minCount", + "sh://maxCount", + "ad4m://local", + "ad4m://readOnly", + "ad4m://resolveLanguage", + "ad4m://setter", + "ad4m://adder", + "ad4m://remover", + "rdf://type", // For CollectionShape detection + ]; + + for (const predicate of expectedPredicates) { + const links = await this.get( + new LinkQuery({ + source: propShapeId, + predicate, + }), + ); + shapeLinks.push(...links.map((l) => l.data)); + } + } + + // Reconstruct shape from links + const { SHACLShape } = await import("../shacl/SHACLShape"); + return SHACLShape.fromLinks(shapeLinks, shapeUri); + } + + /** + * Get all SHACL shapes stored in this Perspective + */ + async getAllShacl(): Promise< + Array<{ name: string; shape: import("../shacl/SHACLShape").SHACLShape }> + > { + const nameLinks = await this.get( + new LinkQuery({ + source: "ad4m://self", + predicate: "ad4m://has_shacl", + }), + ); + + const shapes = []; + for (const nameLink of nameLinks) { + const nameUrl = nameLink.data.target; + const name = Literal.fromUrl(nameUrl).get() as string; + const shapeName = name.replace("shacl://", ""); + + const shape = await this.getShacl(shapeName); + if (shape) { + shapes.push({ name: shapeName, shape }); + } + } + + return shapes; + } + + /** + * **Recommended way to add Flow definitions.** + * + * Store a SHACL Flow (state machine) in this Perspective using the type-safe `SHACLFlow` class. + * The flow is serialized as RDF triples (links) for native AD4M storage and querying. + * + * @param name - Flow name (e.g., 'TODO', 'Approval') + * @param flow - SHACLFlow instance defining the state machine + * + * @example + * ```typescript + * import { SHACLFlow } from '@coasys/ad4m'; + * + * const todoFlow = new SHACLFlow('TODO', 'todo://'); + * todoFlow.flowable = 'any'; + * + * // Define states + * todoFlow.addState({ name: 'ready', value: 0, stateCheck: { predicate: 'todo://state', target: 'todo://ready' }}); + * todoFlow.addState({ name: 'done', value: 1, stateCheck: { predicate: 'todo://state', target: 'todo://done' }}); + * + * // Define start action + * todoFlow.startAction = [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' }]; + * + * // Define transitions + * todoFlow.addTransition({ + * actionName: 'Complete', + * fromState: 'ready', + * toState: 'done', + * actions: [ + * { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, + * { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + * ] + * }); + * + * await perspective.addFlow('TODO', todoFlow); + * ``` + */ + async addFlow( + name: string, + flow: import("../shacl/SHACLFlow").SHACLFlow, + ): Promise { + // Serialize flow to links + const links = flow.toLinks(); + + // Add all links to perspective + for (const link of links) { + await this.add({ + source: link.source, + predicate: link.predicate, + target: link.target, + }); + } + + // Create registration link matching ad4m://has_flow pattern + const flowNameLiteral = Literal.from(name).toUrl(); + await this.add({ + source: "ad4m://self", + predicate: "ad4m://has_flow", + target: flowNameLiteral, + }); + + // Create mapping from name to flow URI + await this.add({ + source: flowNameLiteral, + predicate: "ad4m://flow_uri", + target: flow.flowUri, + }); + } + + /** + * Retrieve a Flow definition by name from this Perspective + * + * @param name - Flow name to retrieve + * @returns The SHACLFlow or null if not found + */ + async getFlow( + name: string, + ): Promise { + const flowNameLiteral = Literal.from(name).toUrl(); + + // Find flow URI from name mapping + const flowUriLinks = await this.get( + new LinkQuery({ + source: flowNameLiteral, + predicate: "ad4m://flow_uri", + }), + ); + + if (flowUriLinks.length === 0) { + return null; + } + + const flowUri = flowUriLinks[0].data.target; + + // Get all links related to this flow + // flowUri format: {namespace}{Name}Flow + // State/transition URIs format: {namespace}{Name}.{stateName} + // Compute alternate prefix by only replacing trailing "Flow" suffix + const alternatePrefix = flowUri.endsWith("Flow") + ? flowUri.slice(0, -4) + "." // Remove trailing 'Flow', add '.' + : flowUri + "."; + + // Query flow-related predicates to avoid fetching all links + const flowPredicates = [ + "rdf://type", + "ad4m://flowName", + "ad4m://flowable", + "ad4m://startAction", + "ad4m://hasState", + "ad4m://hasTransition", + "ad4m://stateName", + "ad4m://stateValue", + "ad4m://stateCheck", + "ad4m://actionName", + "ad4m://fromState", + "ad4m://toState", + "ad4m://transitionActions", + ]; + + const allLinks: any[] = []; + for (const predicate of flowPredicates) { + const links = await this.get(new LinkQuery({ predicate })); + allLinks.push(...links); + } + + const flowLinks = allLinks + .map((l) => l.data) + .filter( + (l) => l.source === flowUri || l.source.startsWith(alternatePrefix), + ); + + // Reconstruct flow from links + const { SHACLFlow } = await import("../shacl/SHACLFlow"); + return SHACLFlow.fromLinks(flowLinks, flowUri); + } + + /** Returns all the Subject classes defined in this perspectives SDNA + * + * Uses SHACL-based lookup (Prolog-free implementation). + */ + async subjectClasses(): Promise { + try { + // Query SHACL class links directly — no need for a separate GraphQL endpoint + const classLinks = await this.get( + new LinkQuery({ + predicate: "rdf://type", + target: "ad4m://SubjectClass", + }), + ); + const classNames = classLinks + .map((l) => { + const source = l.data.source; + // Extract class name from URI like "recipe://RecipeShape" -> "Recipe" + const sepIdx = source.indexOf("://"); + if (sepIdx < 0) return ""; + const afterScheme = source.substring(sepIdx + 3); + const lastPart = afterScheme.split("/").pop() || ""; + // Strip trailing "Shape" suffix if present + return lastPart.endsWith("Shape") ? lastPart.slice(0, -5) : lastPart; + }) + .filter((name) => name.length > 0); + return [...new Set(classNames)]; + } catch (e) { + console.warn("subjectClasses: SHACL lookup failed:", e); + return []; + } + } + + async stringOrTemplateObjectToSubjectClassName( + subjectClass: T, + ): Promise { + if (typeof subjectClass === "string") return subjectClass; + else { + let subjectClasses = await this.subjectClassesByTemplate( + subjectClass as object, + ); + if (subjectClasses[0]) { + return subjectClasses[0]; + } else { + //@ts-ignore + return subjectClass.className; + } + } + } + + /** + * Creates a new subject instance of the given subject class + * + * @param subjectClass Either a string with the name of the subject class, or an object + * with the properties of the subject class. + * @param exprAddr The address of the expression to be turned into a subject instance + * @param initialValues Optional initial values for properties. If provided, these will be + * merged with constructor actions for better performance. + * @param batchId Optional batch ID for grouping operations. If provided, returns the expression address + * instead of the subject proxy since the subject won't exist until the batch is committed. + * @returns A proxy object for the created subject, or just the expression address if in batch mode + */ + async createSubject( + subjectClass: T, + exprAddr: string, + initialValues?: Record, + batchId?: B, + ): Promise { + let className: string; + + if (typeof subjectClass === "string") { + className = subjectClass; + await this._client.createSubject( + this._handle.uuid, + JSON.stringify({ + className, + initialValues, + }), + exprAddr, + initialValues ? JSON.stringify(initialValues) : undefined, + batchId, + ); + } else { + const o = subjectClass as any; + const className = + o.className || + o.constructor?.className || + o.constructor?.prototype?.className; + if (!className) { + throw new Error( + `createSubject: could not resolve className from subject class object. Ensure the class is decorated with @Model.`, + ); + } + await this._client.createSubject( + this._handle.uuid, + JSON.stringify({ + className, + initialValues, + }), + exprAddr, + initialValues ? JSON.stringify(initialValues) : undefined, + batchId, + ); + } + + // Skip subject proxy creation when in batch mode since the subject won't exist until batch is committed + if (batchId) { + return exprAddr as B extends undefined ? T : string; + } + + return this.getSubjectProxy(exprAddr, subjectClass) as Promise< + B extends undefined ? T : string + >; + } + + async getSubjectData(subjectClass: T, exprAddr: string): Promise { + if (typeof subjectClass === "string") { + return JSON.parse( + await this._client.getSubjectData( + this._handle.uuid, + JSON.stringify({ className: subjectClass }), + exprAddr, + ), + ); + } + const o = subjectClass as any; + const className = + o.className || + o.constructor?.className || + o.constructor?.prototype?.className; + if (!className) { + throw new Error( + `getSubjectData: could not resolve className from subject class object. Ensure the class is decorated with @Model.`, + ); + } + return JSON.parse( + await this._client.getSubjectData( + this._handle.uuid, + JSON.stringify({ className }), + exprAddr, + ), + ); + } + + /** + * Gets actions from SHACL links for a given predicate (e.g., ad4m://constructor, ad4m://destructor). + * Returns the parsed action array if found, or null if not found. + */ + private async getActionsFromSHACL( + className: string, + predicate: string, + ): Promise { + // Use regex to match exact class name followed by "Shape" at end of URI + // This prevents "RecipeShape" from matching "MyRecipeShape" + const escaped = this.escapeRegExp(className); + const shapePattern = new RegExp(`[/:#]${escaped}Shape$`); + const links = await this.get(new LinkQuery({ predicate })); + + for (const link of links) { + if (shapePattern.test(link.data.source)) { + // Parse actions from literal://string:{json} + const prefix = "literal://string:"; + if (link.data.target.startsWith(prefix)) { + const jsonStr = link.data.target.slice(prefix.length); + // Decode URL-encoded JSON if needed, with fallback for raw % characters + let decoded = jsonStr; + try { + decoded = decodeURIComponent(jsonStr); + } catch {} + try { + return JSON.parse(decoded); + } catch (e) { + console.warn( + `Failed to parse SHACL actions JSON for ${className}:`, + e, + ); + return null; + } } + } + } + + return null; + } + + /** Removes a subject instance by running its (SDNA defined) destructor, + * which means removing links around the given expression address + * + * @param subjectClass Either a string with the name of the subject class, or an object + * with the properties of the subject class. In the latter case, the first subject class + * that matches the given properties will be used. + * @param exprAddr The address of the expression to be turned into a subject instance + * @param batchId Optional batch ID for grouping operations. If provided, the removal will be part of the batch + * and won't be executed until the batch is committed. + */ + async removeSubject(subjectClass: T, exprAddr: string, batchId?: string) { + let className = + await this.stringOrTemplateObjectToSubjectClassName(subjectClass); + + // Get destructor actions from SHACL links (Prolog-free) + let actions = await this.getActionsFromSHACL( + className, + "ad4m://destructor", + ); + + if (!actions) { + throw `No destructor found for subject class: ${className}. Make sure the class was registered with SHACL.`; + } + + await this.executeAction(actions, exprAddr, undefined, batchId); + } + + /** Checks if the given expression is a subject instance of the given subject class + * @param expression The expression to be checked + * @param subjectClass Either a string with the name of the subject class, or an object + * with the properties of the subject class. In the latter case, the first subject class + * that matches the given properties will be used. + */ + async isSubjectInstance( + expression: string, + subjectClass: T, + ): Promise { + let className = + await this.stringOrTemplateObjectToSubjectClassName(subjectClass); + + // Get metadata from SHACL links + const metadata = await this.getSubjectClassMetadataFromSDNA(className); + if (!metadata) { + console.warn( + `isSubjectInstance: No SHACL metadata found for class ${className}`, + ); + return false; + } + + // If no required triples, any expression with links is an instance + if (metadata.requiredTriples.length === 0) { + const escapedExpression = escapeSurrealString(expression); + const checkQuery = `SELECT count() AS count FROM link WHERE in.uri = '${escapedExpression}'`; + const result = await this.querySurrealDB(checkQuery); + const count = result[0]?.count ?? 0; + const countValue = + typeof count === "object" && count?.Int !== undefined + ? count.Int + : count; + return countValue > 0; + } + + // Check if the expression has all required triples (predicate + optional exact target) + for (const triple of metadata.requiredTriples) { + const escapedExpression = escapeSurrealString(expression); + const escapedPredicate = escapeSurrealString(triple.predicate); + let checkQuery: string; + if (triple.target) { + // Flag: must match both predicate AND exact target value + const escapedTarget = escapeSurrealString(triple.target); + checkQuery = `SELECT count() AS count FROM link WHERE in.uri = '${escapedExpression}' AND predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}'`; + } else { + // Property: just check predicate exists + checkQuery = `SELECT count() AS count FROM link WHERE in.uri = '${escapedExpression}' AND predicate = '${escapedPredicate}'`; + } + const result = await this.querySurrealDB(checkQuery); + + if (!result || result.length === 0) { + return false; + } + + const count = result[0]?.count ?? 0; + // Handle potential object response like {Int: 0} + const countValue = + typeof count === "object" && count?.Int !== undefined + ? count.Int + : count; + + if (countValue === 0) { + return false; + } + } + + return true; + } + + /** For an existing subject instance (existing in the perspective's links) + * this function returns a proxy object that can be used to access the subject's + * properties and methods. + * + * @param base URI of the subject's root expression + * @param subjectClass Either a string with the name of the subject class, or an object + * with the properties of the subject class. In the latter case, the first subject class + * that matches the given properties will be used. + */ + async getSubjectProxy(base: string, subjectClass: T): Promise { + if (!(await this.isSubjectInstance(base, subjectClass))) { + throw `Expression ${base} is not a subject instance of given class: ${JSON.stringify(subjectClass)}`; + } + let className = + await this.stringOrTemplateObjectToSubjectClassName(subjectClass); + let subject = new Subject(this, base, className); + await subject.init(); + return subject as unknown as T; + } + + /** + * Extracts subject class metadata from SDNA by parsing the Prolog text. + * Parses the instance rule to extract required predicates. + * Returns required predicates that define what makes something an instance, + * plus a map of property/collection names to their predicates. + */ + /** + * Gets subject class metadata from SHACL links (Prolog-free implementation). + * Uses the link API directly instead of SurrealDB queries. + */ + async getSubjectClassMetadataFromSDNA(className: string): Promise<{ + requiredPredicates: string[]; + requiredTriples: Array<{ predicate: string; target?: string }>; + properties: Map; + collections: Map< + string, + { predicate: string; instanceFilter?: string; condition?: string } + >; + } | null> { + try { + // Resolve the exact SHACL shape URI from the name mapping to avoid overlapping class name issues + const nameMapping = Literal.fromUrl( + `literal://string:shacl://${className}`, + ); + const shapeUriLinks = await this.get( + new LinkQuery({ + source: nameMapping.toUrl(), + predicate: "ad4m://shacl_shape_uri", + }), + ); + + if (shapeUriLinks.length === 0) { + console.warn(`No SHACL metadata found for ${className}`); + return null; + } - // Check if the expression has all required triples (predicate + optional exact target) - for (const triple of metadata.requiredTriples) { - const escapedExpression = escapeSurrealString(expression); - const escapedPredicate = escapeSurrealString(triple.predicate); - let checkQuery: string; - if (triple.target) { - // Flag: must match both predicate AND exact target value - const escapedTarget = escapeSurrealString(triple.target); - checkQuery = `SELECT count() AS count FROM link WHERE in.uri = '${escapedExpression}' AND predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}'`; - } else { - // Property: just check predicate exists - checkQuery = `SELECT count() AS count FROM link WHERE in.uri = '${escapedExpression}' AND predicate = '${escapedPredicate}'`; - } - const result = await this.querySurrealDB(checkQuery); + const shapeUri = shapeUriLinks[0].data.target; - if (!result || result.length === 0) { - return false; - } + // Get the target class URI from the shape + const targetClassLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "sh://targetClass", + }), + ); - const count = result[0]?.count ?? 0; - // Handle potential object response like {Int: 0} - const countValue = typeof count === 'object' && count?.Int !== undefined ? count.Int : count; + if (targetClassLinks.length === 0) { + console.warn(`No target class found for SHACL shape ${shapeUri}`); + return null; + } + + const classUri = targetClassLinks[0].data.target; + + const requiredPredicates: string[] = []; + const requiredTriples: Array<{ predicate: string; target?: string }> = []; + const properties = new Map< + string, + { predicate: string; resolveLanguage?: string } + >(); + const collections = new Map< + string, + { predicate: string; instanceFilter?: string } + >(); + + // Get all property shapes FIRST to know which predicates are from properties vs flags + const propertyPredicates = new Set(); + const readOnlyPredicates = new Set(); // Track which predicates are read-only + const propertyLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "sh://property", + }), + ); + + for (const propLink of propertyLinks) { + const propUri = propLink.data.target; + // Extract property name from URI (e.g., "todo://Todo.title" -> "title") + const propNameMatch = propUri.match(/\.([^.]+)$/); + if (!propNameMatch) continue; + const propName = propNameMatch[1]; + + // Get property details + const propDetailLinks = await this.get( + new LinkQuery({ + source: propUri, + }), + ); - if (countValue === 0) { - return false; - } + let predicate: string | undefined; + let resolveLanguage: string | undefined; + let isCollection = false; + let isReadOnly = false; + + for (const detail of propDetailLinks) { + if (detail.data.predicate === "sh://path") { + predicate = detail.data.target; + } else if (detail.data.predicate === "ad4m://resolveLanguage") { + resolveLanguage = detail.data.target?.replace( + "literal://string:", + "", + ); + } else if ( + detail.data.predicate === "rdf://type" && + detail.data.target === "ad4m://Collection" + ) { + isCollection = true; + } else if ( + detail.data.predicate === "ad4m://readOnly" && + detail.data.target === "literal://true" + ) { + isReadOnly = true; + } } - return true; - } - - - /** For an existing subject instance (existing in the perspective's links) - * this function returns a proxy object that can be used to access the subject's - * properties and methods. - * - * @param base URI of the subject's root expression - * @param subjectClass Either a string with the name of the subject class, or an object - * with the properties of the subject class. In the latter case, the first subject class - * that matches the given properties will be used. - */ - async getSubjectProxy(base: string, subjectClass: T): Promise { - if(!await this.isSubjectInstance(base, subjectClass)) { - throw `Expression ${base} is not a subject instance of given class: ${JSON.stringify(subjectClass)}` + if (predicate) { + propertyPredicates.add(predicate); // Track predicates that come from properties + if (isReadOnly) { + readOnlyPredicates.add(predicate); // Track which are read-only + } + if (isCollection) { + collections.set(propName, { predicate }); + } else { + properties.set(propName, { predicate, resolveLanguage }); + } } - let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) - let subject = new Subject(this, base, className) - await subject.init() - return subject as unknown as T - } - - /** - * Gets subject class metadata from SHACL links using SHACLShape.fromLinks(). - * Retrieves the SHACL shape and extracts metadata for instance queries. - */ - async getSubjectClassMetadataFromSDNA(className: string): Promise<{ - requiredPredicates: string[], - requiredTriples: Array<{predicate: string, target?: string}>, - properties: Map, - collections: Map - } | null> { - try { - // Use getShacl() to retrieve the shape via SHACLShape.fromLinks() - const shape = await this.getShacl(className); - if (!shape) { - console.warn(`No SHACL metadata found for ${className}`); - return null; - } - - const requiredPredicates: string[] = []; - const requiredTriples: Array<{predicate: string, target?: string}> = []; - const properties = new Map(); - const collections = new Map(); - - // Build property/collection maps and track writable predicates from shape properties - const writablePredicates = new Set(); - for (const prop of shape.properties) { - if (!prop.path || !prop.name) continue; - - if (prop.writable) { - writablePredicates.add(prop.path); - } - - const isCollection = prop.adder && prop.adder.length > 0; - if (isCollection) { - collections.set(prop.name, { predicate: prop.path }); + } + + // Get constructor actions from SHACL shape + const constructorLinks = await this.get( + new LinkQuery({ + source: shapeUri, + predicate: "ad4m://constructor", + }), + ); + + if (constructorLinks.length > 0) { + const constructorTarget = constructorLinks[0].data.target; + // Parse constructor actions from literal://string:[{...}] + if ( + constructorTarget && + constructorTarget.startsWith("literal://string:") + ) { + try { + const actionsJson = constructorTarget.substring( + "literal://string:".length, + ); + const actions = JSON.parse(actionsJson); + for (const action of actions) { + if (action.predicate) { + requiredPredicates.push(action.predicate); + // Flags: fixed target value + in propertyPredicates + IS readOnly -> require exact match + // Properties with initial: has target + in propertyPredicates + NOT readOnly -> any value OK + // Other: not in propertyPredicates -> require exact match if has target + const isWritableProperty = !readOnlyPredicates.has( + action.predicate, + ); + if ( + action.target && + action.target !== "value" && + !isWritableProperty + ) { + // Either a flag (readOnly) or not a property at all - require exact target + requiredTriples.push({ + predicate: action.predicate, + target: action.target, + }); } else { - properties.set(prop.name, { - predicate: prop.path, - resolveLanguage: prop.resolveLanguage - }); + // Non-readOnly property with initial value - just require predicate exists + requiredTriples.push({ predicate: action.predicate }); } + } } - - // Extract required predicates/triples from constructor actions - if (shape.constructor_actions) { - for (const action of shape.constructor_actions) { - if (action.predicate) { - requiredPredicates.push(action.predicate); - const isWritableProperty = writablePredicates.has(action.predicate); - if (action.target && action.target !== 'value' && !isWritableProperty) { - requiredTriples.push({ predicate: action.predicate, target: action.target }); - } else { - requiredTriples.push({ predicate: action.predicate }); - } - } - } - } - - return { requiredPredicates, requiredTriples, properties, collections }; - } catch (e) { - console.error(`Error getting SHACL metadata for ${className}:`, e); - return null; + } catch (e) { + console.warn( + `Failed to parse constructor actions for ${className}:`, + e, + ); + } } - } - /** - * Generates a SurrealDB query to find instances based on class metadata. - */ - private generateSurrealInstanceQuery(metadata: { - requiredPredicates: string[], - requiredTriples: Array<{predicate: string, target?: string}>, - properties: Map, - collections: Map - }): string { - if (metadata.requiredTriples.length === 0) { - // No required triples - any node with links is an instance - return `SELECT DISTINCT uri AS base FROM node WHERE count(->link) > 0`; + } + + return { requiredPredicates, requiredTriples, properties, collections }; + } catch (e) { + console.error(`Error getting SHACL metadata for ${className}:`, e); + return null; + } + } + /** + * Generates a SurrealDB query to find instances based on class metadata. + */ + private generateSurrealInstanceQuery(metadata: { + requiredPredicates: string[]; + requiredTriples: Array<{ predicate: string; target?: string }>; + properties: Map; + collections: Map< + string, + { predicate: string; instanceFilter?: string; condition?: string } + >; + }): string { + if (metadata.requiredTriples.length === 0) { + // No required triples - any node with links is an instance + return `SELECT DISTINCT uri AS base FROM node WHERE count(->link) > 0`; + } + + // Generate WHERE conditions for each required triple (predicate + optional exact target) + const whereConditions = metadata.requiredTriples + .map((triple) => { + const escapedPredicate = escapeSurrealString(triple.predicate); + if (triple.target) { + // Flag: must match both predicate AND exact target value + const escapedTarget = escapeSurrealString(triple.target); + return `count(->link[WHERE predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}']) > 0`; + } else { + // Property: just check predicate exists + return `count(->link[WHERE predicate = '${escapedPredicate}']) > 0`; } + }) + .join(" AND "); - // Generate WHERE conditions for each required triple (predicate + optional exact target) - const whereConditions = metadata.requiredTriples.map(triple => { - const escapedPredicate = escapeSurrealString(triple.predicate); - if (triple.target) { - // Flag: must match both predicate AND exact target value - const escapedTarget = escapeSurrealString(triple.target); - return `count(->link[WHERE predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}']) > 0`; - } else { - // Property: just check predicate exists - return `count(->link[WHERE predicate = '${escapedPredicate}']) > 0`; - } - }).join(' AND '); + return `SELECT uri AS base FROM node WHERE ${whereConditions}`; + } - return `SELECT uri AS base FROM node WHERE ${whereConditions}`; + /** + * Gets a property value using SurrealDB when Prolog fails. + * This is used as a fallback in SdnaOnly mode where link data isn't in Prolog. + */ + async getPropertyValueViaSurreal( + baseExpression: string, + className: string, + propertyName: string, + ): Promise { + const metadata = await this.getSubjectClassMetadataFromSDNA(className); + if (!metadata) { + return undefined; } - /** - * Gets a property value using SurrealDB when Prolog fails. - * This is used as a fallback in SdnaOnly mode where link data isn't in Prolog. - */ - async getPropertyValueViaSurreal(baseExpression: string, className: string, propertyName: string): Promise { - const metadata = await this.getSubjectClassMetadataFromSDNA(className); - if (!metadata) { - return undefined; - } - - const propMeta = metadata.properties.get(propertyName); - if (!propMeta) { - return undefined; - } + const propMeta = metadata.properties.get(propertyName); + if (!propMeta) { + return undefined; + } - const escapedBaseExpression = escapeSurrealString(baseExpression); - const escapedPredicate = escapeSurrealString(propMeta.predicate); - const query = `SELECT out.uri AS value FROM link WHERE in.uri = '${escapedBaseExpression}' AND predicate = '${escapedPredicate}' LIMIT 1`; - const result = await this.querySurrealDB(query); + const escapedBaseExpression = escapeSurrealString(baseExpression); + const escapedPredicate = escapeSurrealString(propMeta.predicate); + const query = `SELECT out.uri AS value FROM link WHERE in.uri = '${escapedBaseExpression}' AND predicate = '${escapedPredicate}' LIMIT 1`; + const result = await this.querySurrealDB(query); - if (!result || result.length === 0) { - return undefined; - } + if (!result || result.length === 0) { + return undefined; + } - const value = result[0].value; + const value = result[0].value; - // Handle expression resolution if needed - if (propMeta.resolveLanguage && value) { - try { - const expression = await this.getExpression(value); - try { - return JSON.parse(expression.data); - } catch (e) { - return expression.data; - } - } catch (err) { - return value; - } + // Handle expression resolution if needed + if (propMeta.resolveLanguage && value) { + try { + const expression = await this.getExpression(value); + try { + return JSON.parse(expression.data); + } catch (e) { + return expression.data; } - + } catch (err) { return value; + } } - /** - * Gets collection values using SurrealDB when Prolog fails. - * This is used as a fallback in SdnaOnly mode where link data isn't in Prolog. - * Note: This is used by Subject.ts (legacy pattern). Ad4mModel.ts uses getModelMetadata() instead. - */ - async getCollectionValuesViaSurreal(baseExpression: string, className: string, collectionName: string): Promise { - const metadata = await this.getSubjectClassMetadataFromSDNA(className); - if (!metadata) { - return []; - } - - const collMeta = metadata.collections.get(collectionName); - if (!collMeta) { - return []; - } - - const escapedBaseExpression = escapeSurrealString(baseExpression); - const escapedPredicate = escapeSurrealString(collMeta.predicate); - const query = `SELECT out.uri AS value, timestamp FROM link WHERE in.uri = '${escapedBaseExpression}' AND predicate = '${escapedPredicate}' ORDER BY timestamp ASC`; - const result = await this.querySurrealDB(query); - - if (!result || result.length === 0) { - return []; - } - - let values = result.map(r => r.value).filter(v => v !== "" && v !== ''); - - // Apply condition filtering if present - if (collMeta.condition && values.length > 0) { - try { - const filteredValues: string[] = []; - - for (const value of values) { - let condition = collMeta.condition - .replace(/\$perspective/g, `'${this.uuid}'`) - .replace(/\$base/g, `'${baseExpression}'`) - .replace(/Target/g, `'${value.replace(/'/g, "\\'")}'`); - - // If condition starts with WHERE, wrap in array length check - if (condition.trim().startsWith('WHERE')) { - condition = `array::len(SELECT * FROM link ${condition}) > 0`; - } - - const filterResult = await this.querySurrealDB(`RETURN ${condition}`); - const isTrue = filterResult === true || (Array.isArray(filterResult) && filterResult.length > 0 && filterResult[0] === true); - if (isTrue) { - filteredValues.push(value); - } - } - - values = filteredValues; - } catch (error) { - console.warn(`Failed to apply condition filter for ${collectionName}:`, error); - } - } - - // Apply instance filter if present - batch-check all values at once - if (collMeta.instanceFilter) { - try { - const filterMetadata = await this.getSubjectClassMetadataFromSDNA(collMeta.instanceFilter); - if (!filterMetadata) { - // Fallback to sequential checks if metadata isn't available - return this.filterInstancesSequential(values, collMeta.instanceFilter); - } - - return await this.batchCheckSubjectInstances(values, filterMetadata); - } catch (err) { - // Fallback to sequential checks on error - return this.filterInstancesSequential(values, collMeta.instanceFilter); - } - } + return value; + } - return values; + /** + * Gets collection values using SurrealDB when Prolog fails. + * This is used as a fallback in SdnaOnly mode where link data isn't in Prolog. + * Note: This is used by Subject.ts (legacy pattern). Ad4mModel.ts uses getModelMetadata() instead. + */ + async getCollectionValuesViaSurreal( + baseExpression: string, + className: string, + collectionName: string, + ): Promise { + const metadata = await this.getSubjectClassMetadataFromSDNA(className); + if (!metadata) { + return []; } - /** - * Batch-checks multiple expressions against subject class metadata using a single or limited SurrealDB queries. - * This avoids N+1 query problems by checking all values at once. - */ - async batchCheckSubjectInstances( - expressions: string[], - metadata: { - requiredPredicates: string[], - requiredTriples: Array<{predicate: string, target?: string}>, - properties: Map, - collections: Map - } - ): Promise { - if (expressions.length === 0) { - return []; - } + const collMeta = metadata.collections.get(collectionName); + if (!collMeta) { + return []; + } - // If no required triples, check which expressions have any links - if (metadata.requiredTriples.length === 0) { - const escapedExpressions = expressions.map(e => `'${escapeSurrealString(e)}'`).join(', '); - const checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] GROUP BY in.uri HAVING count() > 0`; - const result = await this.querySurrealDB(checkQuery); - return result.map(r => r.uri); - } + const escapedBaseExpression = escapeSurrealString(baseExpression); + const escapedPredicate = escapeSurrealString(collMeta.predicate); + const query = `SELECT out.uri AS value, timestamp FROM link WHERE in.uri = '${escapedBaseExpression}' AND predicate = '${escapedPredicate}' ORDER BY timestamp ASC`; + const result = await this.querySurrealDB(query); - // For each required triple, build a query that finds matching expressions - const validExpressionSets: Set[] = []; - - for (const triple of metadata.requiredTriples) { - const escapedExpressions = expressions.map(e => `'${escapeSurrealString(e)}'`).join(', '); - const escapedPredicate = escapeSurrealString(triple.predicate); - - let checkQuery: string; - if (triple.target) { - // Flag: must match both predicate AND exact target value - const escapedTarget = escapeSurrealString(triple.target); - // Note: Removed GROUP BY because it was causing SurrealDB to only return one result - checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}'`; - } else { - // Property: just check predicate exists - // Note: Removed GROUP BY because it was causing SurrealDB to only return one result - checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}'`; - } - - const result = await this.querySurrealDB(checkQuery); - validExpressionSets.push(new Set(result.map(r => r.uri))); - } - - // Find intersection: expressions that passed ALL required triple checks - if (validExpressionSets.length === 0) { - return expressions; - } + if (!result || result.length === 0) { + return []; + } - const firstSet = validExpressionSets[0]; - const validExpressions = expressions.filter(expr => { - return validExpressionSets.every(set => set.has(expr)); - }); + let values = result.map((r) => r.value).filter((v) => v !== "" && v !== ""); - return validExpressions; - } + // Apply condition filtering if present + if (collMeta.condition && values.length > 0) { + try { + const filteredValues: string[] = []; - /** - * Fallback sequential instance checking when batch checking isn't available. - */ - private async filterInstancesSequential(values: string[], instanceFilter: string): Promise { - const filteredValues = []; for (const value of values) { - try { - const isInstance = await this.isSubjectInstance(value, instanceFilter); - if (isInstance) { - filteredValues.push(value); - } - } catch (err) { - // Skip values that fail instance check - continue; - } - } - return filteredValues; - } - - /** Returns all subject instances of the given subject class as proxy objects. - * @param subjectClass Either a string with the name of the subject class, or an object - * with the properties of the subject class. In the latter case, all subject classes - * that match the given properties will be used. - */ - async getAllSubjectInstances(subjectClass: T): Promise { - let classes = [] - let isClassConstructor = typeof subjectClass === "function" - if(typeof subjectClass === "string") { - classes = [subjectClass] - } else if (isClassConstructor) { - // It's an Ad4mModel class constructor - //@ts-ignore - classes = [subjectClass.name] - } else { - classes = await this.subjectClassesByTemplate(subjectClass as object) - } - - let instances = [] - for(let className of classes) { - //console.log(`getAllSubjectInstances: Processing class ${className}`); - // Query SDNA for metadata, then query SurrealDB for instances - const metadata = await this.getSubjectClassMetadataFromSDNA(className); - //console.log(`getAllSubjectInstances: Got metadata for ${className}:`, metadata); - if (metadata) { - const surrealQuery = this.generateSurrealInstanceQuery(metadata); - const results = await this.querySurrealDB(surrealQuery); - // console.log(`getAllSubjectInstances: SurrealDB returned ${results?.length || 0} results`); - - for (const result of results || []) { - //console.log(`getAllSubjectInstances: Creating subject for base ${result.base}`); - try { - let instance; - if (isClassConstructor) { - // Create an instance of the actual Ad4mModel class - //@ts-ignore - instance = new subjectClass(this, result.base); - // Load the instance data from links - await instance.get(); - } else { - // Legacy: Create a Subject proxy - instance = new Subject(this, result.base, className); - await instance.init(); - } - instances.push(instance as unknown as T); - //console.log(`getAllSubjectInstances: Successfully created subject for ${result.base}`); - } catch (e) { - //console.warn(`Failed to create subject for ${result.base}:`, e); - } - } - } else { - //console.warn(`getAllSubjectInstances: No metadata found for ${className}`); - } - } - //console.log(`getAllSubjectInstances: Returning ${instances.length} instances`); - return instances - } - - /** Returns all subject proxies of the given subject class as proxy objects. - * @param subjectClass Either a string with the name of the subject class, or an object - * with the properties of the subject class. In the latter case, all subject classes - * that match the given properties will be used. - */ - async getAllSubjectProxies(subjectClass: T): Promise { - let classes = [] - if(typeof subjectClass === "string") { - classes = [subjectClass] - } else { - classes = await this.subjectClassesByTemplate(subjectClass as object) + let condition = collMeta.condition + .replace(/\$perspective/g, `'${this.uuid}'`) + .replace(/\$base/g, `'${baseExpression}'`) + .replace(/Target/g, `'${value.replace(/'/g, "\\'")}'`); + + // If condition starts with WHERE, wrap in array length check + if (condition.trim().startsWith("WHERE")) { + condition = `array::len(SELECT * FROM link ${condition}) > 0`; + } + + const filterResult = await this.querySurrealDB(`RETURN ${condition}`); + const isTrue = + filterResult === true || + (Array.isArray(filterResult) && + filterResult.length > 0 && + filterResult[0] === true); + if (isTrue) { + filteredValues.push(value); + } } - let instances = [] - for(let className of classes) { - // Query SDNA for metadata, then query SurrealDB for instances - const metadata = await this.getSubjectClassMetadataFromSDNA(className); - if (metadata) { - const surrealQuery = this.generateSurrealInstanceQuery(metadata); - const results = await this.querySurrealDB(surrealQuery); - - for (const result of results || []) { - try { - let subject = new Subject(this, result.base, className); - await subject.init(); - instances.push(subject as unknown as T); - } catch (e) { - // Skip subjects that fail to initialize - } - } - } - } - return instances + values = filteredValues; + } catch (error) { + console.warn( + `Failed to apply condition filter for ${collectionName}:`, + error, + ); + } } - - /** - * Find a subject class by matching an object's properties/collections against SHACL shapes. - * Queries SHACL links client-side to find a class whose properties contain all required ones. - * @returns The matching class name, or null if no match found. - */ - private async findClassByProperties(obj: object): Promise { - // Extract properties and collections from the object - let properties: string[] = []; - let collections: string[] = []; - const proto = Object.getPrototypeOf(obj); - - if (proto?.__properties) { - properties = Object.keys(proto.__properties); - } else { - properties = Object.keys(obj).filter(key => !Array.isArray((obj as any)[key])); - } - - if (proto?.__collections) { - collections = Object.keys(proto.__collections).filter(key => key !== 'isSubjectInstance'); - } else { - collections = Object.keys(obj).filter(key => Array.isArray((obj as any)[key]) && key !== 'isSubjectInstance'); + // Apply instance filter if present - batch-check all values at once + if (collMeta.instanceFilter) { + try { + const filterMetadata = await this.getSubjectClassMetadataFromSDNA( + collMeta.instanceFilter, + ); + if (!filterMetadata) { + // Fallback to sequential checks if metadata isn't available + return this.filterInstancesSequential( + values, + collMeta.instanceFilter, + ); } - if (properties.length === 0 && collections.length === 0) { - return null; + return await this.batchCheckSubjectInstances(values, filterMetadata); + } catch (err) { + // Fallback to sequential checks on error + return this.filterInstancesSequential(values, collMeta.instanceFilter); + } + } + + return values; + } + + /** + * Batch-checks multiple expressions against subject class metadata using a single or limited SurrealDB queries. + * This avoids N+1 query problems by checking all values at once. + */ + async batchCheckSubjectInstances( + expressions: string[], + metadata: { + requiredPredicates: string[]; + requiredTriples: Array<{ predicate: string; target?: string }>; + properties: Map; + collections: Map< + string, + { predicate: string; instanceFilter?: string; condition?: string } + >; + }, + ): Promise { + if (expressions.length === 0) { + return []; + } + + // If no required triples, check which expressions have any links + if (metadata.requiredTriples.length === 0) { + const escapedExpressions = expressions + .map((e) => `'${escapeSurrealString(e)}'`) + .join(", "); + const checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] GROUP BY in.uri HAVING count() > 0`; + const result = await this.querySurrealDB(checkQuery); + return result.map((r) => r.uri); + } + + // For each required triple, build a query that finds matching expressions + const validExpressionSets: Set[] = []; + + for (const triple of metadata.requiredTriples) { + const escapedExpressions = expressions + .map((e) => `'${escapeSurrealString(e)}'`) + .join(", "); + const escapedPredicate = escapeSurrealString(triple.predicate); + + let checkQuery: string; + if (triple.target) { + // Flag: must match both predicate AND exact target value + const escapedTarget = escapeSurrealString(triple.target); + // Note: Removed GROUP BY because it was causing SurrealDB to only return one result + checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}'`; + } else { + // Property: just check predicate exists + // Note: Removed GROUP BY because it was causing SurrealDB to only return one result + checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}'`; + } + + const result = await this.querySurrealDB(checkQuery); + validExpressionSets.push(new Set(result.map((r) => r.uri))); + } + + // Find intersection: expressions that passed ALL required triple checks + if (validExpressionSets.length === 0) { + return expressions; + } + + const firstSet = validExpressionSets[0]; + const validExpressions = expressions.filter((expr) => { + return validExpressionSets.every((set) => set.has(expr)); + }); + + return validExpressions; + } + + /** + * Fallback sequential instance checking when batch checking isn't available. + */ + private async filterInstancesSequential( + values: string[], + instanceFilter: string, + ): Promise { + const filteredValues = []; + for (const value of values) { + try { + const isInstance = await this.isSubjectInstance(value, instanceFilter); + if (isInstance) { + filteredValues.push(value); } - - // Single SurrealDB query to find all classes and their properties/collections - const query = `SELECT - in.uri AS shape_source, - predicate, - out.uri AS target - FROM link - WHERE predicate IN ['rdf://type', 'sh://property', 'sh://collection']`; - - const results = await this.querySurrealDB(query); - if (!results || results.length === 0) return null; - - // Build a map of className -> { properties, collections } - const classShapes: Map = new Map(); - - // First pass: find all subject classes - for (const r of results) { - if (r.predicate === 'rdf://type' && r.target === 'ad4m://SubjectClass') { - const source = r.shape_source; - const sepIdx = source.indexOf('://'); - if (sepIdx < 0) continue; - const className = source.substring(sepIdx + 3).split('/').pop(); - if (!className) continue; - classShapes.set(className, { shapeUri: source, properties: [], collections: [] }); + } catch (err) { + // Skip values that fail instance check + continue; + } + } + return filteredValues; + } + + /** Returns all subject instances of the given subject class as proxy objects. + * @param subjectClass Either a string with the name of the subject class, or an object + * with the properties of the subject class. In the latter case, all subject classes + * that match the given properties will be used. + */ + async getAllSubjectInstances(subjectClass: T): Promise { + let classes = []; + let isClassConstructor = typeof subjectClass === "function"; + if (typeof subjectClass === "string") { + classes = [subjectClass]; + } else if (isClassConstructor) { + // It's an Ad4mModel class constructor + //@ts-ignore + classes = [subjectClass.name]; + } else { + classes = await this.subjectClassesByTemplate(subjectClass as object); + } + + let instances = []; + for (let className of classes) { + //console.log(`getAllSubjectInstances: Processing class ${className}`); + // Query SDNA for metadata, then query SurrealDB for instances + const metadata = await this.getSubjectClassMetadataFromSDNA(className); + //console.log(`getAllSubjectInstances: Got metadata for ${className}:`, metadata); + if (metadata) { + const surrealQuery = this.generateSurrealInstanceQuery(metadata); + const results = await this.querySurrealDB(surrealQuery); + // console.log(`getAllSubjectInstances: SurrealDB returned ${results?.length || 0} results`); + + for (const result of results || []) { + //console.log(`getAllSubjectInstances: Creating subject for base ${result.base}`); + try { + let instance; + if (isClassConstructor) { + // Create an instance of the actual Ad4mModel class + //@ts-ignore + instance = new subjectClass(this, result.base); + // Load the instance data from links + await instance.get(); + } else { + // Legacy: Create a Subject proxy + instance = new Subject(this, result.base, className); + await instance.init(); } + instances.push(instance as unknown as T); + //console.log(`getAllSubjectInstances: Successfully created subject for ${result.base}`); + } catch (e) { + //console.warn(`Failed to create subject for ${result.base}:`, e); + } } - - // Second pass: collect properties and collections for each class - for (const r of results) { - if (r.predicate === 'sh://property' || r.predicate === 'sh://collection') { - // Match shape source to class (e.g., "recipe://RecipeShape" -> "Recipe") - for (const [className, shape] of classShapes) { - if (r.shape_source.endsWith(`${className}Shape`)) { - const dotIdx = r.target.lastIndexOf('.'); - if (dotIdx < 0) continue; - const name = r.target.substring(dotIdx + 1); - if (r.predicate === 'sh://property') { - shape.properties.push(name); - } else { - shape.collections.push(name); - } - } - } - } + } else { + //console.warn(`getAllSubjectInstances: No metadata found for ${className}`); + } + } + //console.log(`getAllSubjectInstances: Returning ${instances.length} instances`); + return instances; + } + + /** Returns all subject proxies of the given subject class as proxy objects. + * @param subjectClass Either a string with the name of the subject class, or an object + * with the properties of the subject class. In the latter case, all subject classes + * that match the given properties will be used. + */ + async getAllSubjectProxies(subjectClass: T): Promise { + let classes = []; + if (typeof subjectClass === "string") { + classes = [subjectClass]; + } else { + classes = await this.subjectClassesByTemplate(subjectClass as object); + } + + let instances = []; + for (let className of classes) { + // Query SDNA for metadata, then query SurrealDB for instances + const metadata = await this.getSubjectClassMetadataFromSDNA(className); + if (metadata) { + const surrealQuery = this.generateSurrealInstanceQuery(metadata); + const results = await this.querySurrealDB(surrealQuery); + + for (const result of results || []) { + try { + let subject = new Subject(this, result.base, className); + await subject.init(); + instances.push(subject as unknown as T); + } catch (e) { + // Skip subjects that fail to initialize + } } - - // Find a class that has all required properties and collections - for (const [className, shape] of classShapes) { - const hasAllProps = properties.every(p => shape.properties.includes(p)); - const hasAllCols = collections.every(c => shape.collections.includes(c)); - if (hasAllProps && hasAllCols) { - return className; + } + } + return instances; + } + + private buildQueryFromTemplate(obj: object): string { + let result; + // We need to avoid strict mode for the following intropsective code + (function (obj) { + // Collect all string properties of the object in a list + let properties = []; + + // Collect all collections of the object in a list + let collections = []; + + // Collect all string properties of the object in a list + const _protoProps = getPropertiesMetadata( + Object.getPrototypeOf(obj)?.constructor, + ); + if (Object.keys(_protoProps).length > 0) { + Object.keys(_protoProps).forEach((p) => properties.push(p)); + } else { + properties.push( + ...Object.keys(obj).filter((key) => !Array.isArray(obj[key])), + ); + } + + // Collect all collections of the object in a list + const _protoCols = getRelationsMetadata( + Object.getPrototypeOf(obj)?.constructor, + ); + if (Object.keys(_protoCols).length > 0) { + Object.keys(_protoCols) + .filter((key) => key !== "isSubjectInstance") + .forEach((c) => { + if (!collections.includes(c)) { + collections.push(c); } - } - - return null; + }); + } else { + collections.push( + ...Object.keys(obj) + .filter((key) => Array.isArray(obj[key])) + .filter((key) => key !== "isSubjectInstance"), + ); + } + + // Helper: distinguish collection setters (setComments, setLocations, …) from + // plain property setters (setName, setState, …) using @HasMany/__collections metadata + // rather than the old "setCollection" string prefix. + const _collectionsMetadata = getRelationsMetadata( + Object.getPrototypeOf(obj)?.constructor ?? (obj as any).constructor, + ); + const isRelationSetter = (key: string): boolean => { + if (!key.startsWith("set") || key.length <= 3) return false; + const suffix = key.substring(3); + const relationKey = suffix.charAt(0).toLowerCase() + suffix.slice(1); + return relationKey in _collectionsMetadata; + }; + + // Collect all set functions of the object in a list + let setFunctions = Object.getOwnPropertyNames(obj).filter( + (key) => + typeof obj[key] === "function" && + key.startsWith("set") && + !isRelationSetter(key), + ); + // Add all set functions of the object's prototype to that list + setFunctions = setFunctions.concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter((key) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(obj), + key, + ); + return ( + descriptor && + typeof descriptor.value === "function" && + key.startsWith("set") && + !isRelationSetter(key) + ); + }), + ); + + // Collect all add functions of the object in a list + let addFunctions = Object.getOwnPropertyNames(obj).filter( + (key) => + Object.prototype.hasOwnProperty.call(obj, key) && + typeof obj[key] === "function" && + key.startsWith("add"), + ); + // Add all add functions of the object's prototype to that list + addFunctions = addFunctions.concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter((key) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(obj), + key, + ); + return ( + descriptor && + typeof descriptor.value === "function" && + key.startsWith("add") + ); + }), + ); + + // Collect all remove functions of the object in a list + let removeFunctions = Object.getOwnPropertyNames(obj).filter( + (key) => + Object.prototype.hasOwnProperty.call(obj, key) && + typeof obj[key] === "function" && + key.startsWith("remove"), + ); + // Add all remove functions of the object's prototype to that list + removeFunctions = removeFunctions.concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter((key) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(obj), + key, + ); + return ( + descriptor && + typeof descriptor.value === "function" && + key.startsWith("remove") + ); + }), + ); + + // Collect all collection setter functions of the object in a list + let setCollectionFunctions = Object.getOwnPropertyNames(obj).filter( + (key) => + Object.prototype.hasOwnProperty.call(obj, key) && + typeof obj[key] === "function" && + key.startsWith("set") && + isRelationSetter(key), + ); + // Add all collection setter functions from the object's prototype to that list + setCollectionFunctions = setCollectionFunctions.concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter((key) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(obj), + key, + ); + return ( + descriptor && + typeof descriptor.value === "function" && + key.startsWith("set") && + isRelationSetter(key) + ); + }), + ); + // Construct query to find all subject classes that have the given properties and collections + let query = `subject_class(Class, C)`; + + for (let property of properties) { + query += `, property(C, "${property}")`; + } + for (let collection of collections) { + query += `, collection(C, "${collection}")`; + } + + for (let setFunction of setFunctions) { + // e.g. "setState" -> "state" + let property = setFunction.substring(3); + property = property.charAt(0).toLowerCase() + property.slice(1); + query += `, property_setter(C, "${property}", _)`; + } + for (let addFunction of addFunctions) { + query += `, collection_adder(C, "${collectionAdderToName(addFunction)}", _)`; + } + + for (let removeFunction of removeFunctions) { + query += `, collection_remover(C, "${collectionRemoverToName(removeFunction)}", _)`; + } + + for (let setCollectionFunction of setCollectionFunctions) { + query += `, collection_setter(C, "${collectionSetterToName(setCollectionFunction)}", _)`; + } + + query += "."; + result = query; + })(obj); + return result; + } + + /** Finds a single subject class whose SHACL shape matches all properties + * and collections present on `obj`. Returns `null` when no match is found. + */ + private async findClassByProperties(obj: object): Promise { + // Extract property / collection names from the object or its prototype + let properties: string[] = []; + let collections: string[] = []; + const proto = Object.getPrototypeOf(obj); + + if (proto?.__properties) { + properties = Object.keys(proto.__properties); + } else { + properties = Object.keys(obj).filter( + (key) => !Array.isArray((obj as any)[key]), + ); + } + + if (proto?.__collections) { + collections = Object.keys(proto.__collections).filter( + (k) => k !== "isSubjectInstance", + ); + } else { + collections = Object.keys(obj).filter( + (key) => + Array.isArray((obj as any)[key]) && key !== "isSubjectInstance", + ); + } + + if (properties.length === 0 && collections.length === 0) return null; + + // Single SurrealDB query to fetch all SHACL links at once + const query = `SELECT + in.uri AS shape_source, + predicate, + out.uri AS target + FROM link + WHERE predicate IN ['rdf://type', 'sh://property', 'sh://collection']`; + + const results = await this.querySurrealDB(query); + if (!results || results.length === 0) return null; + + // Build className -> { shapeUri, properties[], collections[] } + const classShapes = new Map< + string, + { shapeUri: string; properties: string[]; collections: string[] } + >(); + + for (const r of results) { + if (r.predicate === "rdf://type" && r.target === "ad4m://SubjectClass") { + const source: string = r.shape_source; + const sepIdx = source.indexOf("://"); + if (sepIdx < 0) continue; + let className = source + .substring(sepIdx + 3) + .split("/") + .pop(); + if (!className) continue; + if (className.endsWith("Shape")) className = className.slice(0, -5); + classShapes.set(className, { + shapeUri: source, + properties: [], + collections: [], + }); + } } - /** Returns all subject classes that match the given template object. - * This function looks at the properties of the template object and - * its setters and collections to create a Prolog query that finds - * all subject classes that would be converted to a proxy object - * with exactly the same properties and collections. - * - * Since there could be multiple subject classes that match the given - * criteria, this function returns a list of class names. - * - * @param obj The template object - */ - async subjectClassesByTemplate(obj: object): Promise { - // SHACL-based lookup: try property matching first (more precise), fall back to className - try { - const match = await this.findClassByProperties(obj); - if (match) { - return [match]; - } - } catch (e) { - console.warn('subjectClassesByTemplate: property matching failed:', e); - } - - // Fall back to className lookup - try { - // @ts-ignore - className is added dynamically by decorators - const className = obj.className || obj.constructor?.className || obj.constructor?.prototype?.className; - if (className) { - const existingClasses = await this.subjectClasses(); - if (existingClasses.includes(className)) { - return [className]; - } + for (const r of results) { + if ( + r.predicate === "sh://property" || + r.predicate === "sh://collection" + ) { + for (const [className, shape] of classShapes) { + if (r.shape_source.endsWith(`${className}Shape`)) { + const dotIdx = (r.target as string).lastIndexOf("."); + if (dotIdx < 0) continue; + const name = (r.target as string).substring(dotIdx + 1); + if (r.predicate === "sh://property") { + shape.properties.push(name); + } else { + shape.collections.push(name); } - } catch (e) { - console.warn('subjectClassesByTemplate: className lookup failed:', e); + } } - - return []; - } - - /** Takes a JS class (its constructor) and assumes that it was decorated by - * the @subjectClass etc. decorators. It then tests if there is a subject class - * already present in the perspective's SDNA that matches the given class. - * If there is no such class, it gets the JS class's SDNA by calling its - * static generateSDNA() function and adds it to the perspective's SDNA. - */ - async ensureSDNASubjectClass(jsClass: any): Promise { - // Get the class name from the JS class - const className = jsClass.className || jsClass.prototype?.className || jsClass.name; - - // Note: Duplicate checking is handled on the Rust side in add_sdna - - // Generate SHACL SDNA (Prolog-free) - if (!jsClass.generateSHACL) { - throw new Error(`Class ${jsClass.name} must have generateSHACL(). Use @ModelOptions decorator.`); + } + } + + for (const [className, shape] of classShapes) { + const hasAllProps = properties.every((p) => shape.properties.includes(p)); + const hasAllCols = collections.every((c) => + shape.collections.includes(c), + ); + if (hasAllProps && hasAllCols) return className; + } + + return null; + } + + /** Returns all subject classes that match the given template object. + * This function looks at the properties of the template object and + * its setters and collections to create a Prolog query that finds + * all subject classes that would be converted to a proxy object + * with exactly the same properties and collections. + * + * Since there could be multiple subject classes that match the given + * criteria, this function returns a list of class names. + * + * @param obj The template object + */ + async subjectClassesByTemplate(obj: object): Promise { + // Try property-shape matching first (more precise) + try { + const match = await this.findClassByProperties(obj); + if (match) return [match]; + } catch (e) { + console.warn("subjectClassesByTemplate: property matching failed:", e); + } + + // Fall back to className lookup against live SHACL links + try { + const o = obj as any; + const className = + o.className || + o.constructor?.className || + o.constructor?.prototype?.className; + if (className) { + const existingClasses = await this.subjectClasses(); + if (existingClasses.includes(className)) { + return [className]; } - - // Get SHACL shape (W3C standard + AD4M action definitions) - const { shape } = jsClass.generateSHACL(); - - // Serialize SHACL shape to JSON for Rust backend using SHACLShape.toJSON() - const shaclJson = JSON.stringify(shape.toJSON()); - - // Pass SHACL JSON to backend (Prolog-free) - // Backend stores SHACL links directly - await this.addSdna(className, '', 'subject_class', shaclJson); - } - - getNeighbourhoodProxy(): NeighbourhoodProxy { - return this.#client.getNeighbourhoodProxy(this.#handle.uuid) - } - - /** - * Returns a proxy object for working with AI capabilities. - * - * @returns AIClient instance - * - * @example - * ```typescript - * // Use AI to analyze perspective content - * const summary = await perspective.ai.summarize(); - * - * // Generate new content - * const suggestion = await perspective.ai.suggest("next action"); - * ``` - */ - get ai(): AIClient { - return this.#client.aiClient - } - - /** - * Creates a subscription for a Prolog query that updates in real-time. - * - * This method: - * 1. Creates the subscription on the Rust side - * 2. Sets up the subscription callback - * 3. Waits for the initial result to come through the subscription channel - * 4. Returns a fully initialized QuerySubscriptionProxy - * - * The returned subscription is guaranteed to be ready to receive updates, - * as this method waits for the initialization process to complete. - * - * The subscription will be automatically cleaned up on both frontend and backend - * when dispose() is called. Make sure to call dispose() when you're done to - * prevent memory leaks and ensure proper cleanup of resources. - * - * @param query - Prolog query string - * @returns Initialized QuerySubscriptionProxy instance - * - * @example - * ```typescript - * // Subscribe to active todos - * const subscription = await perspective.subscribeInfer(` - * instance(Todo, "Todo"), - * property_getter("Todo", Todo, "state", "active") - * `); - * - * // Subscription is already initialized here - * console.log("Initial result:", subscription.result); - * - * // Set up callback for future updates - * subscription.onResult((todos) => { - * console.log("Active todos:", todos); - * }); - * - * // Clean up subscription when done - * subscription.dispose(); - * ``` - */ - async subscribeInfer(query: string): Promise { - const subscriptionProxy = new QuerySubscriptionProxy( - this.uuid, - query, - this.#client - ); - - // Start the subscription on the Rust side first to get the real subscription ID - await subscriptionProxy.subscribe(); - - // Wait for the initial result - await subscriptionProxy.initialized; - - return subscriptionProxy; - } - - /** - * Creates a subscription for a SurrealQL query that updates in real-time. - * - * This method: - * 1. Creates the subscription on the Rust side - * 2. Sets up the subscription callback - * 3. Waits for the initial result to come through the subscription channel - * 4. Returns a fully initialized QuerySubscriptionProxy - * - * The returned subscription is guaranteed to be ready to receive updates, - * as this method waits for the initialization process to complete. - * - * The subscription will be automatically cleaned up on both frontend and backend - * when dispose() is called. Make sure to call dispose() when you're done to - * prevent memory leaks and ensure proper cleanup of resources. - * - * @param query - SurrealQL query string - * @returns Initialized QuerySubscriptionProxy instance - */ - async subscribeSurrealDB(query: string): Promise { - const subscriptionProxy = new QuerySubscriptionProxy( - this.uuid, - query, - this.#client - ); - subscriptionProxy.isSurrealDB = true; - - // Start the subscription on the Rust side first to get the real subscription ID - await subscriptionProxy.subscribe(); - - // Wait for the initial result - await subscriptionProxy.initialized; - - return subscriptionProxy; - } - -} \ No newline at end of file + } + } catch (e) { + console.warn("subjectClassesByTemplate: className lookup failed:", e); + } + + return []; + } + + /** Takes a JS class (its constructor) and assumes that it was decorated by + * the @subjectClass etc. decorators. It then tests if there is a subject class + * already present in the perspective's SDNA that matches the given class. + * If there is no such class, it gets the JS class's SDNA by calling its + * static generateSDNA() function and adds it to the perspective's SDNA. + */ + async ensureSDNASubjectClass(jsClass: any): Promise { + // Get the class name from the JS class + const className = + jsClass.className || jsClass.prototype?.className || jsClass.name; + + // Note: Duplicate checking is handled on the Rust side in add_sdna + + // Generate SHACL SDNA (Prolog-free) + if (!jsClass.generateSHACL) { + throw new Error( + `Class ${jsClass.name} must have generateSHACL(). Use @ModelOptions decorator.`, + ); + } + + // Get SHACL shape (W3C standard + AD4M action definitions) + const { shape } = jsClass.generateSHACL(); + + // Serialize SHACL shape to JSON for Rust backend + const shaclJson = JSON.stringify({ + target_class: shape.targetClass, + constructor_actions: shape.constructor_actions, + destructor_actions: shape.destructor_actions, + properties: shape.properties.map((p: any) => ({ + path: p.path, + name: p.name, + datatype: p.datatype, + min_count: p.minCount, + max_count: p.maxCount, + read_only: p.readOnly, + local: p.local, + resolve_language: p.resolveLanguage, + node_kind: p.nodeKind, + collection: p.collection, + setter: p.setter, + adder: p.adder, + remover: p.remover, + })), + }); + + // Pass SHACL JSON to backend (Prolog-free) + // Backend stores SHACL links directly + await this.addSdna(className, "", "subject_class", shaclJson); + } + + getNeighbourhoodProxy(): NeighbourhoodProxy { + return this._client.getNeighbourhoodProxy(this._handle.uuid); + } + + /** + * Returns a proxy object for working with AI capabilities. + * + * @returns AIClient instance + * + * @example + * ```typescript + * // Use AI to analyze perspective content + * const summary = await perspective.ai.summarize(); + * + * // Generate new content + * const suggestion = await perspective.ai.suggest("next action"); + * ``` + */ + get ai(): AIClient { + return this._client.aiClient; + } + + /** + * Creates a subscription for a Prolog query that updates in real-time. + * + * This method: + * 1. Creates the subscription on the Rust side + * 2. Sets up the subscription callback + * 3. Waits for the initial result to come through the subscription channel + * 4. Returns a fully initialized QuerySubscriptionProxy + * + * The returned subscription is guaranteed to be ready to receive updates, + * as this method waits for the initialization process to complete. + * + * The subscription will be automatically cleaned up on both frontend and backend + * when dispose() is called. Make sure to call dispose() when you're done to + * prevent memory leaks and ensure proper cleanup of resources. + * + * @param query - Prolog query string + * @returns Initialized QuerySubscriptionProxy instance + * + * @example + * ```typescript + * // Subscribe to active todos + * const subscription = await perspective.subscribeInfer(` + * instance(Todo, "Todo"), + * property_getter("Todo", Todo, "state", "active") + * `); + * + * // Subscription is already initialized here + * console.log("Initial result:", subscription.result); + * + * // Set up callback for future updates + * subscription.onResult((todos) => { + * console.log("Active todos:", todos); + * }); + * + * // Clean up subscription when done + * subscription.dispose(); + * ``` + */ + async subscribeInfer(query: string): Promise { + const subscriptionProxy = new QuerySubscriptionProxy( + this.uuid, + query, + this._client, + ); + + // Start the subscription on the Rust side first to get the real subscription ID + await subscriptionProxy.subscribe(); + + // Wait for the initial result + await subscriptionProxy.initialized; + + return subscriptionProxy; + } +} diff --git a/core/src/runtime/RuntimeClient.ts b/core/src/runtime/RuntimeClient.ts index ce781e07f..ee14a120f 100644 --- a/core/src/runtime/RuntimeClient.ts +++ b/core/src/runtime/RuntimeClient.ts @@ -1,7 +1,20 @@ -import { ApolloClient, gql } from "@apollo/client/core" -import { Perspective, PerspectiveExpression } from "../perspectives/Perspective" -import unwrapApolloResult from "../unwrapApolloResult" -import { RuntimeInfo, ExceptionInfo, SentMessage, NotificationInput, Notification, TriggeredNotification, ImportResult, UserStatistics } from "./RuntimeResolver" +import { ApolloClient, gql } from "@apollo/client/core"; +import { + Perspective, + PerspectiveExpression, +} from "../perspectives/Perspective"; +import unwrapApolloResult from "../unwrapApolloResult"; +import { isSocketCloseError } from "../utils"; +import { + RuntimeInfo, + ExceptionInfo, + SentMessage, + NotificationInput, + Notification, + TriggeredNotification, + ImportResult, + UserStatistics, +} from "./RuntimeResolver"; const PERSPECTIVE_EXPRESSION_FIELDS = ` author @@ -15,7 +28,7 @@ data { } } proof { valid, invalid, signature, key } -` +`; const NOTIFICATION_DEFINITION_FIELDS = ` description @@ -26,253 +39,364 @@ trigger perspectiveIds webhookUrl webhookAuth -` +`; const NOTIFICATION_FIELDS = ` id granted ${NOTIFICATION_DEFINITION_FIELDS} -` +`; const TRIGGERED_NOTIFICATION_FIELDS = ` notification { ${NOTIFICATION_FIELDS} } perspectiveId triggerMatch -` +`; -export type MessageCallback = (message: PerspectiveExpression) => null -export type ExceptionCallback = (info: ExceptionInfo) => null -export type NotificationTriggeredCallback = (notification: TriggeredNotification) => null -export type NotificationRequestedCallback = (notification: Notification) => null +export type MessageCallback = (message: PerspectiveExpression) => null; +export type ExceptionCallback = (info: ExceptionInfo) => null; +export type NotificationTriggeredCallback = ( + notification: TriggeredNotification, +) => null; +export type NotificationRequestedCallback = ( + notification: Notification, +) => null; export class RuntimeClient { - #apolloClient: ApolloClient - #messageReceivedCallbacks: MessageCallback[] - #exceptionOccurredCallbacks: ExceptionCallback[] - #notificationTriggeredCallbacks: NotificationTriggeredCallback[] - #notificationRequestedCallbacks: NotificationRequestedCallback[] - - constructor(client: ApolloClient, subscribe: boolean = true) { - this.#apolloClient = client - this.#messageReceivedCallbacks = [] - this.#exceptionOccurredCallbacks = [] - this.#notificationTriggeredCallbacks = [] - - if(subscribe) { - this.subscribeMessageReceived() - this.subscribeExceptionOccurred() - this.subscribeNotificationTriggered() - } - } - - async info(): Promise { - const { runtimeInfo } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeInfo { - runtimeInfo { - ad4mExecutorVersion, - isInitialized, - isUnlocked - } - }`, - })); - return runtimeInfo - } - - async quit(): Promise { - const result = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeQuit { runtimeQuit }` - })) - - return result.runtimeQuit - } - - async openLink(url: string): Promise { - const { runtimeOpenLink } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeOpenLink($url: String!) { - runtimeOpenLink(url: $url) - }`, - variables: { url } - })) - return runtimeOpenLink - } - - async addTrustedAgents(agents: string[]): Promise { - const { addTrustedAgents } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation addTrustedAgents($agents: [String!]!) { - addTrustedAgents(agents: $agents) - }`, - variables: { agents } - })) - return addTrustedAgents - } - - async deleteTrustedAgents(agents: string[]): Promise { - const { deleteTrustedAgents } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation deleteTrustedAgents($agents: [String!]!) { - deleteTrustedAgents(agents: $agents) - }`, - variables: { agents } - })) - return deleteTrustedAgents - } - - async getTrustedAgents(): Promise { - const { getTrustedAgents } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query getTrustedAgents { - getTrustedAgents - }`, - })) - return getTrustedAgents - } - - async addKnownLinkLanguageTemplates(addresses: string[]): Promise { - const { runtimeAddKnownLinkLanguageTemplates } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeAddKnownLinkLanguageTemplates($addresses: [String!]!) { - runtimeAddKnownLinkLanguageTemplates(addresses: $addresses) - }`, - variables: { addresses } - })) - return runtimeAddKnownLinkLanguageTemplates - } - - async removeKnownLinkLanguageTemplates(addresses: string[]): Promise { - const { runtimeRemoveKnownLinkLanguageTemplates } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeRemoveKnownLinkLanguageTemplates($addresses: [String!]!) { - runtimeRemoveKnownLinkLanguageTemplates(addresses: $addresses) - }`, - variables: { addresses } - })) - return runtimeRemoveKnownLinkLanguageTemplates - } - - async knownLinkLanguageTemplates(): Promise { - const { runtimeKnownLinkLanguageTemplates } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeKnownLinkLanguageTemplates { - runtimeKnownLinkLanguageTemplates - }`, - })) - return runtimeKnownLinkLanguageTemplates - } - - async addFriends(dids: string[]): Promise { - const { runtimeAddFriends } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeAddFriends($dids: [String!]!) { - runtimeAddFriends(dids: $dids) - }`, - variables: { dids } - })) - return runtimeAddFriends - } - - async removeFriends(dids: string[]): Promise { - const { runtimeRemoveFriends } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeRemoveFriends($dids: [String!]!) { - runtimeRemoveFriends(dids: $dids) - }`, - variables: { dids } - })) - return runtimeRemoveFriends - } - - async friends(): Promise { - const { runtimeFriends } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeFriends { - runtimeFriends - }`, - })) - return runtimeFriends - } - - async hcAgentInfos(): Promise { - const { runtimeHcAgentInfos } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeHcAgentInfos { - runtimeHcAgentInfos - }`, - })) - return runtimeHcAgentInfos - } - - async getNetworkMetrics(): Promise { - const { runtimeGetNetworkMetrics } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeGetNetworkMetrics { - runtimeGetNetworkMetrics - }`, - })) - return runtimeGetNetworkMetrics - } - - async restartHolochain(): Promise { - const { runtimeRestartHolochain } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeRestartHolochain { - runtimeRestartHolochain - }`, - })) - return runtimeRestartHolochain - } - - async hcAddAgentInfos(agentInfos: String): Promise { - const { runtimeHcAddAgentInfos } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeHcAddAgentInfos($agentInfos: String!) { - runtimeHcAddAgentInfos(agentInfos: $agentInfos) - }`, - variables: { agentInfos } - })) - return runtimeHcAddAgentInfos - } - - async verifyStringSignedByDid(did: string, didSigningKeyId: string, data: string, signedData: string): Promise { - const { runtimeVerifyStringSignedByDid } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`query runtimeVerifyStringSignedByDid($did: String!, $didSigningKeyId: String!, $data: String!, $signedData: String!) { - runtimeVerifyStringSignedByDid(did: $did, didSigningKeyId: $didSigningKeyId, data: $data, signedData: $signedData) - }`, - variables: { did, didSigningKeyId, data, signedData } - })) - return runtimeVerifyStringSignedByDid - } - - async setStatus(perspective: Perspective): Promise { - const { runtimeSetStatus } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeSetStatus($status: PerspectiveInput!) { - runtimeSetStatus(status: $status) - }`, - variables: { status: perspective } - })) - return runtimeSetStatus - } - - async friendStatus(did: string): Promise { - const { runtimeFriendStatus } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeFriendStatus($did: String!) { + private _apolloClient: ApolloClient; + private _messageReceivedCallbacks: MessageCallback[]; + private _exceptionOccurredCallbacks: ExceptionCallback[]; + private _notificationTriggeredCallbacks: NotificationTriggeredCallback[]; + private _notificationRequestedCallbacks: NotificationRequestedCallback[]; + + constructor(client: ApolloClient, subscribe: boolean = true) { + this._apolloClient = client; + this._messageReceivedCallbacks = []; + this._exceptionOccurredCallbacks = []; + this._notificationTriggeredCallbacks = []; + + if (subscribe) { + this.subscribeMessageReceived(); + this.subscribeExceptionOccurred(); + this.subscribeNotificationTriggered(); + } + } + + async info(): Promise { + const { runtimeInfo } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeInfo { + runtimeInfo { + ad4mExecutorVersion + isInitialized + isUnlocked + } + } + `, + }), + ); + return runtimeInfo; + } + + async quit(): Promise { + const result = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeQuit { + runtimeQuit + } + `, + }), + ); + + return result.runtimeQuit; + } + + async openLink(url: string): Promise { + const { runtimeOpenLink } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeOpenLink($url: String!) { + runtimeOpenLink(url: $url) + } + `, + variables: { url }, + }), + ); + return runtimeOpenLink; + } + + async addTrustedAgents(agents: string[]): Promise { + const { addTrustedAgents } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation addTrustedAgents($agents: [String!]!) { + addTrustedAgents(agents: $agents) + } + `, + variables: { agents }, + }), + ); + return addTrustedAgents; + } + + async deleteTrustedAgents(agents: string[]): Promise { + const { deleteTrustedAgents } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation deleteTrustedAgents($agents: [String!]!) { + deleteTrustedAgents(agents: $agents) + } + `, + variables: { agents }, + }), + ); + return deleteTrustedAgents; + } + + async getTrustedAgents(): Promise { + const { getTrustedAgents } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query getTrustedAgents { + getTrustedAgents + } + `, + }), + ); + return getTrustedAgents; + } + + async addKnownLinkLanguageTemplates(addresses: string[]): Promise { + const { runtimeAddKnownLinkLanguageTemplates } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeAddKnownLinkLanguageTemplates( + $addresses: [String!]! + ) { + runtimeAddKnownLinkLanguageTemplates(addresses: $addresses) + } + `, + variables: { addresses }, + }), + ); + return runtimeAddKnownLinkLanguageTemplates; + } + + async removeKnownLinkLanguageTemplates( + addresses: string[], + ): Promise { + const { runtimeRemoveKnownLinkLanguageTemplates } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeRemoveKnownLinkLanguageTemplates( + $addresses: [String!]! + ) { + runtimeRemoveKnownLinkLanguageTemplates(addresses: $addresses) + } + `, + variables: { addresses }, + }), + ); + return runtimeRemoveKnownLinkLanguageTemplates; + } + + async knownLinkLanguageTemplates(): Promise { + const { runtimeKnownLinkLanguageTemplates } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeKnownLinkLanguageTemplates { + runtimeKnownLinkLanguageTemplates + } + `, + }), + ); + return runtimeKnownLinkLanguageTemplates; + } + + async addFriends(dids: string[]): Promise { + const { runtimeAddFriends } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeAddFriends($dids: [String!]!) { + runtimeAddFriends(dids: $dids) + } + `, + variables: { dids }, + }), + ); + return runtimeAddFriends; + } + + async removeFriends(dids: string[]): Promise { + const { runtimeRemoveFriends } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeRemoveFriends($dids: [String!]!) { + runtimeRemoveFriends(dids: $dids) + } + `, + variables: { dids }, + }), + ); + return runtimeRemoveFriends; + } + + async friends(): Promise { + const { runtimeFriends } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeFriends { + runtimeFriends + } + `, + }), + ); + return runtimeFriends; + } + + async hcAgentInfos(): Promise { + const { runtimeHcAgentInfos } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeHcAgentInfos { + runtimeHcAgentInfos + } + `, + }), + ); + return runtimeHcAgentInfos; + } + + async getNetworkMetrics(): Promise { + const { runtimeGetNetworkMetrics } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeGetNetworkMetrics { + runtimeGetNetworkMetrics + } + `, + }), + ); + return runtimeGetNetworkMetrics; + } + + async restartHolochain(): Promise { + const { runtimeRestartHolochain } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeRestartHolochain { + runtimeRestartHolochain + } + `, + }), + ); + return runtimeRestartHolochain; + } + + async hcAddAgentInfos(agentInfos: String): Promise { + const { runtimeHcAddAgentInfos } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeHcAddAgentInfos($agentInfos: String!) { + runtimeHcAddAgentInfos(agentInfos: $agentInfos) + } + `, + variables: { agentInfos }, + }), + ); + return runtimeHcAddAgentInfos; + } + + async verifyStringSignedByDid( + did: string, + didSigningKeyId: string, + data: string, + signedData: string, + ): Promise { + const { runtimeVerifyStringSignedByDid } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + query runtimeVerifyStringSignedByDid( + $did: String! + $didSigningKeyId: String! + $data: String! + $signedData: String! + ) { + runtimeVerifyStringSignedByDid( + did: $did + didSigningKeyId: $didSigningKeyId + data: $data + signedData: $signedData + ) + } + `, + variables: { did, didSigningKeyId, data, signedData }, + }), + ); + return runtimeVerifyStringSignedByDid; + } + + async setStatus(perspective: Perspective): Promise { + const { runtimeSetStatus } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeSetStatus($status: PerspectiveInput!) { + runtimeSetStatus(status: $status) + } + `, + variables: { status: perspective }, + }), + ); + return runtimeSetStatus; + } + + async friendStatus(did: string): Promise { + const { runtimeFriendStatus } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query runtimeFriendStatus($did: String!) { runtimeFriendStatus(did: $did) { ${PERSPECTIVE_EXPRESSION_FIELDS} } }`, - variables: { did } - })) - return runtimeFriendStatus - } - - async friendSendMessage(did: string, message: Perspective): Promise { - const { runtimeFriendSendMessage } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeFriendSendMessage($did: String!, $message: PerspectiveInput!) { - runtimeFriendSendMessage(did: $did, message: $message) - }`, - variables: { did, message } - })) - return runtimeFriendSendMessage - } - - async messageInbox(filter?: string): Promise { - const { runtimeMessageInbox } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeMessageInbox($filter: String) { + variables: { did }, + }), + ); + return runtimeFriendStatus; + } + + async friendSendMessage(did: string, message: Perspective): Promise { + const { runtimeFriendSendMessage } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeFriendSendMessage( + $did: String! + $message: PerspectiveInput! + ) { + runtimeFriendSendMessage(did: $did, message: $message) + } + `, + variables: { did, message }, + }), + ); + return runtimeFriendSendMessage; + } + + async messageInbox(filter?: string): Promise { + const { runtimeMessageInbox } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query runtimeMessageInbox($filter: String) { runtimeMessageInbox(filter: $filter) { ${PERSPECTIVE_EXPRESSION_FIELDS} } }`, - variables: { filter } - })) - return runtimeMessageInbox - } + variables: { filter }, + }), + ); + return runtimeMessageInbox; + } - async messageOutbox(filter?: string): Promise { - const { runtimeMessageOutbox } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeMessageOutbox($filter: String) { + async messageOutbox(filter?: string): Promise { + const { runtimeMessageOutbox } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query runtimeMessageOutbox($filter: String) { runtimeMessageOutbox(filter: $filter) { recipient, message { @@ -280,251 +404,426 @@ export class RuntimeClient { } } }`, - variables: { filter } - })) - return runtimeMessageOutbox - } - - async requestInstallNotification(notification: NotificationInput) { - const { runtimeRequestInstallNotification } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeRequestInstallNotification($notification: NotificationInput!) { - runtimeRequestInstallNotification(notification: $notification) - }`, - variables: { notification } - })) - return runtimeRequestInstallNotification - } - - async grantNotification(id: string): Promise { - const { runtimeGrantNotification } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeGrantNotification($id: String!) { - runtimeGrantNotification(id: $id) - }`, - variables: { id } - })) - return runtimeGrantNotification - } - - async exportDb(filePath: string): Promise { - const { runtimeExportDb } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeExportDb($filePath: String!) { - runtimeExportDb(filePath: $filePath) - }`, - variables: { filePath } - })) - return runtimeExportDb - } - - async importDb(filePath: string): Promise { - const { runtimeImportDb } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeImportDb($filePath: String!) { - runtimeImportDb(filePath: $filePath) { - perspectives { total imported failed omitted errors } - links { total imported failed omitted errors } - expressions { total imported failed omitted errors } - perspectiveDiffs { total imported failed omitted errors } - notifications { total imported failed omitted errors } - models { total imported failed omitted errors } - defaultModels { total imported failed omitted errors } - tasks { total imported failed omitted errors } - friends { total imported failed omitted errors } - trustedAgents { total imported failed omitted errors } - knownLinkLanguages { total imported failed omitted errors } - } - }`, - variables: { filePath } - })) - return runtimeImportDb - } - - async notifications(): Promise { - const { runtimeNotifications } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeNotifications { + variables: { filter }, + }), + ); + return runtimeMessageOutbox; + } + + async requestInstallNotification(notification: NotificationInput) { + const { runtimeRequestInstallNotification } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeRequestInstallNotification( + $notification: NotificationInput! + ) { + runtimeRequestInstallNotification(notification: $notification) + } + `, + variables: { notification }, + }), + ); + return runtimeRequestInstallNotification; + } + + async grantNotification(id: string): Promise { + const { runtimeGrantNotification } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeGrantNotification($id: String!) { + runtimeGrantNotification(id: $id) + } + `, + variables: { id }, + }), + ); + return runtimeGrantNotification; + } + + async exportDb(filePath: string): Promise { + const { runtimeExportDb } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeExportDb($filePath: String!) { + runtimeExportDb(filePath: $filePath) + } + `, + variables: { filePath }, + }), + ); + return runtimeExportDb; + } + + async importDb(filePath: string): Promise { + const { runtimeImportDb } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeImportDb($filePath: String!) { + runtimeImportDb(filePath: $filePath) { + perspectives { + total + imported + failed + omitted + errors + } + links { + total + imported + failed + omitted + errors + } + expressions { + total + imported + failed + omitted + errors + } + perspectiveDiffs { + total + imported + failed + omitted + errors + } + notifications { + total + imported + failed + omitted + errors + } + models { + total + imported + failed + omitted + errors + } + defaultModels { + total + imported + failed + omitted + errors + } + tasks { + total + imported + failed + omitted + errors + } + friends { + total + imported + failed + omitted + errors + } + trustedAgents { + total + imported + failed + omitted + errors + } + knownLinkLanguages { + total + imported + failed + omitted + errors + } + } + } + `, + variables: { filePath }, + }), + ); + return runtimeImportDb; + } + + async notifications(): Promise { + const { runtimeNotifications } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql`query runtimeNotifications { runtimeNotifications { ${NOTIFICATION_FIELDS} } }`, - })) - return runtimeNotifications - } - - async updateNotification(id: string, notification: NotificationInput): Promise { - const { runtimeUpdateNotification } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeUpdateNotification($id: String!, $notification: NotificationInput!) { - runtimeUpdateNotification(id: $id, notification: $notification) - }`, - variables: { id, notification } - })) - return runtimeUpdateNotification - } - - async removeNotification(id: string): Promise { - const { runtimeRemoveNotification } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeRemoveNotification($id: String!) { - runtimeRemoveNotification(id: $id) - }`, - variables: { id } - })) - return runtimeRemoveNotification - } - - async exportPerspective(uuid: string, filePath: string): Promise { - const { runtimeExportPerspective } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeExportPerspective($perspectiveUuid: String!, $filePath: String!) { - runtimeExportPerspective(perspectiveUuid: $perspectiveUuid, filePath: $filePath) - }`, - variables: { perspectiveUuid: uuid, filePath } - })) - return runtimeExportPerspective - } - - async importPerspective(filePath: string): Promise { - const { runtimeImportPerspective } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeImportPerspective($filePath: String!) { - runtimeImportPerspective(filePath: $filePath) - }`, - variables: { filePath } - })) - return runtimeImportPerspective - } - - async multiUserEnabled(): Promise { - const { runtimeMultiUserEnabled } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeMultiUserEnabled { - runtimeMultiUserEnabled - }`, - })) - return runtimeMultiUserEnabled - } - - async setMultiUserEnabled(enabled: boolean): Promise { - const { runtimeSetMultiUserEnabled } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeSetMultiUserEnabled($enabled: Boolean!) { - runtimeSetMultiUserEnabled(enabled: $enabled) - }`, - variables: { enabled } - })) - return runtimeSetMultiUserEnabled - } - - async listUsers(): Promise { - const { runtimeListUsers } = unwrapApolloResult(await this.#apolloClient.query({ - query: gql`query runtimeListUsers { - runtimeListUsers { - email - did - lastSeen - perspectiveCount - } - }` - })) - return runtimeListUsers - } - - async emailTestModeEnable(): Promise { - const { runtimeEmailTestModeEnable } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeEmailTestModeEnable { - runtimeEmailTestModeEnable - }` - })) - return runtimeEmailTestModeEnable - } - - async emailTestModeDisable(): Promise { - const { runtimeEmailTestModeDisable } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeEmailTestModeDisable { - runtimeEmailTestModeDisable - }` - })) - return runtimeEmailTestModeDisable - } - - async emailTestGetCode(email: string): Promise { - const { runtimeEmailTestGetCode } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeEmailTestGetCode($email: String!) { - runtimeEmailTestGetCode(email: $email) - }`, - variables: { email } - })) - return runtimeEmailTestGetCode - } - - async emailTestClearCodes(): Promise { - const { runtimeEmailTestClearCodes } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeEmailTestClearCodes { - runtimeEmailTestClearCodes - }` - })) - return runtimeEmailTestClearCodes - } - - async emailTestSetExpiry(email: string, verificationType: string, expiresAt: number): Promise { - const { runtimeEmailTestSetExpiry } = unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation runtimeEmailTestSetExpiry($email: String!, $verificationType: String!, $expiresAt: Int!) { - runtimeEmailTestSetExpiry(email: $email, verificationType: $verificationType, expiresAt: $expiresAt) - }`, - variables: { email, verificationType, expiresAt } - })) - return runtimeEmailTestSetExpiry - } - - addNotificationTriggeredCallback(cb: NotificationTriggeredCallback) { - this.#notificationTriggeredCallbacks.push(cb) - } - - subscribeNotificationTriggered() { - this.#apolloClient.subscribe({ - query: gql` subscription { + }), + ); + return runtimeNotifications; + } + + async updateNotification( + id: string, + notification: NotificationInput, + ): Promise { + const { runtimeUpdateNotification } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeUpdateNotification( + $id: String! + $notification: NotificationInput! + ) { + runtimeUpdateNotification(id: $id, notification: $notification) + } + `, + variables: { id, notification }, + }), + ); + return runtimeUpdateNotification; + } + + async removeNotification(id: string): Promise { + const { runtimeRemoveNotification } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeRemoveNotification($id: String!) { + runtimeRemoveNotification(id: $id) + } + `, + variables: { id }, + }), + ); + return runtimeRemoveNotification; + } + + async exportPerspective(uuid: string, filePath: string): Promise { + const { runtimeExportPerspective } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeExportPerspective( + $perspectiveUuid: String! + $filePath: String! + ) { + runtimeExportPerspective( + perspectiveUuid: $perspectiveUuid + filePath: $filePath + ) + } + `, + variables: { perspectiveUuid: uuid, filePath }, + }), + ); + return runtimeExportPerspective; + } + + async importPerspective(filePath: string): Promise { + const { runtimeImportPerspective } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeImportPerspective($filePath: String!) { + runtimeImportPerspective(filePath: $filePath) + } + `, + variables: { filePath }, + }), + ); + return runtimeImportPerspective; + } + + async multiUserEnabled(): Promise { + const { runtimeMultiUserEnabled } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeMultiUserEnabled { + runtimeMultiUserEnabled + } + `, + }), + ); + return runtimeMultiUserEnabled; + } + + async setMultiUserEnabled(enabled: boolean): Promise { + const { runtimeSetMultiUserEnabled } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeSetMultiUserEnabled($enabled: Boolean!) { + runtimeSetMultiUserEnabled(enabled: $enabled) + } + `, + variables: { enabled }, + }), + ); + return runtimeSetMultiUserEnabled; + } + + async listUsers(): Promise { + const { runtimeListUsers } = unwrapApolloResult( + await this._apolloClient.query({ + query: gql` + query runtimeListUsers { + runtimeListUsers { + email + did + lastSeen + perspectiveCount + } + } + `, + }), + ); + return runtimeListUsers; + } + + async emailTestModeEnable(): Promise { + const { runtimeEmailTestModeEnable } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeEmailTestModeEnable { + runtimeEmailTestModeEnable + } + `, + }), + ); + return runtimeEmailTestModeEnable; + } + + async emailTestModeDisable(): Promise { + const { runtimeEmailTestModeDisable } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeEmailTestModeDisable { + runtimeEmailTestModeDisable + } + `, + }), + ); + return runtimeEmailTestModeDisable; + } + + async emailTestGetCode(email: string): Promise { + const { runtimeEmailTestGetCode } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeEmailTestGetCode($email: String!) { + runtimeEmailTestGetCode(email: $email) + } + `, + variables: { email }, + }), + ); + return runtimeEmailTestGetCode; + } + + async emailTestClearCodes(): Promise { + const { runtimeEmailTestClearCodes } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeEmailTestClearCodes { + runtimeEmailTestClearCodes + } + `, + }), + ); + return runtimeEmailTestClearCodes; + } + + async emailTestSetExpiry( + email: string, + verificationType: string, + expiresAt: number, + ): Promise { + const { runtimeEmailTestSetExpiry } = unwrapApolloResult( + await this._apolloClient.mutate({ + mutation: gql` + mutation runtimeEmailTestSetExpiry( + $email: String! + $verificationType: String! + $expiresAt: Int! + ) { + runtimeEmailTestSetExpiry( + email: $email + verificationType: $verificationType + expiresAt: $expiresAt + ) + } + `, + variables: { email, verificationType, expiresAt }, + }), + ); + return runtimeEmailTestSetExpiry; + } + + addNotificationTriggeredCallback(cb: NotificationTriggeredCallback) { + this._notificationTriggeredCallbacks.push(cb); + } + + subscribeNotificationTriggered() { + this._apolloClient + .subscribe({ + query: gql` subscription { runtimeNotificationTriggered { ${TRIGGERED_NOTIFICATION_FIELDS} } } - `}).subscribe({ - next: result => { - this.#notificationTriggeredCallbacks.forEach(cb => { - cb(result.data.runtimeNotificationTriggered) - }) - }, - error: (e) => console.error(e) - }) - } - - addMessageCallback(cb: MessageCallback) { - this.#messageReceivedCallbacks.push(cb) - } - - subscribeMessageReceived() { - this.#apolloClient.subscribe({ - query: gql` subscription { + `, + }) + .subscribe({ + next: (result) => { + this._notificationTriggeredCallbacks.forEach((cb) => { + cb(result.data.runtimeNotificationTriggered); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + } + + addMessageCallback(cb: MessageCallback) { + this._messageReceivedCallbacks.push(cb); + } + + subscribeMessageReceived() { + this._apolloClient + .subscribe({ + query: gql` subscription { runtimeMessageReceived { ${PERSPECTIVE_EXPRESSION_FIELDS} } } - `}).subscribe({ - next: result => { - this.#messageReceivedCallbacks.forEach(cb => { - cb(result.data.runtimeMessageReceived) - }) - }, - error: (e) => console.error(e) - }) - } - - addExceptionCallback(cb: ExceptionCallback) { - this.#exceptionOccurredCallbacks.push(cb) - } - - subscribeExceptionOccurred() { - this.#apolloClient.subscribe({ - query: gql` subscription { - exceptionOccurred { - title - message - type - addon - } - }` - }).subscribe({ - next: result => { - this.#exceptionOccurredCallbacks.forEach(cb => { - cb(result.data.exceptionOccurred) - }) - }, - error: (e) => console.error(e) - }) - } -} \ No newline at end of file + `, + }) + .subscribe({ + next: (result) => { + this._messageReceivedCallbacks.forEach((cb) => { + cb(result.data.runtimeMessageReceived); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + } + + addExceptionCallback(cb: ExceptionCallback) { + this._exceptionOccurredCallbacks.push(cb); + } + + subscribeExceptionOccurred() { + this._apolloClient + .subscribe({ + query: gql` + subscription { + exceptionOccurred { + title + message + type + addon + } + } + `, + }) + .subscribe({ + next: (result) => { + this._exceptionOccurredCallbacks.forEach((cb) => { + cb(result.data.exceptionOccurred); + }); + }, + error: (e) => { + if (!isSocketCloseError(e)) console.error(e); + }, + }); + } +} diff --git a/core/src/shacl/SHACLFlow.test.ts b/core/src/shacl/SHACLFlow.test.ts index 417c1ceb1..7898b03c3 100644 --- a/core/src/shacl/SHACLFlow.test.ts +++ b/core/src/shacl/SHACLFlow.test.ts @@ -1,233 +1,300 @@ -import { SHACLFlow, FlowState, FlowTransition, AD4MAction } from './SHACLFlow'; - -describe('SHACLFlow', () => { - describe('basic construction', () => { - it('creates a flow with name and namespace', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - expect(flow.name).toBe('TODO'); - expect(flow.namespace).toBe('todo://'); - expect(flow.flowUri).toBe('todo://TODOFlow'); +import { SHACLFlow } from "./SHACLFlow"; + +describe("SHACLFlow", () => { + describe("basic construction", () => { + it("creates a flow with name and namespace", () => { + const flow = new SHACLFlow("TODO", "todo://"); + expect(flow.name).toBe("TODO"); + expect(flow.namespace).toBe("todo://"); + expect(flow.flowUri).toBe("todo://TODOFlow"); }); - it('generates correct state URIs', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - expect(flow.stateUri('ready')).toBe('todo://TODO.ready'); - expect(flow.stateUri('done')).toBe('todo://TODO.done'); + it("generates correct state URIs", () => { + const flow = new SHACLFlow("TODO", "todo://"); + expect(flow.stateUri("ready")).toBe("todo://TODO.ready"); + expect(flow.stateUri("done")).toBe("todo://TODO.done"); }); - it('generates correct transition URIs', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - expect(flow.transitionUri('ready', 'doing')).toBe('todo://TODO.readyTodoing'); + it("generates correct transition URIs", () => { + const flow = new SHACLFlow("TODO", "todo://"); + expect(flow.transitionUri("ready", "doing")).toBe( + "todo://TODO.readyTodoing", + ); }); }); - describe('state management', () => { - it('adds and retrieves states', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - + describe("state management", () => { + it("adds and retrieves states", () => { + const flow = new SHACLFlow("TODO", "todo://"); + flow.addState({ - name: 'ready', + name: "ready", value: 0, - stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + stateCheck: { predicate: "todo://state", target: "todo://ready" }, }); - + flow.addState({ - name: 'done', + name: "done", value: 1, - stateCheck: { predicate: 'todo://state', target: 'todo://done' } + stateCheck: { predicate: "todo://state", target: "todo://done" }, }); - + expect(flow.states.length).toBe(2); - expect(flow.states[0].name).toBe('ready'); - expect(flow.states[1].name).toBe('done'); + expect(flow.states[0].name).toBe("ready"); + expect(flow.states[1].name).toBe("done"); }); }); - describe('transition management', () => { - it('adds and retrieves transitions', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - + describe("transition management", () => { + it("adds and retrieves transitions", () => { + const flow = new SHACLFlow("TODO", "todo://"); + flow.addTransition({ - actionName: 'Complete', - fromState: 'ready', - toState: 'done', + actionName: "Complete", + fromState: "ready", + toState: "done", actions: [ - { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, - { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } - ] + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://done", + }, + { + action: "removeLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, + ], }); - + expect(flow.transitions.length).toBe(1); - expect(flow.transitions[0].actionName).toBe('Complete'); + expect(flow.transitions[0].actionName).toBe("Complete"); expect(flow.transitions[0].actions.length).toBe(2); }); }); - describe('toLinks()', () => { - it('serializes flow to links', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - flow.flowable = 'any'; + describe("toLinks()", () => { + it("serializes flow to links", () => { + const flow = new SHACLFlow("TODO", "todo://"); + flow.flowable = "any"; flow.startAction = [ - { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, ]; - + flow.addState({ - name: 'ready', + name: "ready", value: 0, - stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + stateCheck: { predicate: "todo://state", target: "todo://ready" }, }); - + flow.addTransition({ - actionName: 'Start', - fromState: 'ready', - toState: 'doing', - actions: [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' }] + actionName: "Start", + fromState: "ready", + toState: "doing", + actions: [ + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://doing", + }, + ], }); - + const links = flow.toLinks(); - + // Check flow type link - const typeLink = links.find(l => l.predicate === 'rdf://type' && l.target === 'ad4m://Flow'); + const typeLink = links.find( + (l) => l.predicate === "rdf://type" && l.target === "ad4m://Flow", + ); expect(typeLink).toBeDefined(); - expect(typeLink!.source).toBe('todo://TODOFlow'); - + expect(typeLink!.source).toBe("todo://TODOFlow"); + // Check flowable link - const flowableLink = links.find(l => l.predicate === 'ad4m://flowable'); + const flowableLink = links.find((l) => l.predicate === "ad4m://flowable"); expect(flowableLink).toBeDefined(); - expect(flowableLink!.target).toBe('ad4m://any'); - + expect(flowableLink!.target).toBe("ad4m://any"); + // Check start action link - const startActionLink = links.find(l => l.predicate === 'ad4m://startAction'); + const startActionLink = links.find( + (l) => l.predicate === "ad4m://startAction", + ); expect(startActionLink).toBeDefined(); - expect(startActionLink!.target).toContain('addLink'); - + expect(startActionLink!.target).toContain("addLink"); + // Check state link - const stateLink = links.find(l => l.predicate === 'ad4m://hasState'); + const stateLink = links.find((l) => l.predicate === "ad4m://hasState"); expect(stateLink).toBeDefined(); - expect(stateLink!.target).toBe('todo://TODO.ready'); - + expect(stateLink!.target).toBe("todo://TODO.ready"); + // Check transition link - const transitionLink = links.find(l => l.predicate === 'ad4m://hasTransition'); + const transitionLink = links.find( + (l) => l.predicate === "ad4m://hasTransition", + ); expect(transitionLink).toBeDefined(); }); }); - describe('fromLinks()', () => { - it('reconstructs flow from links', () => { - const original = new SHACLFlow('TODO', 'todo://'); - original.flowable = 'any'; + describe("fromLinks()", () => { + it("reconstructs flow from links", () => { + const original = new SHACLFlow("TODO", "todo://"); + original.flowable = "any"; original.startAction = [ - { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, ]; original.addState({ - name: 'ready', + name: "ready", value: 0, - stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + stateCheck: { predicate: "todo://state", target: "todo://ready" }, }); original.addState({ - name: 'done', + name: "done", value: 1, - stateCheck: { predicate: 'todo://state', target: 'todo://done' } + stateCheck: { predicate: "todo://state", target: "todo://done" }, }); original.addTransition({ - actionName: 'Complete', - fromState: 'ready', - toState: 'done', - actions: [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }] + actionName: "Complete", + fromState: "ready", + toState: "done", + actions: [ + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://done", + }, + ], }); - + const links = original.toLinks(); - const reconstructed = SHACLFlow.fromLinks(links, 'todo://TODOFlow'); - - expect(reconstructed.name).toBe('TODO'); - expect(reconstructed.namespace).toBe('todo://'); - expect(reconstructed.flowable).toBe('any'); + const reconstructed = SHACLFlow.fromLinks(links, "todo://TODOFlow"); + + expect(reconstructed.name).toBe("TODO"); + expect(reconstructed.namespace).toBe("todo://"); + expect(reconstructed.flowable).toBe("any"); expect(reconstructed.startAction.length).toBe(1); expect(reconstructed.states.length).toBe(2); expect(reconstructed.transitions.length).toBe(1); - expect(reconstructed.transitions[0].actionName).toBe('Complete'); + expect(reconstructed.transitions[0].actionName).toBe("Complete"); }); }); - describe('JSON serialization', () => { - it('converts to and from JSON', () => { - const original = new SHACLFlow('TODO', 'todo://'); + describe("JSON serialization", () => { + it("converts to and from JSON", () => { + const original = new SHACLFlow("TODO", "todo://"); original.addState({ - name: 'ready', + name: "ready", value: 0, - stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + stateCheck: { predicate: "todo://state", target: "todo://ready" }, }); original.addTransition({ - actionName: 'Start', - fromState: 'ready', - toState: 'doing', - actions: [] + actionName: "Start", + fromState: "ready", + toState: "doing", + actions: [], }); - + const json = original.toJSON(); const reconstructed = SHACLFlow.fromJSON(json); - - expect(reconstructed.name).toBe('TODO'); + + expect(reconstructed.name).toBe("TODO"); expect(reconstructed.states.length).toBe(1); expect(reconstructed.transitions.length).toBe(1); }); }); - describe('full TODO example', () => { - it('creates complete TODO flow matching Prolog example', () => { - const flow = new SHACLFlow('TODO', 'todo://'); - flow.flowable = 'any'; - + describe("full TODO example", () => { + it("creates complete TODO flow matching Prolog example", () => { + const flow = new SHACLFlow("TODO", "todo://"); + flow.flowable = "any"; + // Start action - renders expression as TODO in 'ready' state flow.startAction = [ - { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, ]; - + // Three states flow.addState({ - name: 'ready', + name: "ready", value: 0, - stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + stateCheck: { predicate: "todo://state", target: "todo://ready" }, }); flow.addState({ - name: 'doing', + name: "doing", value: 0.5, - stateCheck: { predicate: 'todo://state', target: 'todo://doing' } + stateCheck: { predicate: "todo://state", target: "todo://doing" }, }); flow.addState({ - name: 'done', + name: "done", value: 1, - stateCheck: { predicate: 'todo://state', target: 'todo://done' } + stateCheck: { predicate: "todo://state", target: "todo://done" }, }); - + // Transitions flow.addTransition({ - actionName: 'Start', - fromState: 'ready', - toState: 'doing', + actionName: "Start", + fromState: "ready", + toState: "doing", actions: [ - { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' }, - { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } - ] + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://doing", + }, + { + action: "removeLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, + ], }); flow.addTransition({ - actionName: 'Finish', - fromState: 'doing', - toState: 'done', + actionName: "Finish", + fromState: "doing", + toState: "done", actions: [ - { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, - { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' } - ] + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://done", + }, + { + action: "removeLink", + source: "this", + predicate: "todo://state", + target: "todo://doing", + }, + ], }); - + // Verify structure expect(flow.states.length).toBe(3); expect(flow.transitions.length).toBe(2); - + // Verify links generation const links = flow.toLinks(); expect(links.length).toBeGreaterThan(15); // Flow + 3 states + 2 transitions = many links - + // Verify round-trip const reconstructed = SHACLFlow.fromLinks(links, flow.flowUri); expect(reconstructed.states.length).toBe(3); diff --git a/core/src/shacl/SHACLShape.test.ts b/core/src/shacl/SHACLShape.test.ts index 0432da6a9..4bd1e39c1 100644 --- a/core/src/shacl/SHACLShape.test.ts +++ b/core/src/shacl/SHACLShape.test.ts @@ -1,24 +1,26 @@ -import { SHACLShape, SHACLPropertyShape, AD4MAction } from './SHACLShape'; +import { SHACLShape, SHACLPropertyShape, AD4MAction } from "./SHACLShape"; -describe('SHACLShape', () => { - describe('toLinks()', () => { - it('creates basic shape links', () => { - const shape = new SHACLShape('recipe://Recipe'); +describe("SHACLShape", () => { + describe("toLinks()", () => { + it("creates basic shape links", () => { + const shape = new SHACLShape("recipe://Recipe"); const links = shape.toLinks(); // Should have targetClass link - const targetClassLink = links.find(l => l.predicate === 'sh://targetClass'); + const targetClassLink = links.find( + (l) => l.predicate === "sh://targetClass", + ); expect(targetClassLink).toBeDefined(); - expect(targetClassLink!.source).toBe('recipe://RecipeShape'); - expect(targetClassLink!.target).toBe('recipe://Recipe'); + expect(targetClassLink!.source).toBe("recipe://RecipeShape"); + expect(targetClassLink!.target).toBe("recipe://Recipe"); }); - it('creates property shape links with named URIs', () => { - const shape = new SHACLShape('recipe://Recipe'); + it("creates property shape links with named URIs", () => { + const shape = new SHACLShape("recipe://Recipe"); const prop: SHACLPropertyShape = { - name: 'name', - path: 'recipe://name', - datatype: 'xsd:string', + name: "name", + path: "recipe://name", + datatype: "xsd:string", minCount: 1, maxCount: 1, }; @@ -26,293 +28,351 @@ describe('SHACLShape', () => { const links = shape.toLinks(); // Property shape should use named URI - const propLink = links.find(l => l.predicate === 'sh://property'); + const propLink = links.find((l) => l.predicate === "sh://property"); expect(propLink).toBeDefined(); - expect(propLink!.target).toBe('recipe://Recipe.name'); // Named URI, not blank node + expect(propLink!.target).toBe("recipe://Recipe.name"); // Named URI, not blank node // Path link - const pathLink = links.find(l => - l.source === 'recipe://Recipe.name' && l.predicate === 'sh://path' + const pathLink = links.find( + (l) => + l.source === "recipe://Recipe.name" && l.predicate === "sh://path", ); expect(pathLink).toBeDefined(); - expect(pathLink!.target).toBe('recipe://name'); + expect(pathLink!.target).toBe("recipe://name"); // Datatype link - const datatypeLink = links.find(l => - l.source === 'recipe://Recipe.name' && l.predicate === 'sh://datatype' + const datatypeLink = links.find( + (l) => + l.source === "recipe://Recipe.name" && + l.predicate === "sh://datatype", ); expect(datatypeLink).toBeDefined(); - expect(datatypeLink!.target).toBe('xsd:string'); + expect(datatypeLink!.target).toBe("xsd:string"); // Cardinality links - const minCountLink = links.find(l => - l.source === 'recipe://Recipe.name' && l.predicate === 'sh://minCount' + const minCountLink = links.find( + (l) => + l.source === "recipe://Recipe.name" && + l.predicate === "sh://minCount", ); expect(minCountLink).toBeDefined(); - expect(minCountLink!.target).toContain('1'); + expect(minCountLink!.target).toContain("1"); - const maxCountLink = links.find(l => - l.source === 'recipe://Recipe.name' && l.predicate === 'sh://maxCount' + const maxCountLink = links.find( + (l) => + l.source === "recipe://Recipe.name" && + l.predicate === "sh://maxCount", ); expect(maxCountLink).toBeDefined(); - expect(maxCountLink!.target).toContain('1'); + expect(maxCountLink!.target).toContain("1"); }); - it('creates action links', () => { - const shape = new SHACLShape('recipe://Recipe'); + it("creates action links", () => { + const shape = new SHACLShape("recipe://Recipe"); const setterAction: AD4MAction = { - action: 'addLink', - source: 'this', - predicate: 'recipe://name', - target: 'value', + action: "addLink", + source: "this", + predicate: "recipe://name", + target: "value", }; const prop: SHACLPropertyShape = { - name: 'title', - path: 'recipe://title', + name: "title", + path: "recipe://title", setter: [setterAction], }; shape.addProperty(prop); const links = shape.toLinks(); // Setter action link - const setterLink = links.find(l => - l.source === 'recipe://Recipe.title' && l.predicate === 'ad4m://setter' + const setterLink = links.find( + (l) => + l.source === "recipe://Recipe.title" && + l.predicate === "ad4m://setter", ); expect(setterLink).toBeDefined(); - expect(setterLink!.target).toContain('addLink'); + expect(setterLink!.target).toContain("addLink"); }); - it('includes constructor and destructor actions', () => { - const shape = new SHACLShape('recipe://Recipe'); - shape.constructor_actions = [{ - action: 'addLink', - source: 'this', - predicate: 'ad4m://type', - target: 'recipe://Recipe', - }]; - shape.destructor_actions = [{ - action: 'removeLink', - source: 'this', - predicate: 'ad4m://type', - target: 'recipe://Recipe', - }]; + it("includes constructor and destructor actions", () => { + const shape = new SHACLShape("recipe://Recipe"); + shape.constructor_actions = [ + { + action: "addLink", + source: "this", + predicate: "ad4m://type", + target: "recipe://Recipe", + }, + ]; + shape.destructor_actions = [ + { + action: "removeLink", + source: "this", + predicate: "ad4m://type", + target: "recipe://Recipe", + }, + ]; const links = shape.toLinks(); - const constructorLink = links.find(l => l.predicate === 'ad4m://constructor'); + const constructorLink = links.find( + (l) => l.predicate === "ad4m://constructor", + ); expect(constructorLink).toBeDefined(); - expect(constructorLink!.target).toContain('addLink'); + expect(constructorLink!.target).toContain("addLink"); - const destructorLink = links.find(l => l.predicate === 'ad4m://destructor'); + const destructorLink = links.find( + (l) => l.predicate === "ad4m://destructor", + ); expect(destructorLink).toBeDefined(); - expect(destructorLink!.target).toContain('removeLink'); + expect(destructorLink!.target).toContain("removeLink"); }); }); - describe('fromLinks()', () => { - it('reconstructs shape from links', () => { - const originalShape = new SHACLShape('recipe://Recipe'); + describe("fromLinks()", () => { + it("reconstructs shape from links", () => { + const originalShape = new SHACLShape("recipe://Recipe"); originalShape.addProperty({ - name: 'name', - path: 'recipe://name', - datatype: 'xsd:string', + name: "name", + path: "recipe://name", + datatype: "xsd:string", minCount: 1, }); - + const links = originalShape.toLinks(); - const reconstructed = SHACLShape.fromLinks(links, 'recipe://RecipeShape'); + const reconstructed = SHACLShape.fromLinks(links, "recipe://RecipeShape"); - expect(reconstructed.targetClass).toBe('recipe://Recipe'); + expect(reconstructed.targetClass).toBe("recipe://Recipe"); expect(reconstructed.properties.length).toBe(1); - expect(reconstructed.properties[0].path).toBe('recipe://name'); - expect(reconstructed.properties[0].datatype).toBe('xsd:string'); + expect(reconstructed.properties[0].path).toBe("recipe://name"); + expect(reconstructed.properties[0].datatype).toBe("xsd:string"); expect(reconstructed.properties[0].minCount).toBe(1); }); - it('handles multiple properties', () => { - const originalShape = new SHACLShape('recipe://Recipe'); + it("handles multiple properties", () => { + const originalShape = new SHACLShape("recipe://Recipe"); originalShape.addProperty({ - name: 'name', - path: 'recipe://name', - datatype: 'xsd:string', + name: "name", + path: "recipe://name", + datatype: "xsd:string", }); originalShape.addProperty({ - name: 'servings', - path: 'recipe://servings', - datatype: 'xsd:integer', + name: "servings", + path: "recipe://servings", + datatype: "xsd:integer", }); - + const links = originalShape.toLinks(); - const reconstructed = SHACLShape.fromLinks(links, 'recipe://RecipeShape'); + const reconstructed = SHACLShape.fromLinks(links, "recipe://RecipeShape"); expect(reconstructed.properties.length).toBe(2); - const nameProp = reconstructed.properties.find(p => p.path === 'recipe://name'); - const servingsProp = reconstructed.properties.find(p => p.path === 'recipe://servings'); + const nameProp = reconstructed.properties.find( + (p) => p.path === "recipe://name", + ); + const servingsProp = reconstructed.properties.find( + (p) => p.path === "recipe://servings", + ); expect(nameProp).toBeDefined(); expect(servingsProp).toBeDefined(); - expect(nameProp!.datatype).toBe('xsd:string'); - expect(servingsProp!.datatype).toBe('xsd:integer'); + expect(nameProp!.datatype).toBe("xsd:string"); + expect(servingsProp!.datatype).toBe("xsd:integer"); }); - it('reconstructs action arrays', () => { - const originalShape = new SHACLShape('recipe://Recipe'); + it("reconstructs action arrays", () => { + const originalShape = new SHACLShape("recipe://Recipe"); const setterAction: AD4MAction = { - action: 'addLink', - source: 'this', - predicate: 'recipe://name', - target: 'value', + action: "addLink", + source: "this", + predicate: "recipe://name", + target: "value", }; originalShape.addProperty({ - name: 'name', - path: 'recipe://name', + name: "name", + path: "recipe://name", setter: [setterAction], }); - + const links = originalShape.toLinks(); - const reconstructed = SHACLShape.fromLinks(links, 'recipe://RecipeShape'); + const reconstructed = SHACLShape.fromLinks(links, "recipe://RecipeShape"); expect(reconstructed.properties[0].setter).toBeDefined(); expect(reconstructed.properties[0].setter!.length).toBe(1); - expect(reconstructed.properties[0].setter![0].action).toBe('addLink'); + expect(reconstructed.properties[0].setter![0].action).toBe("addLink"); }); }); - describe('round-trip serialization', () => { - it('preserves all property attributes', () => { - const original = new SHACLShape('test://Model'); + describe("round-trip serialization", () => { + it("preserves all property attributes", () => { + const original = new SHACLShape("test://Model"); original.addProperty({ - name: 'field', - path: 'test://field', - datatype: 'xsd:string', - nodeKind: 'Literal', + name: "field", + path: "test://field", + datatype: "xsd:string", + nodeKind: "Literal", minCount: 0, maxCount: 5, - pattern: '^[a-z]+$', + pattern: "^[a-z]+$", minInclusive: 10, maxInclusive: 100, - hasValue: 'expectedValue', - resolveLanguage: 'test://language', + hasValue: "expectedValue", + resolveLanguage: "test://language", local: true, - writable: true, - setter: [{ action: 'addLink', source: 'this', predicate: 'test://field', target: 'value' }], - adder: [{ action: 'addLink', source: 'this', predicate: 'test://items', target: 'value' }], - remover: [{ action: 'removeLink', source: 'this', predicate: 'test://items', target: 'value' }], + setter: [ + { + action: "addLink", + source: "this", + predicate: "test://field", + target: "value", + }, + ], + adder: [ + { + action: "addLink", + source: "this", + predicate: "test://items", + target: "value", + }, + ], + remover: [ + { + action: "removeLink", + source: "this", + predicate: "test://items", + target: "value", + }, + ], }); const links = original.toLinks(); - const reconstructed = SHACLShape.fromLinks(links, 'test://ModelShape'); + const reconstructed = SHACLShape.fromLinks(links, "test://ModelShape"); const prop = reconstructed.properties[0]; - expect(prop.path).toBe('test://field'); - expect(prop.datatype).toBe('xsd:string'); - expect(prop.nodeKind).toBe('Literal'); + expect(prop.path).toBe("test://field"); + expect(prop.datatype).toBe("xsd:string"); + expect(prop.nodeKind).toBe("Literal"); expect(prop.minCount).toBe(0); expect(prop.maxCount).toBe(5); - expect(prop.pattern).toBe('^[a-z]+$'); + expect(prop.pattern).toBe("^[a-z]+$"); expect(prop.minInclusive).toBe(10); expect(prop.maxInclusive).toBe(100); - expect(prop.hasValue).toBe('expectedValue'); - expect(prop.resolveLanguage).toBe('test://language'); + expect(prop.hasValue).toBe("expectedValue"); + expect(prop.resolveLanguage).toBe("test://language"); expect(prop.local).toBe(true); - expect(prop.writable).toBe(true); + expect(prop.readOnly).toBeFalsy(); expect(prop.setter).toBeDefined(); expect(prop.adder).toBeDefined(); expect(prop.remover).toBeDefined(); }); - it('preserves constructor and destructor actions', () => { - const original = new SHACLShape('test://Model'); + it("preserves constructor and destructor actions", () => { + const original = new SHACLShape("test://Model"); original.constructor_actions = [ - { action: 'addLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + { + action: "addLink", + source: "this", + predicate: "rdf://type", + target: "test://Model", + }, ]; original.destructor_actions = [ - { action: 'removeLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + { + action: "removeLink", + source: "this", + predicate: "rdf://type", + target: "test://Model", + }, ]; const links = original.toLinks(); - const reconstructed = SHACLShape.fromLinks(links, 'test://ModelShape'); + const reconstructed = SHACLShape.fromLinks(links, "test://ModelShape"); expect(reconstructed.constructor_actions).toBeDefined(); expect(reconstructed.constructor_actions!.length).toBe(1); - expect(reconstructed.constructor_actions![0].action).toBe('addLink'); + expect(reconstructed.constructor_actions![0].action).toBe("addLink"); expect(reconstructed.destructor_actions).toBeDefined(); expect(reconstructed.destructor_actions!.length).toBe(1); - expect(reconstructed.destructor_actions![0].action).toBe('removeLink'); + expect(reconstructed.destructor_actions![0].action).toBe("removeLink"); }); }); - describe('edge cases', () => { - it('handles empty shape', () => { - const shape = new SHACLShape('test://Empty'); + describe("edge cases", () => { + it("handles empty shape", () => { + const shape = new SHACLShape("test://Empty"); const links = shape.toLinks(); - + expect(links.length).toBeGreaterThanOrEqual(1); // At least targetClass link - - const reconstructed = SHACLShape.fromLinks(links, 'test://EmptyShape'); - expect(reconstructed.targetClass).toBe('test://Empty'); + + const reconstructed = SHACLShape.fromLinks(links, "test://EmptyShape"); + expect(reconstructed.targetClass).toBe("test://Empty"); expect(reconstructed.properties.length).toBe(0); }); - it('handles URI with hash fragment', () => { - const shape = new SHACLShape('https://example.com/vocab#Recipe'); + it("handles URI with hash fragment", () => { + const shape = new SHACLShape("https://example.com/vocab#Recipe"); const links = shape.toLinks(); - - const targetClassLink = links.find(l => l.predicate === 'sh://targetClass'); - expect(targetClassLink!.source).toBe('https://example.com/vocab#RecipeShape'); + + const targetClassLink = links.find( + (l) => l.predicate === "sh://targetClass", + ); + expect(targetClassLink!.source).toBe( + "https://example.com/vocab#RecipeShape", + ); }); - it('falls back to blank nodes when property has no name', () => { - const shape = new SHACLShape('test://Model'); + it("falls back to blank nodes when property has no name", () => { + const shape = new SHACLShape("test://Model"); shape.addProperty({ - path: 'test://unnamed', + path: "test://unnamed", // No name property }); const links = shape.toLinks(); - - const propLink = links.find(l => l.predicate === 'sh://property'); + + const propLink = links.find((l) => l.predicate === "sh://property"); expect(propLink).toBeDefined(); // Should use blank node format when no name provided expect(propLink!.target).toMatch(/_:propShape\d+|test:\/\/Model\./); }); }); - describe('toJSON/fromJSON', () => { - it('serializes and deserializes basic shape', () => { - const original = new SHACLShape('recipe://Recipe'); + describe("toJSON/fromJSON", () => { + it("serializes and deserializes basic shape", () => { + const original = new SHACLShape("recipe://Recipe"); original.addProperty({ - name: 'name', - path: 'recipe://name', - datatype: 'xsd:string', + name: "name", + path: "recipe://name", + datatype: "xsd:string", minCount: 1, }); const json = original.toJSON(); const reconstructed = SHACLShape.fromJSON(json); - expect(reconstructed.targetClass).toBe('recipe://Recipe'); + expect(reconstructed.targetClass).toBe("recipe://Recipe"); expect(reconstructed.properties.length).toBe(1); - expect(reconstructed.properties[0].name).toBe('name'); - expect(reconstructed.properties[0].path).toBe('recipe://name'); - expect(reconstructed.properties[0].datatype).toBe('xsd:string'); + expect(reconstructed.properties[0].name).toBe("name"); + expect(reconstructed.properties[0].path).toBe("recipe://name"); + expect(reconstructed.properties[0].datatype).toBe("xsd:string"); expect(reconstructed.properties[0].minCount).toBe(1); }); - it('preserves nodeShapeUri in round-trip', () => { - const original = new SHACLShape('custom://CustomShape', 'recipe://Recipe'); + it("preserves nodeShapeUri in round-trip", () => { + const original = new SHACLShape( + "custom://CustomShape", + "recipe://Recipe", + ); const json = original.toJSON(); const reconstructed = SHACLShape.fromJSON(json); - expect(reconstructed.nodeShapeUri).toBe('custom://CustomShape'); - expect(reconstructed.targetClass).toBe('recipe://Recipe'); + expect(reconstructed.nodeShapeUri).toBe("custom://CustomShape"); + expect(reconstructed.targetClass).toBe("recipe://Recipe"); }); - it('preserves minInclusive and maxInclusive', () => { - const original = new SHACLShape('test://Model'); + it("preserves minInclusive and maxInclusive", () => { + const original = new SHACLShape("test://Model"); original.addProperty({ - name: 'rating', - path: 'test://rating', - datatype: 'xsd:integer', + name: "rating", + path: "test://rating", + datatype: "xsd:integer", minInclusive: 1, maxInclusive: 5, }); @@ -324,74 +384,108 @@ describe('SHACLShape', () => { expect(reconstructed.properties[0].maxInclusive).toBe(5); }); - it('preserves resolveLanguage', () => { - const original = new SHACLShape('test://Model'); + it("preserves resolveLanguage", () => { + const original = new SHACLShape("test://Model"); original.addProperty({ - name: 'content', - path: 'test://content', - resolveLanguage: 'literal', + name: "content", + path: "test://content", + resolveLanguage: "literal", }); const json = original.toJSON(); const reconstructed = SHACLShape.fromJSON(json); - expect(reconstructed.properties[0].resolveLanguage).toBe('literal'); + expect(reconstructed.properties[0].resolveLanguage).toBe("literal"); }); - it('preserves constructor and destructor actions', () => { - const original = new SHACLShape('test://Model'); + it("preserves constructor and destructor actions", () => { + const original = new SHACLShape("test://Model"); original.constructor_actions = [ - { action: 'addLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + { + action: "addLink", + source: "this", + predicate: "rdf://type", + target: "test://Model", + }, ]; original.destructor_actions = [ - { action: 'removeLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + { + action: "removeLink", + source: "this", + predicate: "rdf://type", + target: "test://Model", + }, ]; const json = original.toJSON(); const reconstructed = SHACLShape.fromJSON(json); - expect(reconstructed.constructor_actions).toEqual(original.constructor_actions); - expect(reconstructed.destructor_actions).toEqual(original.destructor_actions); + expect(reconstructed.constructor_actions).toEqual( + original.constructor_actions, + ); + expect(reconstructed.destructor_actions).toEqual( + original.destructor_actions, + ); }); - it('preserves all property attributes', () => { - const original = new SHACLShape('test://Model'); + it("preserves all property attributes", () => { + const original = new SHACLShape("test://Model"); original.addProperty({ - name: 'field', - path: 'test://field', - datatype: 'xsd:string', - nodeKind: 'Literal', + name: "field", + path: "test://field", + datatype: "xsd:string", + nodeKind: "Literal", minCount: 0, maxCount: 5, minInclusive: 0, maxInclusive: 100, - pattern: '^[a-z]+$', - hasValue: 'default', + pattern: "^[a-z]+$", + hasValue: "default", local: true, - writable: true, - resolveLanguage: 'literal', - setter: [{ action: 'addLink', source: 'this', predicate: 'test://field', target: 'value' }], - adder: [{ action: 'addLink', source: 'this', predicate: 'test://items', target: 'value' }], - remover: [{ action: 'removeLink', source: 'this', predicate: 'test://items', target: 'value' }], + resolveLanguage: "literal", + setter: [ + { + action: "addLink", + source: "this", + predicate: "test://field", + target: "value", + }, + ], + adder: [ + { + action: "addLink", + source: "this", + predicate: "test://items", + target: "value", + }, + ], + remover: [ + { + action: "removeLink", + source: "this", + predicate: "test://items", + target: "value", + }, + ], }); const json = original.toJSON(); const reconstructed = SHACLShape.fromJSON(json); const prop = reconstructed.properties[0]; - expect(prop.name).toBe('field'); - expect(prop.path).toBe('test://field'); - expect(prop.datatype).toBe('xsd:string'); - expect(prop.nodeKind).toBe('Literal'); + expect(prop.name).toBe("field"); + expect(prop.path).toBe("test://field"); + expect(prop.datatype).toBe("xsd:string"); + expect(prop.nodeKind).toBe("Literal"); expect(prop.minCount).toBe(0); expect(prop.maxCount).toBe(5); expect(prop.minInclusive).toBe(0); expect(prop.maxInclusive).toBe(100); - expect(prop.pattern).toBe('^[a-z]+$'); - expect(prop.hasValue).toBe('default'); + expect(prop.pattern).toBe("^[a-z]+$"); + expect(prop.hasValue).toBe("default"); expect(prop.local).toBe(true); - expect(prop.writable).toBe(true); - expect(prop.resolveLanguage).toBe('literal'); + expect(prop.readOnly).toBeFalsy(); + expect(prop.resolveLanguage).toBe("literal"); expect(prop.setter).toEqual(original.properties[0].setter); expect(prop.adder).toEqual(original.properties[0].adder); expect(prop.remover).toEqual(original.properties[0].remover); diff --git a/core/src/shacl/SHACLShape.ts b/core/src/shacl/SHACLShape.ts index e8c104f02..2d59f98c3 100644 --- a/core/src/shacl/SHACLShape.ts +++ b/core/src/shacl/SHACLShape.ts @@ -10,16 +10,16 @@ import { Link } from "../links/Links"; */ function extractNamespace(uri: string): string { // Handle hash fragments first (highest priority) - const hashIndex = uri.lastIndexOf('#'); + const hashIndex = uri.lastIndexOf("#"); if (hashIndex !== -1) { return uri.substring(0, hashIndex + 1); } - + // Handle protocol-style URIs with paths const protocolMatch = uri.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(.*)$/); if (protocolMatch) { const afterScheme = protocolMatch[2]; - const lastSlash = afterScheme.lastIndexOf('/'); + const lastSlash = afterScheme.lastIndexOf("/"); if (lastSlash !== -1) { // Has path segments - namespace includes up to last slash return protocolMatch[1] + afterScheme.substring(0, lastSlash + 1); @@ -27,15 +27,15 @@ function extractNamespace(uri: string): string { // Simple protocol URI without path (e.g., "recipe://name") return protocolMatch[1]; } - + // Handle colon-separated (namespace:localName) const colonMatch = uri.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/); if (colonMatch) { return colonMatch[1]; } - + // Fallback: no clear namespace - return ''; + return ""; } /** @@ -44,13 +44,13 @@ function extractNamespace(uri: string): string { */ function escapeTurtleString(value: string): string { return value - .replace(/\\/g, '\\\\') // Backslash must be first - .replace(/"/g, '\\"') // Double quotes - .replace(/\n/g, '\\n') // Newlines - .replace(/\r/g, '\\r') // Carriage returns - .replace(/\t/g, '\\t') // Tabs - .replace(/\b/g, '\\b') // Backspace - .replace(/\f/g, '\\f'); // Form feed + .replace(/\\/g, "\\\\") // Backslash must be first + .replace(/"/g, '\\"') // Double quotes + .replace(/\n/g, "\\n") // Newlines + .replace(/\r/g, "\\r") // Carriage returns + .replace(/\t/g, "\\t") // Tabs + .replace(/\b/g, "\\b") // Backspace + .replace(/\f/g, "\\f"); // Form feed } /** @@ -63,23 +63,23 @@ function escapeTurtleString(value: string): string { */ function extractLocalName(uri: string): string { // Handle hash fragments - const hashIndex = uri.lastIndexOf('#'); + const hashIndex = uri.lastIndexOf("#"); if (hashIndex !== -1) { return uri.substring(hashIndex + 1); } - + // Handle slash-based namespaces - const lastSlash = uri.lastIndexOf('/'); + const lastSlash = uri.lastIndexOf("/"); if (lastSlash !== -1 && lastSlash < uri.length - 1) { return uri.substring(lastSlash + 1); } - + // Handle colon-separated const colonMatch = uri.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:(.+)$/); if (colonMatch) { return colonMatch[1]; } - + // Fallback: entire URI return uri; } @@ -110,7 +110,7 @@ export interface SHACLPropertyShape { datatype?: string; /** Node kind constraint (IRI, Literal, BlankNode) */ - nodeKind?: 'IRI' | 'Literal' | 'BlankNode'; + nodeKind?: "IRI" | "Literal" | "BlankNode"; /** Minimum cardinality (required if >= 1) */ minCount?: number; @@ -133,8 +133,8 @@ export interface SHACLPropertyShape { /** AD4M-specific: Local-only property */ local?: boolean; - /** AD4M-specific: Writable property */ - writable?: boolean; + /** AD4M-specific: Read-only property (no setter actions) */ + readOnly?: boolean; /** AD4M-specific: Language to resolve property values through */ resolveLanguage?: string; @@ -147,6 +147,9 @@ export interface SHACLPropertyShape { /** AD4M-specific: Remover action for collection properties */ remover?: AD4MAction[]; + + /** AD4M-specific: Reverse path traversal — sh:inversePath instead of sh:path */ + inversePath?: boolean; } /** @@ -163,6 +166,13 @@ export class SHACLShape { /** Property constraints */ properties: SHACLPropertyShape[]; + /** + * Parent shape URIs for SHACL inheritance (sh:node references). + * When a model class extends another @Model-decorated class, the child shape + * references the parent shape via sh:node rather than duplicating its properties. + */ + parentShapes?: string[]; + /** AD4M-specific: Constructor actions for creating instances */ constructor_actions?: AD4MAction[]; @@ -198,6 +208,16 @@ export class SHACLShape { this.properties.push(prop); } + /** + * Add a parent shape reference (sh:node) for SHACL inheritance. + * The child shape will reference the parent shape instead of duplicating its properties. + * @param parentShapeUri - URI of the parent SHACL shape + */ + addParentShape(parentShapeUri: string): void { + this.parentShapes = this.parentShapes ?? []; + this.parentShapes.push(parentShapeUri); + } + /** * Set constructor actions for this shape */ @@ -211,7 +231,7 @@ export class SHACLShape { setDestructorActions(actions: AD4MAction[]): void { this.destructor_actions = actions; } - + /** * Serialize shape to Turtle (RDF) format */ @@ -220,71 +240,82 @@ export class SHACLShape { turtle += `@prefix xsd: .\n`; turtle += `@prefix rdf: .\n`; turtle += `@prefix ad4m: .\n\n`; - + turtle += `<${this.nodeShapeUri}>\n`; turtle += ` a sh:NodeShape ;\n`; - + if (this.targetClass) { turtle += ` sh:targetClass <${this.targetClass}> ;\n`; } - + + // Emit sh:node references for parent shapes (SHACL inheritance) + if (this.parentShapes && this.parentShapes.length > 0) { + for (const parentUri of this.parentShapes) { + turtle += ` sh:node <${parentUri}> ;\n`; + } + } + // Add property shapes for (let i = 0; i < this.properties.length; i++) { const prop = this.properties[i]; const isLast = i === this.properties.length - 1; - + turtle += ` sh:property [\n`; - turtle += ` sh:path <${prop.path}> ;\n`; - + if (prop.inversePath) { + turtle += ` sh:path [ sh:inversePath <${prop.path}> ] ;\n`; + } else { + turtle += ` sh:path <${prop.path}> ;\n`; + } + if (prop.datatype) { turtle += ` sh:datatype <${prop.datatype}> ;\n`; } - + if (prop.nodeKind) { turtle += ` sh:nodeKind sh:${prop.nodeKind} ;\n`; } - + if (prop.minCount !== undefined) { turtle += ` sh:minCount ${prop.minCount} ;\n`; } - + if (prop.maxCount !== undefined) { turtle += ` sh:maxCount ${prop.maxCount} ;\n`; } - + if (prop.pattern) { turtle += ` sh:pattern "${escapeTurtleString(prop.pattern)}" ;\n`; } - + if (prop.minInclusive !== undefined) { turtle += ` sh:minInclusive ${prop.minInclusive} ;\n`; } - + if (prop.maxInclusive !== undefined) { turtle += ` sh:maxInclusive ${prop.maxInclusive} ;\n`; } - + if (prop.hasValue) { turtle += ` sh:hasValue "${escapeTurtleString(prop.hasValue)}" ;\n`; } - + // AD4M-specific metadata if (prop.local !== undefined) { turtle += ` ad4m:local ${prop.local} ;\n`; } - - if (prop.writable !== undefined) { - turtle += ` ad4m:writable ${prop.writable} ;\n`; + + if (prop.readOnly !== undefined) { + turtle += ` ad4m:readOnly ${prop.readOnly} ;\n`; } - + // Remove trailing semicolon and close bracket - turtle = turtle.slice(0, -2) + '\n'; + turtle = turtle.slice(0, -2) + "\n"; turtle += isLast ? ` ] .\n` : ` ] ;\n`; } - + return turtle; } - + /** * Serialize shape to AD4M Links (RDF triples) * Stores the shape as a graph of links in a Perspective @@ -296,7 +327,7 @@ export class SHACLShape { links.push({ source: this.nodeShapeUri, predicate: "rdf://type", - target: "sh://NodeShape" + target: "sh://NodeShape", }); // Target class @@ -304,16 +335,30 @@ export class SHACLShape { links.push({ source: this.nodeShapeUri, predicate: "sh://targetClass", - target: this.targetClass + target: this.targetClass, }); } - // Constructor actions - if (this.constructor_actions && this.constructor_actions.length > 0) { + // Parent shape references (sh:node — SHACL inheritance) + if (this.parentShapes && this.parentShapes.length > 0) { + for (const parentUri of this.parentShapes) { + links.push({ + source: this.nodeShapeUri, + predicate: "sh://node", + target: parentUri, + }); + } + } + + // Constructor actions – always emit this link (even for an empty array) + // so the Rust backend can distinguish "class registered but no init steps" + // from "class not registered", and create_subject can succeed with 0 + // commands for classes that have no @Flag / initial-valued properties. + if (this.constructor_actions !== undefined) { links.push({ source: this.nodeShapeUri, predicate: "ad4m://constructor", - target: `literal://string:${JSON.stringify(this.constructor_actions)}` + target: `literal://string:${JSON.stringify(this.constructor_actions)}`, }); } @@ -322,14 +367,14 @@ export class SHACLShape { links.push({ source: this.nodeShapeUri, predicate: "ad4m://destructor", - target: `literal://string:${JSON.stringify(this.destructor_actions)}` + target: `literal://string:${JSON.stringify(this.destructor_actions)}`, }); } - + // Property shapes (each gets a named URI: {namespace}/{ClassName}.{propertyName}) for (let i = 0; i < this.properties.length; i++) { const prop = this.properties[i]; - + // Generate named property shape URI let propShapeId: string; if (prop.name && this.targetClass) { @@ -342,100 +387,100 @@ export class SHACLShape { // Fallback to blank node if name is missing propShapeId = `_:propShape${i}`; } - + // Link shape to property shape links.push({ source: this.nodeShapeUri, predicate: "sh://property", - target: propShapeId + target: propShapeId, }); - - // Property path + + // Property path (forward or inverse) links.push({ source: propShapeId, - predicate: "sh://path", - target: prop.path + predicate: prop.inversePath ? "sh://inversePath" : "sh://path", + target: prop.path, }); - + // Constraints if (prop.datatype) { links.push({ source: propShapeId, predicate: "sh://datatype", - target: prop.datatype + target: prop.datatype, }); } - + if (prop.nodeKind) { links.push({ source: propShapeId, predicate: "sh://nodeKind", - target: `sh://${prop.nodeKind}` + target: `sh://${prop.nodeKind}`, }); } - + if (prop.minCount !== undefined) { links.push({ source: propShapeId, predicate: "sh://minCount", - target: `literal://${prop.minCount}^^xsd:integer` + target: `literal://${prop.minCount}^^xsd:integer`, }); } - + if (prop.maxCount !== undefined) { links.push({ source: propShapeId, predicate: "sh://maxCount", - target: `literal://${prop.maxCount}^^xsd:integer` + target: `literal://${prop.maxCount}^^xsd:integer`, }); } - + if (prop.pattern) { links.push({ source: propShapeId, predicate: "sh://pattern", - target: `literal://${prop.pattern}` + target: `literal://${prop.pattern}`, }); } - + if (prop.minInclusive !== undefined) { links.push({ source: propShapeId, predicate: "sh://minInclusive", - target: `literal://${prop.minInclusive}` + target: `literal://${prop.minInclusive}`, }); } - + if (prop.maxInclusive !== undefined) { links.push({ source: propShapeId, predicate: "sh://maxInclusive", - target: `literal://${prop.maxInclusive}` + target: `literal://${prop.maxInclusive}`, }); } - + if (prop.hasValue) { links.push({ source: propShapeId, predicate: "sh://hasValue", - target: `literal://${prop.hasValue}` + target: `literal://${prop.hasValue}`, }); } - + // AD4M-specific metadata if (prop.local !== undefined) { links.push({ source: propShapeId, predicate: "ad4m://local", - target: `literal://${prop.local}` + target: `literal://${prop.local}`, }); } - - if (prop.writable !== undefined) { + + if (prop.readOnly !== undefined) { links.push({ source: propShapeId, - predicate: "ad4m://writable", - target: `literal://${prop.writable}` + predicate: "ad4m://readOnly", + target: `literal://${prop.readOnly}`, }); } @@ -443,7 +488,7 @@ export class SHACLShape { links.push({ source: propShapeId, predicate: "ad4m://resolveLanguage", - target: `literal://string:${prop.resolveLanguage}` + target: `literal://string:${prop.resolveLanguage}`, }); } @@ -452,7 +497,7 @@ export class SHACLShape { links.push({ source: propShapeId, predicate: "ad4m://setter", - target: `literal://string:${JSON.stringify(prop.setter)}` + target: `literal://string:${JSON.stringify(prop.setter)}`, }); } @@ -460,7 +505,7 @@ export class SHACLShape { links.push({ source: propShapeId, predicate: "ad4m://adder", - target: `literal://string:${JSON.stringify(prop.adder)}` + target: `literal://string:${JSON.stringify(prop.adder)}`, }); } @@ -468,32 +513,32 @@ export class SHACLShape { links.push({ source: propShapeId, predicate: "ad4m://remover", - target: `literal://string:${JSON.stringify(prop.remover)}` + target: `literal://string:${JSON.stringify(prop.remover)}`, }); } } return links; } - + /** * Reconstruct shape from AD4M Links */ static fromLinks(links: Link[], shapeUri: string): SHACLShape { // Find target class - const targetClassLink = links.find(l => - l.source === shapeUri && l.predicate === "sh://targetClass" + const targetClassLink = links.find( + (l) => l.source === shapeUri && l.predicate === "sh://targetClass", ); - + const shape = new SHACLShape(shapeUri, targetClassLink?.target); // Find constructor actions - const constructorLink = links.find(l => - l.source === shapeUri && l.predicate === "ad4m://constructor" + const constructorLink = links.find( + (l) => l.source === shapeUri && l.predicate === "ad4m://constructor", ); if (constructorLink) { try { - const jsonStr = constructorLink.target.replace('literal://string:', ''); + const jsonStr = constructorLink.target.replace("literal://string:", ""); shape.constructor_actions = JSON.parse(jsonStr); } catch (e) { // Ignore parse errors @@ -501,12 +546,12 @@ export class SHACLShape { } // Find destructor actions - const destructorLink = links.find(l => - l.source === shapeUri && l.predicate === "ad4m://destructor" + const destructorLink = links.find( + (l) => l.source === shapeUri && l.predicate === "ad4m://destructor", ); if (destructorLink) { try { - const jsonStr = destructorLink.target.replace('literal://string:', ''); + const jsonStr = destructorLink.target.replace("literal://string:", ""); shape.destructor_actions = JSON.parse(jsonStr); } catch (e) { // Ignore parse errors @@ -514,161 +559,169 @@ export class SHACLShape { } // Find all property shapes - const propShapeLinks = links.filter(l => - l.source === shapeUri && l.predicate === "sh://property" + const propShapeLinks = links.filter( + (l) => l.source === shapeUri && l.predicate === "sh://property", ); - + for (const propLink of propShapeLinks) { const propShapeId = propLink.target; - + // Reconstruct property from its links - const pathLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://path" + const pathLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://path", ); - + if (!pathLink) continue; - + // Extract property name from propShapeId if it's a named URI // Format: {namespace}{ClassName}.{propertyName} let propertyName: string | undefined; - if (!propShapeId.startsWith('_:')) { - const lastDotIndex = propShapeId.lastIndexOf('.'); + if (!propShapeId.startsWith("_:")) { + const lastDotIndex = propShapeId.lastIndexOf("."); if (lastDotIndex !== -1) { propertyName = propShapeId.substring(lastDotIndex + 1); } } - + const prop: SHACLPropertyShape = { name: propertyName, - path: pathLink.target + path: pathLink.target, }; - + // Extract constraints - const datatypeLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://datatype" + const datatypeLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://datatype", ); if (datatypeLink) prop.datatype = datatypeLink.target; - - const nodeKindLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://nodeKind" + + const nodeKindLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://nodeKind", ); if (nodeKindLink) { - prop.nodeKind = nodeKindLink.target.replace('sh://', '') as any; + prop.nodeKind = nodeKindLink.target.replace("sh://", "") as any; } - - const minCountLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://minCount" + + const minCountLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://minCount", ); if (minCountLink) { // Handle both formats: literal://5^^xsd:integer and literal://number:5 - let val = minCountLink.target.replace('literal://', '').replace(/\^\^.*$/, ''); - if (val.startsWith('number:')) val = val.substring(7); + let val = minCountLink.target + .replace("literal://", "") + .replace(/\^\^.*$/, ""); + if (val.startsWith("number:")) val = val.substring(7); prop.minCount = parseInt(val); } - - const maxCountLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://maxCount" + + const maxCountLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://maxCount", ); if (maxCountLink) { // Handle both formats: literal://5^^xsd:integer and literal://number:5 - let val = maxCountLink.target.replace('literal://', '').replace(/\^\^.*$/, ''); - if (val.startsWith('number:')) val = val.substring(7); + let val = maxCountLink.target + .replace("literal://", "") + .replace(/\^\^.*$/, ""); + if (val.startsWith("number:")) val = val.substring(7); prop.maxCount = parseInt(val); } - - const patternLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://pattern" + + const patternLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://pattern", ); if (patternLink) { - prop.pattern = patternLink.target.replace('literal://', ''); + prop.pattern = patternLink.target.replace("literal://", ""); } - - const minInclusiveLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://minInclusive" + + const minInclusiveLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://minInclusive", ); if (minInclusiveLink) { // Handle both formats: literal://5 and literal://number:5 - let val = minInclusiveLink.target.replace('literal://', ''); - if (val.startsWith('number:')) val = val.substring(7); + let val = minInclusiveLink.target.replace("literal://", ""); + if (val.startsWith("number:")) val = val.substring(7); prop.minInclusive = parseFloat(val); } - const maxInclusiveLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://maxInclusive" + const maxInclusiveLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://maxInclusive", ); if (maxInclusiveLink) { // Handle both formats: literal://5 and literal://number:5 - let val = maxInclusiveLink.target.replace('literal://', ''); - if (val.startsWith('number:')) val = val.substring(7); + let val = maxInclusiveLink.target.replace("literal://", ""); + if (val.startsWith("number:")) val = val.substring(7); prop.maxInclusive = parseFloat(val); } - - const hasValueLink = links.find(l => - l.source === propShapeId && l.predicate === "sh://hasValue" + + const hasValueLink = links.find( + (l) => l.source === propShapeId && l.predicate === "sh://hasValue", ); if (hasValueLink) { - prop.hasValue = hasValueLink.target.replace('literal://', ''); + prop.hasValue = hasValueLink.target.replace("literal://", ""); } - + // AD4M-specific - const localLink = links.find(l => - l.source === propShapeId && l.predicate === "ad4m://local" + const localLink = links.find( + (l) => l.source === propShapeId && l.predicate === "ad4m://local", ); if (localLink) { // Handle both formats: literal://true and literal://boolean:true - let val = localLink.target.replace('literal://', ''); - if (val.startsWith('boolean:')) val = val.substring(8); - prop.local = val === 'true'; + let val = localLink.target.replace("literal://", ""); + if (val.startsWith("boolean:")) val = val.substring(8); + prop.local = val === "true"; } - - const writableLink = links.find(l => - l.source === propShapeId && l.predicate === "ad4m://writable" + + const readOnlyLink = links.find( + (l) => l.source === propShapeId && l.predicate === "ad4m://readOnly", ); - if (writableLink) { + if (readOnlyLink) { // Handle both formats: literal://true and literal://boolean:true - let val = writableLink.target.replace('literal://', ''); - if (val.startsWith('boolean:')) val = val.substring(8); - prop.writable = val === 'true'; + let val = readOnlyLink.target.replace("literal://", ""); + if (val.startsWith("boolean:")) val = val.substring(8); + prop.readOnly = val === "true"; } - const resolveLangLink = links.find(l => - l.source === propShapeId && l.predicate === "ad4m://resolveLanguage" + const resolveLangLink = links.find( + (l) => + l.source === propShapeId && l.predicate === "ad4m://resolveLanguage", ); if (resolveLangLink) { - prop.resolveLanguage = resolveLangLink.target.replace('literal://string:', ''); + prop.resolveLanguage = resolveLangLink.target.replace( + "literal://string:", + "", + ); } // Parse action arrays - const setterLink = links.find(l => - l.source === propShapeId && l.predicate === "ad4m://setter" + const setterLink = links.find( + (l) => l.source === propShapeId && l.predicate === "ad4m://setter", ); if (setterLink) { try { - const jsonStr = setterLink.target.replace('literal://string:', ''); + const jsonStr = setterLink.target.replace("literal://string:", ""); prop.setter = JSON.parse(jsonStr); } catch (e) { // Ignore parse errors } } - const adderLink = links.find(l => - l.source === propShapeId && l.predicate === "ad4m://adder" + const adderLink = links.find( + (l) => l.source === propShapeId && l.predicate === "ad4m://adder", ); if (adderLink) { try { - const jsonStr = adderLink.target.replace('literal://string:', ''); + const jsonStr = adderLink.target.replace("literal://string:", ""); prop.adder = JSON.parse(jsonStr); } catch (e) { // Ignore parse errors } } - const removerLink = links.find(l => - l.source === propShapeId && l.predicate === "ad4m://remover" + const removerLink = links.find( + (l) => l.source === propShapeId && l.predicate === "ad4m://remover", ); if (removerLink) { try { - const jsonStr = removerLink.target.replace('literal://string:', ''); + const jsonStr = removerLink.target.replace("literal://string:", ""); prop.remover = JSON.parse(jsonStr); } catch (e) { // Ignore parse errors @@ -677,21 +730,21 @@ export class SHACLShape { shape.addProperty(prop); } - + return shape; } /** * Convert the shape to a JSON-serializable object. * Useful for passing to addSdna() as shaclJson parameter. - * + * * @returns JSON-serializable object representing the shape */ toJSON(): object { return { node_shape_uri: this.nodeShapeUri, target_class: this.targetClass, - properties: this.properties.map(p => ({ + properties: this.properties.map((p) => ({ path: p.path, name: p.name, datatype: p.datatype, @@ -703,7 +756,7 @@ export class SHACLShape { pattern: p.pattern, has_value: p.hasValue, local: p.local, - writable: p.writable, + read_only: p.readOnly, resolve_language: p.resolveLanguage, setter: p.setter, adder: p.adder, @@ -721,7 +774,7 @@ export class SHACLShape { const shape = json.node_shape_uri ? new SHACLShape(json.node_shape_uri, json.target_class) : new SHACLShape(json.target_class); - + for (const p of json.properties || []) { shape.addProperty({ path: p.path, @@ -735,21 +788,21 @@ export class SHACLShape { pattern: p.pattern, hasValue: p.has_value, local: p.local, - writable: p.writable, + readOnly: p.read_only, resolveLanguage: p.resolve_language, setter: p.setter, adder: p.adder, remover: p.remover, }); } - + if (json.constructor_actions) { shape.constructor_actions = json.constructor_actions; } if (json.destructor_actions) { shape.destructor_actions = json.destructor_actions; } - + return shape; } } diff --git a/core/src/utils.ts b/core/src/utils.ts index 692b5038d..f97b51264 100644 --- a/core/src/utils.ts +++ b/core/src/utils.ts @@ -1,16 +1,30 @@ +/** + * WebSocket close code 1006 (abnormal closure) is emitted on every active + * GraphQL subscription when the server process shuts down — it simply means + * the server didn't send a clean close frame. This is expected at the end + * of every test run and whenever the executor is stopped, so it must not be + * treated as an application error. All other errors are still surfaced. + */ +export function isSocketCloseError(e: any): boolean { + if (!e) return false; + if (typeof e.code === "number" && e.code === 1006) return true; + const msg = String(e?.message ?? e); + return msg.startsWith("Socket closed with event 1006"); +} + export function formatList(list) { if (!list?.length) { - return ""; + return ""; } if (list.length === 1) { - return list.toString(); + return list.toString(); } if (list.length === 2) { - return list.join(' and '); + return list.join(" and "); } - return list.slice(0, -1).join(', ') + ', and ' + list.slice(-1); -}; + return list.slice(0, -1).join(", ") + ", and " + list.slice(-1); +} export function capSentence(cap) { const can = cap.can.includes("*") ? ["READ", "WRITE", "UPDATE"] : cap.can; @@ -20,24 +34,24 @@ export function capSentence(cap) { : cap.with.pointers; return `${formatList( - can + can, )} your ${domain} actions, with access to ${formatList(pointers)}`; } /** * Escapes a string value for safe use in SurrealQL queries. - * + * * @description * Prevents SQL injection by properly escaping special characters in string values * that will be interpolated into SurrealQL queries. This handles the most common * special characters that could break SQL queries or enable injection attacks. - * + * * Single quotes, backslashes, and other special characters are escaped using * backslash notation, which is the standard escaping mechanism for SurrealQL. - * + * * @param value - The string value to escape * @returns The escaped string safe for SurrealQL interpolation (without surrounding quotes) - * + * * @example * ```typescript * const userInput = "user's input with 'quotes'"; @@ -47,11 +61,11 @@ export function capSentence(cap) { * ``` */ export function escapeSurrealString(value: string): string { - return value - .replace(/\\/g, '\\\\') // Backslash -> \\ - .replace(/'/g, "\\'") // Single quote -> \' - .replace(/"/g, '\\"') // Double quote -> \" - .replace(/\n/g, '\\n') // Newline -> \n - .replace(/\r/g, '\\r') // Carriage return -> \r - .replace(/\t/g, '\\t'); // Tab -> \t + return value + .replace(/\\/g, "\\\\") // Backslash -> \\ + .replace(/'/g, "\\'") // Single quote -> \' + .replace(/"/g, '\\"') // Double quote -> \" + .replace(/\n/g, "\\n") // Newline -> \n + .replace(/\r/g, "\\r") // Carriage return -> \r + .replace(/\t/g, "\\t"); // Tab -> \t } diff --git a/core/tsconfig.json b/core/tsconfig.json index 2d24a74f6..72ba86686 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -1,6 +1,5 @@ { "include": ["./src/**/*.ts", "./src/*.ts", "./shims"], - "exclude": ["./src/*.test.ts", "./src/*.test.ts"], "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ // "paths": { @@ -9,18 +8,18 @@ // }, /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'es2015', or 'ESNEXT'. */ + "target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'es2015', or 'ESNEXT'. */, "lib": ["es2020", "esnext.asynciterable"], - "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ - "declaration": true, /* Generates corresponding '.d.ts' file. */ + "declaration": true /* Generates corresponding '.d.ts' file. */, // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ - "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, "outDir": "./lib", // "composite": true, /* Enable project compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ @@ -49,14 +48,14 @@ // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ @@ -71,10 +70,10 @@ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ /* Advanced Options */ - "skipLibCheck": true, /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true - }, + } } diff --git a/deno.lock b/deno.lock index 4d19a5310..55736cd73 100644 --- a/deno.lock +++ b/deno.lock @@ -383,18 +383,10 @@ "npm:patch-package@^6.5.0", "npm:prettier@latest", "npm:readline-sync@1.4.10", - "npm:turbo@latest" + "npm:turbo@^2.8.13" ] }, "members": { - "ad4m-hooks/helpers": { - "packageJson": { - "dependencies": [ - "npm:@types/uuid@9", - "npm:uuid@*" - ] - } - }, "ad4m-hooks/react": { "packageJson": { "dependencies": [ diff --git a/package.json b/package.json index 91e004895..ac9f0108d 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "cli", "dapp", "ad4m-hooks/react", - "ad4m-hooks/vue", - "ad4m-hooks/helpers" + "ad4m-hooks/vue" ], "private": true, "scripts": { @@ -29,7 +28,8 @@ "test:macos": "turbo run test:macos --concurrency=1", "test:linux": "turbo run test:linux --concurrency=1", "test:windows": "turbo run test:windows --concurrency=1", - "test-main": "turbo run test-main", + "test-main": "turbo run test:ci", + "test:ci": "turbo run test:ci", "package-ad4m": "turbo run package-ad4m", "package-ad4m:cuda": "turbo run package-ad4m:cuda", "build-languages": "turbo run build-languages --concurrency=1", @@ -56,7 +56,7 @@ "patch-package": "^6.5.0", "prettier": "latest", "readline-sync": "1.4.10", - "turbo": "latest" + "turbo": "^2.8.13" }, "engines": { "node": ">=16.0.0" @@ -66,6 +66,11 @@ ], "packageManager": "pnpm@9.15.0", "pnpm": { + "overrides": { + "graphql-ws": "5.14.3", + "react": "18.2.0", + "react-dom": "18.2.0" + }, "patchedDependencies": { "bn.js@4.12.0": "patches/bn.js@4.12.0.patch", "brorand@1.1.0": "patches/brorand@1.1.0.patch", @@ -73,7 +78,6 @@ "@stablelib/random@1.0.2": "patches/@stablelib__random@1.0.2.patch", "node-gyp-build@4.8.0": "patches/node-gyp-build@4.8.0.patch", "@peculiar/webcrypto@1.4.5": "patches/@peculiar__webcrypto@1.4.5.patch", - "safe-buffer@5.2.1": "patches/safe-buffer@5.2.1.patch", "safer-buffer@2.1.2": "patches/safer-buffer@2.1.2.patch" } }, diff --git a/patches/safe-buffer@5.2.1.patch b/patches/safe-buffer@5.2.1.patch index bebebe6c3..667154c00 100644 --- a/patches/safe-buffer@5.2.1.patch +++ b/patches/safe-buffer@5.2.1.patch @@ -6,7 +6,7 @@ index f8d3ec98852f449b44b7d89fc82bae737c69f3fc..57363e84b27073499c987e7a01f05c8a /*! safe-buffer. MIT License. Feross Aboukhadijeh */ /* eslint-disable node/no-deprecated-api */ -var buffer = require('buffer') -+import buffer from 'node:buffer' ++var buffer = require('node:buffer') var Buffer = buffer.Buffer // alternative to using Object.keys for old browsers diff --git a/patches/safer-buffer@2.1.2.patch b/patches/safer-buffer@2.1.2.patch index d21583af2..19d6c7fdb 100644 --- a/patches/safer-buffer@2.1.2.patch +++ b/patches/safer-buffer@2.1.2.patch @@ -7,7 +7,7 @@ index 37c7e1aa6cbd4effd94ee28bd7b0655756b80cea..c2a303b058b0029f74b467cb59c8aa7a 'use strict' -var buffer = require('buffer') -+import buffer from 'node:buffer' ++var buffer = require('buffer') var Buffer = buffer.Buffer var safer = {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5573b1a79..10b94e116 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + graphql-ws: 5.14.3 + react: 18.2.0 + react-dom: 18.2.0 + patchedDependencies: '@peculiar/webcrypto@1.4.5': hash: saeohqf4hlpnnys7brc6umjxqq @@ -23,11 +28,8 @@ patchedDependencies: node-gyp-build@4.8.0: hash: tidq6bjknpovdjep75bj5ccgke path: patches/node-gyp-build@4.8.0.patch - safe-buffer@5.2.1: - hash: qcepvj3ww73f2shgrehxggbrbq - path: patches/safe-buffer@5.2.1.patch safer-buffer@2.1.2: - hash: sdxbjiwrw3yiqjkfb6uxghzoza + hash: eylf6l5slqn6yo3m4cwwpkhvma path: patches/safer-buffer@2.1.2.patch importers: @@ -45,35 +47,19 @@ importers: version: 6.5.1 prettier: specifier: latest - version: 3.2.4 + version: 3.8.1 readline-sync: specifier: 1.4.10 version: 1.4.10 turbo: - specifier: latest - version: 1.11.3 - - ad4m-hooks/helpers: - dependencies: - '@coasys/ad4m': - specifier: workspace:* - version: link:../../core - uuid: - specifier: '*' - version: 9.0.1 - devDependencies: - '@types/uuid': - specifier: ^9.0.0 - version: 9.0.1 + specifier: ^2.8.13 + version: 2.8.13 ad4m-hooks/react: dependencies: '@coasys/ad4m': - specifier: workspace:0.12.0-rc1-dev.2 - version: link:../../core - '@coasys/hooks-helpers': specifier: workspace:* - version: link:../helpers + version: link:../../core '@types/react': specifier: ^18.2.55 version: 18.2.55 @@ -84,17 +70,14 @@ importers: specifier: '*' version: 10.19.3 react: - specifier: '*' + specifier: 18.2.0 version: 18.2.0 ad4m-hooks/vue: dependencies: '@coasys/ad4m': - specifier: workspace:0.12.0-rc1-dev.2 - version: link:../../core - '@coasys/hooks-helpers': specifier: workspace:* - version: link:../helpers + version: link:../../core vue: specifier: ^3.2.47 version: 3.4.19(typescript@5.3.3) @@ -603,9 +586,9 @@ importers: devDependencies: '@apollo/client': specifier: 3.7.10 - version: 3.7.10(graphql-ws@5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@coasys/ad4m': - specifier: workspace:0.12.0-rc1-dev.2 + specifier: workspace:* version: link:../core '@types/node': specifier: ^16.11.11 @@ -617,8 +600,8 @@ importers: specifier: ^0.0.10 version: 0.0.10(esbuild@0.15.18)(lit@2.8.0)(svgo@2.8.0) graphql-ws: - specifier: 5.12.0 - version: 5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) + specifier: 5.14.3 + version: 5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) np: specifier: ^7.6.2 version: 7.7.0 @@ -636,7 +619,7 @@ importers: dependencies: '@apollo/client': specifier: 3.7.10 - version: 3.7.10(graphql-ws@5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@holochain/client': specifier: 0.16.0 version: 0.16.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -687,8 +670,8 @@ importers: specifier: ^3.1.4 version: 3.1.8 graphql-ws: - specifier: 5.12.0 - version: 5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) + specifier: 5.14.3 + version: 5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) honkit: specifier: ^4.0.0 version: 4.0.8 @@ -699,10 +682,10 @@ importers: specifier: ^8.0.0 version: 8.0.0 react: - specifier: '*' + specifier: 18.2.0 version: 18.2.0 react-dom: - specifier: '*' + specifier: 18.2.0 version: 18.2.0(react@18.2.0) rollup: specifier: ^2.56.3 @@ -741,10 +724,10 @@ importers: specifier: ^1.4.7 version: 1.4.13(@types/react@18.2.48)(bufferutil@4.0.8)(immer@9.0.21)(react@18.2.0)(typescript@5.3.3)(utf-8-validate@5.0.10)(viem@1.21.4(bufferutil@4.0.8)(typescript@5.3.3)(utf-8-validate@5.0.10)(zod@3.22.4))(zod@3.22.4) react: - specifier: ^18.2.0 + specifier: 18.2.0 version: 18.2.0 react-dom: - specifier: ^18.2.0 + specifier: 18.2.0 version: 18.2.0(react@18.2.0) typescript: specifier: ^5.2.2 @@ -778,10 +761,10 @@ importers: specifier: latest version: 4.6.1(@types/react@18.2.55)(immer@9.0.21)(next@13.5.11(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.70.0))(nextra@4.6.1(next@13.5.11(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.70.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@4.9.5))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(use-sync-external-store@1.6.0(react@18.2.0)) react: - specifier: ^18.2.0 + specifier: 18.2.0 version: 18.2.0 react-dom: - specifier: ^18.2.0 + specifier: 18.2.0 version: 18.2.0(react@18.2.0) devDependencies: '@types/node': @@ -810,13 +793,10 @@ importers: devDependencies: '@apollo/client': specifier: 3.7.10 - version: 3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@17.0.2))(react@17.0.2) + version: 3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@coasys/ad4m': - specifier: link:../../core + specifier: workspace:* version: link:../../core - '@coasys/ad4m-connect': - specifier: link:../../connect - version: link:../../connect '@peculiar/webcrypto': specifier: ^1.1.7 version: 1.4.5(patch_hash=saeohqf4hlpnnys7brc6umjxqq) @@ -881,7 +861,7 @@ importers: specifier: 11.2.0 version: 11.2.0 graphql-ws: - specifier: ^5.14.2 + specifier: 5.14.3 version: 5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) http: specifier: 0.0.1-security @@ -902,8 +882,8 @@ importers: specifier: ^1.0.1 version: 1.0.1 react: - specifier: ^17.0.1 - version: 17.0.2 + specifier: 18.2.0 + version: 18.2.0 run-script-os: specifier: ^1.1.6 version: 1.1.6 @@ -930,7 +910,7 @@ importers: dependencies: '@apollo/client': specifier: 3.7.10 - version: 3.7.10(graphql-ws@5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@coasys/ad4m': specifier: workspace:* version: link:../core @@ -989,8 +969,8 @@ importers: specifier: 15.7.2 version: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) graphql-ws: - specifier: 5.12.0 - version: 5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) + specifier: 5.14.3 + version: 5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) nanoid: specifier: ^3.3.4 version: 3.3.7 @@ -1001,10 +981,10 @@ importers: specifier: ^3.19.0 version: 3.19.0(preact@10.19.3) react: - specifier: ^18.2.0 + specifier: 18.2.0 version: 18.2.0 react-dom: - specifier: ^18.2.0 + specifier: 18.2.0 version: 18.2.0(react@18.2.0) react-qr-code: specifier: ^2.0.7 @@ -1110,9 +1090,9 @@ packages: resolution: {integrity: sha512-/k1MfrqPKYiPNdHcOzdxg9cEx96vhAGxAcSorzfBvV29XtFQcYW2cPNQOTjK/fpSMtqVo8UNmu5vwQAWD1gfCg==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-ws: ^5.5.5 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + graphql-ws: 5.14.3 + react: 18.2.0 + react-dom: 18.2.0 subscriptions-transport-ws: ^0.9.0 || ^0.11.0 peerDependenciesMeta: graphql-ws: @@ -1130,12 +1110,14 @@ packages: '@apollo/server-gateway-interface@1.1.1': resolution: {integrity: sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==} + deprecated: '@apollo/server-gateway-interface v1 is part of Apollo Server v4, which is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v2 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.' peerDependencies: graphql: 14.x || 15.x || 16.x '@apollo/server@4.10.0': resolution: {integrity: sha512-pLx//lZ/pvUfWL9G8Np8+y3ujc0pYc8U7dwD6ztt9FAw8NmCPzPaDzlXLBAjGU6WnkqVBOnz8b3dOwRNjLYSUA==} engines: {node: '>=14.16.0'} + deprecated: Apollo Server v4 is end-of-life since January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details. peerDependencies: graphql: ^16.6.0 @@ -2510,14 +2492,14 @@ packages: '@floating-ui/react-dom@2.1.7': resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 18.2.0 + react-dom: 18.2.0 '@floating-ui/react@0.26.28': resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 18.2.0 + react-dom: 18.2.0 '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} @@ -2549,8 +2531,8 @@ packages: resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==} engines: {node: '>=10'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - react-dom: ^18 || ^19 || ^19.0.0-rc + react: 18.2.0 + react-dom: 18.2.0 '@holochain/client@0.16.0': resolution: {integrity: sha512-GJEl6F3OSlDX71H+rtyUXpEuor7O9MhvNIi+Tq6obrysu71JsbXfR1rtmSBiNb9fttHOZLW60EzY/Lj3I9dv8g==} @@ -2558,6 +2540,7 @@ packages: '@holochain/serialization@0.1.0-beta-rc.3': resolution: {integrity: sha512-DJx4V2KXHVLciyOGjOYKTM/JLBpBEZ3RsPIRCgf7qmwhQdxXvhi2p+oFFRD51yUT5uC1/MzIVeJCl/R60PwFbw==} + deprecated: Holochain no longer requires canonical serialization of zome call payloads '@honkit/asciidoc@4.0.8': resolution: {integrity: sha512-wyVBKfX9yM5P8nm81ew1cdTR0hKWFB9hRTvwGXBVS+ipD+WFTQWxVw3qNQapMKRiiVq/L3QA6bpkIDul3EJ43w==} @@ -3210,26 +3193,26 @@ packages: '@react-aria/focus@3.21.4': resolution: {integrity: sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react: 18.2.0 + react-dom: 18.2.0 '@react-aria/interactions@3.27.0': resolution: {integrity: sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react: 18.2.0 + react-dom: 18.2.0 '@react-aria/ssr@3.9.10': resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} engines: {node: '>= 12'} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react: 18.2.0 '@react-aria/utils@3.33.0': resolution: {integrity: sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react: 18.2.0 + react-dom: 18.2.0 '@react-stately/flags@3.1.2': resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} @@ -3237,12 +3220,12 @@ packages: '@react-stately/utils@3.11.0': resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react: 18.2.0 '@react-types/shared@3.33.0': resolution: {integrity: sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react: 18.2.0 '@remix-run/router@1.14.2': resolution: {integrity: sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==} @@ -3672,8 +3655,8 @@ packages: '@tanstack/react-query@4.36.1': resolution: {integrity: sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: 18.2.0 + react-dom: 18.2.0 react-native: '*' peerDependenciesMeta: react-dom: @@ -3684,8 +3667,8 @@ packages: '@tanstack/react-virtual@3.13.18': resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 18.2.0 + react-dom: 18.2.0 '@tanstack/virtual-core@3.13.18': resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} @@ -3811,8 +3794,8 @@ packages: resolution: {integrity: sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==} engines: {node: '>=12'} peerDependencies: - react: <18.0.0 - react-dom: <18.0.0 + react: 18.2.0 + react-dom: 18.2.0 '@testing-library/user-event@13.5.0': resolution: {integrity: sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==} @@ -3829,7 +3812,7 @@ packages: '@theguild/remark-mermaid@0.3.0': resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==} peerDependencies: - react: ^18.2.0 || ^19.0.0 + react: 18.2.0 '@theguild/remark-npm2yarn@0.3.3': resolution: {integrity: sha512-ma6DvR03gdbvwqfKx1omqhg9May/VYGdMHvTzB4VuxkyS7KzfZ/lzrj43hmcsggpMje0x7SADA/pcMph0ejRnA==} @@ -4414,6 +4397,7 @@ packages: '@walletconnect/ethereum-provider@2.11.0': resolution: {integrity: sha512-YrTeHVjuSuhlUw7SQ6xBJXDuJ6iAC+RwINm9nVhoKYJSHAy3EVSJZOofMKrnecL0iRMtD29nj57mxAInIBRuZA==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/events@1.0.1': resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} @@ -4470,6 +4454,7 @@ packages: '@walletconnect/modal@2.6.2': resolution: {integrity: sha512-eFopgKi8AjKf/0U4SemvcYw9zlLpx9njVN8sf6DAkowC2Md0gPU/UNEbH1Wwj407pEKnEds98pKWib1NN1ACoA==} + deprecated: Please follow the migration guide on https://docs.reown.com/appkit/upgrade/wcm '@walletconnect/randombytes@1.0.3': resolution: {integrity: sha512-35lpzxcHFbTN3ABefC9W+uBpNZl1GC4Wpx0ed30gibfO/y9oLdy1NznbV96HARQKSBV9J9M/rrtIvf6a23jfYw==} @@ -4485,6 +4470,7 @@ packages: '@walletconnect/sign-client@2.11.0': resolution: {integrity: sha512-H2ukscibBS+6WrzQWh+WyVBqO5z4F5et12JcwobdwgHnJSlqIoZxqnUYYWNCI5rUR5UKsKWaUyto4AE9N5dw4Q==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/time@1.0.2': resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} @@ -4494,6 +4480,7 @@ packages: '@walletconnect/universal-provider@2.11.0': resolution: {integrity: sha512-zgJv8jDvIMP4Qse/D9oIRXGdfoNqonsrjPZanQ/CHNe7oXGOBiQND2IIeX+tS0H7uNA0TPvctljCLiIN9nw4eA==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/utils@2.11.0': resolution: {integrity: sha512-hxkHPlTlDQILHfIKXlmzgNJau/YcSBC3XHUSuZuKZbNEw3duFT6h6pm3HT/1+j1a22IG05WDsNBuTCRkwss+BQ==} @@ -4623,6 +4610,7 @@ packages: acorn-import-assertions@1.9.0: resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + deprecated: package has been renamed to acorn-import-attributes peerDependencies: acorn: ^8 @@ -5164,7 +5152,7 @@ packages: better-react-mathjax@2.3.0: resolution: {integrity: sha512-K0ceQC+jQmB+NLDogO5HCpqmYf18AU2FxDbLdduYgkHYWZApFggkHE4dIaXCV1NqeoscESYXXo1GSkY6fA295w==} peerDependencies: - react: '>=16.8' + react: 18.2.0 bfj@7.1.0: resolution: {integrity: sha512-I6MMLkn+anzNdCUp9hMRyui1HaNEUCco50lxbvNS4+EyXg8lN3nJ48PjPWtbH8UVS9CuMoaKE9U2V3l29DaRQw==} @@ -5225,6 +5213,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} @@ -7495,6 +7484,7 @@ packages: fluent-ffmpeg@2.1.3: resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} engines: {node: '>=18'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. follow-redirects@1.15.5: resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} @@ -7608,6 +7598,7 @@ packages: fstream@1.0.12: resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} engines: {node: '>=0.6'} + deprecated: This package is no longer supported. function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -7721,19 +7712,24 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.1.3: resolution: {integrity: sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.0: resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -7811,12 +7807,6 @@ packages: peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-ws@5.12.0: - resolution: {integrity: sha512-PA3ImUp8utrpEjoxBMhvxsjkStvFEdU0E1gEBREt8HZIWkxOUymwJBhFnBL7t/iHhUq1GVPeZevPinkZFENxTw==} - engines: {node: '>=10'} - peerDependencies: - graphql: '>=0.11 <=16' - graphql-ws@5.14.3: resolution: {integrity: sha512-F/i2xNIVbaEF2xWggID0X/UZQa2V8kqKDPO8hwmu53bVOcTL7uNkxnexeEgSCVxYBQUTUNEI8+e4LO1FOhKPKQ==} engines: {node: '>=10'} @@ -8259,6 +8249,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.3: resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} @@ -9421,6 +9412,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.memoize@3.0.4: resolution: {integrity: sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A==} @@ -10097,8 +10089,8 @@ packages: next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react: 18.2.0 + react-dom: 18.2.0 next@13.5.11: resolution: {integrity: sha512-WUPJ6WbAX9tdC86kGTu92qkrRdgRqVrY++nwM+shmWQwmyxt4zhZfR59moXSI4N8GDYCBY3lIAqhzjDd4rTC8Q==} @@ -10106,8 +10098,8 @@ packages: hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 + react: 18.2.0 + react-dom: 18.2.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': @@ -10120,16 +10112,16 @@ packages: peerDependencies: next: '>=14' nextra: 4.6.1 - react: '>=18' - react-dom: '>=18' + react: 18.2.0 + react-dom: 18.2.0 nextra@4.6.1: resolution: {integrity: sha512-yz5WMJFZ5c58y14a6Rmwt+SJUYDdIgzWSxwtnpD4XAJTq3mbOqOg3VTaJqLiJjwRSxoFRHNA1yAhnhbvbw9zSg==} engines: {node: '>=18'} peerDependencies: next: '>=14' - react: '>=18' - react-dom: '>=18' + react: 18.2.0 + react-dom: 18.2.0 nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -10156,6 +10148,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch-native@1.6.1: resolution: {integrity: sha512-bW9T/uJDPAJB2YNYEpWzE54U5O3MQidXsOyTfnbKYtTtFexRvGzb1waphBN4ZwP6EcIvYYEOwW0b72BpAqydTw==} @@ -11396,8 +11389,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - prettier@3.2.4: - resolution: {integrity: sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -11501,6 +11494,10 @@ packages: q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + deprecated: |- + You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qr.js@0.0.0: resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==} @@ -11570,6 +11567,7 @@ packages: raw-body@1.1.7: resolution: {integrity: sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg==} engines: {node: '>= 0.8.0'} + deprecated: No longer maintained. Please upgrade to a stable version. raw-body@2.5.1: resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} @@ -11596,7 +11594,7 @@ packages: react-compiler-runtime@19.1.0-rc.3: resolution: {integrity: sha512-Cssogys2XZu6SqxRdX2xd8cQAf57BBvFbLEBlIa77161lninbKUn/EqbecCe7W3eqDQfg3rIoOwzExzgCh7h/g==} peerDependencies: - react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental + react: 18.2.0 react-dev-utils@12.0.1: resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} @@ -11611,7 +11609,7 @@ packages: react-dom@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: - react: ^18.2.0 + react: 18.2.0 react-error-overlay@6.0.11: resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==} @@ -11631,13 +11629,13 @@ packages: react-medium-image-zoom@5.4.0: resolution: {integrity: sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 18.2.0 + react-dom: 18.2.0 react-qr-code@2.0.12: resolution: {integrity: sha512-k+pzP5CKLEGBRwZsDPp98/CAJeXlsYRHM2iZn1Sd5Th/HnKhIZCSg27PXO58zk8z02RaEryg+60xa4vyywMJwg==} peerDependencies: - react: ^16.x || ^17.x || ^18.x + react: 18.2.0 react-native-svg: '*' peerDependenciesMeta: react-native-svg: @@ -11659,14 +11657,14 @@ packages: resolution: {integrity: sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==} engines: {node: '>=14.0.0'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react: 18.2.0 + react-dom: 18.2.0 react-router@6.21.3: resolution: {integrity: sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==} engines: {node: '>=14.0.0'} peerDependencies: - react: '>=16.8' + react: 18.2.0 react-scripts@5.0.1: resolution: {integrity: sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==} @@ -11674,16 +11672,12 @@ packages: hasBin: true peerDependencies: eslint: '*' - react: '>= 16' + react: 18.2.0 typescript: ^3.2.1 || ^4 peerDependenciesMeta: typescript: optional: true - react@17.0.2: - resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} - engines: {node: '>=0.10.0'} - react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -12013,14 +12007,17 @@ packages: rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true ripemd160@2.0.2: @@ -12444,6 +12441,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -12712,7 +12710,7 @@ packages: peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: 18.2.0 peerDependenciesMeta: '@babel/core': optional: true @@ -13065,6 +13063,7 @@ packages: try-resolve@1.0.1: resolution: {integrity: sha512-yHeaPjCBzVaXwWl5IMUapTaTC2rn/eBYg2fsG2L+CvJd+ttFbk0ylDnpTO3wVhosmE1tQEvcebbBeKLCwScQSQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. tryer@1.0.1: resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==} @@ -13153,38 +13152,38 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - turbo-darwin-64@1.11.3: - resolution: {integrity: sha512-IsOOg2bVbIt3o/X8Ew9fbQp5t1hTHN3fGNQYrPQwMR2W1kIAC6RfbVD4A9OeibPGyEPUpwOH79hZ9ydFH5kifw==} + turbo-darwin-64@2.8.13: + resolution: {integrity: sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@1.11.3: - resolution: {integrity: sha512-FsJL7k0SaPbJzI/KCnrf/fi3PgCDCjTliMc/kEFkuWVA6Httc3Q4lxyLIIinz69q6JTx8wzh6yznUMzJRI3+dg==} + turbo-darwin-arm64@2.8.13: + resolution: {integrity: sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ==} cpu: [arm64] os: [darwin] - turbo-linux-64@1.11.3: - resolution: {integrity: sha512-SvW7pvTVRGsqtSkII5w+wriZXvxqkluw5FO/MNAdFw0qmoov+PZ237+37/NgArqE3zVn1GX9P6nUx9VO+xcQAg==} + turbo-linux-64@2.8.13: + resolution: {integrity: sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw==} cpu: [x64] os: [linux] - turbo-linux-arm64@1.11.3: - resolution: {integrity: sha512-YhUfBi1deB3m+3M55X458J6B7RsIS7UtM3P1z13cUIhF+pOt65BgnaSnkHLwETidmhRh8Dl3GelaQGrB3RdCDw==} + turbo-linux-arm64@2.8.13: + resolution: {integrity: sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg==} cpu: [arm64] os: [linux] - turbo-windows-64@1.11.3: - resolution: {integrity: sha512-s+vEnuM2TiZuAUUUpmBHDr6vnNbJgj+5JYfnYmVklYs16kXh+EppafYQOAkcRIMAh7GjV3pLq5/uGqc7seZeHA==} + turbo-windows-64@2.8.13: + resolution: {integrity: sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA==} cpu: [x64] os: [win32] - turbo-windows-arm64@1.11.3: - resolution: {integrity: sha512-ZR5z5Zpc7cASwfdRAV5yNScCZBsgGSbcwiA/u3farCacbPiXsfoWUkz28iyrx21/TRW0bi6dbsB2v17swa8bjw==} + turbo-windows-arm64@2.8.13: + resolution: {integrity: sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA==} cpu: [arm64] os: [win32] - turbo@1.11.3: - resolution: {integrity: sha512-RCJOUFcFMQNIGKSjC9YmA5yVP1qtDiBA0Lv9VIgrXraI5Da1liVvl3VJPsoDNIR9eFMyA/aagx1iyj6UWem5hA==} + turbo@2.8.13: + resolution: {integrity: sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A==} hasBin: true tweetnacl@0.14.5: @@ -13549,12 +13548,12 @@ packages: use-sync-external-store@1.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: 18.2.0 use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 18.2.0 use@3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} @@ -13634,7 +13633,7 @@ packages: engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=16.8' - react: '>=16.8' + react: 18.2.0 peerDependenciesMeta: '@types/react': optional: true @@ -13783,7 +13782,7 @@ packages: wagmi@1.4.13: resolution: {integrity: sha512-AScVYFjqNt1wMgL99Bob7MLdhoTZ3XKiOZL5HVBdy4W1sh7QodA3gQ8IsmTuUrQ7oQaTxjiXEhwg7sWNrPBvJA==} peerDependencies: - react: '>=17.0.0' + react: 18.2.0 typescript: '>=5.0.4' viem: '>=0.3.35' peerDependenciesMeta: @@ -13890,6 +13889,7 @@ packages: whatwg-encoding@1.0.5: resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -13977,6 +13977,7 @@ packages: workbox-google-analytics@6.6.0: resolution: {integrity: sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained workbox-navigation-preload@6.6.0: resolution: {integrity: sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==} @@ -14174,7 +14175,7 @@ packages: peerDependencies: '@types/react': '>=16.8' immer: '>=9.0.6' - react: '>=16.8' + react: 18.2.0 peerDependenciesMeta: '@types/react': optional: true @@ -14189,7 +14190,7 @@ packages: peerDependencies: '@types/react': '>=18.0.0' immer: '>=9.0.6' - react: '>=18.0.0' + react: 18.2.0 use-sync-external-store: '>=1.2.0' peerDependenciesMeta: '@types/react': @@ -14244,48 +14245,6 @@ snapshots: dependencies: graphql: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) - '@apollo/client@3.7.10(graphql-ws@5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) - '@wry/context': 0.7.4 - '@wry/equality': 0.5.7 - '@wry/trie': 0.3.2 - graphql: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) - graphql-tag: 2.12.6(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) - hoist-non-react-statics: 3.3.2 - optimism: 0.16.2 - prop-types: 15.8.1 - response-iterator: 0.2.6 - symbol-observable: 4.0.0 - ts-invariant: 0.10.3 - tslib: 2.6.2 - zen-observable-ts: 1.2.5 - optionalDependencies: - graphql-ws: 5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@apollo/client@3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@17.0.2))(react@17.0.2)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) - '@wry/context': 0.7.4 - '@wry/equality': 0.5.7 - '@wry/trie': 0.3.2 - graphql: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) - graphql-tag: 2.12.6(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) - hoist-non-react-statics: 3.3.2 - optimism: 0.16.2 - prop-types: 15.8.1 - response-iterator: 0.2.6 - symbol-observable: 4.0.0 - ts-invariant: 0.10.3 - tslib: 2.6.2 - zen-observable-ts: 1.2.5 - optionalDependencies: - graphql-ws: 5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) - react: 17.0.2 - react-dom: 18.2.0(react@17.0.2) - '@apollo/client@3.7.10(graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)))(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)) @@ -16322,7 +16281,7 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.2 - '@jest/test-sequencer@26.6.3(bufferutil@4.0.8)(ts-node@10.9.1(@types/node@16.18.76)(typescript@4.9.5))(utf-8-validate@5.0.10)': + '@jest/test-sequencer@26.6.3': dependencies: '@jest/test-result': 26.6.2 graceful-fs: 4.2.11 @@ -16330,11 +16289,7 @@ snapshots: jest-runner: 26.6.3(bufferutil@4.0.8)(ts-node@10.9.1(@types/node@16.18.76)(typescript@4.9.5))(utf-8-validate@5.0.10) jest-runtime: 26.6.3(bufferutil@4.0.8)(ts-node@10.9.1(@types/node@16.18.76)(typescript@4.9.5))(utf-8-validate@5.0.10) transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - ts-node - - utf-8-validate '@jest/test-sequencer@27.5.1': dependencies: @@ -19237,11 +19192,11 @@ snapshots: bn.js: 4.12.0(patch_hash=mdjtmbbjulugflauukpfkw6p4q) inherits: 2.0.4 minimalistic-assert: 1.0.1 - safer-buffer: 2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza) + safer-buffer: 2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma) asn1@0.2.6: dependencies: - safer-buffer: 2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza) + safer-buffer: 2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma) asn1js@3.0.5: dependencies: @@ -19477,7 +19432,7 @@ snapshots: base-x@3.0.9: dependencies: - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 base64-js@1.5.1: {} @@ -19656,7 +19611,7 @@ snapshots: JSONStream: 1.3.5 combine-source-map: 0.8.0 defined: 1.0.1 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 through2: 2.0.5 umd: 3.0.3 @@ -19675,7 +19630,7 @@ snapshots: create-hash: 1.2.0 evp_bytestokey: 1.0.3 inherits: 2.0.4 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 browserify-cipher@1.0.1: dependencies: @@ -19688,7 +19643,7 @@ snapshots: cipher-base: 1.0.4 des.js: 1.1.0 inherits: 2.0.4 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 browserify-rsa@4.1.0: dependencies: @@ -19705,7 +19660,7 @@ snapshots: inherits: 2.0.4 parse-asn1: 5.1.6 readable-stream: 3.6.2 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 browserify-zlib@0.2.0: dependencies: @@ -20079,7 +20034,7 @@ snapshots: cipher-base@1.0.4: dependencies: inherits: 2.0.4 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 citty@0.1.5: dependencies: @@ -20319,7 +20274,7 @@ snapshots: content-disposition@0.5.4: dependencies: - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 content-type@1.0.5: {} @@ -20423,7 +20378,7 @@ snapshots: create-hash: 1.2.0 inherits: 2.0.4 ripemd160: 2.0.2 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 sha.js: 2.4.11 create-require@1.1.1: {} @@ -21263,7 +21218,7 @@ snapshots: ecc-jsbn@0.1.2: dependencies: jsbn: 0.1.1 - safer-buffer: 2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza) + safer-buffer: 2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma) ee-first@1.1.1: {} @@ -22089,7 +22044,7 @@ snapshots: evp_bytestokey@1.0.3: dependencies: md5.js: 1.3.5 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 exec-sh@0.3.6: {} @@ -22202,7 +22157,7 @@ snapshots: proxy-addr: 2.0.7 qs: 6.11.0 range-parser: 1.2.1 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 send: 0.18.0 serve-static: 1.15.0 setprototypeof: 1.2.0 @@ -22809,10 +22764,6 @@ snapshots: graphql: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) tslib: 2.6.2 - graphql-ws@5.12.0(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)): - dependencies: - graphql: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) - graphql-ws@5.14.3(graphql@15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q)): dependencies: graphql: 15.7.2(patch_hash=nr4gprddtjag7fz5nm4wirqs4q) @@ -22917,7 +22868,7 @@ snapshots: dependencies: inherits: 2.0.4 readable-stream: 3.6.2 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 hash.js@1.1.7: dependencies: @@ -23326,11 +23277,11 @@ snapshots: iconv-lite@0.4.24: dependencies: - safer-buffer: 2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza) + safer-buffer: 2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma) iconv-lite@0.6.3: dependencies: - safer-buffer: 2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza) + safer-buffer: 2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma) icss-replace-symbols@1.1.0: {} @@ -24008,7 +23959,7 @@ snapshots: jest-config@26.6.3(bufferutil@4.0.8)(ts-node@10.9.1(@types/node@16.18.76)(typescript@4.9.5))(utf-8-validate@5.0.10): dependencies: '@babel/core': 7.23.9 - '@jest/test-sequencer': 26.6.3(bufferutil@4.0.8)(ts-node@10.9.1(@types/node@16.18.76)(typescript@4.9.5))(utf-8-validate@5.0.10) + '@jest/test-sequencer': 26.6.3 '@jest/types': 26.6.2 babel-jest: 26.6.3(@babel/core@7.23.9) chalk: 4.1.2 @@ -25261,7 +25212,7 @@ snapshots: dependencies: hash-base: 3.1.0 inherits: 2.0.4 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 md5@2.3.0: dependencies: @@ -26782,7 +26733,7 @@ snapshots: browserify-aes: 1.2.0 evp_bytestokey: 1.0.3 pbkdf2: 3.1.2 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 parse-entities@2.0.0: dependencies: @@ -26931,7 +26882,7 @@ snapshots: create-hash: 1.2.0 create-hmac: 1.1.7 ripemd160: 2.0.2 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 sha.js: 2.4.11 pend@1.2.0: {} @@ -27716,7 +27667,7 @@ snapshots: prettier@2.8.8: {} - prettier@3.2.4: {} + prettier@3.8.1: {} pretty-bytes@5.6.0: {} @@ -27801,7 +27752,7 @@ snapshots: create-hash: 1.2.0 parse-asn1: 5.1.6 randombytes: 2.1.0 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 pump@3.0.0: dependencies: @@ -27879,12 +27830,12 @@ snapshots: randombytes@2.1.0: dependencies: - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 randomfill@1.0.4: dependencies: randombytes: 2.1.0 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 range-parser@1.2.1: {} @@ -27966,13 +27917,6 @@ snapshots: - supports-color - vue-template-compiler - react-dom@18.2.0(react@17.0.2): - dependencies: - loose-envify: 1.4.0 - react: 17.0.2 - scheduler: 0.23.0 - optional: true - react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 @@ -28105,11 +28049,6 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - react@17.0.2: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react@18.2.0: dependencies: loose-envify: 1.4.0 @@ -28459,7 +28398,7 @@ snapshots: oauth-sign: 0.9.0 performance-now: 2.1.0 qs: 6.5.3 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 tough-cookie: 2.5.0 tunnel-agent: 0.6.0 uuid: 3.4.0 @@ -28725,7 +28664,7 @@ snapshots: safe-buffer@5.1.2: {} - safe-buffer@5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq): {} + safe-buffer@5.2.1: {} safe-identifier@0.4.2: {} @@ -28743,7 +28682,7 @@ snapshots: safe-stable-stringify@2.4.3: {} - safer-buffer@2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza): {} + safer-buffer@2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma): {} sander@0.5.1: dependencies: @@ -28962,7 +28901,7 @@ snapshots: sha.js@2.4.11: dependencies: inherits: 2.0.4 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 shasum-object@1.0.0: dependencies: @@ -29210,7 +29149,7 @@ snapshots: ecc-jsbn: 0.1.2 getpass: 0.1.7 jsbn: 0.1.1 - safer-buffer: 2.1.2(patch_hash=sdxbjiwrw3yiqjkfb6uxghzoza) + safer-buffer: 2.1.2(patch_hash=eylf6l5slqn6yo3m4cwwpkhvma) tweetnacl: 0.14.5 stable@0.1.8: {} @@ -29359,7 +29298,7 @@ snapshots: string_decoder@1.3.0: dependencies: - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 stringify-entities@4.0.4: dependencies: @@ -29931,37 +29870,37 @@ snapshots: tunnel-agent@0.6.0: dependencies: - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 tunnel@0.0.6: optional: true - turbo-darwin-64@1.11.3: + turbo-darwin-64@2.8.13: optional: true - turbo-darwin-arm64@1.11.3: + turbo-darwin-arm64@2.8.13: optional: true - turbo-linux-64@1.11.3: + turbo-linux-64@2.8.13: optional: true - turbo-linux-arm64@1.11.3: + turbo-linux-arm64@2.8.13: optional: true - turbo-windows-64@1.11.3: + turbo-windows-64@2.8.13: optional: true - turbo-windows-arm64@1.11.3: + turbo-windows-arm64@2.8.13: optional: true - turbo@1.11.3: + turbo@2.8.13: optionalDependencies: - turbo-darwin-64: 1.11.3 - turbo-darwin-arm64: 1.11.3 - turbo-linux-64: 1.11.3 - turbo-linux-arm64: 1.11.3 - turbo-windows-64: 1.11.3 - turbo-windows-arm64: 1.11.3 + turbo-darwin-64: 2.8.13 + turbo-darwin-arm64: 2.8.13 + turbo-linux-64: 2.8.13 + turbo-linux-arm64: 2.8.13 + turbo-windows-64: 2.8.13 + turbo-windows-arm64: 2.8.13 tweetnacl@0.14.5: {} @@ -30748,7 +30687,7 @@ snapshots: websocket-driver@0.7.4: dependencies: http-parser-js: 0.5.8 - safe-buffer: 5.2.1(patch_hash=qcepvj3ww73f2shgrehxggbrbq) + safe-buffer: 5.2.1 websocket-extensions: 0.1.4 websocket-extensions@0.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ea4a6619..a0c40ffb8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,6 @@ packages: - 'dapp' - 'ad4m-hooks/react' - 'ad4m-hooks/vue' - - 'ad4m-hooks/helpers' # exclude packages that are inside test directories - '!**/test/**' hoist: false diff --git a/rust-executor/src/agent/mod.rs b/rust-executor/src/agent/mod.rs index 4b313e1a3..5113d522b 100644 --- a/rust-executor/src/agent/mod.rs +++ b/rust-executor/src/agent/mod.rs @@ -232,6 +232,12 @@ lazy_static! { impl AgentService { pub fn init_global_instance(app_path: String) { + // Ensure the ad4m subdirectory exists before any save/load operations. + // init.rs creates app_data_path itself, but not the nested ad4m/ dir + // that agent.json and agentProfile.json live in. + let ad4m_dir = format!("{}/ad4m", app_path); + std::fs::create_dir_all(&ad4m_dir).expect("Failed to create agent data directory"); + let mut agent_instance = AGENT_SERVICE.lock().unwrap(); *agent_instance = Some(AgentService::new(app_path)); } diff --git a/rust-executor/src/graphql/mutation_resolvers.rs b/rust-executor/src/graphql/mutation_resolvers.rs index 6e376b38a..acc679e27 100644 --- a/rust-executor/src/graphql/mutation_resolvers.rs +++ b/rust-executor/src/graphql/mutation_resolvers.rs @@ -2205,32 +2205,6 @@ impl Mutation { }) } - async fn perspective_subscribe_surreal_query( - &self, - context: &RequestContext, - uuid: String, - query: String, - ) -> FieldResult { - check_capability( - &context.capabilities, - &perspective_query_capability(vec![uuid.clone()]), - )?; - - // Extract user context from auth token - let agent_context = crate::agent::AgentContext::from_auth_token(context.auth_token.clone()); - let user_email = agent_context.user_email; - - let perspective = get_perspective_with_uuid_field_error(&uuid)?; - let (subscription_id, result_string) = perspective - .subscribe_and_query_surreal(query, user_email) - .await?; - - Ok(QuerySubscription { - subscription_id, - result: result_string, - }) - } - async fn perspective_keep_alive_query( &self, context: &RequestContext, @@ -2247,22 +2221,6 @@ impl Mutation { Ok(true) } - async fn perspective_keep_alive_surreal_query( - &self, - context: &RequestContext, - uuid: String, - subscription_id: String, - ) -> FieldResult { - check_capability( - &context.capabilities, - &perspective_query_capability(vec![uuid.clone()]), - )?; - - let perspective = get_perspective_with_uuid_field_error(&uuid)?; - perspective.keepalive_surreal_query(subscription_id).await?; - Ok(true) - } - async fn perspective_dispose_query_subscription( &self, context: &RequestContext, @@ -2280,23 +2238,6 @@ impl Mutation { .await?) } - async fn perspective_dispose_surreal_query_subscription( - &self, - context: &RequestContext, - uuid: String, - subscription_id: String, - ) -> FieldResult { - check_capability( - &context.capabilities, - &perspective_query_capability(vec![uuid.clone()]), - )?; - - let perspective = get_perspective_with_uuid_field_error(&uuid)?; - Ok(perspective - .dispose_surreal_query_subscription(subscription_id) - .await?) - } - async fn runtime_add_friends( &self, context: &RequestContext, diff --git a/rust-executor/src/perspectives/perspective_instance.rs b/rust-executor/src/perspectives/perspective_instance.rs index 0f4762222..be1fc12fa 100644 --- a/rust-executor/src/perspectives/perspective_instance.rs +++ b/rust-executor/src/perspectives/perspective_instance.rs @@ -88,8 +88,8 @@ pub enum Action { RemoveLink, #[serde(rename = "setSingleTarget")] SetSingleTarget, - #[serde(rename = "collectionSetter")] - CollectionSetter, + #[serde(rename = "relationSetter", alias = "collectionSetter")] + RelationSetter, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -163,14 +163,6 @@ struct SubscribedQuery { user_email: Option, } -#[derive(Clone)] -struct SurrealSubscribedQuery { - query: String, - last_result: String, - last_keepalive: Instant, - user_email: Option, -} - #[derive(Clone)] pub struct PerspectiveInstance { pub persisted: Arc>, @@ -185,11 +177,9 @@ pub struct PerspectiveInstance { link_language: Arc>>, trigger_notification_check: Arc>, trigger_prolog_subscription_check: Arc>, - trigger_surreal_subscription_check: Arc>, commit_debounce_timer: Arc>>, immediate_commits_remaining: Arc>, subscribed_queries: Arc>>, - surreal_subscribed_queries: Arc>>, batch_store: Arc>>, // Fallback sync tracking for ensure_public_links_are_shared last_successful_fallback_sync: Arc>>, @@ -219,11 +209,9 @@ impl PerspectiveInstance { link_language: Arc::new(RwLock::new(None)), trigger_notification_check: Arc::new(Mutex::new(false)), trigger_prolog_subscription_check: Arc::new(Mutex::new(false)), - trigger_surreal_subscription_check: Arc::new(Mutex::new(false)), commit_debounce_timer: Arc::new(Mutex::new(None)), immediate_commits_remaining: Arc::new(Mutex::new(IMMEDIATE_COMMITS_COUNT)), subscribed_queries: Arc::new(Mutex::new(HashMap::new())), - surreal_subscribed_queries: Arc::new(Mutex::new(HashMap::new())), batch_store: Arc::new(RwLock::new(HashMap::new())), // Initialize fallback sync tracking last_successful_fallback_sync: Arc::new(Mutex::new(None)), @@ -240,7 +228,6 @@ impl PerspectiveInstance { self.nh_sync_loop(), self.pending_diffs_loop(), self.subscribed_queries_loop(), - self.surreal_subscription_cleanup_loop(), self.fallback_sync_loop() ); } @@ -2661,7 +2648,6 @@ impl PerspectiveInstance { // Trigger notification, prolog subscription, and surreal subscription checks *(self_clone.trigger_notification_check.lock().await) = true; *(self_clone.trigger_prolog_subscription_check.lock().await) = true; - *(self_clone.trigger_surreal_subscription_check.lock().await) = true; self_clone.pubsub_publish_diff(diff).await; @@ -2787,7 +2773,6 @@ impl PerspectiveInstance { // Trigger notification and subscription checks after prolog facts are updated *(self_clone.trigger_notification_check.lock().await) = true; *(self_clone.trigger_prolog_subscription_check.lock().await) = true; - *(self_clone.trigger_surreal_subscription_check.lock().await) = true; } //log::info!("🔧 PROLOG UPDATE: Total prolog update task took {:?}", spawn_start.elapsed()); @@ -3382,6 +3367,22 @@ impl PerspectiveInstance { self.remove_link(link_expression.into(), batch_id.clone()) .await?; } + // When operating inside a batch, get_links() above only + // sees committed SurrealDB state. If the same predicate + // was already added in this (uncommitted) batch — e.g. a + // second save() on the same instance within a transaction + // — that pending addition is invisible to get_links() and + // would survive alongside the new value after commit. + // Evict any matching pending additions now so only the + // final value ends up in the batch. + if let Some(ref bid) = batch_id { + let mut batches = self.batch_store.write().await; + if let Some(diff) = batches.get_mut(bid) { + diff.additions.retain(|link| { + !(link.data.source == source && link.data.predicate == predicate) + }); + } + } self.add_link( Link { source, @@ -3394,7 +3395,7 @@ impl PerspectiveInstance { ) .await?; } - Action::CollectionSetter => { + Action::RelationSetter => { let link_expressions = self .get_links(&LinkQuery { source: Some(source.clone()), @@ -3541,12 +3542,10 @@ impl PerspectiveInstance { } async fn get_constructor_actions(&self, class_name: &str) -> Result, AnyError> { - self.get_shape_actions_from_shacl(class_name, "ad4m://constructor") + Ok(self + .get_shape_actions_from_shacl(class_name, "ad4m://constructor") .await? - .ok_or(anyhow!( - "No SHACL constructor found for class: {}. Ensure the class has SHACL definitions.", - class_name - )) + .unwrap_or_default()) } async fn get_destructor_actions(&self, class_name: &str) -> Result, AnyError> { @@ -3685,14 +3684,25 @@ impl PerspectiveInstance { //log::info!("🎯 CREATE SUBJECT: Property '{}' setter resolved in {:?}", // prop, prop_start.elapsed()); - // Compare predicates between setter and constructor commands + // Merge setter commands into the constructor command list. + // When a setter predicate matches a constructor command, we + // REPLACE the entire constructor command with the setter command + // (using the resolved target value). This preserves the setter's + // action type (e.g. SetSingleTarget) so that re-saves correctly + // remove old links before writing the new value, rather than + // leaving both the stale and updated link in the graph. for setter_cmd in setter_commands.iter() { let mut overwritten = false; if let Some(setter_pred) = &setter_cmd.predicate { for cmd in commands.iter_mut() { if let Some(pred) = &cmd.predicate { if pred == setter_pred { - cmd.target = Some(target_value.clone()); + // Replace the whole command, not just the target, + // so the setter action (e.g. SetSingleTarget) is used. + *cmd = Command { + target: Some(target_value.clone()), + ..setter_cmd.clone() + }; overwritten = true; break; } @@ -4028,196 +4038,6 @@ impl PerspectiveInstance { } } - pub async fn subscribe_and_query_surreal( - &self, - query: String, - user_email: Option, - ) -> Result<(String, String), AnyError> { - // Check if we already have a subscription with the same query and user - let existing_subscription = { - let queries = self.surreal_subscribed_queries.lock().await; - queries - .iter() - .find(|(_, q)| q.query == query && q.user_email == user_email) - .map(|(id, _)| id.clone()) - }; - - // Return existing subscription if found - if let Some(existing_id) = existing_subscription { - let existing_result = { - let queries = self.surreal_subscribed_queries.lock().await; - queries.get(&existing_id).map(|q| q.last_result.clone()) - }; - - if let Some(last_result) = existing_result { - let result_string = format!("#init#{}", last_result); - for delay in [100, 500, 1000, 10000, 15000, 20000, 25000] { - self.send_subscription_update( - existing_id.clone(), - result_string.clone(), - Some(Duration::from_millis(delay)), - ) - .await; - } - return Ok((existing_id, last_result)); - } - } - - let subscription_id = Uuid::new_v4().to_string(); - - // Execute surreal query with user context for $agentDid and $perspectiveId substitution - let initial_result_vec = self - .surreal_query_notification(query.clone(), user_email.clone()) - .await?; - let result_string = serde_json::to_string(&initial_result_vec)?; - - let subscribed_query = SurrealSubscribedQuery { - query, - last_result: result_string.clone(), - last_keepalive: Instant::now(), - user_email, - }; - - // Now insert the subscription - self.surreal_subscribed_queries - .lock() - .await - .insert(subscription_id.clone(), subscribed_query); - - // Send initial result with #init# prefix for compatibility - let init_msg = format!("#init#{}", result_string); - // Send multiple updates to ensure client gets it (same as Prolog implementation) - for delay in [100, 500, 1000, 10000, 15000, 20000, 25000] { - self.send_subscription_update( - subscription_id.clone(), - init_msg.clone(), - Some(Duration::from_millis(delay)), - ) - .await; - } - - Ok((subscription_id, result_string)) - } - - pub async fn keepalive_surreal_query(&self, subscription_id: String) -> Result<(), AnyError> { - let mut queries = self.surreal_subscribed_queries.lock().await; - if let Some(query) = queries.get_mut(&subscription_id) { - query.last_keepalive = Instant::now(); - Ok(()) - } else { - Err(anyhow!("Surreal subscription not found")) - } - } - - pub async fn dispose_surreal_query_subscription( - &self, - subscription_id: String, - ) -> Result { - let mut queries = self.surreal_subscribed_queries.lock().await; - Ok(queries.remove(&subscription_id).is_some()) - } - - async fn surreal_subscription_cleanup_loop(&self) { - while !*self.is_teardown.lock().await { - // Check trigger without holding lock during the operation - let should_check = { *self.trigger_surreal_subscription_check.lock().await }; - - if should_check { - self.check_surreal_subscribed_queries().await; - *self.trigger_surreal_subscription_check.lock().await = false; - } - sleep(Duration::from_millis(QUERY_SUBSCRIPTION_CHECK_INTERVAL)).await; - } - } - - async fn check_surreal_subscribed_queries(&self) { - let mut queries_to_remove = Vec::new(); - let mut query_futures = Vec::new(); - let now = Instant::now(); - - // Collect only the minimal data needed: ID, query string, user_email, and keepalive time - // DON'T clone the potentially huge last_result string - let queries = { - let queries_guard = self.surreal_subscribed_queries.lock().await; - queries_guard - .iter() - .map(|(id, query)| { - ( - id.clone(), - query.query.clone(), - query.user_email.clone(), - query.last_keepalive, - ) - }) - .collect::>() - }; - - // Create futures for each query check - for (id, query_string, user_email, last_keepalive) in queries { - // Check for timeout - if now.duration_since(last_keepalive).as_secs() > QUERY_SUBSCRIPTION_TIMEOUT { - queries_to_remove.push(id); - continue; - } - - // Spawn query check future - let self_clone = self.clone(); - let query_future = async move { - match self_clone - .surreal_query_notification(query_string, user_email) - .await - { - Ok(result_vec) => { - if let Ok(result_string) = serde_json::to_string(&result_vec) { - // Compare with stored last_result only now, avoiding the clone earlier - let mut queries = self_clone.surreal_subscribed_queries.lock().await; - if let Some(stored_query) = queries.get_mut(&id) { - if result_string != stored_query.last_result { - // Release lock before sending update - drop(queries); - self_clone - .send_subscription_update( - id.clone(), - result_string.clone(), - None, - ) - .await; - // Re-acquire lock to update the result - let mut queries = - self_clone.surreal_subscribed_queries.lock().await; - if let Some(stored_query) = queries.get_mut(&id) { - stored_query.last_result = result_string; - } - } - } - } - } - Err(e) => { - log::warn!( - "SurrealDB subscription query failed for subscription {}: {}", - id, - e - ); - // Note: We don't remove the subscription on query failure - // to allow for transient errors. It will be removed on timeout. - } - } - }; - query_futures.push(query_future); - } - - // Wait for all query futures to complete - future::join_all(query_futures).await; - - // Remove timed out queries - if !queries_to_remove.is_empty() { - let mut queries = self.surreal_subscribed_queries.lock().await; - for id in queries_to_remove { - queries.remove(&id); - } - } - } - async fn check_subscribed_queries(&self) { let mut queries_to_remove = Vec::new(); let mut query_futures = Vec::new(); @@ -4557,18 +4377,14 @@ impl PerspectiveInstance { // Only spawn prolog facts update if there are changes to update if !combined_diff.additions.is_empty() || !combined_diff.removals.is_empty() { - //let prolog_start = std::time::Instant::now(); - //log::info!("🔄 BATCH COMMIT: Starting prolog facts update - {} add, {} rem", - // combined_diff.additions.len(), combined_diff.removals.len()); - - // Update prolog facts once for all changes and wait for completion - // Update Prolog: subscription engine (immediate) + query engine (lazy) - // Update both Prolog engines: subscription (immediate) + query (lazy) - self.update_prolog_engines(combined_diff.clone()).await; - + // Persist to SurrealDB BEFORE firing pubsub/prolog events. + // update_prolog_engines() spawns a task that publishes link-added events; + // if SurrealDB isn't written yet when those events arrive, any subscriber + // that immediately calls findAll() will get stale results. self.persist_link_diff(&combined_diff).await?; - //log::info!("🔄 BATCH COMMIT: Prolog facts update completed in {:?}", prolog_start.elapsed()); + // Now it's safe to fire link-added events — SurrealDB is already committed. + self.update_prolog_engines(combined_diff.clone()).await; } //log::info!("🔄 BATCH COMMIT: Total batch commit took {:?}", commit_start.elapsed()); @@ -5502,14 +5318,12 @@ mod tests { "path": "recipe://name", "name": "name", "datatype": "xsd://string", - "writable": true, "setter": [{"action": "setSingleTarget", "source": "this", "predicate": "recipe://name", "target": "value"}] }, { "path": "recipe://rating", "name": "rating", "datatype": "xsd://string", - "writable": true, "setter": [{"action": "setSingleTarget", "source": "this", "predicate": "recipe://rating", "target": "value"}] } ] diff --git a/rust-executor/src/perspectives/shacl_parser.rs b/rust-executor/src/perspectives/shacl_parser.rs index bfd4296bb..650593ecc 100644 --- a/rust-executor/src/perspectives/shacl_parser.rs +++ b/rust-executor/src/perspectives/shacl_parser.rs @@ -34,7 +34,7 @@ pub struct PropertyShape { pub datatype: Option, pub min_count: Option, pub max_count: Option, - pub writable: Option, + pub read_only: Option, pub local: Option, pub resolve_language: Option, pub node_kind: Option, @@ -322,8 +322,11 @@ pub fn parse_shacl_to_links(shacl_json: &str, class_name: &str) -> Result Result{\n const { Deno: Deno1 } = globalThis;\n if (typeof Deno1?.build?.os === \"string\") {\n return Deno1.build.os;\n }\n const { navigator } = globalThis;\n if (navigator?.appVersion?.includes?.(\"Win\")) {\n return \"windows\";\n }\n return \"linux\";\n})();\nconst isWindows = osType === \"windows\";\nconst CHAR_FORWARD_SLASH = 47;\nfunction assertPath(path) {\n if (typeof path !== \"string\") {\n throw new TypeError(`Path must be a string. Received ${JSON.stringify(path)}`);\n }\n}\nfunction isPosixPathSeparator(code) {\n return code === 47;\n}\nfunction isPathSeparator(code) {\n return isPosixPathSeparator(code) || code === 92;\n}\nfunction isWindowsDeviceRoot(code) {\n return code >= 97 && code <= 122 || code >= 65 && code <= 90;\n}\nfunction normalizeString(path, allowAboveRoot, separator, isPathSeparator) {\n let res = \"\";\n let lastSegmentLength = 0;\n let lastSlash = -1;\n let dots = 0;\n let code;\n for(let i = 0, len = path.length; i <= len; ++i){\n if (i < len) code = path.charCodeAt(i);\n else if (isPathSeparator(code)) break;\n else code = CHAR_FORWARD_SLASH;\n if (isPathSeparator(code)) {\n if (lastSlash === i - 1 || dots === 1) {} else if (lastSlash !== i - 1 && dots === 2) {\n if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 || res.charCodeAt(res.length - 2) !== 46) {\n if (res.length > 2) {\n const lastSlashIndex = res.lastIndexOf(separator);\n if (lastSlashIndex === -1) {\n res = \"\";\n lastSegmentLength = 0;\n } else {\n res = res.slice(0, lastSlashIndex);\n lastSegmentLength = res.length - 1 - res.lastIndexOf(separator);\n }\n lastSlash = i;\n dots = 0;\n continue;\n } else if (res.length === 2 || res.length === 1) {\n res = \"\";\n lastSegmentLength = 0;\n lastSlash = i;\n dots = 0;\n continue;\n }\n }\n if (allowAboveRoot) {\n if (res.length > 0) res += `${separator}..`;\n else res = \"..\";\n lastSegmentLength = 2;\n }\n } else {\n if (res.length > 0) res += separator + path.slice(lastSlash + 1, i);\n else res = path.slice(lastSlash + 1, i);\n lastSegmentLength = i - lastSlash - 1;\n }\n lastSlash = i;\n dots = 0;\n } else if (code === 46 && dots !== -1) {\n ++dots;\n } else {\n dots = -1;\n }\n }\n return res;\n}\nfunction _format(sep, pathObject) {\n const dir = pathObject.dir || pathObject.root;\n const base = pathObject.base || (pathObject.name || \"\") + (pathObject.ext || \"\");\n if (!dir) return base;\n if (base === sep) return dir;\n if (dir === pathObject.root) return dir + base;\n return dir + sep + base;\n}\nconst WHITESPACE_ENCODINGS = {\n \"\\u0009\": \"%09\",\n \"\\u000A\": \"%0A\",\n \"\\u000B\": \"%0B\",\n \"\\u000C\": \"%0C\",\n \"\\u000D\": \"%0D\",\n \"\\u0020\": \"%20\"\n};\nfunction encodeWhitespace(string) {\n return string.replaceAll(/[\\s]/g, (c)=>{\n return WHITESPACE_ENCODINGS[c] ?? c;\n });\n}\nfunction lastPathSegment(path, isSep, start = 0) {\n let matchedNonSeparator = false;\n let end = path.length;\n for(let i = path.length - 1; i >= start; --i){\n if (isSep(path.charCodeAt(i))) {\n if (matchedNonSeparator) {\n start = i + 1;\n break;\n }\n } else if (!matchedNonSeparator) {\n matchedNonSeparator = true;\n end = i + 1;\n }\n }\n return path.slice(start, end);\n}\nfunction stripTrailingSeparators(segment, isSep) {\n if (segment.length <= 1) {\n return segment;\n }\n let end = segment.length;\n for(let i = segment.length - 1; i > 0; i--){\n if (isSep(segment.charCodeAt(i))) {\n end = i;\n } else {\n break;\n }\n }\n return segment.slice(0, end);\n}\nfunction stripSuffix(name, suffix) {\n if (suffix.length >= name.length) {\n return name;\n }\n const lenDiff = name.length - suffix.length;\n for(let i = suffix.length - 1; i >= 0; --i){\n if (name.charCodeAt(lenDiff + i) !== suffix.charCodeAt(i)) {\n return name;\n }\n }\n return name.slice(0, -suffix.length);\n}\nclass DenoStdInternalError extends Error {\n constructor(message){\n super(message);\n this.name = \"DenoStdInternalError\";\n }\n}\nfunction assert(expr, msg = \"\") {\n if (!expr) {\n throw new DenoStdInternalError(msg);\n }\n}\nconst sep = \"\\\\\";\nconst delimiter = \";\";\nfunction resolve(...pathSegments) {\n let resolvedDevice = \"\";\n let resolvedTail = \"\";\n let resolvedAbsolute = false;\n for(let i = pathSegments.length - 1; i >= -1; i--){\n let path;\n const { Deno: Deno1 } = globalThis;\n if (i >= 0) {\n path = pathSegments[i];\n } else if (!resolvedDevice) {\n if (typeof Deno1?.cwd !== \"function\") {\n throw new TypeError(\"Resolved a drive-letter-less path without a CWD.\");\n }\n path = Deno1.cwd();\n } else {\n if (typeof Deno1?.env?.get !== \"function\" || typeof Deno1?.cwd !== \"function\") {\n throw new TypeError(\"Resolved a relative path without a CWD.\");\n }\n path = Deno1.cwd();\n if (path === undefined || path.slice(0, 3).toLowerCase() !== `${resolvedDevice.toLowerCase()}\\\\`) {\n path = `${resolvedDevice}\\\\`;\n }\n }\n assertPath(path);\n const len = path.length;\n if (len === 0) continue;\n let rootEnd = 0;\n let device = \"\";\n let isAbsolute = false;\n const code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n isAbsolute = true;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n const firstPart = path.slice(last, j);\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n device = `\\\\\\\\${firstPart}\\\\${path.slice(last)}`;\n rootEnd = j;\n } else if (j !== last) {\n device = `\\\\\\\\${firstPart}\\\\${path.slice(last, j)}`;\n rootEnd = j;\n }\n }\n }\n } else {\n rootEnd = 1;\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n device = path.slice(0, 2);\n rootEnd = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) {\n isAbsolute = true;\n rootEnd = 3;\n }\n }\n }\n }\n } else if (isPathSeparator(code)) {\n rootEnd = 1;\n isAbsolute = true;\n }\n if (device.length > 0 && resolvedDevice.length > 0 && device.toLowerCase() !== resolvedDevice.toLowerCase()) {\n continue;\n }\n if (resolvedDevice.length === 0 && device.length > 0) {\n resolvedDevice = device;\n }\n if (!resolvedAbsolute) {\n resolvedTail = `${path.slice(rootEnd)}\\\\${resolvedTail}`;\n resolvedAbsolute = isAbsolute;\n }\n if (resolvedAbsolute && resolvedDevice.length > 0) break;\n }\n resolvedTail = normalizeString(resolvedTail, !resolvedAbsolute, \"\\\\\", isPathSeparator);\n return resolvedDevice + (resolvedAbsolute ? \"\\\\\" : \"\") + resolvedTail || \".\";\n}\nfunction normalize(path) {\n assertPath(path);\n const len = path.length;\n if (len === 0) return \".\";\n let rootEnd = 0;\n let device;\n let isAbsolute = false;\n const code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n isAbsolute = true;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n const firstPart = path.slice(last, j);\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n return `\\\\\\\\${firstPart}\\\\${path.slice(last)}\\\\`;\n } else if (j !== last) {\n device = `\\\\\\\\${firstPart}\\\\${path.slice(last, j)}`;\n rootEnd = j;\n }\n }\n }\n } else {\n rootEnd = 1;\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n device = path.slice(0, 2);\n rootEnd = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) {\n isAbsolute = true;\n rootEnd = 3;\n }\n }\n }\n }\n } else if (isPathSeparator(code)) {\n return \"\\\\\";\n }\n let tail;\n if (rootEnd < len) {\n tail = normalizeString(path.slice(rootEnd), !isAbsolute, \"\\\\\", isPathSeparator);\n } else {\n tail = \"\";\n }\n if (tail.length === 0 && !isAbsolute) tail = \".\";\n if (tail.length > 0 && isPathSeparator(path.charCodeAt(len - 1))) {\n tail += \"\\\\\";\n }\n if (device === undefined) {\n if (isAbsolute) {\n if (tail.length > 0) return `\\\\${tail}`;\n else return \"\\\\\";\n } else if (tail.length > 0) {\n return tail;\n } else {\n return \"\";\n }\n } else if (isAbsolute) {\n if (tail.length > 0) return `${device}\\\\${tail}`;\n else return `${device}\\\\`;\n } else if (tail.length > 0) {\n return device + tail;\n } else {\n return device;\n }\n}\nfunction isAbsolute(path) {\n assertPath(path);\n const len = path.length;\n if (len === 0) return false;\n const code = path.charCodeAt(0);\n if (isPathSeparator(code)) {\n return true;\n } else if (isWindowsDeviceRoot(code)) {\n if (len > 2 && path.charCodeAt(1) === 58) {\n if (isPathSeparator(path.charCodeAt(2))) return true;\n }\n }\n return false;\n}\nfunction join(...paths) {\n const pathsCount = paths.length;\n if (pathsCount === 0) return \".\";\n let joined;\n let firstPart = null;\n for(let i = 0; i < pathsCount; ++i){\n const path = paths[i];\n assertPath(path);\n if (path.length > 0) {\n if (joined === undefined) joined = firstPart = path;\n else joined += `\\\\${path}`;\n }\n }\n if (joined === undefined) return \".\";\n let needsReplace = true;\n let slashCount = 0;\n assert(firstPart != null);\n if (isPathSeparator(firstPart.charCodeAt(0))) {\n ++slashCount;\n const firstLen = firstPart.length;\n if (firstLen > 1) {\n if (isPathSeparator(firstPart.charCodeAt(1))) {\n ++slashCount;\n if (firstLen > 2) {\n if (isPathSeparator(firstPart.charCodeAt(2))) ++slashCount;\n else {\n needsReplace = false;\n }\n }\n }\n }\n }\n if (needsReplace) {\n for(; slashCount < joined.length; ++slashCount){\n if (!isPathSeparator(joined.charCodeAt(slashCount))) break;\n }\n if (slashCount >= 2) joined = `\\\\${joined.slice(slashCount)}`;\n }\n return normalize(joined);\n}\nfunction relative(from, to) {\n assertPath(from);\n assertPath(to);\n if (from === to) return \"\";\n const fromOrig = resolve(from);\n const toOrig = resolve(to);\n if (fromOrig === toOrig) return \"\";\n from = fromOrig.toLowerCase();\n to = toOrig.toLowerCase();\n if (from === to) return \"\";\n let fromStart = 0;\n let fromEnd = from.length;\n for(; fromStart < fromEnd; ++fromStart){\n if (from.charCodeAt(fromStart) !== 92) break;\n }\n for(; fromEnd - 1 > fromStart; --fromEnd){\n if (from.charCodeAt(fromEnd - 1) !== 92) break;\n }\n const fromLen = fromEnd - fromStart;\n let toStart = 0;\n let toEnd = to.length;\n for(; toStart < toEnd; ++toStart){\n if (to.charCodeAt(toStart) !== 92) break;\n }\n for(; toEnd - 1 > toStart; --toEnd){\n if (to.charCodeAt(toEnd - 1) !== 92) break;\n }\n const toLen = toEnd - toStart;\n const length = fromLen < toLen ? fromLen : toLen;\n let lastCommonSep = -1;\n let i = 0;\n for(; i <= length; ++i){\n if (i === length) {\n if (toLen > length) {\n if (to.charCodeAt(toStart + i) === 92) {\n return toOrig.slice(toStart + i + 1);\n } else if (i === 2) {\n return toOrig.slice(toStart + i);\n }\n }\n if (fromLen > length) {\n if (from.charCodeAt(fromStart + i) === 92) {\n lastCommonSep = i;\n } else if (i === 2) {\n lastCommonSep = 3;\n }\n }\n break;\n }\n const fromCode = from.charCodeAt(fromStart + i);\n const toCode = to.charCodeAt(toStart + i);\n if (fromCode !== toCode) break;\n else if (fromCode === 92) lastCommonSep = i;\n }\n if (i !== length && lastCommonSep === -1) {\n return toOrig;\n }\n let out = \"\";\n if (lastCommonSep === -1) lastCommonSep = 0;\n for(i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i){\n if (i === fromEnd || from.charCodeAt(i) === 92) {\n if (out.length === 0) out += \"..\";\n else out += \"\\\\..\";\n }\n }\n if (out.length > 0) {\n return out + toOrig.slice(toStart + lastCommonSep, toEnd);\n } else {\n toStart += lastCommonSep;\n if (toOrig.charCodeAt(toStart) === 92) ++toStart;\n return toOrig.slice(toStart, toEnd);\n }\n}\nfunction toNamespacedPath(path) {\n if (typeof path !== \"string\") return path;\n if (path.length === 0) return \"\";\n const resolvedPath = resolve(path);\n if (resolvedPath.length >= 3) {\n if (resolvedPath.charCodeAt(0) === 92) {\n if (resolvedPath.charCodeAt(1) === 92) {\n const code = resolvedPath.charCodeAt(2);\n if (code !== 63 && code !== 46) {\n return `\\\\\\\\?\\\\UNC\\\\${resolvedPath.slice(2)}`;\n }\n }\n } else if (isWindowsDeviceRoot(resolvedPath.charCodeAt(0))) {\n if (resolvedPath.charCodeAt(1) === 58 && resolvedPath.charCodeAt(2) === 92) {\n return `\\\\\\\\?\\\\${resolvedPath}`;\n }\n }\n }\n return path;\n}\nfunction dirname(path) {\n assertPath(path);\n const len = path.length;\n if (len === 0) return \".\";\n let rootEnd = -1;\n let end = -1;\n let matchedSlash = true;\n let offset = 0;\n const code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n rootEnd = offset = 1;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n return path;\n }\n if (j !== last) {\n rootEnd = offset = j + 1;\n }\n }\n }\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n rootEnd = offset = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) rootEnd = offset = 3;\n }\n }\n }\n } else if (isPathSeparator(code)) {\n return path;\n }\n for(let i = len - 1; i >= offset; --i){\n if (isPathSeparator(path.charCodeAt(i))) {\n if (!matchedSlash) {\n end = i;\n break;\n }\n } else {\n matchedSlash = false;\n }\n }\n if (end === -1) {\n if (rootEnd === -1) return \".\";\n else end = rootEnd;\n }\n return stripTrailingSeparators(path.slice(0, end), isPosixPathSeparator);\n}\nfunction basename(path, suffix = \"\") {\n assertPath(path);\n if (path.length === 0) return path;\n if (typeof suffix !== \"string\") {\n throw new TypeError(`Suffix must be a string. Received ${JSON.stringify(suffix)}`);\n }\n let start = 0;\n if (path.length >= 2) {\n const drive = path.charCodeAt(0);\n if (isWindowsDeviceRoot(drive)) {\n if (path.charCodeAt(1) === 58) start = 2;\n }\n }\n const lastSegment = lastPathSegment(path, isPathSeparator, start);\n const strippedSegment = stripTrailingSeparators(lastSegment, isPathSeparator);\n return suffix ? stripSuffix(strippedSegment, suffix) : strippedSegment;\n}\nfunction extname(path) {\n assertPath(path);\n let start = 0;\n let startDot = -1;\n let startPart = 0;\n let end = -1;\n let matchedSlash = true;\n let preDotState = 0;\n if (path.length >= 2 && path.charCodeAt(1) === 58 && isWindowsDeviceRoot(path.charCodeAt(0))) {\n start = startPart = 2;\n }\n for(let i = path.length - 1; i >= start; --i){\n const code = path.charCodeAt(i);\n if (isPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n return \"\";\n }\n return path.slice(startDot, end);\n}\nfunction format(pathObject) {\n if (pathObject === null || typeof pathObject !== \"object\") {\n throw new TypeError(`The \"pathObject\" argument must be of type Object. Received type ${typeof pathObject}`);\n }\n return _format(\"\\\\\", pathObject);\n}\nfunction parse(path) {\n assertPath(path);\n const ret = {\n root: \"\",\n dir: \"\",\n base: \"\",\n ext: \"\",\n name: \"\"\n };\n const len = path.length;\n if (len === 0) return ret;\n let rootEnd = 0;\n let code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n rootEnd = 1;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n rootEnd = j;\n } else if (j !== last) {\n rootEnd = j + 1;\n }\n }\n }\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n rootEnd = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) {\n if (len === 3) {\n ret.root = ret.dir = path;\n ret.base = \"\\\\\";\n return ret;\n }\n rootEnd = 3;\n }\n } else {\n ret.root = ret.dir = path;\n return ret;\n }\n }\n }\n } else if (isPathSeparator(code)) {\n ret.root = ret.dir = path;\n ret.base = \"\\\\\";\n return ret;\n }\n if (rootEnd > 0) ret.root = path.slice(0, rootEnd);\n let startDot = -1;\n let startPart = rootEnd;\n let end = -1;\n let matchedSlash = true;\n let i = path.length - 1;\n let preDotState = 0;\n for(; i >= rootEnd; --i){\n code = path.charCodeAt(i);\n if (isPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n if (end !== -1) {\n ret.base = ret.name = path.slice(startPart, end);\n }\n } else {\n ret.name = path.slice(startPart, startDot);\n ret.base = path.slice(startPart, end);\n ret.ext = path.slice(startDot, end);\n }\n ret.base = ret.base || \"\\\\\";\n if (startPart > 0 && startPart !== rootEnd) {\n ret.dir = path.slice(0, startPart - 1);\n } else ret.dir = ret.root;\n return ret;\n}\nfunction fromFileUrl(url) {\n url = url instanceof URL ? url : new URL(url);\n if (url.protocol != \"file:\") {\n throw new TypeError(\"Must be a file URL.\");\n }\n let path = decodeURIComponent(url.pathname.replace(/\\//g, \"\\\\\").replace(/%(?![0-9A-Fa-f]{2})/g, \"%25\")).replace(/^\\\\*([A-Za-z]:)(\\\\|$)/, \"$1\\\\\");\n if (url.hostname != \"\") {\n path = `\\\\\\\\${url.hostname}${path}`;\n }\n return path;\n}\nfunction toFileUrl(path) {\n if (!isAbsolute(path)) {\n throw new TypeError(\"Must be an absolute path.\");\n }\n const [, hostname, pathname] = path.match(/^(?:[/\\\\]{2}([^/\\\\]+)(?=[/\\\\](?:[^/\\\\]|$)))?(.*)/);\n const url = new URL(\"file:///\");\n url.pathname = encodeWhitespace(pathname.replace(/%/g, \"%25\"));\n if (hostname != null && hostname != \"localhost\") {\n url.hostname = hostname;\n if (!url.hostname) {\n throw new TypeError(\"Invalid hostname.\");\n }\n }\n return url;\n}\nconst mod = {\n sep: sep,\n delimiter: delimiter,\n resolve: resolve,\n normalize: normalize,\n isAbsolute: isAbsolute,\n join: join,\n relative: relative,\n toNamespacedPath: toNamespacedPath,\n dirname: dirname,\n basename: basename,\n extname: extname,\n format: format,\n parse: parse,\n fromFileUrl: fromFileUrl,\n toFileUrl: toFileUrl\n};\nconst sep1 = \"/\";\nconst delimiter1 = \":\";\nfunction resolve1(...pathSegments) {\n let resolvedPath = \"\";\n let resolvedAbsolute = false;\n for(let i = pathSegments.length - 1; i >= -1 && !resolvedAbsolute; i--){\n let path;\n if (i >= 0) path = pathSegments[i];\n else {\n const { Deno: Deno1 } = globalThis;\n if (typeof Deno1?.cwd !== \"function\") {\n throw new TypeError(\"Resolved a relative path without a CWD.\");\n }\n path = Deno1.cwd();\n }\n assertPath(path);\n if (path.length === 0) {\n continue;\n }\n resolvedPath = `${path}/${resolvedPath}`;\n resolvedAbsolute = isPosixPathSeparator(path.charCodeAt(0));\n }\n resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, \"/\", isPosixPathSeparator);\n if (resolvedAbsolute) {\n if (resolvedPath.length > 0) return `/${resolvedPath}`;\n else return \"/\";\n } else if (resolvedPath.length > 0) return resolvedPath;\n else return \".\";\n}\nfunction normalize1(path) {\n assertPath(path);\n if (path.length === 0) return \".\";\n const isAbsolute = isPosixPathSeparator(path.charCodeAt(0));\n const trailingSeparator = isPosixPathSeparator(path.charCodeAt(path.length - 1));\n path = normalizeString(path, !isAbsolute, \"/\", isPosixPathSeparator);\n if (path.length === 0 && !isAbsolute) path = \".\";\n if (path.length > 0 && trailingSeparator) path += \"/\";\n if (isAbsolute) return `/${path}`;\n return path;\n}\nfunction isAbsolute1(path) {\n assertPath(path);\n return path.length > 0 && isPosixPathSeparator(path.charCodeAt(0));\n}\nfunction join1(...paths) {\n if (paths.length === 0) return \".\";\n let joined;\n for(let i = 0, len = paths.length; i < len; ++i){\n const path = paths[i];\n assertPath(path);\n if (path.length > 0) {\n if (!joined) joined = path;\n else joined += `/${path}`;\n }\n }\n if (!joined) return \".\";\n return normalize1(joined);\n}\nfunction relative1(from, to) {\n assertPath(from);\n assertPath(to);\n if (from === to) return \"\";\n from = resolve1(from);\n to = resolve1(to);\n if (from === to) return \"\";\n let fromStart = 1;\n const fromEnd = from.length;\n for(; fromStart < fromEnd; ++fromStart){\n if (!isPosixPathSeparator(from.charCodeAt(fromStart))) break;\n }\n const fromLen = fromEnd - fromStart;\n let toStart = 1;\n const toEnd = to.length;\n for(; toStart < toEnd; ++toStart){\n if (!isPosixPathSeparator(to.charCodeAt(toStart))) break;\n }\n const toLen = toEnd - toStart;\n const length = fromLen < toLen ? fromLen : toLen;\n let lastCommonSep = -1;\n let i = 0;\n for(; i <= length; ++i){\n if (i === length) {\n if (toLen > length) {\n if (isPosixPathSeparator(to.charCodeAt(toStart + i))) {\n return to.slice(toStart + i + 1);\n } else if (i === 0) {\n return to.slice(toStart + i);\n }\n } else if (fromLen > length) {\n if (isPosixPathSeparator(from.charCodeAt(fromStart + i))) {\n lastCommonSep = i;\n } else if (i === 0) {\n lastCommonSep = 0;\n }\n }\n break;\n }\n const fromCode = from.charCodeAt(fromStart + i);\n const toCode = to.charCodeAt(toStart + i);\n if (fromCode !== toCode) break;\n else if (isPosixPathSeparator(fromCode)) lastCommonSep = i;\n }\n let out = \"\";\n for(i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i){\n if (i === fromEnd || isPosixPathSeparator(from.charCodeAt(i))) {\n if (out.length === 0) out += \"..\";\n else out += \"/..\";\n }\n }\n if (out.length > 0) return out + to.slice(toStart + lastCommonSep);\n else {\n toStart += lastCommonSep;\n if (isPosixPathSeparator(to.charCodeAt(toStart))) ++toStart;\n return to.slice(toStart);\n }\n}\nfunction toNamespacedPath1(path) {\n return path;\n}\nfunction dirname1(path) {\n if (path.length === 0) return \".\";\n let end = -1;\n let matchedNonSeparator = false;\n for(let i = path.length - 1; i >= 1; --i){\n if (isPosixPathSeparator(path.charCodeAt(i))) {\n if (matchedNonSeparator) {\n end = i;\n break;\n }\n } else {\n matchedNonSeparator = true;\n }\n }\n if (end === -1) {\n return isPosixPathSeparator(path.charCodeAt(0)) ? \"/\" : \".\";\n }\n return stripTrailingSeparators(path.slice(0, end), isPosixPathSeparator);\n}\nfunction basename1(path, suffix = \"\") {\n assertPath(path);\n if (path.length === 0) return path;\n if (typeof suffix !== \"string\") {\n throw new TypeError(`Suffix must be a string. Received ${JSON.stringify(suffix)}`);\n }\n const lastSegment = lastPathSegment(path, isPosixPathSeparator);\n const strippedSegment = stripTrailingSeparators(lastSegment, isPosixPathSeparator);\n return suffix ? stripSuffix(strippedSegment, suffix) : strippedSegment;\n}\nfunction extname1(path) {\n assertPath(path);\n let startDot = -1;\n let startPart = 0;\n let end = -1;\n let matchedSlash = true;\n let preDotState = 0;\n for(let i = path.length - 1; i >= 0; --i){\n const code = path.charCodeAt(i);\n if (isPosixPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n return \"\";\n }\n return path.slice(startDot, end);\n}\nfunction format1(pathObject) {\n if (pathObject === null || typeof pathObject !== \"object\") {\n throw new TypeError(`The \"pathObject\" argument must be of type Object. Received type ${typeof pathObject}`);\n }\n return _format(\"/\", pathObject);\n}\nfunction parse1(path) {\n assertPath(path);\n const ret = {\n root: \"\",\n dir: \"\",\n base: \"\",\n ext: \"\",\n name: \"\"\n };\n if (path.length === 0) return ret;\n const isAbsolute = isPosixPathSeparator(path.charCodeAt(0));\n let start;\n if (isAbsolute) {\n ret.root = \"/\";\n start = 1;\n } else {\n start = 0;\n }\n let startDot = -1;\n let startPart = 0;\n let end = -1;\n let matchedSlash = true;\n let i = path.length - 1;\n let preDotState = 0;\n for(; i >= start; --i){\n const code = path.charCodeAt(i);\n if (isPosixPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n if (end !== -1) {\n if (startPart === 0 && isAbsolute) {\n ret.base = ret.name = path.slice(1, end);\n } else {\n ret.base = ret.name = path.slice(startPart, end);\n }\n }\n ret.base = ret.base || \"/\";\n } else {\n if (startPart === 0 && isAbsolute) {\n ret.name = path.slice(1, startDot);\n ret.base = path.slice(1, end);\n } else {\n ret.name = path.slice(startPart, startDot);\n ret.base = path.slice(startPart, end);\n }\n ret.ext = path.slice(startDot, end);\n }\n if (startPart > 0) {\n ret.dir = stripTrailingSeparators(path.slice(0, startPart - 1), isPosixPathSeparator);\n } else if (isAbsolute) ret.dir = \"/\";\n return ret;\n}\nfunction fromFileUrl1(url) {\n url = url instanceof URL ? url : new URL(url);\n if (url.protocol != \"file:\") {\n throw new TypeError(\"Must be a file URL.\");\n }\n return decodeURIComponent(url.pathname.replace(/%(?![0-9A-Fa-f]{2})/g, \"%25\"));\n}\nfunction toFileUrl1(path) {\n if (!isAbsolute1(path)) {\n throw new TypeError(\"Must be an absolute path.\");\n }\n const url = new URL(\"file:///\");\n url.pathname = encodeWhitespace(path.replace(/%/g, \"%25\").replace(/\\\\/g, \"%5C\"));\n return url;\n}\nconst mod1 = {\n sep: sep1,\n delimiter: delimiter1,\n resolve: resolve1,\n normalize: normalize1,\n isAbsolute: isAbsolute1,\n join: join1,\n relative: relative1,\n toNamespacedPath: toNamespacedPath1,\n dirname: dirname1,\n basename: basename1,\n extname: extname1,\n format: format1,\n parse: parse1,\n fromFileUrl: fromFileUrl1,\n toFileUrl: toFileUrl1\n};\nconst path = isWindows ? mod : mod1;\nconst { join: join2 , normalize: normalize2 } = path;\nconst path1 = isWindows ? mod : mod1;\nconst { basename: basename2 , delimiter: delimiter2 , dirname: dirname2 , extname: extname2 , format: format2 , fromFileUrl: fromFileUrl2 , isAbsolute: isAbsolute2 , join: join3 , normalize: normalize3 , parse: parse2 , relative: relative2 , resolve: resolve2 , sep: sep2 , toFileUrl: toFileUrl2 , toNamespacedPath: toNamespacedPath2 } = path1;\nasync function exists(path, options) {\n try {\n const stat = await Deno.stat(path);\n if (options && (options.isReadable || options.isDirectory || options.isFile)) {\n if (options.isDirectory && options.isFile) {\n throw new TypeError(\"ExistsOptions.options.isDirectory and ExistsOptions.options.isFile must not be true together.\");\n }\n if (options.isDirectory && !stat.isDirectory || options.isFile && !stat.isFile) {\n return false;\n }\n if (options.isReadable) {\n if (stat.mode == null) {\n return true;\n }\n if (Deno.uid() == stat.uid) {\n return (stat.mode & 0o400) == 0o400;\n } else if (Deno.gid() == stat.gid) {\n return (stat.mode & 0o040) == 0o040;\n }\n return (stat.mode & 0o004) == 0o004;\n }\n }\n return true;\n } catch (error) {\n if (error instanceof Deno.errors.NotFound) {\n return false;\n }\n if (error instanceof Deno.errors.PermissionDenied) {\n if ((await Deno.permissions.query({\n name: \"read\",\n path\n })).state === \"granted\") {\n return !options?.isReadable;\n }\n }\n throw error;\n }\n}\nnew Deno.errors.AlreadyExists(\"dest already exists.\");\nvar EOL;\n(function(EOL) {\n EOL[\"LF\"] = \"\\n\";\n EOL[\"CRLF\"] = \"\\r\\n\";\n})(EOL || (EOL = {}));\nclass LangAdapter {\n putAdapter;\n #storagePath;\n constructor(context){\n if (\"storagePath\" in context.customSettings) {\n this.#storagePath = context.customSettings[\"storagePath\"];\n } else {\n this.#storagePath = \"./tst-tmp/languages\";\n }\n }\n async getLanguageSource(address) {\n const bundlePath = join3(this.#storagePath, `bundle-${address}.js`);\n try {\n await exists(bundlePath);\n const metaFile = Deno.readTextFileSync(bundlePath);\n return metaFile;\n } catch {\n throw new Error(\"Did not find language source for given address:\" + address);\n }\n }\n}\nclass PutAdapter {\n #agent;\n #storagePath;\n constructor(context){\n this.#agent = context.agent;\n if (\"storagePath\" in context.customSettings) {\n this.#storagePath = context.customSettings[\"storagePath\"];\n } else {\n this.#storagePath = \"./tst-tmp/languages\";\n }\n }\n async createPublic(language) {\n const hash = UTILS.hash(language.bundle.toString());\n if (hash != language.meta.address) throw new Error(`Language Persistence: Can't store language. Address stated in meta differs from actual file\\nWanted: ${language.meta.address}\\nGot: ${hash}`);\n const agent = this.#agent;\n const expression = agent.createSignedExpression(language.meta);\n const metaPath = join3(this.#storagePath, `meta-${hash}.json`);\n const bundlePath = join3(this.#storagePath, `bundle-${hash}.js`);\n console.log(\"Writing meta & bundle path: \", metaPath, bundlePath);\n Deno.writeTextFileSync(metaPath, JSON.stringify(expression));\n Deno.writeTextFileSync(bundlePath, language.bundle.toString());\n return hash;\n }\n}\nclass Adapter {\n putAdapter;\n #storagePath;\n constructor(context){\n this.putAdapter = new PutAdapter(context);\n if (\"storagePath\" in context.customSettings) {\n this.#storagePath = context.customSettings[\"storagePath\"];\n } else {\n this.#storagePath = \"./tst-tmp/languages\";\n }\n }\n async get(address) {\n const metaPath = join3(this.#storagePath, `meta-${address}.json`);\n try {\n // await Deno.stat(metaPath);\n const metaFileText = Deno.readTextFileSync(metaPath);\n const metaFile = JSON.parse(metaFileText);\n return metaFile;\n } catch (e) {\n console.log(\"Did not find meta file for given address:\" + address, e);\n return null;\n }\n }\n}\nconst name = \"languages\";\nfunction interactions(expression) {\n return [];\n}\nasync function create(context) {\n const expressionAdapter = new Adapter(context);\n const languageAdapter = new LangAdapter(context);\n return {\n name,\n expressionAdapter,\n languageAdapter,\n interactions\n };\n}\nexport { name as name };\nexport { create as default };\n"} \ No newline at end of file +{"trustedAgents":["did:key:zQ3shkkuZLvqeFgHdgZgFMUx8VGkgVWsLA83w2oekhZxoCW2n"],"knownLinkLanguages":["QmzSYwdhHHwPAvjfp5GEakD6sTgZaSPkPxgtH2qVHM1D38coPhj"],"directMessageLanguage":"QmzSYwdnBbbeHfGyzyqdeKFy26C4k4uzhkJGN5i6L7BKLRBgcAU","agentLanguage":"QmzSYwdfcTf1VtGDrTMGd1LzRDkjnRsvYRiYk45GqRaRjLvM1Mi","perspectiveLanguage":"QmzSYwddxFCzVD63LgR8MTBaUEcwf9jhB3XjLbYBp2q8V1MqVtS","neighbourhoodLanguage":"QmzSYwdexVtzt8GEY37qzRy15mNL59XrpjvZJjgYXa43j6CewKE","languageLanguageBundle":""} \ No newline at end of file diff --git a/tests/js/helpers/assertions.ts b/tests/js/helpers/assertions.ts new file mode 100644 index 000000000..31e69c891 --- /dev/null +++ b/tests/js/helpers/assertions.ts @@ -0,0 +1,28 @@ +/** + * Polls `condition` up to `timeoutMs` milliseconds, resolving when it returns + * (or resolves to) `true`. Throws a descriptive error on timeout. + * + * Works with both sync and async condition functions. + */ +export async function waitUntil( + condition: () => boolean | Promise, + timeoutMs = 6000, + label = "condition", +): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + if (await condition()) return; + if (Date.now() >= deadline) { + throw new Error( + `waitUntil timed out after ${timeoutMs}ms waiting for: ${label}`, + ); + } + await new Promise((r) => setTimeout(r, 100)); + } +} + +/** + * Clears all links in a perspective in a single removeLinks() batch call. + * Call at the start of each test that needs a clean slate. + */ +export { wipePerspective } from "../utils/utils.js"; diff --git a/tests/js/helpers/executor.ts b/tests/js/helpers/executor.ts new file mode 100644 index 000000000..c965baee9 --- /dev/null +++ b/tests/js/helpers/executor.ts @@ -0,0 +1,133 @@ +import { ChildProcess } from "node:child_process"; +import { Ad4mClient } from "@coasys/ad4m"; +import { startExecutor, apolloClient } from "../utils/utils.js"; +import { getFreePorts } from "./ports.js"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --------------------------------------------------------------------------- +// Global cleanup registry +// When the Mocha extension (or any signal) terminates the Node process we must +// kill any executor child-processes we spawned, otherwise they stay alive, +// hold ports, and keep Node's event-loop running (making "stop" appear broken). +// --------------------------------------------------------------------------- +const _activeExecutors = new Set(); + +function _killAll() { + for (const p of _activeExecutors) { + if (!p.killed) { + try { + p.kill("SIGTERM"); + } catch {} + } + } +} + +// Register once at module load time — safe to call process.exit() inside these +// because once/exit handlers won't re-enter. +process.once("SIGTERM", () => { + _killAll(); + process.exit(0); +}); +process.once("SIGINT", () => { + _killAll(); + process.exit(0); +}); +process.once("exit", _killAll); + +const TEST_DIR = path.join(__dirname, "..", "tst-tmp"); +const BOOTSTRAP_SEED = path.join(__dirname, "..", "bootstrapSeed.json"); + +export type AgentHandle = { + /** Connected Ad4mClient ready for use */ + client: Ad4mClient; + /** gqlPort — useful if a second client needs to connect to the same executor */ + gqlPort: number; + /** Kills the executor process; safe to call multiple times */ + stop(): Promise; +}; + +/** + * Starts a fresh executor instance for a named agent and returns an + * AgentHandle. Ports are allocated dynamically — no hardcoded numbers. + * + * Typical use: + * + * let agent: AgentHandle; + * before(async () => { agent = await startAgent('my-test'); }); + * after(async () => { await agent.stop(); }); + */ +export async function startAgent( + agentName: string, + opts: { + passphrase?: string; + bootstrapSeedPath?: string; + /** When set, starts the executor in admin-credential mode and connects + * the returned client using that credential as the bearer token. */ + adminCredential?: string; + } = {}, +): Promise { + const [gqlPort, hcAdminPort, hcAppPort] = await getFreePorts(3); + + const appDataPath = path.join(TEST_DIR, "agents", agentName); + const bootstrapSeedPath = opts.bootstrapSeedPath ?? BOOTSTRAP_SEED; + + const executorProcess = await startExecutor( + appDataPath, + bootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + false, + opts.adminCredential, + ); + _activeExecutors.add(executorProcess); + + const client = new Ad4mClient(apolloClient(gqlPort, opts.adminCredential)); + await client.agent.generate(opts.passphrase ?? "test-passphrase"); + await client.runtime.setMultiUserEnabled(true); + + async function stop(): Promise { + _activeExecutors.delete(executorProcess); + await new Promise((resolve) => { + // Already exited? + if (executorProcess.exitCode !== null) { + resolve(); + return; + } + // Resolve when the process actually exits (not just when the signal is sent). + // This prevents the next test from starting while SurrealDB/HC ports are still held. + const fallbackTimer = setTimeout(() => { + try { + executorProcess.kill("SIGKILL"); + } catch {} + resolve(); + }, 15_000); + fallbackTimer.unref(); + executorProcess.once("exit", () => { + clearTimeout(fallbackTimer); + resolve(); + }); + if (!executorProcess.killed) { + executorProcess.kill("SIGTERM"); + } + }); + } + + return { client, gqlPort, stop }; +} + +/** + * Starts a second Ad4mClient that connects to an already-running executor + * (identified by its gqlPort). Useful for testing multi-client scenarios + * without spawning an extra executor process. + */ +export function connectClient(gqlPort: number, token?: string): Ad4mClient { + return new Ad4mClient(apolloClient(gqlPort, token)); +} + +export { TEST_DIR, BOOTSTRAP_SEED }; +export type { ChildProcess }; diff --git a/tests/js/helpers/index.ts b/tests/js/helpers/index.ts new file mode 100644 index 000000000..aeb1ab98a --- /dev/null +++ b/tests/js/helpers/index.ts @@ -0,0 +1,8 @@ +export { getFreePorts, getFreePort } from "./ports.js"; +export { + startAgent, + connectClient, + TEST_DIR, + BOOTSTRAP_SEED, +} from "./executor.js"; +export { waitUntil } from "./assertions.js"; diff --git a/tests/js/helpers/ports.ts b/tests/js/helpers/ports.ts new file mode 100644 index 000000000..f868cda60 --- /dev/null +++ b/tests/js/helpers/ports.ts @@ -0,0 +1,34 @@ +import net from "net"; + +/** + * Returns `count` port numbers that are free at the moment of the call. + * + * Uses a bind-test on 127.0.0.1 — much safer than picking a static range and + * hoping nothing collides. The ports are released immediately after probing, + * so a race is theoretically possible but vanishingly unlikely in practice. + */ +export function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +/** + * Returns `count` free ports, all distinct. + * Each probe opens and immediately closes a TCP server, so the ports are + * released for the executor to bind to. + */ +export async function getFreePorts(count: number): Promise { + const ports: number[] = []; + for (let i = 0; i < count; i++) { + ports.push(await getFreePort()); + } + return ports; +} diff --git a/tests/js/package.json b/tests/js/package.json index f04cec512..3fcd2f910 100644 --- a/tests/js/package.json +++ b/tests/js/package.json @@ -3,32 +3,42 @@ "description": "Node.js package that allows the running/interfacing of AD4M Languages & Perspectives.", "type": "module", "scripts": { - "test-main": "node scripts/cleanTestingData.js && pnpm run prepare-test && pnpm run test-all && node scripts/cleanTestingData.js", - "test:windows": "pnpm run prepare-test:windows && pnpm run test-all:windows && node scripts/cleanTestingData.js", - "test-all:windows": "node scripts/cleanup.js && pnpm run test-simple && node scripts/cleanup.js && pnpm run test-app && node scripts/cleanup.js && pnpm run test-auth && node scripts/cleanup.js && pnpm run test-integration && node scripts/cleanup.js && pnpm run test-prolog-and-literals", - "test-all": "node scripts/cleanup.js && pnpm run test-simple && node scripts/cleanup.js && pnpm run test-app && node scripts/cleanup.js && pnpm run test-auth && node scripts/cleanup.js && pnpm run test-integration && node scripts/cleanup.js && pnpm run test-prolog-and-literals && node scripts/cleanup.js", - "test-simple": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/simple.test.ts", - "test-integration": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/integration.test.ts", - "test-app": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/app.test.ts", - "test-auth": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/authentication.test.ts", - "test-multi-user-connect": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/multi-user-connect.test.ts", - "test-multi-user-simple": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/multi-user-simple.test.ts", - "test-multi-user-with-setup": "./test-multi-user-with-setup.sh", - "test-email-verification": "ts-mocha -p tsconfig.json --timeout 1200000 --exit tests/email-verification.test.ts", - "test-prolog-and-literals": "ts-mocha -p tsconfig.json --timeout 1200000 --serial --exit tests/prolog-and-literals.test.ts", "prepare-test": "run-script-os", - "prepare-test:macos": "./scripts/build-test-language.sh && ./scripts/prepareTestDirectory.sh && deno run --allow-all scripts/get-builtin-test-langs.js && pnpm run inject-language-language && pnpm run publish-test-languages && pnpm run inject-publishing-agent", - "prepare-test:linux": "./scripts/build-test-language.sh && ./scripts/prepareTestDirectory.sh && deno run --allow-all scripts/get-builtin-test-langs.js && pnpm run inject-language-language && pnpm run publish-test-languages && pnpm run inject-publishing-agent", - "prepare-test:windows": "powershell -ExecutionPolicy Bypass -File ./scripts/build-test-language.ps1 && powershell -ExecutionPolicy Bypass -File ./scripts/prepareTestDirectory.ps1 && deno run --allow-all scripts/get-builtin-test-langs.js && pnpm run inject-language-language && pnpm run publish-test-languages && pnpm run inject-publishing-agent", + "prepare-test:default": "node scripts/resetBootstrapSeed.js && ./scripts/build-test-language.sh && ./scripts/prepareTestDirectory.sh && deno run --allow-all scripts/get-builtin-test-langs.js && pnpm run inject-language-language && pnpm run publish-test-languages && pnpm run inject-publishing-agent", + "prepare-test:windows": "node scripts/resetBootstrapSeed.js && powershell -ExecutionPolicy Bypass -File ./scripts/build-test-language.ps1 && powershell -ExecutionPolicy Bypass -File ./scripts/prepareTestDirectory.ps1 && deno run --allow-all scripts/get-builtin-test-langs.js && pnpm run inject-language-language && pnpm run publish-test-languages && pnpm run inject-publishing-agent", "inject-language-language": "node scripts/injectLanguageLanguageBundle.js", "inject-publishing-agent": "node scripts/injectPublishingAgent.js", "publish-test-languages": "node --no-warnings=ExperimentalWarning --experimental-specifier-resolution=node --loader ts-node/esm ./utils/publishTestLangs.ts", - "test-single-prepare": "node scripts/cleanTestingData.js && pnpm run prepare-test && node scripts/cleanup.js" + "test-smoke": "ts-mocha --config .mocharc-cli.json tests/smoke.test.ts", + "test-auth-app": "ts-mocha --config .mocharc-cli.json tests/auth/auth-app.test.ts", + "test-auth-core": "ts-mocha --config .mocharc-cli.json tests/auth/auth-core.test.ts", + "test-auth-email-verification": "ts-mocha --config .mocharc-cli.json tests/auth/auth-email-verification.test.ts", + "test-auth": "ts-mocha --config .mocharc-cli.json tests/auth/auth-app.test.ts tests/auth/auth-core.test.ts tests/auth/auth-email-verification.test.ts", + "test-integration": "ts-mocha --config .mocharc-cli.json tests/integration/integration.test.ts", + "test-sdna-core": "ts-mocha --config .mocharc-cli.json tests/sdna/sdna-core.test.ts", + "test-sdna-smart-literal": "ts-mocha --config .mocharc-cli.json tests/sdna/sdna-smart-literal.test.ts", + "test-sdna": "ts-mocha --config .mocharc-cli.json tests/sdna/sdna-core.test.ts tests/sdna/sdna-smart-literal.test.ts", + "test-model": "node scripts/cleanup.js && ts-mocha --config .mocharc-cli.json --timeout 120000 --require tests/model/hooks.ts tests/model/model-core.test.ts tests/model/model-query.test.ts tests/model/model-subscriptions.test.ts tests/model/model-transactions.test.ts tests/model/model-inheritance.test.ts tests/model/model-prolog.test.ts tests/model/model-where-operators.test.ts tests/model/model-from-json-schema.test.ts tests/model/model-getters.test.ts", + "test-multi-user-auth": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-auth.test.ts", + "test-multi-user-config": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-config.test.ts", + "test-multi-user-isolation": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-isolation.test.ts", + "test-multi-user-sdna": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-sdna.test.ts", + "test-multi-user-profiles": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-profiles.test.ts", + "test-multi-user-neighbourhood": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-neighbourhood.test.ts", + "test-multi-user-multi-node": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-multi-node.test.ts", + "test-multi-user-subscriptions": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-subscriptions.test.ts", + "test-multi-user-notifications": "ts-mocha --config .mocharc-cli.json tests/multi-user/multi-user-notifications.test.ts", + "test-multi-user": "./scripts/test-multi-user.sh", + "test:ci": "node scripts/cleanup.js && pnpm run test-smoke && node scripts/cleanup.js && pnpm run test-auth && node scripts/cleanup.js && pnpm run test-integration && node scripts/cleanup.js && pnpm run test-sdna && node scripts/cleanup.js && pnpm run test-model", + "test:ci:full": "pnpm run test:ci && node scripts/cleanup.js && pnpm run test-multi-user", + "test:ci:windows": "pnpm run test:ci", + "test": "node scripts/cleanTestingData.js && pnpm run prepare-test && pnpm run test:ci && node scripts/cleanTestingData.js", + "test:windows": "pnpm run prepare-test:windows && pnpm run test:ci:windows && node scripts/cleanTestingData.js", + "setup": "node scripts/cleanTestingData.js && pnpm run prepare-test && node scripts/cleanup.js" }, "devDependencies": { "@apollo/client": "3.7.10", - "@coasys/ad4m": "link:../../core", - "@coasys/ad4m-connect": "link:../../connect", + "@coasys/ad4m": "workspace:*", "@peculiar/webcrypto": "^1.1.7", "@types/chai": "*", "@types/chai-as-promised": "*", diff --git a/tests/js/publishBootstrapSeed.json b/tests/js/publishBootstrapSeed.json index 5160a631a..f89332125 100644 --- a/tests/js/publishBootstrapSeed.json +++ b/tests/js/publishBootstrapSeed.json @@ -1 +1 @@ -{"trustedAgents":[],"knownLinkLanguages":[],"directMessageLanguage":"","agentLanguage":"","perspectiveLanguage":"","neighbourhoodLanguage":"","languageLanguageBundle":"// deno-fmt-ignore-file\n// deno-lint-ignore-file\n// This code was bundled using `deno bundle` and it's not recommended to edit it manually\n\nconst osType = (()=>{\n const { Deno: Deno1 } = globalThis;\n if (typeof Deno1?.build?.os === \"string\") {\n return Deno1.build.os;\n }\n const { navigator } = globalThis;\n if (navigator?.appVersion?.includes?.(\"Win\")) {\n return \"windows\";\n }\n return \"linux\";\n})();\nconst isWindows = osType === \"windows\";\nconst CHAR_FORWARD_SLASH = 47;\nfunction assertPath(path) {\n if (typeof path !== \"string\") {\n throw new TypeError(`Path must be a string. Received ${JSON.stringify(path)}`);\n }\n}\nfunction isPosixPathSeparator(code) {\n return code === 47;\n}\nfunction isPathSeparator(code) {\n return isPosixPathSeparator(code) || code === 92;\n}\nfunction isWindowsDeviceRoot(code) {\n return code >= 97 && code <= 122 || code >= 65 && code <= 90;\n}\nfunction normalizeString(path, allowAboveRoot, separator, isPathSeparator) {\n let res = \"\";\n let lastSegmentLength = 0;\n let lastSlash = -1;\n let dots = 0;\n let code;\n for(let i = 0, len = path.length; i <= len; ++i){\n if (i < len) code = path.charCodeAt(i);\n else if (isPathSeparator(code)) break;\n else code = CHAR_FORWARD_SLASH;\n if (isPathSeparator(code)) {\n if (lastSlash === i - 1 || dots === 1) {} else if (lastSlash !== i - 1 && dots === 2) {\n if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 || res.charCodeAt(res.length - 2) !== 46) {\n if (res.length > 2) {\n const lastSlashIndex = res.lastIndexOf(separator);\n if (lastSlashIndex === -1) {\n res = \"\";\n lastSegmentLength = 0;\n } else {\n res = res.slice(0, lastSlashIndex);\n lastSegmentLength = res.length - 1 - res.lastIndexOf(separator);\n }\n lastSlash = i;\n dots = 0;\n continue;\n } else if (res.length === 2 || res.length === 1) {\n res = \"\";\n lastSegmentLength = 0;\n lastSlash = i;\n dots = 0;\n continue;\n }\n }\n if (allowAboveRoot) {\n if (res.length > 0) res += `${separator}..`;\n else res = \"..\";\n lastSegmentLength = 2;\n }\n } else {\n if (res.length > 0) res += separator + path.slice(lastSlash + 1, i);\n else res = path.slice(lastSlash + 1, i);\n lastSegmentLength = i - lastSlash - 1;\n }\n lastSlash = i;\n dots = 0;\n } else if (code === 46 && dots !== -1) {\n ++dots;\n } else {\n dots = -1;\n }\n }\n return res;\n}\nfunction _format(sep, pathObject) {\n const dir = pathObject.dir || pathObject.root;\n const base = pathObject.base || (pathObject.name || \"\") + (pathObject.ext || \"\");\n if (!dir) return base;\n if (base === sep) return dir;\n if (dir === pathObject.root) return dir + base;\n return dir + sep + base;\n}\nconst WHITESPACE_ENCODINGS = {\n \"\\u0009\": \"%09\",\n \"\\u000A\": \"%0A\",\n \"\\u000B\": \"%0B\",\n \"\\u000C\": \"%0C\",\n \"\\u000D\": \"%0D\",\n \"\\u0020\": \"%20\"\n};\nfunction encodeWhitespace(string) {\n return string.replaceAll(/[\\s]/g, (c)=>{\n return WHITESPACE_ENCODINGS[c] ?? c;\n });\n}\nfunction lastPathSegment(path, isSep, start = 0) {\n let matchedNonSeparator = false;\n let end = path.length;\n for(let i = path.length - 1; i >= start; --i){\n if (isSep(path.charCodeAt(i))) {\n if (matchedNonSeparator) {\n start = i + 1;\n break;\n }\n } else if (!matchedNonSeparator) {\n matchedNonSeparator = true;\n end = i + 1;\n }\n }\n return path.slice(start, end);\n}\nfunction stripTrailingSeparators(segment, isSep) {\n if (segment.length <= 1) {\n return segment;\n }\n let end = segment.length;\n for(let i = segment.length - 1; i > 0; i--){\n if (isSep(segment.charCodeAt(i))) {\n end = i;\n } else {\n break;\n }\n }\n return segment.slice(0, end);\n}\nfunction stripSuffix(name, suffix) {\n if (suffix.length >= name.length) {\n return name;\n }\n const lenDiff = name.length - suffix.length;\n for(let i = suffix.length - 1; i >= 0; --i){\n if (name.charCodeAt(lenDiff + i) !== suffix.charCodeAt(i)) {\n return name;\n }\n }\n return name.slice(0, -suffix.length);\n}\nclass DenoStdInternalError extends Error {\n constructor(message){\n super(message);\n this.name = \"DenoStdInternalError\";\n }\n}\nfunction assert(expr, msg = \"\") {\n if (!expr) {\n throw new DenoStdInternalError(msg);\n }\n}\nconst sep = \"\\\\\";\nconst delimiter = \";\";\nfunction resolve(...pathSegments) {\n let resolvedDevice = \"\";\n let resolvedTail = \"\";\n let resolvedAbsolute = false;\n for(let i = pathSegments.length - 1; i >= -1; i--){\n let path;\n const { Deno: Deno1 } = globalThis;\n if (i >= 0) {\n path = pathSegments[i];\n } else if (!resolvedDevice) {\n if (typeof Deno1?.cwd !== \"function\") {\n throw new TypeError(\"Resolved a drive-letter-less path without a CWD.\");\n }\n path = Deno1.cwd();\n } else {\n if (typeof Deno1?.env?.get !== \"function\" || typeof Deno1?.cwd !== \"function\") {\n throw new TypeError(\"Resolved a relative path without a CWD.\");\n }\n path = Deno1.cwd();\n if (path === undefined || path.slice(0, 3).toLowerCase() !== `${resolvedDevice.toLowerCase()}\\\\`) {\n path = `${resolvedDevice}\\\\`;\n }\n }\n assertPath(path);\n const len = path.length;\n if (len === 0) continue;\n let rootEnd = 0;\n let device = \"\";\n let isAbsolute = false;\n const code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n isAbsolute = true;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n const firstPart = path.slice(last, j);\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n device = `\\\\\\\\${firstPart}\\\\${path.slice(last)}`;\n rootEnd = j;\n } else if (j !== last) {\n device = `\\\\\\\\${firstPart}\\\\${path.slice(last, j)}`;\n rootEnd = j;\n }\n }\n }\n } else {\n rootEnd = 1;\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n device = path.slice(0, 2);\n rootEnd = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) {\n isAbsolute = true;\n rootEnd = 3;\n }\n }\n }\n }\n } else if (isPathSeparator(code)) {\n rootEnd = 1;\n isAbsolute = true;\n }\n if (device.length > 0 && resolvedDevice.length > 0 && device.toLowerCase() !== resolvedDevice.toLowerCase()) {\n continue;\n }\n if (resolvedDevice.length === 0 && device.length > 0) {\n resolvedDevice = device;\n }\n if (!resolvedAbsolute) {\n resolvedTail = `${path.slice(rootEnd)}\\\\${resolvedTail}`;\n resolvedAbsolute = isAbsolute;\n }\n if (resolvedAbsolute && resolvedDevice.length > 0) break;\n }\n resolvedTail = normalizeString(resolvedTail, !resolvedAbsolute, \"\\\\\", isPathSeparator);\n return resolvedDevice + (resolvedAbsolute ? \"\\\\\" : \"\") + resolvedTail || \".\";\n}\nfunction normalize(path) {\n assertPath(path);\n const len = path.length;\n if (len === 0) return \".\";\n let rootEnd = 0;\n let device;\n let isAbsolute = false;\n const code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n isAbsolute = true;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n const firstPart = path.slice(last, j);\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n return `\\\\\\\\${firstPart}\\\\${path.slice(last)}\\\\`;\n } else if (j !== last) {\n device = `\\\\\\\\${firstPart}\\\\${path.slice(last, j)}`;\n rootEnd = j;\n }\n }\n }\n } else {\n rootEnd = 1;\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n device = path.slice(0, 2);\n rootEnd = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) {\n isAbsolute = true;\n rootEnd = 3;\n }\n }\n }\n }\n } else if (isPathSeparator(code)) {\n return \"\\\\\";\n }\n let tail;\n if (rootEnd < len) {\n tail = normalizeString(path.slice(rootEnd), !isAbsolute, \"\\\\\", isPathSeparator);\n } else {\n tail = \"\";\n }\n if (tail.length === 0 && !isAbsolute) tail = \".\";\n if (tail.length > 0 && isPathSeparator(path.charCodeAt(len - 1))) {\n tail += \"\\\\\";\n }\n if (device === undefined) {\n if (isAbsolute) {\n if (tail.length > 0) return `\\\\${tail}`;\n else return \"\\\\\";\n } else if (tail.length > 0) {\n return tail;\n } else {\n return \"\";\n }\n } else if (isAbsolute) {\n if (tail.length > 0) return `${device}\\\\${tail}`;\n else return `${device}\\\\`;\n } else if (tail.length > 0) {\n return device + tail;\n } else {\n return device;\n }\n}\nfunction isAbsolute(path) {\n assertPath(path);\n const len = path.length;\n if (len === 0) return false;\n const code = path.charCodeAt(0);\n if (isPathSeparator(code)) {\n return true;\n } else if (isWindowsDeviceRoot(code)) {\n if (len > 2 && path.charCodeAt(1) === 58) {\n if (isPathSeparator(path.charCodeAt(2))) return true;\n }\n }\n return false;\n}\nfunction join(...paths) {\n const pathsCount = paths.length;\n if (pathsCount === 0) return \".\";\n let joined;\n let firstPart = null;\n for(let i = 0; i < pathsCount; ++i){\n const path = paths[i];\n assertPath(path);\n if (path.length > 0) {\n if (joined === undefined) joined = firstPart = path;\n else joined += `\\\\${path}`;\n }\n }\n if (joined === undefined) return \".\";\n let needsReplace = true;\n let slashCount = 0;\n assert(firstPart != null);\n if (isPathSeparator(firstPart.charCodeAt(0))) {\n ++slashCount;\n const firstLen = firstPart.length;\n if (firstLen > 1) {\n if (isPathSeparator(firstPart.charCodeAt(1))) {\n ++slashCount;\n if (firstLen > 2) {\n if (isPathSeparator(firstPart.charCodeAt(2))) ++slashCount;\n else {\n needsReplace = false;\n }\n }\n }\n }\n }\n if (needsReplace) {\n for(; slashCount < joined.length; ++slashCount){\n if (!isPathSeparator(joined.charCodeAt(slashCount))) break;\n }\n if (slashCount >= 2) joined = `\\\\${joined.slice(slashCount)}`;\n }\n return normalize(joined);\n}\nfunction relative(from, to) {\n assertPath(from);\n assertPath(to);\n if (from === to) return \"\";\n const fromOrig = resolve(from);\n const toOrig = resolve(to);\n if (fromOrig === toOrig) return \"\";\n from = fromOrig.toLowerCase();\n to = toOrig.toLowerCase();\n if (from === to) return \"\";\n let fromStart = 0;\n let fromEnd = from.length;\n for(; fromStart < fromEnd; ++fromStart){\n if (from.charCodeAt(fromStart) !== 92) break;\n }\n for(; fromEnd - 1 > fromStart; --fromEnd){\n if (from.charCodeAt(fromEnd - 1) !== 92) break;\n }\n const fromLen = fromEnd - fromStart;\n let toStart = 0;\n let toEnd = to.length;\n for(; toStart < toEnd; ++toStart){\n if (to.charCodeAt(toStart) !== 92) break;\n }\n for(; toEnd - 1 > toStart; --toEnd){\n if (to.charCodeAt(toEnd - 1) !== 92) break;\n }\n const toLen = toEnd - toStart;\n const length = fromLen < toLen ? fromLen : toLen;\n let lastCommonSep = -1;\n let i = 0;\n for(; i <= length; ++i){\n if (i === length) {\n if (toLen > length) {\n if (to.charCodeAt(toStart + i) === 92) {\n return toOrig.slice(toStart + i + 1);\n } else if (i === 2) {\n return toOrig.slice(toStart + i);\n }\n }\n if (fromLen > length) {\n if (from.charCodeAt(fromStart + i) === 92) {\n lastCommonSep = i;\n } else if (i === 2) {\n lastCommonSep = 3;\n }\n }\n break;\n }\n const fromCode = from.charCodeAt(fromStart + i);\n const toCode = to.charCodeAt(toStart + i);\n if (fromCode !== toCode) break;\n else if (fromCode === 92) lastCommonSep = i;\n }\n if (i !== length && lastCommonSep === -1) {\n return toOrig;\n }\n let out = \"\";\n if (lastCommonSep === -1) lastCommonSep = 0;\n for(i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i){\n if (i === fromEnd || from.charCodeAt(i) === 92) {\n if (out.length === 0) out += \"..\";\n else out += \"\\\\..\";\n }\n }\n if (out.length > 0) {\n return out + toOrig.slice(toStart + lastCommonSep, toEnd);\n } else {\n toStart += lastCommonSep;\n if (toOrig.charCodeAt(toStart) === 92) ++toStart;\n return toOrig.slice(toStart, toEnd);\n }\n}\nfunction toNamespacedPath(path) {\n if (typeof path !== \"string\") return path;\n if (path.length === 0) return \"\";\n const resolvedPath = resolve(path);\n if (resolvedPath.length >= 3) {\n if (resolvedPath.charCodeAt(0) === 92) {\n if (resolvedPath.charCodeAt(1) === 92) {\n const code = resolvedPath.charCodeAt(2);\n if (code !== 63 && code !== 46) {\n return `\\\\\\\\?\\\\UNC\\\\${resolvedPath.slice(2)}`;\n }\n }\n } else if (isWindowsDeviceRoot(resolvedPath.charCodeAt(0))) {\n if (resolvedPath.charCodeAt(1) === 58 && resolvedPath.charCodeAt(2) === 92) {\n return `\\\\\\\\?\\\\${resolvedPath}`;\n }\n }\n }\n return path;\n}\nfunction dirname(path) {\n assertPath(path);\n const len = path.length;\n if (len === 0) return \".\";\n let rootEnd = -1;\n let end = -1;\n let matchedSlash = true;\n let offset = 0;\n const code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n rootEnd = offset = 1;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n return path;\n }\n if (j !== last) {\n rootEnd = offset = j + 1;\n }\n }\n }\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n rootEnd = offset = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) rootEnd = offset = 3;\n }\n }\n }\n } else if (isPathSeparator(code)) {\n return path;\n }\n for(let i = len - 1; i >= offset; --i){\n if (isPathSeparator(path.charCodeAt(i))) {\n if (!matchedSlash) {\n end = i;\n break;\n }\n } else {\n matchedSlash = false;\n }\n }\n if (end === -1) {\n if (rootEnd === -1) return \".\";\n else end = rootEnd;\n }\n return stripTrailingSeparators(path.slice(0, end), isPosixPathSeparator);\n}\nfunction basename(path, suffix = \"\") {\n assertPath(path);\n if (path.length === 0) return path;\n if (typeof suffix !== \"string\") {\n throw new TypeError(`Suffix must be a string. Received ${JSON.stringify(suffix)}`);\n }\n let start = 0;\n if (path.length >= 2) {\n const drive = path.charCodeAt(0);\n if (isWindowsDeviceRoot(drive)) {\n if (path.charCodeAt(1) === 58) start = 2;\n }\n }\n const lastSegment = lastPathSegment(path, isPathSeparator, start);\n const strippedSegment = stripTrailingSeparators(lastSegment, isPathSeparator);\n return suffix ? stripSuffix(strippedSegment, suffix) : strippedSegment;\n}\nfunction extname(path) {\n assertPath(path);\n let start = 0;\n let startDot = -1;\n let startPart = 0;\n let end = -1;\n let matchedSlash = true;\n let preDotState = 0;\n if (path.length >= 2 && path.charCodeAt(1) === 58 && isWindowsDeviceRoot(path.charCodeAt(0))) {\n start = startPart = 2;\n }\n for(let i = path.length - 1; i >= start; --i){\n const code = path.charCodeAt(i);\n if (isPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n return \"\";\n }\n return path.slice(startDot, end);\n}\nfunction format(pathObject) {\n if (pathObject === null || typeof pathObject !== \"object\") {\n throw new TypeError(`The \"pathObject\" argument must be of type Object. Received type ${typeof pathObject}`);\n }\n return _format(\"\\\\\", pathObject);\n}\nfunction parse(path) {\n assertPath(path);\n const ret = {\n root: \"\",\n dir: \"\",\n base: \"\",\n ext: \"\",\n name: \"\"\n };\n const len = path.length;\n if (len === 0) return ret;\n let rootEnd = 0;\n let code = path.charCodeAt(0);\n if (len > 1) {\n if (isPathSeparator(code)) {\n rootEnd = 1;\n if (isPathSeparator(path.charCodeAt(1))) {\n let j = 2;\n let last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (!isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j < len && j !== last) {\n last = j;\n for(; j < len; ++j){\n if (isPathSeparator(path.charCodeAt(j))) break;\n }\n if (j === len) {\n rootEnd = j;\n } else if (j !== last) {\n rootEnd = j + 1;\n }\n }\n }\n }\n } else if (isWindowsDeviceRoot(code)) {\n if (path.charCodeAt(1) === 58) {\n rootEnd = 2;\n if (len > 2) {\n if (isPathSeparator(path.charCodeAt(2))) {\n if (len === 3) {\n ret.root = ret.dir = path;\n ret.base = \"\\\\\";\n return ret;\n }\n rootEnd = 3;\n }\n } else {\n ret.root = ret.dir = path;\n return ret;\n }\n }\n }\n } else if (isPathSeparator(code)) {\n ret.root = ret.dir = path;\n ret.base = \"\\\\\";\n return ret;\n }\n if (rootEnd > 0) ret.root = path.slice(0, rootEnd);\n let startDot = -1;\n let startPart = rootEnd;\n let end = -1;\n let matchedSlash = true;\n let i = path.length - 1;\n let preDotState = 0;\n for(; i >= rootEnd; --i){\n code = path.charCodeAt(i);\n if (isPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n if (end !== -1) {\n ret.base = ret.name = path.slice(startPart, end);\n }\n } else {\n ret.name = path.slice(startPart, startDot);\n ret.base = path.slice(startPart, end);\n ret.ext = path.slice(startDot, end);\n }\n ret.base = ret.base || \"\\\\\";\n if (startPart > 0 && startPart !== rootEnd) {\n ret.dir = path.slice(0, startPart - 1);\n } else ret.dir = ret.root;\n return ret;\n}\nfunction fromFileUrl(url) {\n url = url instanceof URL ? url : new URL(url);\n if (url.protocol != \"file:\") {\n throw new TypeError(\"Must be a file URL.\");\n }\n let path = decodeURIComponent(url.pathname.replace(/\\//g, \"\\\\\").replace(/%(?![0-9A-Fa-f]{2})/g, \"%25\")).replace(/^\\\\*([A-Za-z]:)(\\\\|$)/, \"$1\\\\\");\n if (url.hostname != \"\") {\n path = `\\\\\\\\${url.hostname}${path}`;\n }\n return path;\n}\nfunction toFileUrl(path) {\n if (!isAbsolute(path)) {\n throw new TypeError(\"Must be an absolute path.\");\n }\n const [, hostname, pathname] = path.match(/^(?:[/\\\\]{2}([^/\\\\]+)(?=[/\\\\](?:[^/\\\\]|$)))?(.*)/);\n const url = new URL(\"file:///\");\n url.pathname = encodeWhitespace(pathname.replace(/%/g, \"%25\"));\n if (hostname != null && hostname != \"localhost\") {\n url.hostname = hostname;\n if (!url.hostname) {\n throw new TypeError(\"Invalid hostname.\");\n }\n }\n return url;\n}\nconst mod = {\n sep: sep,\n delimiter: delimiter,\n resolve: resolve,\n normalize: normalize,\n isAbsolute: isAbsolute,\n join: join,\n relative: relative,\n toNamespacedPath: toNamespacedPath,\n dirname: dirname,\n basename: basename,\n extname: extname,\n format: format,\n parse: parse,\n fromFileUrl: fromFileUrl,\n toFileUrl: toFileUrl\n};\nconst sep1 = \"/\";\nconst delimiter1 = \":\";\nfunction resolve1(...pathSegments) {\n let resolvedPath = \"\";\n let resolvedAbsolute = false;\n for(let i = pathSegments.length - 1; i >= -1 && !resolvedAbsolute; i--){\n let path;\n if (i >= 0) path = pathSegments[i];\n else {\n const { Deno: Deno1 } = globalThis;\n if (typeof Deno1?.cwd !== \"function\") {\n throw new TypeError(\"Resolved a relative path without a CWD.\");\n }\n path = Deno1.cwd();\n }\n assertPath(path);\n if (path.length === 0) {\n continue;\n }\n resolvedPath = `${path}/${resolvedPath}`;\n resolvedAbsolute = isPosixPathSeparator(path.charCodeAt(0));\n }\n resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, \"/\", isPosixPathSeparator);\n if (resolvedAbsolute) {\n if (resolvedPath.length > 0) return `/${resolvedPath}`;\n else return \"/\";\n } else if (resolvedPath.length > 0) return resolvedPath;\n else return \".\";\n}\nfunction normalize1(path) {\n assertPath(path);\n if (path.length === 0) return \".\";\n const isAbsolute = isPosixPathSeparator(path.charCodeAt(0));\n const trailingSeparator = isPosixPathSeparator(path.charCodeAt(path.length - 1));\n path = normalizeString(path, !isAbsolute, \"/\", isPosixPathSeparator);\n if (path.length === 0 && !isAbsolute) path = \".\";\n if (path.length > 0 && trailingSeparator) path += \"/\";\n if (isAbsolute) return `/${path}`;\n return path;\n}\nfunction isAbsolute1(path) {\n assertPath(path);\n return path.length > 0 && isPosixPathSeparator(path.charCodeAt(0));\n}\nfunction join1(...paths) {\n if (paths.length === 0) return \".\";\n let joined;\n for(let i = 0, len = paths.length; i < len; ++i){\n const path = paths[i];\n assertPath(path);\n if (path.length > 0) {\n if (!joined) joined = path;\n else joined += `/${path}`;\n }\n }\n if (!joined) return \".\";\n return normalize1(joined);\n}\nfunction relative1(from, to) {\n assertPath(from);\n assertPath(to);\n if (from === to) return \"\";\n from = resolve1(from);\n to = resolve1(to);\n if (from === to) return \"\";\n let fromStart = 1;\n const fromEnd = from.length;\n for(; fromStart < fromEnd; ++fromStart){\n if (!isPosixPathSeparator(from.charCodeAt(fromStart))) break;\n }\n const fromLen = fromEnd - fromStart;\n let toStart = 1;\n const toEnd = to.length;\n for(; toStart < toEnd; ++toStart){\n if (!isPosixPathSeparator(to.charCodeAt(toStart))) break;\n }\n const toLen = toEnd - toStart;\n const length = fromLen < toLen ? fromLen : toLen;\n let lastCommonSep = -1;\n let i = 0;\n for(; i <= length; ++i){\n if (i === length) {\n if (toLen > length) {\n if (isPosixPathSeparator(to.charCodeAt(toStart + i))) {\n return to.slice(toStart + i + 1);\n } else if (i === 0) {\n return to.slice(toStart + i);\n }\n } else if (fromLen > length) {\n if (isPosixPathSeparator(from.charCodeAt(fromStart + i))) {\n lastCommonSep = i;\n } else if (i === 0) {\n lastCommonSep = 0;\n }\n }\n break;\n }\n const fromCode = from.charCodeAt(fromStart + i);\n const toCode = to.charCodeAt(toStart + i);\n if (fromCode !== toCode) break;\n else if (isPosixPathSeparator(fromCode)) lastCommonSep = i;\n }\n let out = \"\";\n for(i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i){\n if (i === fromEnd || isPosixPathSeparator(from.charCodeAt(i))) {\n if (out.length === 0) out += \"..\";\n else out += \"/..\";\n }\n }\n if (out.length > 0) return out + to.slice(toStart + lastCommonSep);\n else {\n toStart += lastCommonSep;\n if (isPosixPathSeparator(to.charCodeAt(toStart))) ++toStart;\n return to.slice(toStart);\n }\n}\nfunction toNamespacedPath1(path) {\n return path;\n}\nfunction dirname1(path) {\n if (path.length === 0) return \".\";\n let end = -1;\n let matchedNonSeparator = false;\n for(let i = path.length - 1; i >= 1; --i){\n if (isPosixPathSeparator(path.charCodeAt(i))) {\n if (matchedNonSeparator) {\n end = i;\n break;\n }\n } else {\n matchedNonSeparator = true;\n }\n }\n if (end === -1) {\n return isPosixPathSeparator(path.charCodeAt(0)) ? \"/\" : \".\";\n }\n return stripTrailingSeparators(path.slice(0, end), isPosixPathSeparator);\n}\nfunction basename1(path, suffix = \"\") {\n assertPath(path);\n if (path.length === 0) return path;\n if (typeof suffix !== \"string\") {\n throw new TypeError(`Suffix must be a string. Received ${JSON.stringify(suffix)}`);\n }\n const lastSegment = lastPathSegment(path, isPosixPathSeparator);\n const strippedSegment = stripTrailingSeparators(lastSegment, isPosixPathSeparator);\n return suffix ? stripSuffix(strippedSegment, suffix) : strippedSegment;\n}\nfunction extname1(path) {\n assertPath(path);\n let startDot = -1;\n let startPart = 0;\n let end = -1;\n let matchedSlash = true;\n let preDotState = 0;\n for(let i = path.length - 1; i >= 0; --i){\n const code = path.charCodeAt(i);\n if (isPosixPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n return \"\";\n }\n return path.slice(startDot, end);\n}\nfunction format1(pathObject) {\n if (pathObject === null || typeof pathObject !== \"object\") {\n throw new TypeError(`The \"pathObject\" argument must be of type Object. Received type ${typeof pathObject}`);\n }\n return _format(\"/\", pathObject);\n}\nfunction parse1(path) {\n assertPath(path);\n const ret = {\n root: \"\",\n dir: \"\",\n base: \"\",\n ext: \"\",\n name: \"\"\n };\n if (path.length === 0) return ret;\n const isAbsolute = isPosixPathSeparator(path.charCodeAt(0));\n let start;\n if (isAbsolute) {\n ret.root = \"/\";\n start = 1;\n } else {\n start = 0;\n }\n let startDot = -1;\n let startPart = 0;\n let end = -1;\n let matchedSlash = true;\n let i = path.length - 1;\n let preDotState = 0;\n for(; i >= start; --i){\n const code = path.charCodeAt(i);\n if (isPosixPathSeparator(code)) {\n if (!matchedSlash) {\n startPart = i + 1;\n break;\n }\n continue;\n }\n if (end === -1) {\n matchedSlash = false;\n end = i + 1;\n }\n if (code === 46) {\n if (startDot === -1) startDot = i;\n else if (preDotState !== 1) preDotState = 1;\n } else if (startDot !== -1) {\n preDotState = -1;\n }\n }\n if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {\n if (end !== -1) {\n if (startPart === 0 && isAbsolute) {\n ret.base = ret.name = path.slice(1, end);\n } else {\n ret.base = ret.name = path.slice(startPart, end);\n }\n }\n ret.base = ret.base || \"/\";\n } else {\n if (startPart === 0 && isAbsolute) {\n ret.name = path.slice(1, startDot);\n ret.base = path.slice(1, end);\n } else {\n ret.name = path.slice(startPart, startDot);\n ret.base = path.slice(startPart, end);\n }\n ret.ext = path.slice(startDot, end);\n }\n if (startPart > 0) {\n ret.dir = stripTrailingSeparators(path.slice(0, startPart - 1), isPosixPathSeparator);\n } else if (isAbsolute) ret.dir = \"/\";\n return ret;\n}\nfunction fromFileUrl1(url) {\n url = url instanceof URL ? url : new URL(url);\n if (url.protocol != \"file:\") {\n throw new TypeError(\"Must be a file URL.\");\n }\n return decodeURIComponent(url.pathname.replace(/%(?![0-9A-Fa-f]{2})/g, \"%25\"));\n}\nfunction toFileUrl1(path) {\n if (!isAbsolute1(path)) {\n throw new TypeError(\"Must be an absolute path.\");\n }\n const url = new URL(\"file:///\");\n url.pathname = encodeWhitespace(path.replace(/%/g, \"%25\").replace(/\\\\/g, \"%5C\"));\n return url;\n}\nconst mod1 = {\n sep: sep1,\n delimiter: delimiter1,\n resolve: resolve1,\n normalize: normalize1,\n isAbsolute: isAbsolute1,\n join: join1,\n relative: relative1,\n toNamespacedPath: toNamespacedPath1,\n dirname: dirname1,\n basename: basename1,\n extname: extname1,\n format: format1,\n parse: parse1,\n fromFileUrl: fromFileUrl1,\n toFileUrl: toFileUrl1\n};\nconst path = isWindows ? mod : mod1;\nconst { join: join2 , normalize: normalize2 } = path;\nconst path1 = isWindows ? mod : mod1;\nconst { basename: basename2 , delimiter: delimiter2 , dirname: dirname2 , extname: extname2 , format: format2 , fromFileUrl: fromFileUrl2 , isAbsolute: isAbsolute2 , join: join3 , normalize: normalize3 , parse: parse2 , relative: relative2 , resolve: resolve2 , sep: sep2 , toFileUrl: toFileUrl2 , toNamespacedPath: toNamespacedPath2 } = path1;\nasync function exists(path, options) {\n try {\n const stat = await Deno.stat(path);\n if (options && (options.isReadable || options.isDirectory || options.isFile)) {\n if (options.isDirectory && options.isFile) {\n throw new TypeError(\"ExistsOptions.options.isDirectory and ExistsOptions.options.isFile must not be true together.\");\n }\n if (options.isDirectory && !stat.isDirectory || options.isFile && !stat.isFile) {\n return false;\n }\n if (options.isReadable) {\n if (stat.mode == null) {\n return true;\n }\n if (Deno.uid() == stat.uid) {\n return (stat.mode & 0o400) == 0o400;\n } else if (Deno.gid() == stat.gid) {\n return (stat.mode & 0o040) == 0o040;\n }\n return (stat.mode & 0o004) == 0o004;\n }\n }\n return true;\n } catch (error) {\n if (error instanceof Deno.errors.NotFound) {\n return false;\n }\n if (error instanceof Deno.errors.PermissionDenied) {\n if ((await Deno.permissions.query({\n name: \"read\",\n path\n })).state === \"granted\") {\n return !options?.isReadable;\n }\n }\n throw error;\n }\n}\nnew Deno.errors.AlreadyExists(\"dest already exists.\");\nvar EOL;\n(function(EOL) {\n EOL[\"LF\"] = \"\\n\";\n EOL[\"CRLF\"] = \"\\r\\n\";\n})(EOL || (EOL = {}));\nclass LangAdapter {\n putAdapter;\n #storagePath;\n constructor(context){\n if (\"storagePath\" in context.customSettings) {\n this.#storagePath = context.customSettings[\"storagePath\"];\n } else {\n this.#storagePath = \"./tst-tmp/languages\";\n }\n }\n async getLanguageSource(address) {\n const bundlePath = join3(this.#storagePath, `bundle-${address}.js`);\n try {\n await exists(bundlePath);\n const metaFile = Deno.readTextFileSync(bundlePath);\n return metaFile;\n } catch {\n throw new Error(\"Did not find language source for given address:\" + address);\n }\n }\n}\nclass PutAdapter {\n #agent;\n #storagePath;\n constructor(context){\n this.#agent = context.agent;\n if (\"storagePath\" in context.customSettings) {\n this.#storagePath = context.customSettings[\"storagePath\"];\n } else {\n this.#storagePath = \"./tst-tmp/languages\";\n }\n }\n async createPublic(language) {\n const hash = UTILS.hash(language.bundle.toString());\n if (hash != language.meta.address) throw new Error(`Language Persistence: Can't store language. Address stated in meta differs from actual file\\nWanted: ${language.meta.address}\\nGot: ${hash}`);\n const agent = this.#agent;\n const expression = agent.createSignedExpression(language.meta);\n const metaPath = join3(this.#storagePath, `meta-${hash}.json`);\n const bundlePath = join3(this.#storagePath, `bundle-${hash}.js`);\n console.log(\"Writing meta & bundle path: \", metaPath, bundlePath);\n Deno.writeTextFileSync(metaPath, JSON.stringify(expression));\n Deno.writeTextFileSync(bundlePath, language.bundle.toString());\n return hash;\n }\n}\nclass Adapter {\n putAdapter;\n #storagePath;\n constructor(context){\n this.putAdapter = new PutAdapter(context);\n if (\"storagePath\" in context.customSettings) {\n this.#storagePath = context.customSettings[\"storagePath\"];\n } else {\n this.#storagePath = \"./tst-tmp/languages\";\n }\n }\n async get(address) {\n const metaPath = join3(this.#storagePath, `meta-${address}.json`);\n try {\n // await Deno.stat(metaPath);\n const metaFileText = Deno.readTextFileSync(metaPath);\n const metaFile = JSON.parse(metaFileText);\n return metaFile;\n } catch (e) {\n console.log(\"Did not find meta file for given address:\" + address, e);\n return null;\n }\n }\n}\nconst name = \"languages\";\nfunction interactions(expression) {\n return [];\n}\nasync function create(context) {\n const expressionAdapter = new Adapter(context);\n const languageAdapter = new LangAdapter(context);\n return {\n name,\n expressionAdapter,\n languageAdapter,\n interactions\n };\n}\nexport { name as name };\nexport { create as default };\n"} \ No newline at end of file +{"trustedAgents":["did:key:zQ3shkkuZLvqeFgHdgZgFMUx8VGkgVWsLA83w2oekhZxoCW2n"],"knownLinkLanguages":[],"directMessageLanguage":"","agentLanguage":"","perspectiveLanguage":"","neighbourhoodLanguage":"","languageLanguageBundle":""} \ No newline at end of file diff --git a/tests/js/scripts/cleanup.js b/tests/js/scripts/cleanup.js index b285cd608..fd4449cd5 100644 --- a/tests/js/scripts/cleanup.js +++ b/tests/js/scripts/cleanup.js @@ -1,44 +1,71 @@ -// Cleanup script: kill ad4m-executor processes by port, NOT by name. -// Killing by name (e.g. pkill / kill-process-by-name) would kill executors -// belonging to OTHER concurrent CI jobs on the same machine. -// Each test file uses a unique port range, so port-based kills are safe. - -import { execSync } from 'child_process'; - -// Ports used by individual test files in the test-all sequence. -// NOTE: Do NOT include setup ports (publishTestLangs.ts: 15700/15703/15706). -// Those belong to the prepare phase and are cleaned up by each job's own -// cleanup_processes() or by publishTestLangs.ts itself when it exits. -// Including them here would kill the setup executor of OTHER concurrent CI -// jobs sharing this self-hosted runner. -const TEST_PORTS = [ - 15000, 15001, 15002, // app.test.ts - 15100, 15101, 15102, // authentication.test.ts (suite 1) - 15200, 15201, 15202, 15203, // authentication.test.ts (suite 2) - 15300, 15301, 15302, // integration.test.ts (alice) - 15400, 15401, 15402, // integration.test.ts (bob — multi-user section) - 15600, 15601, 15602, // simple.test.ts - 15800, 15801, 15802, // multi-user-connect.test.ts - 15900, 15901, 15902, // multi-user-simple.test.ts - 15920, 15921, 15922, // email-verification.test.ts - 16600, 16601, 16602, // prolog-and-literals.test.ts - 16000, 16001, 16002, // mcp-http.test.ts - 16010, 16011, 16012, // mcp-auth.test.ts - 3001, // MCP HTTP server -]; +// Cleanup script: kill processes belonging to THIS checkout only. +// +// Why not kill by process name? +// pkill -f "ad4m" / kill-process-by-name hits executors from OTHER concurrent +// CI jobs on the same self-hosted runner — unsafe. +// +// Why not kill by hardcoded port? +// Our tests use getFreePorts() (dynamic allocation) — no fixed list exists. +// +// Why not kill kitsune2-bootstrap-srv by name? +// Same reason: concurrent jobs each spawn one and they'd kill each other's. +// +// Solution: +// ad4m-executor → killed by absolute path to binary (unique per checkout) +// kitsune2 → killed by PID written to tst-tmp/kitsune2-bootstrap.pid +// at spawn time in utils.ts (unique per process) + +import { execSync } from "child_process"; +import { rmSync, existsSync, readdirSync, readFileSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Absolute path to this checkout's executor binary — unique across checkouts. +const executorBinary = resolve( + __dirname, + "..", + "..", + "..", + "target", + "release", + "ad4m-executor", +); async function cleanup() { - let killed = 0; - for (const port of TEST_PORTS) { + // Kill only THIS checkout's ad4m-executor (path-based = concurrent CI safe). + try { + execSync(`pkill -9 -f "${executorBinary}" 2>/dev/null || true`, { + stdio: "ignore", + }); + } catch (_) {} + + // Kill THIS run's kitsune2-bootstrap-srv by the PID written at spawn time. + // Killing by name would hit concurrent jobs' instances — use PID instead. + const pidFile = join(__dirname, "..", "tst-tmp", "kitsune2-bootstrap.pid"); + if (existsSync(pidFile)) { try { - execSync(`lsof -ti:${port} | xargs -r kill -9`, { stdio: 'ignore' }); - killed++; - } catch (e) { - // Port not in use — that's fine - } + const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10); + if (!isNaN(pid)) process.kill(pid, 9); + } catch (_) {} + try { + rmSync(pidFile); + } catch (_) {} } - if (killed > 0) { - console.log(`cleanup: killed processes on ${killed} ports`); + + // Brief pause so the OS reclaims ports before the next executor starts. + await new Promise((r) => setTimeout(r, 500)); + + // Wipe per-test agent data directories so the next executor starts clean. + // Prevents SurrealDB "disk I/O error" panics from half-written DB files. + // Preserve tst-tmp/agents/p (the publishing agent set up by prepare-test). + const agentsDir = join(__dirname, "..", "tst-tmp", "agents"); + if (existsSync(agentsDir)) { + for (const entry of readdirSync(agentsDir)) { + if (entry === "p") continue; + rmSync(join(agentsDir, entry), { recursive: true, force: true }); + } } } diff --git a/tests/js/scripts/injectPublishingAgent.js b/tests/js/scripts/injectPublishingAgent.js index 1316a1462..9be3902ea 100644 --- a/tests/js/scripts/injectPublishingAgent.js +++ b/tests/js/scripts/injectPublishingAgent.js @@ -4,18 +4,67 @@ const publishingAgentPath = "./tst-tmp/agents/p/ad4m/agent.json"; const bootstrapSeedPath = "./bootstrapSeed.json"; async function main() { - if (fs.existsSync(publishingAgentPath)) { - const didData = JSON.parse(fs.readFileSync(publishingAgentPath).toString()); - if (fs.existsSync(bootstrapSeedPath)) { - const bootstrapSeed = JSON.parse(fs.readFileSync(bootstrapSeedPath).toString()); - bootstrapSeed["trustedAgents"].push(didData["did"]); - fs.writeFileSync(bootstrapSeedPath, JSON.stringify(bootstrapSeed)); - } else { - throw new Error(`Could not find boostrapSeed at path: ${bootstrapSeedPath}`) - } + if (fs.existsSync(publishingAgentPath)) { + const didData = JSON.parse(fs.readFileSync(publishingAgentPath).toString()); + if (fs.existsSync(bootstrapSeedPath)) { + const bootstrapSeed = JSON.parse( + fs.readFileSync(bootstrapSeedPath).toString(), + ); + const did = didData["did"]; + if (!bootstrapSeed["trustedAgents"].includes(did)) { + bootstrapSeed["trustedAgents"].push(did); + } + fs.writeFileSync(bootstrapSeedPath, JSON.stringify(bootstrapSeed)); } else { - throw new Error(`Could not find publishingAgent at path: ${publishingAgentPath}`) + throw new Error( + `Could not find boostrapSeed at path: ${bootstrapSeedPath}`, + ); } + } else { + throw new Error( + `Could not find publishingAgent at path: ${publishingAgentPath}`, + ); + } } -main(); \ No newline at end of file +// Retry wrapper — the publishing-agent file is written by a preceding build +// step and may not be visible immediately (FS sync lag, slow CI machines). +// Retries on ENOENT up to TIMEOUT_MS with exponential backoff; any other error +// is rethrown immediately. +const TIMEOUT_MS = 30_000; +const INITIAL_DELAY_MS = 200; + +async function mainWithRetry() { + const deadline = Date.now() + TIMEOUT_MS; + let delay = INITIAL_DELAY_MS; + + while (true) { + try { + await main(); + return; // success + } catch (err) { + const isTransient = + err?.code === "ENOENT" || + (err?.message ?? "").includes("Could not find"); + + if (!isTransient) throw err; // hard error — fail immediately + + if (Date.now() + delay > deadline) { + throw new Error( + `injectPublishingAgent timed out after ${TIMEOUT_MS}ms waiting for files to appear. Last error: ${err.message}`, + ); + } + + console.warn( + `injectPublishingAgent: retrying in ${delay}ms — ${err.message}`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, 5_000); + } + } +} + +mainWithRetry().catch((err) => { + console.error("injectPublishingAgent failed:", err.message); + process.exit(1); +}); diff --git a/tests/js/scripts/resetBootstrapSeed.js b/tests/js/scripts/resetBootstrapSeed.js new file mode 100644 index 000000000..1fc74fe10 --- /dev/null +++ b/tests/js/scripts/resetBootstrapSeed.js @@ -0,0 +1,30 @@ +// Resets only the dynamically-generated fields in bootstrapSeed.json and +// publishBootstrapSeed.json before each test run. +// +// The stable language-address fields (agentLanguage, perspectiveLanguage, etc.) +// are content-addressed and should remain committed — they only change when +// the bootstrap language bundles change. Only the fields that are regenerated +// per-run are cleared here: +// - trustedAgents: reset to the single baseline DID (publishing agent is +// re-injected by inject-publishing-agent.js each run) +// - languageLanguageBundle: cleared (re-injected by inject-language-language.js) +import fs from "fs"; + +const BASE_TRUSTED_AGENT = + "did:key:zQ3shkkuZLvqeFgHdgZgFMUx8VGkgVWsLA83w2oekhZxoCW2n"; + +function resetSeed(path) { + if (!fs.existsSync(path)) { + throw new Error(`Could not find bootstrap seed at path: ${path}`); + } + const seed = JSON.parse(fs.readFileSync(path).toString()); + seed["trustedAgents"] = [BASE_TRUSTED_AGENT]; + seed["languageLanguageBundle"] = ""; + fs.writeFileSync(path, JSON.stringify(seed)); +} + +resetSeed("./bootstrapSeed.json"); +resetSeed("./publishBootstrapSeed.json"); +console.log( + "Bootstrap seed files reset (trustedAgents + languageLanguageBundle cleared).", +); diff --git a/tests/js/scripts/test-multi-user.sh b/tests/js/scripts/test-multi-user.sh new file mode 100755 index 000000000..17814d8fa --- /dev/null +++ b/tests/js/scripts/test-multi-user.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +SUITES=( + test-multi-user-auth + test-multi-user-config + test-multi-user-isolation + test-multi-user-sdna + test-multi-user-profiles + test-multi-user-neighbourhood + test-multi-user-multi-node + test-multi-user-subscriptions + test-multi-user-notifications +) + +# Guarantee cleanup runs even if a suite fails or the script is interrupted. +trap 'node scripts/cleanup.js' EXIT + +# Run prepare-test once before any suite so that tst-tmp/languages/ is +# populated with the language bundles (bundle-{hash}.js / meta-{hash}.json). +# Without this, executors started by the multi-user tests can't install the +# agent language via the language-language and byDID / updatePublicPerspective +# calls fail with "No Agent Language installed!" on a fresh CI workdir. +echo "" +echo "▶ Running prepare-test..." +node scripts/cleanup.js +pnpm run prepare-test + +for suite in "${SUITES[@]}"; do + echo "" + echo "▶ Running $suite..." + node scripts/cleanup.js + pnpm run "$suite" +done diff --git a/tests/js/sdna/subject.pl b/tests/js/sdna/subject.pl index 7589d8181..fb118d858 100644 --- a/tests/js/sdna/subject.pl +++ b/tests/js/sdna/subject.pl @@ -18,16 +18,16 @@ collection_getter(c, Base, "comments", List) :- findall(C, triple(Base, "todo://comment", C), List). collection_adder(c, "comments", '[{action: "addLink", source: "this", predicate: "todo://comment", target: "value"}]'). collection_remover(c, "comments", '[{action: "removeLink", source: "this", predicate: "todo://comment", target: "value"}]'). -collection_setter(c, "comments", '[{action: "collectionSetter", source: "this", predicate: "todo://comment", target: "value"}]'). +collection_setter(c, "comments", '[{action: "relationSetter", source: "this", predicate: "todo://comment", target: "value"}]'). collection(c, "entries"). collection_getter(c, Base, "entries", List) :- findall(C, triple(Base, "flux://entry_type", C), List). collection_adder(c, "entries", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). collection_remover(c, "entries", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_setter(c, "entries", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_setter(c, "entries", '[{action: "relationSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). collection(c, "messages"). collection_getter(c, Base, "messages", List) :- setof(Target, (triple(Base, "flux://entry_type", Target), instance(OtherClass, Target), subject_class("Message", OtherClass)), List). collection_adder(c, "messages", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). collection_remover(c, "messages", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_setter(c, "messages", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_setter(c, "messages", '[{action: "relationSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). diff --git a/tests/js/test-multi-user-with-setup.sh b/tests/js/test-multi-user-with-setup.sh deleted file mode 100755 index 1e81352b9..000000000 --- a/tests/js/test-multi-user-with-setup.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash - -# Script to run multi-user test with proper setup -echo "🚀 Multi-User Test with Setup" -echo "=============================" - -# Function to kill any AD4M processes belonging to THIS test run (by port, not by name). -# Do NOT use pkill/killall by name — that would kill executors from other concurrent CI jobs -# running on the same machine. Each test suite uses a unique port range. -cleanup_processes() { - echo "🧹 Killing any existing AD4M processes on our ports..." - # Kill only processes on the ports used by THIS test suite. - # setup (publishTestLangs): 15703-15705 ← unique to this job - lsof -ti:15703 | xargs -r kill -9 2>/dev/null || true - lsof -ti:15704 | xargs -r kill -9 2>/dev/null || true - lsof -ti:15705 | xargs -r kill -9 2>/dev/null || true - # multi-user-simple.test.ts: 15900-15902 - lsof -ti:15900 | xargs -r kill -9 2>/dev/null || true - lsof -ti:15901 | xargs -r kill -9 2>/dev/null || true - lsof -ti:15902 | xargs -r kill -9 2>/dev/null || true - sleep 1 -} - -# Unique setup port range for this CI job so it doesn't conflict with -# integration-tests-js (15700-15702) or integration-tests-email-verification (15706-15708). -export AD4M_SETUP_GQL_PORT=15703 -export AD4M_SETUP_HC_ADMIN_PORT=15704 -export AD4M_SETUP_HC_APP_PORT=15705 - -# Function to clean up test directories -cleanup_directories() { - echo "🧹 Cleaning up test directories..." - rm -rf tst-tmp 2>/dev/null || true - rm -rf .ad4m 2>/dev/null || true -} - -# Trap to ensure cleanup on script exit -trap 'cleanup_processes; cleanup_directories' EXIT - -# Step 1: Initial cleanup -cleanup_processes -cleanup_directories - -echo "🧹 Cleaning testing data..." -node scripts/cleanTestingData.js - -# Step 2: Prepare test environment -echo "🔧 Preparing test environment..." -echo " - Building test languages..." -./scripts/build-test-language.sh - -echo " - Preparing test directory..." -./scripts/prepareTestDirectory.sh - -echo " - Getting builtin test languages..." -deno run --allow-all scripts/get-builtin-test-langs.js - -echo " - Injecting language language..." -pnpm run inject-language-language - -echo " - Publishing test languages..." -pnpm run publish-test-languages - -echo " - Injecting publishing agent..." -pnpm run inject-publishing-agent - -echo "✅ Test environment prepared" - -# Step 3: Ensure executor is killed before running test -echo "🧪 Preparing to run multi-user test..." -cleanup_processes -sleep 3 - -# Step 4: Run the multi-user test -echo "🧪 Running multi-user test..." -pnpm run test-multi-user-simple - -echo "✅ Multi-user test with setup complete!" - diff --git a/tests/js/tests/TEST-REORGANISATION.md b/tests/js/tests/TEST-REORGANISATION.md new file mode 100644 index 000000000..5033355eb --- /dev/null +++ b/tests/js/tests/TEST-REORGANISATION.md @@ -0,0 +1,26 @@ +# Test Reorganisation + +--- + +## 1. Shared executor architecture (future) + +Currently each suite spawns and kills a fresh executor process. This is the dominant cost per run and also prevents building a browser-based interactive test harness (like the We test app). + +**The opportunity:** AD4M's multi-user mode already supports multiple agent identities on a single executor. The `startAgent` helper already allocates unique ports and data dirs per agent — the missing piece is a persistent executor that hosts all of them. + +**Suites that could share an executor** (stateless beyond their own perspective/agent): + +- `test-smoke` +- `test-auth` (auth-app, auth-core) +- `test-sdna` +- `test-model` +- Most `test-multi-user-*` suites + +**Suites that must keep their own executor** (test lifecycle or network topology): + +- `auth-email-verification` — needs specific proxy/bootstrap URLs at startup +- `multi-user-neighbourhood` — needs `runHcLocalServices()` +- `multi-user-multi-node` — tests inter-executor DHT connectivity +- `integration` — Alice/Bob/Jim need separate Holochain nodes + +**Why it matters:** Shared executor would make the full suite dramatically faster, and would unlock a browser-based test harness using the same pattern as the We test app (connect once, run scenarios against live perspectives). diff --git a/tests/js/tests/agent-language.ts b/tests/js/tests/agent-language.ts deleted file mode 100644 index 40e5c837f..000000000 --- a/tests/js/tests/agent-language.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { TestContext } from './integration.test' -import { sleep } from '../utils/utils' -import { expect } from "chai"; - -export default function agentLanguageTests(testContext: TestContext) { - return () => { - it("works across remote agents", async () => { - const alice = testContext.alice! - const didAlice = (await alice.agent.status()).did! - const bob = testContext.bob! - const didBob = (await bob.agent.status()).did! - - const aliceHerself = await alice.agent.me() - const bobHimself = await bob.agent.me() - - // Helper function to retry agent lookup with logging - async function retryAgentLookup( - client: typeof alice, - targetDid: string, - clientName: string, - targetName: string, - maxAttempts: number = 90 - ) { - let result = await client.agent.byDID(targetDid) - let attempts = 0 - while (!result && attempts < maxAttempts) { - if (attempts % 10 === 0) { - console.log(`${clientName} looking up ${targetName}... attempt ${attempts}/${maxAttempts}`) - } - await sleep(1000) - result = await client.agent.byDID(targetDid) - attempts++ - } - if (!result) { - console.error(`${clientName} failed to find ${targetName} after ${maxAttempts} attempts`) - console.error(`Target DID: ${targetDid}`) - } - return result - } - - await sleep(5000) - - // Both lookups now have retry logic - const bobSeenFromAlice = await retryAgentLookup(alice, didBob, "Alice", "Bob") - expect(bobSeenFromAlice, "Alice should be able to see Bob's agent profile").to.not.be.null - expect(bobSeenFromAlice).to.be.eql(bobHimself) - - const aliceSeenFromBob = await retryAgentLookup(bob, didAlice, "Bob", "Alice") - expect(aliceSeenFromBob, "Bob should be able to see Alice's agent profile").to.not.be.null - expect(aliceSeenFromBob).to.be.eql(aliceHerself) - }) - } -} \ No newline at end of file diff --git a/tests/js/tests/agent.ts b/tests/js/tests/agent.ts deleted file mode 100644 index 8cf0a976a..000000000 --- a/tests/js/tests/agent.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Perspective, LinkExpression, Link, ExpressionProof, EntanglementProofInput } from "@coasys/ad4m"; -import { TestContext } from './integration.test' -import { sleep } from '../utils/utils' -import { expect } from "chai"; -import * as sinon from "sinon"; - -export default function agentTests(testContext: TestContext) { - return () => { - describe('basic agent operations', () => { - it('can get and create agent store', async () => { - const ad4mClient = testContext.ad4mClient! - - const agentUpdated = sinon.fake() - ad4mClient.agent.addAgentStatusChangedListener(agentUpdated) - - const generate = await ad4mClient.agent.generate("passphrase") - expect(generate.isInitialized).to.be.true; - expect(generate.isUnlocked).to.be.true; - - await sleep(1000) - expect(agentUpdated.calledOnce).to.be.true; - - // //Should be able to create a perspective - // const create = await ad4mClient.perspective.add("test"); - // expect(create.name).to.equal("test"); - - const lockAgent = await ad4mClient.agent.lock("passphrase"); - - expect(lockAgent.isInitialized).to.be.true; - expect(lockAgent.isUnlocked).to.be.false; - - await sleep(1000) - expect(agentUpdated.calledTwice).to.be.true; - - // //Should not be able to create a perspective - // const createLocked = await ad4mClient.perspective.add("test2"); - // console.log(createLocked); - - const unlockAgent = await ad4mClient.agent.unlock("passphrase"); - expect(unlockAgent.isInitialized).to.be.true; - expect(unlockAgent.isUnlocked).to.be.true; - - await sleep(1000) - expect(agentUpdated.calledThrice).to.be.true; - - // //Should be able to create a perspective - // const create = await ad4mClient.perspective.add("test3"); - // expect(create.name).to.equal("test3"); - - const agentDump = await ad4mClient.agent.status(); - expect(agentDump.isInitialized).to.be.true; - expect(agentDump.isUnlocked).to.be.true; - }), - it('can get and create agent expression profile', async () => { - const ad4mClient = testContext.ad4mClient! - - const agentUpdated = sinon.fake() - ad4mClient.agent.addUpdatedListener(agentUpdated) - - const currentAgent = await ad4mClient.agent.me(); - expect(currentAgent.perspective).not.to.be.undefined; - expect(currentAgent.perspective!.links.length).to.equal(0); - expect(currentAgent.directMessageLanguage).not.to.be.undefined; - const oldDmLang = currentAgent.directMessageLanguage! - - let link = new LinkExpression(); - link.author = "did:test"; - link.timestamp = new Date().toISOString(); - link.data = new Link({source: "ad4m://src", target: "test://target", predicate: "ad4m://pred"}); - link.proof = new ExpressionProof("sig", "key") - const updatePerspective = await ad4mClient.agent.updatePublicPerspective(new Perspective([link])) - expect(currentAgent.perspective).not.to.be.undefined - expect(updatePerspective.perspective!.links.length).to.equal(1); - - await sleep(500) - expect(agentUpdated.calledOnce).to.be.true; - expect(agentUpdated.getCall(0).args[0]).to.deep.equal(updatePerspective) - - const updatePublicLanguage = await ad4mClient.agent.updateDirectMessageLanguage("newlang"); - expect(currentAgent.perspective).not.to.be.undefined - expect(updatePublicLanguage.perspective!.links.length).to.equal(1); - expect(updatePublicLanguage.directMessageLanguage).to.equal("newlang"); - - await sleep(500) - expect(agentUpdated.calledTwice).to.be.true; - expect(agentUpdated.getCall(1).args[0]).to.deep.equal(updatePublicLanguage) - - const currentAgentPostUpdate = await ad4mClient.agent.me(); - expect(currentAgent.perspective).not.to.be.undefined; - expect(currentAgentPostUpdate.perspective!.links.length).to.equal(1); - expect(currentAgentPostUpdate.directMessageLanguage).to.equal("newlang"); - - const getByDid = await ad4mClient.agent.byDID(currentAgent.did); - expect(getByDid.did).to.equal(currentAgent.did); - expect(currentAgent.perspective).not.to.be.undefined; - expect(getByDid.perspective!.links.length).to.equal(1); - expect(getByDid.directMessageLanguage).to.equal("newlang"); - - await ad4mClient.agent.updateDirectMessageLanguage(oldDmLang); - - const getInvalidDid = await ad4mClient.agent.byDID("na"); - expect(getInvalidDid).to.equal(null); - }) - it('can mutate agent public profile', async () => { - const ad4mClient = testContext.ad4mClient!; - - const currentAgent = await ad4mClient.agent.me(); - expect(currentAgent.perspective).not.to.be.undefined; - expect(currentAgent.perspective!.links.length).to.equal(1); - expect(currentAgent.directMessageLanguage).not.to.be.undefined; - - await ad4mClient.agent.mutatePublicPerspective({ - additions: [new Link({ - source: "test://source-test", - predicate: "test://predicate-test", - target: "test://target-test" - })], - removals: [] - }); - - const currentAgentPostMutation = await ad4mClient.agent.me(); - expect(currentAgentPostMutation.perspective).not.to.be.undefined; - expect(currentAgentPostMutation.perspective!.links.length).to.equal(2); - const link = currentAgentPostMutation.perspective!.links[0]; - - await ad4mClient.agent.mutatePublicPerspective({ - additions: [], - removals: [link] - }); - - const currentAgentPostDeletion = await ad4mClient.agent.me(); - expect(currentAgentPostDeletion.perspective).not.to.be.undefined; - expect(currentAgentPostDeletion.perspective!.links.length).to.equal(1); - }) - it('can create entanglementProofPreFlight', async () => { - const ad4mClient = testContext.ad4mClient!; - - //Check can generate a preflight key - const preFlight = await ad4mClient.agent.entanglementProofPreFlight("ethAddr", "ethereum"); - expect(preFlight.deviceKey).to.equal("ethAddr"); - expect(preFlight.deviceKeyType).to.equal("ethereum"); - expect(preFlight.didSignedByDeviceKey).to.be.null; - - const verify = await ad4mClient.runtime.verifyStringSignedByDid(preFlight.did, preFlight.didSigningKeyId, "ethAddr", preFlight.deviceKeySignedByDid); - expect(verify).to.be.true; - - //Check can save a entanglement proof - preFlight.didSignedByDeviceKey = "ethSignedDID"; - const addProof = await ad4mClient.agent.addEntanglementProofs([preFlight as EntanglementProofInput]); - expect(addProof[0]).to.deep.equal(preFlight); - - //Check can get entanglment proofs - const getProofs = await ad4mClient.agent.getEntanglementProofs(); - expect(getProofs[0]).to.deep.equal(preFlight); - - //Check can delete entanglement proofs - const deleteProofs = await ad4mClient.agent.deleteEntanglementProofs([preFlight as EntanglementProofInput]); - expect(deleteProofs.length).to.be.equal(0); - - //Check entanglement proof is deleted on get - const getProofsPostDelete = await ad4mClient.agent.getEntanglementProofs(); - expect(getProofsPostDelete.length).to.be.equal(0); - }) - it('can signMessage', async () => { - const ad4mClient = testContext.ad4mClient!; - - const signed = await ad4mClient.agent.signMessage("test"); - expect(signed).to.not.be.null; - }) - }) - } -} \ No newline at end of file diff --git a/tests/js/tests/ai.ts b/tests/js/tests/ai.ts deleted file mode 100644 index f5a00e728..000000000 --- a/tests/js/tests/ai.ts +++ /dev/null @@ -1,671 +0,0 @@ -import { TestContext } from './integration.test' -import { expect } from "chai"; -import fs from 'fs'; -//@ts-ignore -import ffmpeg from 'fluent-ffmpeg'; -import { Readable } from 'stream'; -import { ModelInput } from '@coasys/ad4m/lib/src/ai/AIResolver'; - -// Helper function to convert audio file to PCM data -async function convertAudioToPCM(audioFilePath: string): Promise { - const pcmData: Buffer = await new Promise((resolve, reject) => { - const chunks: any[] = []; - ffmpeg() - .input(audioFilePath) - .inputFormat('m4a') - .toFormat('f32le') - .audioFrequency(16000) - .audioChannels(1) - .on('error', reject) - .pipe() - .on('data', (chunk: any) => { - chunks.push(chunk) - }) - .on('end', () => { - const finalBuffer = Buffer.concat(chunks); - console.log("Total PCM data size:", finalBuffer.length); - resolve(finalBuffer); - }) - .on('error', reject); - }); - - return new Float32Array(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength / Float32Array.BYTES_PER_ELEMENT); -} - -// Helper function to stream audio data in chunks -async function streamAudioData( - audioData: Float32Array, - streamIds: string | string[], - feedTranscriptionStream: (streamIds: string | string[], audio: number[]) => Promise, - chunkSize: number = 8000 -): Promise { - for (let i = 0; i < audioData.length; i += chunkSize) { - let end = i + chunkSize; - if (end > audioData.length) { - end = audioData.length; - } - console.log(`Sending chunk: ${i} - ${end}`); - const chunk = audioData.slice(i, end); - const numberArray = Array.from(chunk); // Convert Float32Array to number[] - await feedTranscriptionStream(streamIds, numberArray); - // Simulate real-time processing by adding a small delay - await new Promise(resolve => setTimeout(resolve, 500)); - } -} - -// Helper function to wait for transcription results -async function waitForTranscription( - condition: () => boolean, - maxWaitSeconds: number = 60 -): Promise { - let i = 0; - while (!condition() && i < maxWaitSeconds) { - await new Promise(resolve => setTimeout(resolve, 1000)); - i += 1; - } -} - -export default function aiTests(testContext: TestContext) { - return () => { - describe('AI service', () => { - // This is used in the skipped tests below - // They are skipped for CI, run on local device with GPU - let testModelFileName: string = "llama_3_1_8b_chat" - let testModelId: string = "" - - it("can perform Model CRUD operations", async () => { - const ad4mClient = testContext.ad4mClient! - - // Test adding an API model - const apiModelInput: ModelInput = { - name: "TestApiModel", - api: { - baseUrl: "https://api.example.com/", - apiKey: "test-api-key", - model: "llama", - apiType: "OPEN_AI" - }, - modelType: "LLM" - } - - const addApiResult = await ad4mClient.ai.addModel(apiModelInput) - expect(addApiResult).to.be.a.string - - // Test adding a local model - const localModelInput: ModelInput = { - name: "TestLocalModel", - local: { - fileName: "test_model.bin", - tokenizerSource: { - repo: "test-repo", - revision: "main", - fileName: "tokenizer.json" - }, - huggingfaceRepo: "test-repo", - revision: "main" - }, - modelType: "EMBEDDING" - } - - const addLocalResult = await ad4mClient.ai.addModel(localModelInput) - expect(addLocalResult).to.be.a.string - - // Test getting models - const models = await ad4mClient.ai.getModels() - expect(models).to.be.an('array') - expect(models.length).to.be.at.least(2) - - const addedApiModel = models.find(model => model.name === "TestApiModel") - expect(addedApiModel).to.exist - expect(addedApiModel?.id).to.equal(addApiResult) - expect(addedApiModel?.api?.baseUrl).to.equal("https://api.example.com/") - expect(addedApiModel?.api?.apiKey).to.equal("test-api-key") - expect(addedApiModel?.api?.model).to.equal("llama") - expect(addedApiModel?.api?.apiType).to.equal("OPEN_AI") - - const addedLocalModel = models.find(model => model.name === "TestLocalModel") - expect(addedLocalModel).to.exist - expect(addedLocalModel?.id).to.equal(addLocalResult) - expect(addedLocalModel?.local?.fileName).to.equal("test_model.bin") - expect(addedLocalModel?.local?.tokenizerSource?.repo).to.equal("test-repo") - expect(addedLocalModel?.local?.tokenizerSource?.revision).to.equal("main") - expect(addedLocalModel?.local?.tokenizerSource?.fileName).to.equal("tokenizer.json") - expect(addedLocalModel?.local?.huggingfaceRepo).to.equal("test-repo") - expect(addedLocalModel?.local?.revision).to.equal("main") - - // Test removing models - const removeApiResult = await ad4mClient.ai.removeModel(addedApiModel!.id) - expect(removeApiResult).to.be.true - - const removeLocalResult = await ad4mClient.ai.removeModel(addedLocalModel!.id) - expect(removeLocalResult).to.be.true - - // Verify the models were removed - const updatedModels = await ad4mClient.ai.getModels() - const removedApiModel = updatedModels.find(model => model.name === "TestApiModel") - expect(removedApiModel).to.be.undefined - - const removedLocalModel = updatedModels.find(model => model.name === "TestLocalModel") - expect(removedLocalModel).to.be.undefined - }) - - it('can update model', async () => { - const ad4mClient = testContext.ad4mClient! - - // Create initial API model - const initialModel: ModelInput = { - name: "TestUpdateModel", - api: { - baseUrl: "https://api.example.com/", - apiKey: "initial-key", - model: "llama", - apiType: "OPEN_AI" - }, - modelType: "LLM" - } - - // Add initial model - const addResult = await ad4mClient.ai.addModel(initialModel) - expect(addResult).to.be.a.string - - // Get the model to retrieve its ID - const models = await ad4mClient.ai.getModels() - const addedModel = models.find(model => model.name === "TestUpdateModel") - expect(addedModel).to.exist - - // Create updated model data - const bogusModelUrls: ModelInput = { - name: "UpdatedModel", - local: { - fileName: "updated_model.bin", - tokenizerSource: { - repo: "updated-repo", - revision: "main", - fileName: "updated_tokenizer.json" - }, - huggingfaceRepo: "updated-repo", - revision: "main" - }, - modelType: "EMBEDDING" - } - - // Update the model - let updateResult = false - let error = {} - try { - updateResult = await ad4mClient.ai.updateModel(addedModel!.id, bogusModelUrls) - }catch(e) { - //@ts-ignore - error = e - console.log(error) - } - expect(updateResult).to.be.false - expect(error).to.have.property('message') - //@ts-ignore - expect(error.message).to.include('Failed to update model') - - - // Create updated model data - const updatedModel: ModelInput = { - name: "UpdatedModel", - api: { - baseUrl: "https://api.example.com/v2", - apiKey: "updated-api-key", - model: "gpt-4", - apiType: "OPEN_AI" - }, - modelType: "LLM" - } - - updateResult = await ad4mClient.ai.updateModel(addedModel!.id, updatedModel) - - expect(updateResult).to.be.true - - // Verify the update - const updatedModels = await ad4mClient.ai.getModels() - const retrievedModel = updatedModels.find(model => model.id === addedModel!.id) - expect(retrievedModel).to.exist - expect(retrievedModel?.name).to.equal("UpdatedModel") - expect(retrievedModel?.local).to.be.null - expect(retrievedModel?.api?.baseUrl).to.equal("https://api.example.com/v2") - expect(retrievedModel?.api?.apiKey).to.equal("updated-api-key") - expect(retrievedModel?.api?.model).to.equal("gpt-4") - expect(retrievedModel?.api?.apiType).to.equal("OPEN_AI") - expect(retrievedModel?.modelType).to.equal("LLM") - - // Clean up - const removeResult = await ad4mClient.ai.removeModel(addedModel!.id) - expect(removeResult).to.be.true - }) - - it.skip('can update model and verify it works', async () => { - const ad4mClient = testContext.ad4mClient! - - // Create initial model - const initialModel: ModelInput = { - name: "TestModel", - local: { - fileName: "llama_tiny_1_1b_chat" - }, - modelType: "LLM" - } - - // Add initial model - const modelId = await ad4mClient.ai.addModel(initialModel) - expect(modelId).to.be.a.string - - // Wait for model to be loaded - let status; - do { - status = await ad4mClient.ai.modelLoadingStatus(modelId); - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second between checks - } while (status.progress < 100); - - testModelId = modelId - - // Create task using "default" as model_id - const task = await ad4mClient.ai.addTask( - "test-task", - modelId, - "You are a helpful assistant", - [{ input: "Say hi", output: "Hello!" }] - ) - - // Test that initial model works - const prompt = "Say hello" - const initialResponse = await ad4mClient.ai.prompt(task.taskId, prompt) - expect(initialResponse).to.be.a.string - expect(initialResponse.length).to.be.greaterThan(0) - - // Create updated model config - const updatedModel: ModelInput = { - name: "UpdatedTestModel", - local: { fileName: testModelFileName }, - modelType: "LLM" - } - - // Update the model - const updateResult = await ad4mClient.ai.updateModel(modelId, updatedModel) - expect(updateResult).to.be.true - - // Wait for model to be loaded - do { - status = await ad4mClient.ai.modelLoadingStatus(modelId); - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second between checks - } while (status.progress < 100); - - // Verify model was updated in DB - const models = await ad4mClient.ai.getModels() - const retrievedModel = models.find(m => m.id === modelId) - expect(retrievedModel).to.exist - expect(retrievedModel?.name).to.equal("UpdatedTestModel") - expect(retrievedModel?.local?.fileName).to.equal(testModelFileName) - - // Test that updated model still works - const updatedResponse = await ad4mClient.ai.prompt(task.taskId, prompt) - expect(updatedResponse).to.be.a.string - expect(updatedResponse.length).to.be.greaterThan(0) - - // keep model around for other tests - }) - - it ('AI model status', async () => { - const ad4mClient = testContext.ad4mClient! - const status = await ad4mClient.ai.modelLoadingStatus("bert-id"); - expect(status).to.have.property('model'); - expect(status).to.have.property('status'); - }) - - it('can set and get default model', async () => { - const ad4mClient = testContext.ad4mClient! - - // Create test models first - const apiModelInput: ModelInput = { - name: "TestDefaultApiModel", - api: { - baseUrl: "https://api.example.com/", - apiKey: "test-api-key", - model: "llama", - apiType: "OPEN_AI" - }, - modelType: "LLM" - } - - let id = await ad4mClient.ai.addModel(apiModelInput) - - // Set default model - const setResult = await ad4mClient.ai.setDefaultModel("LLM", id) - expect(setResult).to.be.true - - // Verify default model is set correctly - const defaultModel = await ad4mClient.ai.getDefaultModel("LLM") - expect(defaultModel.name).to.equal("TestDefaultApiModel") - expect(defaultModel.api?.baseUrl).to.equal("https://api.example.com/") - - // Clean up - await ad4mClient.ai.removeModel(id) - }) - - it.skip('can use "default" as model_id in tasks and prompting works', async () => { - const ad4mClient = testContext.ad4mClient! - await ad4mClient.ai.setDefaultModel("LLM", testModelId) - - // Create task using "default" as model_id - const task = await ad4mClient.ai.addTask( - "default-model-task", - "default", - "You are a helpful assistant. Whatever you say, it will include 'hello'", - [{ input: "Say hi", output: "Hello!" }] - ) - expect(task).to.have.property('taskId') - expect(task.modelId).to.equal("default") - - // Test prompting works with the task - const response = await ad4mClient.ai.prompt(task.taskId, "Say hi") - expect(response).to.be.a('string') - expect(response.toLowerCase()).to.include('hello') - - - // Create another test model - const newModelInput: ModelInput = { - name: "TestDefaultModel2", - local: { fileName: "llama_3_1_8b_chat" }, - modelType: "LLM" - } - const newModelId = await ad4mClient.ai.addModel(newModelInput) - - // Wait for new model to be loaded - let newModelStatus; - do { - newModelStatus = await ad4mClient.ai.modelLoadingStatus(newModelId); - await new Promise(resolve => setTimeout(resolve, 1000)); - } while (newModelStatus.progress < 100); - - // Change default model to new one - await ad4mClient.ai.setDefaultModel("LLM", newModelId) - - // Verify new default model is set - const newDefaultModel = await ad4mClient.ai.getDefaultModel("LLM") - expect(newDefaultModel.name).to.equal("TestDefaultModel2") - - // Test that prompting still works with the task using "default" - const response2 = await ad4mClient.ai.prompt(task.taskId, "Say hi") - expect(response2).to.be.a('string') - expect(response2.toLowerCase()).to.include('hello') - - // Clean up - await ad4mClient.ai.removeTask(task.taskId) - await ad4mClient.ai.removeModel(newModelId) - }) - - it.skip('can do Tasks CRUD', async() => { - const ad4mClient = testContext.ad4mClient! - - // Add a task - const newTask = await ad4mClient.ai.addTask( - "test-name", - testModelId, - "This is a test system prompt", - [{ input: "Test input", output: "Test output" }] - ); - expect(newTask).to.have.property('taskId'); - expect(newTask.name).to.equal('test-name'); - expect(newTask.modelId).to.equal(testModelId); - expect(newTask.systemPrompt).to.equal("This is a test system prompt"); - expect(newTask.promptExamples).to.deep.equal([{ input: "Test input", output: "Test output" }]); - - // Get all tasks - const tasks = await ad4mClient.ai.tasks(); - expect(tasks).to.be.an('array'); - expect(tasks).to.have.lengthOf.at.least(1); - expect(tasks.find(task => task.taskId === newTask.taskId)).to.deep.equal(newTask); - - // Update a task - const updatedTask = await ad4mClient.ai.updateTask(newTask.taskId, { - ...newTask, - systemPrompt: "Updated system prompt", - promptExamples: [{ input: "Updated input", output: "Updated output" }] - }); - expect(updatedTask.taskId).to.equal(newTask.taskId); - expect(updatedTask.systemPrompt).to.equal("Updated system prompt"); - expect(updatedTask.promptExamples).to.deep.equal([{ input: "Updated input", output: "Updated output" }]); - - // Remove a task - const removedTask = await ad4mClient.ai.removeTask(newTask.taskId); - expect(removedTask).to.deep.equal(updatedTask); - - // Verify task is removed - const tasksAfterRemoval = await ad4mClient.ai.tasks(); - expect(tasksAfterRemoval.find(task => task.taskId === newTask.taskId)).to.be.undefined; - }).timeout(900000) - - it.skip('can prompt a task', async () => { - const ad4mClient = testContext.ad4mClient! - - // Create a new task - const newTask = await ad4mClient.ai.addTask( - "test-name", - testModelId, - "You are inside a test. Please ALWAYS respond with 'works', plus something else.", - [ - { input: "What's the capital of France?", output: "works. Also that is Paris" }, - { input: "What's the largets planet in our solar system?", output: "works. That is Jupiter." } - - ] - ); - - expect(newTask).to.have.property('taskId'); - - // Prompt the task - const promptResult = await ad4mClient.ai.prompt(newTask.taskId, "What's the largest planet in our solar system?"); - - console.log("PROMPT RESULT:", promptResult) - // Check if the result is a non-empty string - expect(promptResult).to.be.a('string'); - expect(promptResult.length).to.be.greaterThan(0); - - // Check if the result mentions Jupiter - expect(promptResult.toLowerCase()).to.include('works'); - - // Clean up: remove the task - await ad4mClient.ai.removeTask(newTask.taskId); - }).timeout(900000) - - it.skip('can prompt several tasks in a row fast', async () => { - const ad4mClient = testContext.ad4mClient! - - console.log("test 1"); - - // Create a new task - const newTask = await ad4mClient.ai.addTask( - "test-name", - testModelId, - "You are inside a test. Please respond with a short, unique message each time.", - [ - { input: "Test long 1", output: "This is a much longer response that includes various details. It talks about the weather being sunny, the importance of staying hydrated, and even mentions a recipe for chocolate chip cookies. The response goes on to discuss the benefits of regular exercise, the plot of a popular novel, and concludes with a fun fact about the migration patterns of monarch butterflies." }, - { input: "Test long 2", output: "This is another much longer response that delves into various topics. It begins by discussing the intricate process of photosynthesis in plants, then transitions to the history of ancient civilizations, touching on the rise and fall of the Roman Empire. The response continues with an explanation of quantum mechanics and its implications for our understanding of the universe. It then explores the evolution of human language, the impact of climate change on global ecosystems, and the potential for artificial intelligence to revolutionize healthcare. The response concludes with a brief overview of the cultural significance of tea ceremonies in different parts of the world." }, - { input: "Test long 3", output: "This extensive response covers a wide range of subjects, starting with an in-depth analysis of sustainable urban planning and its impact on modern cities. It then shifts to discuss the evolution of musical instruments throughout history, touching on the development of the piano, guitar, and electronic synthesizers. The text continues with an exploration of the human immune system, detailing how it fights off pathogens and the importance of vaccinations. Next, it delves into the world of astronomy, describing the life cycle of stars and the formation of galaxies. The response also includes a section on the history of cryptography, from ancient ciphers to modern encryption algorithms used in digital security. It concludes with a discussion on the philosophy of ethics, examining various moral frameworks and their applications in contemporary society." }, - ] - ); - - console.log("test 2"); - - expect(newTask).to.have.property('taskId'); - - // Create an array of 10 prompts - const prompts = Array.from({ length: 1 }, (_, i) => `This is a much longer test prompt number ${i + 1}. It includes various details to make it more substantial. For instance, it mentions that the sky is blue, grass is green, and water is essential for life. It also touches on the fact that technology is rapidly advancing, climate change is a global concern, and education is crucial for personal growth. Additionally, it notes that music can evoke powerful emotions, reading broadens the mind, and exercise is important for maintaining good health. Lastly, it states that kindness can make a significant difference in someone's day.`); - - console.log("test 3"); - - // Run 10 prompts simultaneously - const promptResults = await Promise.all( - prompts.map(prompt => ad4mClient.ai.prompt(newTask.taskId, prompt)) - ); - - console.log("test 4", promptResults); - - // Check results - promptResults.forEach((result, index) => { - expect(result).to.be.a('string'); - expect(result.length).to.be.greaterThan(0); - console.log(`Prompt ${index + 1} result:`, result); - }); - - console.log("test 5"); - - // Clean up: remove the task - await ad4mClient.ai.removeTask(newTask.taskId); - - console.log("test 6"); - }) - - it('can embed text to vectors', async () => { - const ad4mClient = testContext.ad4mClient! - - let vector = await ad4mClient.ai.embed("bert", "Test string"); - expect(typeof vector).to.equal("object") - expect(Array.isArray(vector)).to.be.true - expect(vector.length).to.be.greaterThan(300) - }) - - it.skip('can do audio to text transcription', async() => { - const ad4mClient = testContext.ad4mClient!; - const audioData = await convertAudioToPCM('../transcription_test.m4a'); - let transcribedText = ''; - - // These should be the default parameters - const customParams = { - startThreshold: 0.3, - startWindow: 150, - endThreshold: 0.2, - endWindow: 300, - timeBeforeSpeech: 100 - }; - - const streamId = await ad4mClient.ai.openTranscriptionStream("Whisper", (text) => { - console.log("Received transcription:", text); - transcribedText += text; - }, customParams); - - // Type assertion for the feedTranscriptionStream function with unknown intermediate - const feedStream = (ad4mClient.ai.feedTranscriptionStream.bind(ad4mClient.ai) as unknown) as (streamIds: string | string[], audio: number[]) => Promise; - await streamAudioData(audioData, streamId, feedStream); - - try { - await ad4mClient.ai.closeTranscriptionStream(streamId); - } catch(e) { - console.log("Error trying to close TranscriptionStream:", e); - } - - await waitForTranscription(() => transcribedText.length > 0); - - // Assertions - expect(transcribedText).to.be.a('string'); - expect(transcribedText.length).to.be.greaterThan(0); - expect(transcribedText).to.include("If you can read this, transcription is working."); - console.log("Final transcription:", transcribedText); - }); - - it.skip('can do fast (word-by-word) audio transcription', async() => { - const ad4mClient = testContext.ad4mClient!; - const audioData = await convertAudioToPCM('../transcription_test.m4a'); - let transcribedWords: string[] = []; - - // Configure parameters for word-by-word detection - // Use very short end window and low thresholds to separate words - const wordByWordParams = { - startThreshold: 0.25, // Lower threshold to detect softer speech - startWindow: 100, // Quick start detection - endThreshold: 0.15, // Lower threshold to detect end of words - endWindow: 100, // Short pause between words (100ms) - timeBeforeSpeech: 20 // Include minimal context before speech - }; - - const streamId = await ad4mClient.ai.openTranscriptionStream("Whisper", (text) => { - console.log("Received word:", text); - if (text.trim()) { // Only add non-empty text - transcribedWords.push(text.trim()); - } - }, wordByWordParams); - - // Type assertion for the feedTranscriptionStream function with unknown intermediate - const feedStream = (ad4mClient.ai.feedTranscriptionStream.bind(ad4mClient.ai) as unknown) as (streamIds: string | string[], audio: number[]) => Promise; - await streamAudioData(audioData, streamId, feedStream); - - try { - await ad4mClient.ai.closeTranscriptionStream(streamId); - } catch(e) { - console.log("Error trying to close TranscriptionStream:", e); - } - - await waitForTranscription(() => transcribedWords.length > 0); - - // Assertions - expect(transcribedWords).to.be.an('array'); - expect(transcribedWords.length).to.be.greaterThan(1); - expect(transcribedWords.join(' ')).to.include("If you can read this, transcription is working"); - - console.log("Transcribed words:", transcribedWords); - }); - - it.skip('can do fast and accurate transcription simultaneously', async() => { - const ad4mClient = testContext.ad4mClient!; - const audioData = await convertAudioToPCM('../transcription_test.m4a'); - let fastTranscription = ''; - let accurateTranscription = ''; - - // Configure parameters for word-by-word detection (fast) - const fastParams = { - startThreshold: 0.25, - startWindow: 100, - endThreshold: 0.15, - endWindow: 100, - timeBeforeSpeech: 20 - }; - - // Configure parameters for accurate transcription - const accurateParams = { - startThreshold: 0.3, - startWindow: 150, - endThreshold: 0.2, - endWindow: 500, - timeBeforeSpeech: 100 - }; - - // Open two streams with different parameters - const fastStreamId = await ad4mClient.ai.openTranscriptionStream("whisper_tiny", (text) => { - console.log("Received fast transcription:", text); - fastTranscription += text; - }, fastParams); - - const accurateStreamId = await ad4mClient.ai.openTranscriptionStream("whisper_small", (text) => { - console.log("Received accurate transcription:", text); - accurateTranscription += text; - }, accurateParams); - - // Feed audio to both streams simultaneously - const feedStream = (ad4mClient.ai.feedTranscriptionStream.bind(ad4mClient.ai) as unknown) as (streamIds: string | string[], audio: number[]) => Promise; - await streamAudioData(audioData, [fastStreamId, accurateStreamId], feedStream); - - try { - await ad4mClient.ai.closeTranscriptionStream(fastStreamId); - await ad4mClient.ai.closeTranscriptionStream(accurateStreamId); - } catch(e) { - console.log("Error trying to close TranscriptionStream:", e); - } - - await waitForTranscription(() => fastTranscription.length > 0 && accurateTranscription.length > 0); - - // Assertions - expect(fastTranscription).to.be.a('string'); - expect(accurateTranscription).to.be.a('string'); - expect(fastTranscription.length).to.be.greaterThan(0); - expect(accurateTranscription.length).to.be.greaterThan(0); - expect(fastTranscription).to.include("If you can read this"); - expect(accurateTranscription).to.include("If you can read this, transcription is working"); - console.log("Fast transcription:", fastTranscription); - console.log("Accurate transcription:", accurateTranscription); - }); - }) - } -} \ No newline at end of file diff --git a/tests/js/tests/app.test.ts b/tests/js/tests/app.test.ts deleted file mode 100644 index b58842cb4..000000000 --- a/tests/js/tests/app.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import path from "path"; -import { Ad4mClient, CapabilityInput, AuthInfoInput } from "@coasys/ad4m"; -import fs from "fs"; -import { fileURLToPath } from 'url'; -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { apolloClient, sleep, startExecutor } from "../utils/utils"; -import fetch from 'node-fetch' -import { ChildProcess } from "child_process"; - -//@ts-ignore -global.fetch = fetch - -const expect = chai.expect; -chai.use(chaiAsPromised); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Apps integration tests", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "apps-agent"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15000 - const hcAdminPort = 15001 - const hcAppPort = 15002 - - let adminAd4mClient: Ad4mClient | null = null - let unAuthenticatedAppAd4mClient: Ad4mClient | null = null - let requestId: string; - - let executorProcess: ChildProcess | null = null - - before(async () => { - if(!fs.existsSync(TEST_DIR)) { - throw Error("Please ensure that prepare-test is run before running tests!"); - } - if(!fs.existsSync(path.join(TEST_DIR, 'agents'))) - fs.mkdirSync(path.join(TEST_DIR, 'agents')) - if(!fs.existsSync(appDataPath)) - fs.mkdirSync(appDataPath) - - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort , false, "123"); - - adminAd4mClient = new Ad4mClient(apolloClient(gqlPort, "123"), false) - await adminAd4mClient.agent.generate("passphrase") - - unAuthenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort), false) - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - }) - - it("once token issued user can get all authenticated apps", async () => { - requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["*"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appDomain": "test.ad4m.org","appUrl":"https://demo-link","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["*"]}]}}`) - let jwt = await adminAd4mClient!.agent.generateJwt(requestId, rand) - - let authenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort, jwt), false) - - const call = async () => { - return await authenticatedAppAd4mClient!.agent.getApps(); - } - - await expect((await call()).length).to.be.equal(1); - }); - - it("can revoke token", async () => { - const oldApps = await adminAd4mClient!.agent.getApps(); - - expect(oldApps.length).to.be.equal(1); - expect(oldApps[0].revoked).to.be.false; - - const newApps = await adminAd4mClient!.agent.revokeToken(requestId); - - expect(newApps.length).to.be.equal(1); - expect(newApps[0].revoked).to.be.equal(true); - - // check if the app can request another token. - requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["*"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appDomain":"test.ad4m.org","appUrl":"https://demo-link","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["*"]}]}}`) - let jwt = await adminAd4mClient!.agent.generateJwt(requestId, rand) - - let authenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort, jwt), false) - - const call = async () => { - return await authenticatedAppAd4mClient!.agent.getApps(); - } - - await expect((await call()).length).to.be.equal(2); - }); - - it("can remove apps", async () => { - const oldApps = await adminAd4mClient!.agent.getApps(); - - expect(oldApps.length).to.be.equal(2); - - const newApps = await adminAd4mClient!.agent.removeApp(requestId); - - expect(newApps.length).to.be.equal(1); - - // check if the app can request another token. - requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["*"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appDomain":"test.ad4m.org","appUrl":"https://demo-link","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["*"]}]}}`) - let jwt = await adminAd4mClient!.agent.generateJwt(requestId, rand) - - // @ts-ignore - let authenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort, jwt), false) - - const call = async () => { - return await authenticatedAppAd4mClient!.agent.getApps(); - } - - await expect((await call()).length).to.be.equal(2); - }); -}) diff --git a/tests/js/tests/auth/auth-app.test.ts b/tests/js/tests/auth/auth-app.test.ts new file mode 100644 index 000000000..a350a1cc5 --- /dev/null +++ b/tests/js/tests/auth/auth-app.test.ts @@ -0,0 +1,144 @@ +import { Ad4mClient, CapabilityInput, AuthInfoInput } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient } from "../../utils/utils"; +import { startAgent, connectClient, AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Apps integration tests", () => { + let agent: AgentHandle; + let adminAd4mClient: Ad4mClient; + let unAuthenticatedAppAd4mClient: Ad4mClient; + let requestId: string; + + before(async () => { + agent = await startAgent("apps-agent", { adminCredential: "123" }); + adminAd4mClient = agent.client; + unAuthenticatedAppAd4mClient = connectClient(agent.gqlPort); + }); + + after(async () => { + await agent.stop(); + }); + + it("once token issued user can get all authenticated apps", async () => { + requestId = await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["*"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appDomain": "test.ad4m.org","appUrl":"https://demo-link","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["*"]}]}}`, + ); + let jwt = await adminAd4mClient.agent.generateJwt(requestId, rand); + + let authenticatedAppAd4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, jwt), + false, + ); + + const call = async () => { + return await authenticatedAppAd4mClient.agent.getApps(); + }; + + await expect((await call()).length).to.be.equal(1); + }); + + it("can revoke token", async () => { + const oldApps = await adminAd4mClient.agent.getApps(); + + expect(oldApps.length).to.be.equal(1); + expect(oldApps[0].revoked).to.be.false; + + const newApps = await adminAd4mClient.agent.revokeToken(requestId); + + expect(newApps.length).to.be.equal(1); + expect(newApps[0].revoked).to.be.equal(true); + + // check if the app can request another token. + requestId = await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["*"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appDomain":"test.ad4m.org","appUrl":"https://demo-link","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["*"]}]}}`, + ); + let jwt = await adminAd4mClient.agent.generateJwt(requestId, rand); + + let authenticatedAppAd4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, jwt), + false, + ); + + const call = async () => { + return await authenticatedAppAd4mClient.agent.getApps(); + }; + + await expect((await call()).length).to.be.equal(2); + }); + + it("can remove apps", async () => { + const oldApps = await adminAd4mClient.agent.getApps(); + + expect(oldApps.length).to.be.equal(2); + + const newApps = await adminAd4mClient.agent.removeApp(requestId); + + expect(newApps.length).to.be.equal(1); + + // check if the app can request another token. + requestId = await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["*"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appDomain":"test.ad4m.org","appUrl":"https://demo-link","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["*"]}]}}`, + ); + let jwt = await adminAd4mClient.agent.generateJwt(requestId, rand); + + let authenticatedAppAd4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, jwt), + false, + ); + + const call = async () => { + return await authenticatedAppAd4mClient.agent.getApps(); + }; + + await expect((await call()).length).to.be.equal(2); + }); +}); diff --git a/tests/js/tests/auth/auth-core.test.ts b/tests/js/tests/auth/auth-core.test.ts new file mode 100644 index 000000000..eaedd4867 --- /dev/null +++ b/tests/js/tests/auth/auth-core.test.ts @@ -0,0 +1,327 @@ +import { Ad4mClient, AuthInfoInput, CapabilityInput } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient, sleep } from "../../utils/utils"; +import { startAgent, connectClient, AgentHandle } from "../../helpers/executor"; +import { ExceptionInfo } from "@coasys/ad4m/lib/src/runtime/RuntimeResolver"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Authentication integration tests", () => { + describe("admin credential is not set", () => { + let agent: AgentHandle; + let ad4mClient: Ad4mClient; + + before(async () => { + agent = await startAgent("unauth-agent"); + ad4mClient = agent.client; + }); + + after(async () => { + await agent.stop(); + }); + + it("unauthenticated user has all the capabilities", async () => { + let status = await ad4mClient.agent.status(); + expect(status.isUnlocked).to.be.true; + + let requestId = await ad4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + expect(requestId).match(/.+/); + + let rand = await ad4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`, + ); + expect(rand).match(/\d+/); + + let jwt = await ad4mClient.agent.generateJwt(requestId, rand); + expect(jwt).match(/.+/); + }); + }); + + describe("admin credential is set", () => { + let agent: AgentHandle; + let adminAd4mClient: Ad4mClient; + let unAuthenticatedAppAd4mClient: Ad4mClient; + + before(async () => { + agent = await startAgent("agent1", { adminCredential: "123" }); + adminAd4mClient = agent.client; + unAuthenticatedAppAd4mClient = connectClient(agent.gqlPort); + }); + + after(async () => { + await agent.stop(); + }); + + it("unauthenticated user can not query agent status", async () => { + const call = async () => { + return await unAuthenticatedAppAd4mClient.agent.status(); + }; + + await expect(call()).to.be.rejectedWith("Capability is not matched"); + }); + + it("unauthenticated user can request capability", async () => { + const call = async () => { + return await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + }; + + expect(await call()).to.be.ok.match(/.+/); + }); + + it("admin user can permit capability", async () => { + let requestId = + await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + const call = async () => { + return await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`, + ); + }; + + expect(await call()).to.be.ok.match(/\d+/); + }); + + it("unauthenticated user can generate jwt with a secret", async () => { + let requestId = + await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`, + ); + + const call = async () => { + return await adminAd4mClient.agent.generateJwt(requestId, rand); + }; + + expect(await call()).to.be.ok.match(/.+/); + }); + + it("authenticated user can query agent status if capability matched", async () => { + let requestId = + await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`, + ); + let jwt = await adminAd4mClient.agent.generateJwt(requestId, rand); + + let authenticatedAppAd4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, jwt), + false, + ); + expect((await authenticatedAppAd4mClient.agent.status()).isUnlocked).to.be + .true; + }); + + it("user with invalid jwt can not query agent status", async () => { + let ad4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, "invalid-jwt"), + false, + ); + + const call = async () => { + return await ad4mClient.agent.status(); + }; + + await expect(call()).to.be.rejectedWith("InvalidToken"); + }); + + it("authenticated user can not query agent status if capability is not matched", async () => { + let requestId = + await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["CREATE"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["CREATE"]}]}}`, + ); + let jwt = await adminAd4mClient.agent.generateJwt(requestId, rand); + + let authenticatedAppAd4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, jwt), + false, + ); + + const call = async () => { + return await authenticatedAppAd4mClient.agent.status(); + }; + + await expect(call()).to.be.rejectedWith("Capability is not matched"); + }); + + it("user with revoked token can not query agent status", async () => { + let requestId = + await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + let rand = await adminAd4mClient.agent.permitCapability( + `{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`, + ); + let jwt = await adminAd4mClient.agent.generateJwt(requestId, rand); + + let authenticatedAppAd4mClient = new Ad4mClient( + apolloClient(agent.gqlPort, jwt), + false, + ); + expect((await authenticatedAppAd4mClient.agent.status()).isUnlocked).to.be + .true; + + let oldApps = await adminAd4mClient.agent.getApps(); + let newApps = await adminAd4mClient.agent.revokeToken(requestId); + // revoking token should not change the number of apps + expect(newApps.length).to.be.equal(oldApps.length); + newApps.forEach((app, i) => { + if (app.requestId === requestId) { + expect(app.revoked).to.be.true; + } + }); + + const call = async () => { + return await authenticatedAppAd4mClient.agent.status(); + }; + + await expect(call()).to.be.rejectedWith("Unauthorized access"); + }); + + it("requesting a capability toke should trigger a CapabilityRequested exception", async () => { + let excpetions: ExceptionInfo[] = []; + adminAd4mClient.runtime.addExceptionCallback((e) => { + excpetions.push(e); + return null; + }); + // Note: subscribeExceptionOccurred() is already called by the Ad4mClient + // constructor — calling it again creates a second subscription and doubles + // every event delivery. + + // Allow in-flight events to settle before making our call + await sleep(500); + + let requestId = + await unAuthenticatedAppAd4mClient.agent.requestCapability({ + appName: "demo-app", + appDesc: "demo-desc", + appDomain: "test.ad4m.org", + appUrl: "https://demo-link", + capabilities: [ + { + with: { + domain: "agent", + pointers: ["*"], + }, + can: ["READ"], + }, + ] as CapabilityInput[], + } as AuthInfoInput); + + await sleep(1000); + + // Filter to only exceptions matching this specific request — stale + // CAPABILITY_REQUESTED events from previous tests are ignored. + const matching = excpetions.filter((e) => { + if (e.type !== "CAPABILITY_REQUESTED") return false; + try { + return JSON.parse(e.addon!).requestId === requestId; + } catch { + return false; + } + }); + + expect(matching.length).to.be.equal(1); + expect(matching[0].type).to.be.equal("CAPABILITY_REQUESTED"); + let auth_info = JSON.parse(matching[0].addon!); + expect(auth_info.requestId).to.be.equal(requestId); + }); + }); +}); diff --git a/tests/js/tests/auth/auth-email-verification.test.ts b/tests/js/tests/auth/auth-email-verification.test.ts new file mode 100644 index 000000000..9a23c968f --- /dev/null +++ b/tests/js/tests/auth/auth-email-verification.test.ts @@ -0,0 +1,620 @@ +import path from "path"; +import { Ad4mClient } from "@coasys/ad4m"; +import fs from "fs-extra"; +import { fileURLToPath } from "url"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { + apolloClient, + sleep, + startExecutor, + runHcLocalServices, + waitForExit, +} from "../../utils/utils"; +import { getFreePorts } from "../../helpers/ports"; +import { ChildProcess } from "node:child_process"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Email Verification Flow Tests with Mock Email Service + * + * These tests use a mock email service that captures verification codes + * instead of actually sending emails. This allows testing the full email + * verification flow without requiring SMTP configuration. + */ +describe("Email Verification with Mock Service", () => { + const TEST_DIR = path.join(`${__dirname}/../../tst-tmp`); + const appDataPath = path.join(TEST_DIR, "agents", "email-verification-agent"); + const bootstrapSeedPath = path.join(`${__dirname}/../../bootstrapSeed.json`); + let gqlPort: number; + let hcAdminPort: number; + let hcAppPort: number; + + let executorProcess: ChildProcess | null = null; + let adminAd4mClient: Ad4mClient | null = null; + + let proxyUrl: string | null = null; + let bootstrapUrl: string | null = null; + let localServicesProcess: ChildProcess | null = null; + + before(async () => { + [gqlPort, hcAdminPort, hcAppPort] = await getFreePorts(3); + + if (!fs.existsSync(appDataPath)) { + fs.mkdirSync(appDataPath, { recursive: true }); + } + + // Start local Holochain services + let localServices = await runHcLocalServices(); + proxyUrl = localServices.proxyUrl; + bootstrapUrl = localServices.bootstrapUrl; + localServicesProcess = localServices.process; + + // Start executor with local services + executorProcess = await startExecutor( + appDataPath, + bootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + ); + + adminAd4mClient = new Ad4mClient(apolloClient(gqlPort), false); + + // Generate initial admin agent (needed for JWT signing) + await adminAd4mClient.agent.generate("passphrase"); + + // Enable multi-user mode + await adminAd4mClient.runtime.setMultiUserEnabled(true); + + // Enable email test mode - THIS IS KEY! + await adminAd4mClient.runtime.emailTestModeEnable(); + console.log("✅ Email test mode enabled - codes will be captured"); + }); + + after(async () => { + if (adminAd4mClient) { + try { + await adminAd4mClient.runtime.emailTestModeDisable(); + } catch (e) { + console.error("Failed to disable email test mode:", e); + } + } + + await waitForExit(executorProcess); + await waitForExit(localServicesProcess); + }); + + beforeEach(async () => { + // Clear codes before each test + await adminAd4mClient!.runtime.emailTestClearCodes(); + }); + + describe("Email Verification Flow - New User Signup", () => { + it("should complete full signup flow with email verification", async () => { + const email = "newuser@example.com"; + const password = "SecurePass123!"; + + console.log("\n🔹 Step 1: Request login verification for new user"); + const verifyRequest = + await adminAd4mClient!.agent.requestLoginVerification(email); + + expect(verifyRequest.success).to.be.true; + expect(verifyRequest.requiresPassword).to.be.true; // New user + expect(verifyRequest.isExistingUser).to.be.false; // New user + console.log( + `✅ Result: success=${verifyRequest.success}, requiresPassword=${verifyRequest.requiresPassword}, isExistingUser=${verifyRequest.isExistingUser}`, + ); + console.log(` Message: ${verifyRequest.message}`); + + console.log("\n🔹 Step 2: Create user (triggers verification email)"); + const createResult = await adminAd4mClient!.agent.createUser( + email, + password, + ); + + expect(createResult.success).to.be.true; + expect(createResult.did).to.be.a("string"); + console.log(`✅ User created with DID: ${createResult.did}`); + + console.log("\n🔹 Step 3: Retrieve captured verification code"); + const code = await adminAd4mClient!.runtime.emailTestGetCode(email); + + expect(code).to.exist; + expect(code).to.be.a("string"); + expect(code!.length).to.equal(6); + expect(/^\d{6}$/.test(code!)).to.be.true; // Must be 6 digits + console.log(`✅ Captured code: ${code}`); + + console.log("\n🔹 Step 4: Verify email with code"); + const token = await adminAd4mClient!.agent.verifyEmailCode( + email, + code!, + "signup", + ); + + expect(token).to.be.a("string"); + expect(token.length).to.be.greaterThan(0); + console.log(`✅ JWT token received: ${token.substring(0, 20)}...`); + + console.log("\n✅ Full signup flow completed successfully!"); + }); + + it("should reject invalid verification codes", async () => { + const email = "invalid-code@example.com"; + const password = "SecurePass123!"; + + // Create user + await adminAd4mClient!.agent.createUser(email, password); + + // Get the real code (but don't use it) + const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(realCode).to.exist; + + // Try to verify with wrong code + try { + await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup"); + expect.fail("Should have thrown error for invalid code"); + } catch (e: any) { + expect(e.message).to.include("Invalid"); + console.log(`✅ Invalid code correctly rejected: ${e.message}`); + } + }); + + it("should enforce code expiration (15 minutes)", async () => { + const email = "expiry@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const code = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code).to.exist; + + // Verify the code works when fresh + const token = await adminAd4mClient!.agent.verifyEmailCode( + email, + code!, + "signup", + ); + expect(token).to.exist; + console.log("✅ Fresh code works"); + + // Create a new code for expiration testing + await adminAd4mClient!.agent.requestLoginVerification(email); + const expiredCode = + await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(expiredCode).to.exist; + + // Simulate expiration by setting expiry time to past (1 hour ago) + const pastTimestamp = Math.floor(Date.now() / 1000) - 60 * 60; + await adminAd4mClient!.runtime.emailTestSetExpiry( + email, + "login", + pastTimestamp, + ); + + // Try to verify with expired code - should fail + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + expiredCode!, + "login", + ); + expect.fail("Should have thrown error for expired code"); + } catch (e: any) { + expect(e.message).to.match(/Invalid|expired|Expired/i); + console.log(`✅ Expired code correctly rejected: ${e.message}`); + } + }); + }); + + describe("Email Verification Flow - Existing User Login", () => { + const existingEmail = "existing@example.com"; + const existingPassword = "ExistingPass123!"; + + before(async () => { + // Create and verify a user first + await adminAd4mClient!.agent.createUser(existingEmail, existingPassword); + const signupCode = + await adminAd4mClient!.runtime.emailTestGetCode(existingEmail); + await adminAd4mClient!.agent.verifyEmailCode( + existingEmail, + signupCode!, + "signup", + ); + await adminAd4mClient!.runtime.emailTestClearCodes(); + }); + + it("should complete login flow with email verification (passwordless)", async () => { + console.log("\n🔹 Step 1: Request login verification for existing user"); + const verifyRequest = + await adminAd4mClient!.agent.requestLoginVerification(existingEmail); + + expect(verifyRequest.success).to.be.true; + expect(verifyRequest.requiresPassword).to.be.false; // Existing user + expect(verifyRequest.isExistingUser).to.be.true; // Existing user + console.log( + `✅ Result: success=${verifyRequest.success}, requiresPassword=${verifyRequest.requiresPassword}, isExistingUser=${verifyRequest.isExistingUser}`, + ); + + console.log("\n🔹 Step 2: Retrieve captured verification code"); + const code = + await adminAd4mClient!.runtime.emailTestGetCode(existingEmail); + + expect(code).to.exist; + expect(code).to.be.a("string"); + expect(code!.length).to.equal(6); + console.log(`✅ Captured login code: ${code}`); + + console.log("\n🔹 Step 3: Verify code for login"); + const token = await adminAd4mClient!.agent.verifyEmailCode( + existingEmail, + code!, + "login", + ); + + expect(token).to.be.a("string"); + expect(token.length).to.be.greaterThan(0); + console.log(`✅ Logged in with JWT: ${token.substring(0, 20)}...`); + + console.log("\n✅ Passwordless login flow completed!"); + }); + + it("should still support password-based login (backwards compatibility)", async () => { + // Even with email verification enabled, password login should work + const token = await adminAd4mClient!.agent.loginUser( + existingEmail, + existingPassword, + ); + + expect(token).to.be.a("string"); + expect(token.length).to.be.greaterThan(0); + + console.log("✅ Password-based login still works (backwards compatible)"); + }); + }); + + describe("Rate Limiting", () => { + it("should enforce rate limiting on verification requests", async () => { + const email = "ratelimit@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + // First verification code + const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); + await adminAd4mClient!.agent.verifyEmailCode(email, code1!, "signup"); + + // Request login verification + const request1 = + await adminAd4mClient!.agent.requestLoginVerification(email); + expect(request1.success).to.be.true; + + // Immediately try again - should be rate limited + const request2 = + await adminAd4mClient!.agent.requestLoginVerification(email); + expect(request2.success).to.be.false; + expect(request2.message).to.match(/rate limit|too many|wait/i); + console.log(`✅ Rate limiting enforced: ${request2.message}`); + }); + }); + + describe("Mock Service Functionality", () => { + it("should capture codes for multiple users simultaneously", async () => { + const users = [ + { email: "user1@example.com", password: "pass1" }, + { email: "user2@example.com", password: "pass2" }, + { email: "user3@example.com", password: "pass3" }, + ]; + + // Create all users + for (const user of users) { + await adminAd4mClient!.agent.createUser(user.email, user.password); + } + + // Retrieve all codes + for (const user of users) { + const code = await adminAd4mClient!.runtime.emailTestGetCode( + user.email, + ); + expect(code).to.exist; + expect(code!.length).to.equal(6); + console.log(`✅ ${user.email} -> code: ${code}`); + } + + console.log("✅ Multiple users handled simultaneously"); + }); + + it("should clear codes when requested", async () => { + const email = "cleartest@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + let code = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code).to.exist; + + await adminAd4mClient!.runtime.emailTestClearCodes(); + + code = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code).to.be.null; + + console.log("✅ Codes cleared successfully"); + }); + + it("should have test mode enabled during tests", async () => { + // Verify test mode is active by checking we can capture codes + const email = "testmode@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const code = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code).to.exist; // Only possible if test mode is enabled + + console.log("✅ Test mode is active and working"); + }); + }); + + describe("Security Features", () => { + it("should generate unique codes for each request", async () => { + const email = "uniquecodes@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); + await adminAd4mClient!.agent.verifyEmailCode(email, code1!, "signup"); + + // Request new code + await adminAd4mClient!.agent.requestLoginVerification(email); + await sleep(100); // Small delay to ensure new code generation + + const code2 = await adminAd4mClient!.runtime.emailTestGetCode(email); + + // Codes should be different + expect(code1).to.not.equal(code2); + console.log(`✅ Unique codes: ${code1} != ${code2}`); + }); + + it("should only allow code to be used once (single-use)", async () => { + const email = "singleuse@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const code = await adminAd4mClient!.runtime.emailTestGetCode(email); + + // Use code once + await adminAd4mClient!.agent.verifyEmailCode(email, code!, "signup"); + + // Try to use same code again + try { + await adminAd4mClient!.agent.verifyEmailCode(email, code!, "signup"); + expect.fail("Should not allow reusing the same code"); + } catch (e: any) { + console.log(`✅ Single-use enforced: ${e.message}`); + } + }); + + describe("Failed Attempt Rate Limiting", () => { + it("should track failed verification attempts", async () => { + const email = "failedattempts@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(realCode).to.exist; + + // Try wrong codes multiple times + for (let i = 1; i <= 4; i++) { + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + "000000", + "signup", + ); + expect.fail(`Should have failed on attempt ${i}`); + } catch (e: any) { + expect(e.message).to.match(/Invalid|expired/i); + console.log(`✅ Failed attempt ${i} correctly rejected`); + } + } + + // Code should still be valid (not yet invalidated) + const stillValid = + await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(stillValid).to.equal(realCode); + console.log("✅ Code still valid after 4 failed attempts"); + }); + + it("should invalidate code after 5 failed attempts", async () => { + const email = "invalidate@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(realCode).to.exist; + + // Make 5 failed attempts + for (let i = 1; i <= 5; i++) { + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + "000000", + "signup", + ); + expect.fail(`Should have failed on attempt ${i}`); + } catch (e: any) { + if (i < 5) { + // First 4 attempts should just say invalid + expect(e.message).to.match(/Invalid|expired/i); + console.log(`✅ Failed attempt ${i} rejected`); + } else { + // 5th attempt should indicate code is invalidated + expect(e.message).to.match(/invalidated|too many/i); + console.log( + `✅ Code invalidated after ${i} failed attempts: ${e.message}`, + ); + } + } + } + + // Code should be deleted/invalidated + const codeAfterInvalidation = + await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(codeAfterInvalidation).to.be.null; + console.log("✅ Code successfully invalidated and removed"); + + // Even the real code should not work anymore + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + realCode!, + "signup", + ); + expect.fail("Should not accept code after invalidation"); + } catch (e: any) { + expect(e.message).to.match(/Invalid|expired|invalidated/i); + console.log( + `✅ Real code correctly rejected after invalidation: ${e.message}`, + ); + } + }); + + it("should require new code request after invalidation", async () => { + const email = "newcode@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code1).to.exist; + + // Invalidate the code with 5 failed attempts + for (let i = 1; i <= 5; i++) { + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + "000000", + "signup", + ); + } catch (e: any) { + // Expected to fail + } + } + + // Verify code is gone + const codeAfterInvalidation = + await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(codeAfterInvalidation).to.be.null; + + // Request a new code + const verifyRequest = + await adminAd4mClient!.agent.requestLoginVerification(email); + expect(verifyRequest.success).to.be.true; + + // Get the new code + const code2 = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code2).to.exist; + expect(code2).to.not.equal(code1); + console.log(`✅ New code generated: ${code1} -> ${code2}`); + + // New code should work + const token = await adminAd4mClient!.agent.verifyEmailCode( + email, + code2!, + "login", + ); + expect(token).to.be.a("string"); + expect(token.length).to.be.greaterThan(0); + console.log("✅ New code works after invalidation"); + }); + + it("should reset failed attempts counter on successful verification", async () => { + const email = "reset@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code1).to.exist; + + // Make 3 failed attempts + for (let i = 1; i <= 3; i++) { + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + "000000", + "signup", + ); + expect.fail(`Should have failed on attempt ${i}`); + } catch (e: any) { + expect(e.message).to.match(/Invalid|expired/i); + } + } + + // Now verify with correct code - should work + const token = await adminAd4mClient!.agent.verifyEmailCode( + email, + code1!, + "signup", + ); + expect(token).to.be.a("string"); + console.log("✅ Correct code works after failed attempts"); + + // Request a new code for login + await adminAd4mClient!.agent.requestLoginVerification(email); + const code2 = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(code2).to.exist; + + // Failed attempts counter should be reset (we can make 5 more attempts) + for (let i = 1; i <= 4; i++) { + try { + await adminAd4mClient!.agent.verifyEmailCode( + email, + "000000", + "login", + ); + expect.fail(`Should have failed on attempt ${i}`); + } catch (e: any) { + expect(e.message).to.match(/Invalid|expired/i); + } + } + + // Code should still be valid + const stillValid = + await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(stillValid).to.equal(code2); + console.log( + "✅ Failed attempts counter reset after successful verification", + ); + }); + + it("should handle concurrent failed attempts correctly", async () => { + const email = "concurrent@example.com"; + await adminAd4mClient!.agent.createUser(email, "password123"); + + const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(realCode).to.exist; + + // Make multiple failed attempts concurrently + const promises = Array.from({ length: 5 }, () => + adminAd4mClient!.agent + .verifyEmailCode(email, "000000", "signup") + .catch((e) => e), + ); + + const results = await Promise.all(promises); + + // All should fail + results.forEach((result, i) => { + if (result instanceof Error) { + expect(result.message).to.match(/Invalid|expired|invalidated/i); + } else { + expect.fail(`Attempt ${i + 1} should have failed`); + } + }); + + // Code should be invalidated + const codeAfterInvalidation = + await adminAd4mClient!.runtime.emailTestGetCode(email); + expect(codeAfterInvalidation).to.be.null; + console.log( + "✅ Concurrent failed attempts correctly handled and code invalidated", + ); + }); + }); + }); +}); diff --git a/tests/js/tests/authentication.test.ts b/tests/js/tests/authentication.test.ts deleted file mode 100644 index cea0b66e5..000000000 --- a/tests/js/tests/authentication.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import path from "path"; -import { Ad4mClient, AuthInfoInput, CapabilityInput } from "@coasys/ad4m"; -import fs from "fs-extra"; -import { fileURLToPath } from 'url'; -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { apolloClient, sleep, startExecutor } from "../utils/utils"; -import { ChildProcess } from 'node:child_process'; -import fetch from 'node-fetch' -import { ExceptionInfo } from "@coasys/ad4m/lib/src/runtime/RuntimeResolver"; - -//@ts-ignore -global.fetch = fetch - -const expect = chai.expect; -chai.use(chaiAsPromised); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Authentication integration tests", () => { - describe("admin credential is not set", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "unauth-agent"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15100 - const hcAdminPort = 15101 - const hcAppPort = 15102 - - let executorProcess: ChildProcess | null = null - let ad4mClient: Ad4mClient | null = null - - before(async () => { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }); - } - - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort); - - ad4mClient = new Ad4mClient(apolloClient(gqlPort), false) - await ad4mClient.agent.generate("passphrase") - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - }) - - it("unauthenticated user has all the capabilities", async () => { - let status = await ad4mClient!.agent.status() - expect(status.isUnlocked).to.be.true; - - let requestId = await ad4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - expect(requestId).match(/.+/); - - let rand = await ad4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`) - expect(rand).match(/\d+/); - - let jwt = await ad4mClient!.agent.generateJwt(requestId, rand) - expect(jwt).match(/.+/); - }) - }) - - describe("admin credential is set", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "auth-agent"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15200 - const hcAdminPort = 15202 - const hcAppPort = 15203 - - let executorProcess: ChildProcess | null = null - let adminAd4mClient: Ad4mClient | null = null - let unAuthenticatedAppAd4mClient: Ad4mClient | null = null - - before(async () => { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }); - } - - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort, false, "123"); - - adminAd4mClient = new Ad4mClient(apolloClient(gqlPort, "123"), false) - await adminAd4mClient.agent.generate("passphrase") - - unAuthenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort), false) - }) - - after(async () => { - if (executorProcess) { - executorProcess.kill() - } - }) - - it("unauthenticated user can not query agent status", async () => { - const call = async () => { - return await unAuthenticatedAppAd4mClient!.agent.status() - } - - await expect(call()).to.be.rejectedWith("Capability is not matched"); - }) - - it("unauthenticated user can request capability", async () => { - const call = async () => { - return await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - } - - expect(await call()).to.be.ok.match(/.+/); - }) - - it("admin user can permit capability", async () => { - let requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - const call = async () => { - return await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`) - } - - expect(await call()).to.be.ok.match(/\d+/); - }) - - it("unauthenticated user can generate jwt with a secret", async () => { - let requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`) - - const call = async () => { - return await adminAd4mClient!.agent.generateJwt(requestId, rand) - } - - expect(await call()).to.be.ok.match(/.+/); - }) - - it("authenticated user can query agent status if capability matched", async () => { - let requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`) - let jwt = await adminAd4mClient!.agent.generateJwt(requestId, rand) - - // @ts-ignore - let authenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort, jwt), false) - expect((await authenticatedAppAd4mClient!.agent.status()).isUnlocked).to.be.true; - }) - - it("user with invalid jwt can not query agent status", async () => { - // @ts-ignore - let ad4mClient = new Ad4mClient(apolloClient(gqlPort, "invalid-jwt"), false) - - const call = async () => { - return await ad4mClient!.agent.status() - } - - await expect(call()).to.be.rejectedWith("InvalidToken"); - }) - - it("authenticated user can not query agent status if capability is not matched", async () => { - let requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["CREATE"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["CREATE"]}]}}`) - let jwt = await adminAd4mClient!.agent.generateJwt(requestId, rand) - - // @ts-ignore - let authenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort, jwt), false) - - const call = async () => { - return await authenticatedAppAd4mClient!.agent.status() - } - - await expect(call()).to.be.rejectedWith("Capability is not matched"); - }) - - it("user with revoked token can not query agent status", async () => { - let requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - let rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"demo-app","appDesc":"demo-desc","appUrl":"demo-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`) - let jwt = await adminAd4mClient!.agent.generateJwt(requestId, rand) - - // @ts-ignore - let authenticatedAppAd4mClient = new Ad4mClient(apolloClient(gqlPort, jwt), false) - expect((await authenticatedAppAd4mClient!.agent.status()).isUnlocked).to.be.true; - - let oldApps = await adminAd4mClient!.agent.getApps(); - let newApps = await adminAd4mClient!.agent.revokeToken(requestId); - // revoking token should not change the number of apps - expect(newApps.length).to.be.equal(oldApps.length); - newApps.forEach((app, i) => { - if(app.requestId === requestId) { - expect(app.revoked).to.be.true; - } - }) - - const call = async () => { - return await authenticatedAppAd4mClient!.agent.status() - } - - await expect(call()).to.be.rejectedWith("Unauthorized access"); - }) - - it("requesting a capability toke should trigger a CapabilityRequested exception", async () => { - let excpetions: ExceptionInfo[] = []; - adminAd4mClient!.runtime.addExceptionCallback((e) => { excpetions.push(e); return null; }) - adminAd4mClient!.runtime.subscribeExceptionOccurred(); - - await sleep(1000); - - let requestId = await unAuthenticatedAppAd4mClient!.agent.requestCapability({ - appName: "demo-app", - appDesc: "demo-desc", - appDomain: "test.ad4m.org", - appUrl: "https://demo-link", - capabilities: [ - { - with: { - domain:"agent", - pointers:["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput) - - await sleep(1000); - - expect(excpetions.length).to.be.equal(1); - expect(excpetions[0].type).to.be.equal("CAPABILITY_REQUESTED"); - let auth_info = JSON.parse(excpetions[0].addon!); - expect(auth_info.requestId).to.be.equal(requestId); - }) - }) -}) diff --git a/tests/js/tests/direct-messages.ts b/tests/js/tests/direct-messages.ts deleted file mode 100644 index 58d737e6a..000000000 --- a/tests/js/tests/direct-messages.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ExpressionProof, Link, LinkExpressionInput, Literal, Perspective } from '@coasys/ad4m' -import { TestContext } from './integration.test' -import { sleep } from '../utils/utils' -import { expect } from "chai"; -import * as sinon from "sinon"; - -export default function directMessageTests(testContext: TestContext) { - return () => { - let link = new LinkExpressionInput() - link.author = "did:test"; - link.timestamp = new Date().toISOString(); - link.data = new Link({ - source: Literal.from("me").toUrl(), - predicate: Literal.from("thinks").toUrl(), - target: Literal.from("nothing").toUrl() - }); - link.proof = new ExpressionProof("sig", "key"); - const message = new Perspective([link]) - - it("don't work when we're not friends", async () => { - const alice = testContext.alice! - const bob = testContext.bob! - const { did } = await bob.agent.status() - - let hasThrown = false - try{ - await alice.runtime.friendStatus(did!) - }catch(e) { - hasThrown = true - } - - expect(hasThrown).to.be.true; - }) - - describe("with Alice and Bob being friends", () => { - //@ts-ignore - let alice, bob, didAlice, didBob - - before(async () => { - alice = testContext.alice! - didAlice = (await alice.agent.status()).did - bob = testContext.bob! - didBob = (await bob.agent.status()).did - - await alice.runtime.addFriends([didBob!]) - await bob.runtime.addFriends([didAlice!]) - }) - - it("Alice can get Bob's status", async () => { - let link = new LinkExpressionInput() - link.author = "did:test"; - link.timestamp = new Date().toISOString(); - link.data = new Link({ - //@ts-ignore - source: didBob, - predicate: Literal.from("is").toUrl(), - target: Literal.from("online").toUrl() - }); - link.proof = new ExpressionProof("sig", "key"); - const statusBob = new Perspective([link]) - //@ts-ignore - await bob.runtime.setStatus(statusBob) - await sleep(1000) - //@ts-ignore - const statusAlice = await alice.runtime.friendStatus(didBob) - expect(statusAlice).not.to.be.undefined; - delete statusAlice.data.links[0].proof.invalid - delete statusAlice.data.links[0].proof.valid - expect(statusAlice.data).to.be.eql(statusBob) - }) - - it("Alice can send a message to Bob", async () => { - const bobMessageCallback = sinon.fake() - //@ts-ignore - await bob.runtime.addMessageCallback(bobMessageCallback) - //@ts-ignore - await alice.runtime.friendSendMessage(didBob, message) - await sleep(1000) - //@ts-ignore - const bobsInbox = await bob.runtime.messageInbox() - expect(bobsInbox.length).to.be.equal(1) - - expect(bobMessageCallback.calledOnce).to.be.true; - expect(bobMessageCallback.getCall(0).args[0]).to.be.eql(bobsInbox[0]) - - delete bobsInbox[0].data.links[0].proof.invalid - delete bobsInbox[0].data.links[0].proof.valid - expect(bobsInbox[0].data).to.be.eql(message) - - //@ts-ignore - expect((await bob.runtime.messageInbox(didAlice)).length).to.be.equal(1) - //@ts-ignore - expect((await bob.runtime.messageInbox("did:test:other")).length).to.be.equal(0) - - }) - - it("Alice finds her sent message in the outbox", async () => { - //@ts-ignore - const outbox = await alice.runtime.messageOutbox() - expect(outbox.length).to.be.equal(1) - //@ts-ignore - expect(outbox[0].recipient).to.be.equal(didBob) - delete outbox[0].message.data.links[0].proof.invalid - delete outbox[0].message.data.links[0].proof.valid - expect(outbox[0].message.data).to.be.eql(message) - - //@ts-ignore - const filteredOutbox = await alice.runtime.messageOutbox("did:test:other") - expect(filteredOutbox.length).to.be.equal(0) - }) - }) - } -} diff --git a/tests/js/tests/email-verification.test.ts b/tests/js/tests/email-verification.test.ts deleted file mode 100644 index d4a70a5c4..000000000 --- a/tests/js/tests/email-verification.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -import path from "path"; -import { Ad4mClient } from "@coasys/ad4m"; -import fs from "fs-extra"; -import { fileURLToPath } from 'url'; -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { apolloClient, sleep, startExecutor, runHcLocalServices } from "../utils/utils"; -import { ChildProcess } from 'node:child_process'; -import fetch from 'node-fetch' - -//@ts-ignore -global.fetch = fetch - -const expect = chai.expect; -chai.use(chaiAsPromised); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Email Verification Flow Tests with Mock Email Service - * - * These tests use a mock email service that captures verification codes - * instead of actually sending emails. This allows testing the full email - * verification flow without requiring SMTP configuration. - */ -describe("Email Verification with Mock Service", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "email-verification"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15920 - const hcAdminPort = 15921 - const hcAppPort = 15922 - - let executorProcess: ChildProcess | null = null - let adminAd4mClient: Ad4mClient | null = null - - let proxyUrl: string | null = null; - let bootstrapUrl: string | null = null; - let localServicesProcess: ChildProcess | null = null; - - before(async () => { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }); - } - - // Start local Holochain services - let localServices = await runHcLocalServices(); - proxyUrl = localServices.proxyUrl; - bootstrapUrl = localServices.bootstrapUrl; - localServicesProcess = localServices.process; - - // Start executor with local services - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort, false, undefined, proxyUrl!, bootstrapUrl!); - - // @ts-ignore - Suppress Apollo type mismatch - adminAd4mClient = new Ad4mClient(apolloClient(gqlPort), false) - - // Generate initial admin agent (needed for JWT signing) - await adminAd4mClient.agent.generate("passphrase") - - // Enable multi-user mode - await adminAd4mClient.runtime.setMultiUserEnabled(true) - - // Enable email test mode - THIS IS KEY! - await adminAd4mClient.runtime.emailTestModeEnable() - console.log("✅ Email test mode enabled - codes will be captured"); - }) - - after(async () => { - if (adminAd4mClient) { - await adminAd4mClient.runtime.emailTestModeDisable() - } - - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - if (localServicesProcess) { - while (!localServicesProcess?.killed) { - let status = localServicesProcess?.kill(); - console.log("killed local services with", status); - await sleep(500); - } - } - }) - - beforeEach(async () => { - // Clear codes before each test - await adminAd4mClient!.runtime.emailTestClearCodes() - }) - - describe("Email Verification Flow - New User Signup", () => { - it("should complete full signup flow with email verification", async () => { - const email = "newuser@example.com"; - const password = "SecurePass123!"; - - console.log("\n🔹 Step 1: Request login verification for new user"); - const verifyRequest = await adminAd4mClient!.agent.requestLoginVerification(email); - - expect(verifyRequest.success).to.be.true; - expect(verifyRequest.requiresPassword).to.be.true; // New user - expect(verifyRequest.isExistingUser).to.be.false; // New user - console.log(`✅ Result: success=${verifyRequest.success}, requiresPassword=${verifyRequest.requiresPassword}, isExistingUser=${verifyRequest.isExistingUser}`); - console.log(` Message: ${verifyRequest.message}`); - - console.log("\n🔹 Step 2: Create user (triggers verification email)"); - const createResult = await adminAd4mClient!.agent.createUser(email, password); - - expect(createResult.success).to.be.true; - expect(createResult.did).to.be.a("string"); - console.log(`✅ User created with DID: ${createResult.did}`); - - console.log("\n🔹 Step 3: Retrieve captured verification code"); - const code = await adminAd4mClient!.runtime.emailTestGetCode(email); - - expect(code).to.exist; - expect(code).to.be.a("string"); - expect(code!.length).to.equal(6); - expect(/^\d{6}$/.test(code!)).to.be.true; // Must be 6 digits - console.log(`✅ Captured code: ${code}`); - - console.log("\n🔹 Step 4: Verify email with code"); - const token = await adminAd4mClient!.agent.verifyEmailCode(email, code!, "signup"); - - expect(token).to.be.a("string"); - expect(token.length).to.be.greaterThan(0); - console.log(`✅ JWT token received: ${token.substring(0, 20)}...`); - - console.log("\n✅ Full signup flow completed successfully!"); - }); - - it("should reject invalid verification codes", async () => { - const email = "invalid-code@example.com"; - const password = "SecurePass123!"; - - // Create user - await adminAd4mClient!.agent.createUser(email, password); - - // Get the real code (but don't use it) - const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(realCode).to.exist; - - // Try to verify with wrong code - try { - await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup"); - expect.fail("Should have thrown error for invalid code"); - } catch (e: any) { - expect(e.message).to.include("Invalid"); - console.log(`✅ Invalid code correctly rejected: ${e.message}`); - } - }); - - it("should enforce code expiration (15 minutes)", async () => { - const email = "expiry@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const code = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code).to.exist; - - // Verify the code works when fresh - const token = await adminAd4mClient!.agent.verifyEmailCode(email, code!, "signup"); - expect(token).to.exist; - console.log("✅ Fresh code works"); - - // Create a new code for expiration testing - await adminAd4mClient!.agent.requestLoginVerification(email); - const expiredCode = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(expiredCode).to.exist; - - // Simulate expiration by setting expiry time to past (1 hour ago) - const pastTimestamp = Math.floor(Date.now() / 1000) - (60 * 60); - await adminAd4mClient!.runtime.emailTestSetExpiry(email, "login", pastTimestamp); - - // Try to verify with expired code - should fail - try { - await adminAd4mClient!.agent.verifyEmailCode(email, expiredCode!, "login"); - expect.fail("Should have thrown error for expired code"); - } catch (e: any) { - expect(e.message).to.match(/Invalid|expired|Expired/i); - console.log(`✅ Expired code correctly rejected: ${e.message}`); - } - }); - }); - - describe("Email Verification Flow - Existing User Login", () => { - const existingEmail = "existing@example.com"; - const existingPassword = "ExistingPass123!"; - - before(async () => { - // Create and verify a user first - await adminAd4mClient!.agent.createUser(existingEmail, existingPassword); - const signupCode = await adminAd4mClient!.runtime.emailTestGetCode(existingEmail); - await adminAd4mClient!.agent.verifyEmailCode(existingEmail, signupCode!, "signup"); - await adminAd4mClient!.runtime.emailTestClearCodes(); - }); - - it("should complete login flow with email verification (passwordless)", async () => { - console.log("\n🔹 Step 1: Request login verification for existing user"); - const verifyRequest = await adminAd4mClient!.agent.requestLoginVerification(existingEmail); - - expect(verifyRequest.success).to.be.true; - expect(verifyRequest.requiresPassword).to.be.false; // Existing user - expect(verifyRequest.isExistingUser).to.be.true; // Existing user - console.log(`✅ Result: success=${verifyRequest.success}, requiresPassword=${verifyRequest.requiresPassword}, isExistingUser=${verifyRequest.isExistingUser}`); - - console.log("\n🔹 Step 2: Retrieve captured verification code"); - const code = await adminAd4mClient!.runtime.emailTestGetCode(existingEmail); - - expect(code).to.exist; - expect(code).to.be.a("string"); - expect(code!.length).to.equal(6); - console.log(`✅ Captured login code: ${code}`); - - console.log("\n🔹 Step 3: Verify code for login"); - const token = await adminAd4mClient!.agent.verifyEmailCode(existingEmail, code!, "login"); - - expect(token).to.be.a("string"); - expect(token.length).to.be.greaterThan(0); - console.log(`✅ Logged in with JWT: ${token.substring(0, 20)}...`); - - console.log("\n✅ Passwordless login flow completed!"); - }); - - it("should still support password-based login (backwards compatibility)", async () => { - // Even with email verification enabled, password login should work - const token = await adminAd4mClient!.agent.loginUser(existingEmail, existingPassword); - - expect(token).to.be.a("string"); - expect(token.length).to.be.greaterThan(0); - - console.log("✅ Password-based login still works (backwards compatible)"); - }); - }); - - describe("Rate Limiting", () => { - it("should enforce rate limiting on verification requests", async () => { - const email = "ratelimit@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - // First verification code - const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); - await adminAd4mClient!.agent.verifyEmailCode(email, code1!, "signup"); - - // Request login verification - const request1 = await adminAd4mClient!.agent.requestLoginVerification(email); - expect(request1.success).to.be.true; - - // Immediately try again - should be rate limited - const request2 = await adminAd4mClient!.agent.requestLoginVerification(email); - expect(request2.success).to.be.false; - expect(request2.message).to.match(/rate limit|too many|wait/i); - console.log(`✅ Rate limiting enforced: ${request2.message}`); - }); - }); - - describe("Mock Service Functionality", () => { - it("should capture codes for multiple users simultaneously", async () => { - const users = [ - { email: "user1@example.com", password: "pass1" }, - { email: "user2@example.com", password: "pass2" }, - { email: "user3@example.com", password: "pass3" }, - ]; - - // Create all users - for (const user of users) { - await adminAd4mClient!.agent.createUser(user.email, user.password); - } - - // Retrieve all codes - for (const user of users) { - const code = await adminAd4mClient!.runtime.emailTestGetCode(user.email); - expect(code).to.exist; - expect(code!.length).to.equal(6); - console.log(`✅ ${user.email} -> code: ${code}`); - } - - console.log("✅ Multiple users handled simultaneously"); - }); - - it("should clear codes when requested", async () => { - const email = "cleartest@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - let code = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code).to.exist; - - await adminAd4mClient!.runtime.emailTestClearCodes(); - - code = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code).to.be.null; - - console.log("✅ Codes cleared successfully"); - }); - - it("should have test mode enabled during tests", async () => { - // Verify test mode is active by checking we can capture codes - const email = "testmode@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const code = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code).to.exist; // Only possible if test mode is enabled - - console.log("✅ Test mode is active and working"); - }); - }); - - describe("Security Features", () => { - it("should generate unique codes for each request", async () => { - const email = "uniquecodes@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); - await adminAd4mClient!.agent.verifyEmailCode(email, code1!, "signup"); - - // Request new code - await adminAd4mClient!.agent.requestLoginVerification(email); - await sleep(100); // Small delay to ensure new code generation - - const code2 = await adminAd4mClient!.runtime.emailTestGetCode(email); - - // Codes should be different - expect(code1).to.not.equal(code2); - console.log(`✅ Unique codes: ${code1} != ${code2}`); - }); - - it("should only allow code to be used once (single-use)", async () => { - const email = "singleuse@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const code = await adminAd4mClient!.runtime.emailTestGetCode(email); - - // Use code once - await adminAd4mClient!.agent.verifyEmailCode(email, code!, "signup"); - - // Try to use same code again - try { - await adminAd4mClient!.agent.verifyEmailCode(email, code!, "signup"); - expect.fail("Should not allow reusing the same code"); - } catch (e: any) { - console.log(`✅ Single-use enforced: ${e.message}`); - } - }); - - describe("Failed Attempt Rate Limiting", () => { - it("should track failed verification attempts", async () => { - const email = "failedattempts@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(realCode).to.exist; - - // Try wrong codes multiple times - for (let i = 1; i <= 4; i++) { - try { - await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup"); - expect.fail(`Should have failed on attempt ${i}`); - } catch (e: any) { - expect(e.message).to.match(/Invalid|expired/i); - console.log(`✅ Failed attempt ${i} correctly rejected`); - } - } - - // Code should still be valid (not yet invalidated) - const stillValid = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(stillValid).to.equal(realCode); - console.log("✅ Code still valid after 4 failed attempts"); - }); - - it("should invalidate code after 5 failed attempts", async () => { - const email = "invalidate@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(realCode).to.exist; - - // Make 5 failed attempts - for (let i = 1; i <= 5; i++) { - try { - await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup"); - expect.fail(`Should have failed on attempt ${i}`); - } catch (e: any) { - if (i < 5) { - // First 4 attempts should just say invalid - expect(e.message).to.match(/Invalid|expired/i); - console.log(`✅ Failed attempt ${i} rejected`); - } else { - // 5th attempt should indicate code is invalidated - expect(e.message).to.match(/invalidated|too many/i); - console.log(`✅ Code invalidated after ${i} failed attempts: ${e.message}`); - } - } - } - - // Code should be deleted/invalidated - const codeAfterInvalidation = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(codeAfterInvalidation).to.be.null; - console.log("✅ Code successfully invalidated and removed"); - - // Even the real code should not work anymore - try { - await adminAd4mClient!.agent.verifyEmailCode(email, realCode!, "signup"); - expect.fail("Should not accept code after invalidation"); - } catch (e: any) { - expect(e.message).to.match(/Invalid|expired|invalidated/i); - console.log(`✅ Real code correctly rejected after invalidation: ${e.message}`); - } - }); - - it("should require new code request after invalidation", async () => { - const email = "newcode@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code1).to.exist; - - // Invalidate the code with 5 failed attempts - for (let i = 1; i <= 5; i++) { - try { - await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup"); - } catch (e: any) { - // Expected to fail - } - } - - // Verify code is gone - const codeAfterInvalidation = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(codeAfterInvalidation).to.be.null; - - // Request a new code - const verifyRequest = await adminAd4mClient!.agent.requestLoginVerification(email); - expect(verifyRequest.success).to.be.true; - - // Get the new code - const code2 = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code2).to.exist; - expect(code2).to.not.equal(code1); - console.log(`✅ New code generated: ${code1} -> ${code2}`); - - // New code should work - const token = await adminAd4mClient!.agent.verifyEmailCode(email, code2!, "login"); - expect(token).to.be.a("string"); - expect(token.length).to.be.greaterThan(0); - console.log("✅ New code works after invalidation"); - }); - - it("should reset failed attempts counter on successful verification", async () => { - const email = "reset@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const code1 = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code1).to.exist; - - // Make 3 failed attempts - for (let i = 1; i <= 3; i++) { - try { - await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup"); - expect.fail(`Should have failed on attempt ${i}`); - } catch (e: any) { - expect(e.message).to.match(/Invalid|expired/i); - } - } - - // Now verify with correct code - should work - const token = await adminAd4mClient!.agent.verifyEmailCode(email, code1!, "signup"); - expect(token).to.be.a("string"); - console.log("✅ Correct code works after failed attempts"); - - // Request a new code for login - await adminAd4mClient!.agent.requestLoginVerification(email); - const code2 = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(code2).to.exist; - - // Failed attempts counter should be reset (we can make 5 more attempts) - for (let i = 1; i <= 4; i++) { - try { - await adminAd4mClient!.agent.verifyEmailCode(email, "000000", "login"); - expect.fail(`Should have failed on attempt ${i}`); - } catch (e: any) { - expect(e.message).to.match(/Invalid|expired/i); - } - } - - // Code should still be valid - const stillValid = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(stillValid).to.equal(code2); - console.log("✅ Failed attempts counter reset after successful verification"); - }); - - it("should handle concurrent failed attempts correctly", async () => { - const email = "concurrent@example.com"; - await adminAd4mClient!.agent.createUser(email, "password123"); - - const realCode = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(realCode).to.exist; - - // Make multiple failed attempts concurrently - const promises = Array.from({ length: 5 }, () => - adminAd4mClient!.agent.verifyEmailCode(email, "000000", "signup").catch(e => e) - ); - - const results = await Promise.all(promises); - - // All should fail - results.forEach((result, i) => { - if (result instanceof Error) { - expect(result.message).to.match(/Invalid|expired|invalidated/i); - } else { - expect.fail(`Attempt ${i + 1} should have failed`); - } - }); - - // Code should be invalidated - const codeAfterInvalidation = await adminAd4mClient!.runtime.emailTestGetCode(email); - expect(codeAfterInvalidation).to.be.null; - console.log("✅ Concurrent failed attempts correctly handled and code invalidated"); - }); - }); - }); -}); diff --git a/tests/js/tests/expression.ts b/tests/js/tests/expression.ts deleted file mode 100644 index 4773efbfc..000000000 --- a/tests/js/tests/expression.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { InteractionCall, LanguageMetaInput, Literal, parseExprUrl } from '@coasys/ad4m' -import { TestContext } from './integration.test' -import fs from "fs"; -import { expect } from "chai"; - -export default function expressionTests(testContext: TestContext) { - return () => { - describe('Expressions', () => { - let noteLangAddress = ""; - - before(async () => { - const ad4mClient = testContext.ad4mClient! - - //Publish mocking interactions language so it can be used - const publish = await ad4mClient.languages.publish("./languages/note-store/build/bundle.js", {name: "note-store", description: "A test language for saving simple strings"} as LanguageMetaInput) - - noteLangAddress = publish.address; - }) - - it('can get() my agent expression', async () => { - const ad4mClient = testContext.ad4mClient! - const me = await ad4mClient.agent.me() - - const agent = await ad4mClient.expression.get(me.did) - console.warn(agent); - - expect(agent.proof.valid).to.be.true; - expect(agent.proof.invalid).to.be.false; - }) - - it('can getManyExpressions()', async () => { - const ad4mClient = testContext.ad4mClient!; - const me = await ad4mClient.agent.me(); - - const agentAndNull = await ad4mClient.expression.getMany([me.did, "lang://getNull", me.did]); - expect(agentAndNull.length).to.be.equal(3); - expect(JSON.parse(agentAndNull[0].data).did).to.be.equal(me.did); - expect(agentAndNull[1]).to.be.null; - expect(JSON.parse(agentAndNull[2].data).did).to.be.equal(me.did); - }) - - it('can getRaw() my agent expression', async () => { - const ad4mClient = testContext.ad4mClient! - const me = await ad4mClient.agent.me() - - const agent = await ad4mClient.expression.getRaw(me.did) - expect(JSON.parse(agent).data.did).to.be.equal(me.did); - expect(JSON.parse(agent).data.directMessageLanguage).to.be.equal(me.directMessageLanguage); - }) - - it('can create()', async () => { - const ad4mClient = testContext.ad4mClient! - let me = await ad4mClient.agent.me() - - const result = await ad4mClient.expression.create(me, "did") - expect(result).to.be.equal(me.did) - }) - - it('can create valid signatures', async () => { - const ad4mClient = testContext.ad4mClient! - - const exprAddr = await ad4mClient.expression.create("test note", noteLangAddress) - expect(exprAddr).not.to.be.undefined; - - const expr = await ad4mClient.expression.get(exprAddr) - expect(expr).not.to.be.undefined; - expect(expr.proof.valid).to.be.true; - }) - - it('can get expression from cache', async () => { - const ad4mClient = testContext.ad4mClient! - - const exprAddr = await ad4mClient.expression.create("test note", noteLangAddress) - expect(exprAddr).not.to.be.undefined; - - const expr = await ad4mClient.expression.get(exprAddr) - expect(expr).not.to.be.undefined; - expect(expr.proof.valid).to.be.true; - expect(expr.data).to.be.equal("\"test note\""); - - const exprCacheHit = await ad4mClient.expression.get(exprAddr) - expect(exprCacheHit).not.to.be.undefined; - expect(exprCacheHit.proof.valid).to.be.true; - expect(exprCacheHit.data).to.be.equal("\"test note\""); - - const objExpr = await ad4mClient.expression.create({"key": "value"}, noteLangAddress) - expect(objExpr).not.to.be.undefined; - - const exprObj = await ad4mClient.expression.get(objExpr) - expect(exprObj).not.to.be.undefined; - expect(exprObj.proof.valid).to.be.true; - expect(exprObj.data).to.be.equal(JSON.stringify({"key": "value"})); - - const exprObjCacheHit = await ad4mClient.expression.get(objExpr) - expect(exprObjCacheHit).not.to.be.undefined; - expect(exprObjCacheHit.proof.valid).to.be.true; - expect(exprObjCacheHit.data).to.be.equal(JSON.stringify({"key": "value"})); - }) - - it('can use expression interactions', async () => { - const ad4mClient = testContext.ad4mClient! - //Publish mocking interactions language so it can be used - const publish = await ad4mClient.languages.publish("./languages/test-language/build/bundle.js", {name: "test-language", description: "A test language for interactions"} as LanguageMetaInput) - - //@ts-ignore - const testLangAddress = publish.address; - - const exprAddr = await ad4mClient.expression.create("test note", testLangAddress) - expect(exprAddr).not.to.be.undefined; - - let expr = await ad4mClient.expression.get(exprAddr) - expect(expr).not.to.be.undefined; - expect(expr.proof.valid).to.be.true; - expect(expr.data).to.be.equal("\"test note\""); - - const interactions = await ad4mClient.expression.interactions(exprAddr) - - expect(interactions.length).to.be.equal(1) - expect(interactions[0].name).to.be.equal('modify') - - const interactionCall = new InteractionCall('modify', { newValue: 'modified note' }) - const result = await ad4mClient.expression.interact(exprAddr, interactionCall) - expect(result).to.be.equal('ok') - - expr = await ad4mClient.expression.get(exprAddr) - expect(expr).not.to.be.undefined; - expect(expr.proof.valid).to.be.true; - expect(expr.data).to.be.equal("\"modified note\""); - }) - - it('Literal language expressions can be created with signature and can get resolved from URL', async () => { - const ad4mClient = testContext.ad4mClient! - - const TEST_DATA = "Hello World" - const addr = await ad4mClient.expression.create(TEST_DATA, "literal") - const exprRef = parseExprUrl(addr) - expect(exprRef.language.address).to.be.equal("literal") - - const expr = Literal.fromUrl(addr).get() - - expect(expr.data).to.be.equal(TEST_DATA) - - const expr2Raw = await ad4mClient.expression.getRaw(addr) - const expr2 = JSON.parse(expr2Raw) - console.log(expr2) - - expr.proof.valid = true - expr.proof.invalid = false - expect(expr2).to.be.eql(expr) - }) - }) - } -} \ No newline at end of file diff --git a/tests/js/tests/integration.test.ts b/tests/js/tests/integration.test.ts deleted file mode 100644 index 848888f0e..000000000 --- a/tests/js/tests/integration.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import fs from 'fs-extra' -import path from 'path' -import { isProcessRunning, sleep } from "../utils/utils"; -import { Ad4mClient, ExpressionProof, Link, LinkExpression, Perspective } from "@coasys/ad4m"; -import { fileURLToPath } from 'url'; -import { expect } from "chai"; -import { startExecutor, apolloClient, runHcLocalServices, killByPorts } from "../utils/utils"; -import { ChildProcess } from 'child_process'; -import perspectiveTests from "./perspective"; -import agentTests from "./agent"; -import aiTests from "./ai"; -import languageTests from "./language"; -import expressionTests from "./expression"; -import neighbourhoodTests from "./neighbourhood"; -import runtimeTests from "./runtime"; -//import { Crypto } from "@peculiar/webcrypto" -import directMessageTests from "./direct-messages"; -import agentLanguageTests from "./agent-language"; -import socialDNATests from "./social-dna-flow"; -import fetch from "node-fetch"; - -//@ts-ignore -global.fetch = fetch - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -//@ts-ignore -//global.crypto = new Crypto(); - -const TEST_DIR = `${__dirname}/../tst-tmp` - -export class TestContext { - //#ad4mClient: Ad4mClient | undefined - #alice: Ad4mClient | undefined - #bob: Ad4mClient | undefined - - #aliceCore: ChildProcess | undefined - #bobCore: ChildProcess | undefined - - get ad4mClient(): Ad4mClient { - return this.#alice! - } - - get alice(): Ad4mClient { - return this.#alice! - } - - get bob(): Ad4mClient { - return this.#bob! - } - - set alice(client: Ad4mClient) { - this.#alice = client - } - - set bob(client: Ad4mClient) { - this.#bob = client - } - - set aliceCore(aliceCore: ChildProcess) { - this.#aliceCore = aliceCore - } - - set bobCore(bobCore: ChildProcess) { - this.#bobCore = bobCore - } - - async makeAllNodesKnown() { - const aliceAgentInfo = await this.#alice!.runtime.hcAgentInfos(); - const bobAgentInfo = await this.#bob!.runtime.hcAgentInfos(); - - await this.#alice!.runtime.hcAddAgentInfos(bobAgentInfo); - await this.#bob!.runtime.hcAddAgentInfos(aliceAgentInfo); - } -} -let testContext: TestContext = new TestContext() - -describe("Integration tests", function () { - //@ts-ignore - this.timeout(200000) - const appDataPath = path.join(TEST_DIR, 'agents', 'alice') - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15300 - const hcAdminPort = 15301 - const hcAppPort = 15302 - - let executorProcess: ChildProcess | null = null - - let proxyUrl: string | null = null; - let bootstrapUrl: string | null = null; - let localServicesProcess: ChildProcess | null = null; - let relayUrl: string | null = null; - - before(async () => { - if(!fs.existsSync(TEST_DIR)) { - throw Error("Please ensure that prepare-test is run before running tests!"); - } - if(!fs.existsSync(path.join(TEST_DIR, 'agents'))) - fs.mkdirSync(path.join(TEST_DIR, 'agents')) - if(!fs.existsSync(appDataPath)) - fs.mkdirSync(appDataPath) - - let localServices = await runHcLocalServices(); - proxyUrl = localServices.proxyUrl; - bootstrapUrl = localServices.bootstrapUrl; - localServicesProcess = localServices.process; - relayUrl = localServices.relayUrl; - - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort, false, undefined, proxyUrl!, bootstrapUrl!, relayUrl!); - - testContext.alice = new Ad4mClient(apolloClient(gqlPort)) - testContext.aliceCore = executorProcess - }) - - after(async () => { - if (executorProcess) { - executorProcess.kill('SIGTERM'); - await sleep(500); - if (!executorProcess.killed) executorProcess.kill('SIGKILL'); - } - if (localServicesProcess) { - localServicesProcess.kill('SIGKILL'); - } - // Port-based kill as safety net — catches the executor even if kill() missed it - killByPorts([gqlPort, hcAdminPort, hcAppPort]); - }) - - describe('Agent / Agent-Setup', agentTests(testContext)) - describe('Artificial Intelligence', aiTests(testContext)) - describe('Runtime', runtimeTests(testContext)) - describe('Expression', expressionTests(testContext)) - describe('Perspective', perspectiveTests(testContext)) - describe('Social DNA', socialDNATests(testContext)) - - describe('with Alice and Bob', () => { - let bobExecutorProcess: ChildProcess | null = null - before(async () => { - const bobAppDataPath = path.join(TEST_DIR, 'agents', 'bob') - const bobBootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const bobGqlPort = 15400 - const bobHcAdminPort = 15401 - const bobHcAppPort = 15402 - - if(!fs.existsSync(path.join(TEST_DIR, 'agents'))) - fs.mkdirSync(path.join(TEST_DIR, 'agents')) - if(!fs.existsSync(bobAppDataPath)) - fs.mkdirSync(bobAppDataPath) - - bobExecutorProcess = await startExecutor(bobAppDataPath, bobBootstrapSeedPath, - bobGqlPort, bobHcAdminPort, bobHcAppPort, false, undefined, proxyUrl!, bootstrapUrl!, relayUrl!); - - testContext.bob = new Ad4mClient(apolloClient(bobGqlPort)) - testContext.bobCore = bobExecutorProcess - await testContext.bob.agent.generate("passphrase") - - const status = await testContext.bob.agent.status() - - expect(status.isInitialized).to.be.true; - expect(status.isUnlocked).to.be.true; - - let link = new LinkExpression(); - link.author = "did:test"; - link.timestamp = new Date().toISOString(); - link.data = new Link({source: "ad4m://src", target: "test://target", predicate: "ad4m://pred"}); - link.proof = new ExpressionProof("sig", "key") - - await testContext.bob.agent.updatePublicPerspective(new Perspective([link])) - - await testContext.makeAllNodesKnown() - }) - - after(async () => { - if (bobExecutorProcess) { - bobExecutorProcess.kill('SIGTERM'); - await sleep(500); - if (!bobExecutorProcess.killed) bobExecutorProcess.kill('SIGKILL'); - } - // Port-based kill as safety net (bob: 15400/15401/15402) - killByPorts([15400, 15401, 15402]); - }) - - describe('Agent Language', agentLanguageTests(testContext)) - describe('Language', languageTests(testContext)) - describe('Neighbourhood', neighbourhoodTests(testContext)) - //describe('Direct Messages', directMessageTests(testContext)) - }) -}) \ No newline at end of file diff --git a/tests/js/tests/integration/agent-language.suite.ts b/tests/js/tests/integration/agent-language.suite.ts new file mode 100644 index 000000000..61c27e416 --- /dev/null +++ b/tests/js/tests/integration/agent-language.suite.ts @@ -0,0 +1,73 @@ +import { TestContext } from "./integration.test"; +import { sleep } from "../../utils/utils"; +import { expect } from "chai"; + +export default function agentLanguageTests(testContext: TestContext) { + return () => { + it("works across remote agents", async () => { + const alice = testContext.alice!; + const didAlice = (await alice.agent.status()).did!; + const bob = testContext.bob!; + const didBob = (await bob.agent.status()).did!; + + const aliceHerself = await alice.agent.me(); + const bobHimself = await bob.agent.me(); + + // Helper function to retry agent lookup with logging + async function retryAgentLookup( + client: typeof alice, + targetDid: string, + clientName: string, + targetName: string, + maxAttempts: number = 90, + ) { + let result = await client.agent.byDID(targetDid); + let attempts = 0; + while (!result && attempts < maxAttempts) { + if (attempts % 10 === 0) { + console.log( + `${clientName} looking up ${targetName}... attempt ${attempts}/${maxAttempts}`, + ); + } + await sleep(1000); + result = await client.agent.byDID(targetDid); + attempts++; + } + if (!result) { + console.error( + `${clientName} failed to find ${targetName} after ${maxAttempts} attempts`, + ); + console.error(`Target DID: ${targetDid}`); + } + return result; + } + + await sleep(5000); + + // Both lookups now have retry logic + const bobSeenFromAlice = await retryAgentLookup( + alice, + didBob, + "Alice", + "Bob", + ); + expect( + bobSeenFromAlice, + "Alice should be able to see Bob's agent profile", + ).to.not.be.null; + expect(bobSeenFromAlice).to.be.eql(bobHimself); + + const aliceSeenFromBob = await retryAgentLookup( + bob, + didAlice, + "Bob", + "Alice", + ); + expect( + aliceSeenFromBob, + "Bob should be able to see Alice's agent profile", + ).to.not.be.null; + expect(aliceSeenFromBob).to.be.eql(aliceHerself); + }); + }; +} diff --git a/tests/js/tests/integration/agent.suite.ts b/tests/js/tests/integration/agent.suite.ts new file mode 100644 index 000000000..e3d3fffdb --- /dev/null +++ b/tests/js/tests/integration/agent.suite.ts @@ -0,0 +1,209 @@ +import { + Perspective, + LinkExpression, + Link, + ExpressionProof, + EntanglementProofInput, +} from "@coasys/ad4m"; +import { TestContext } from "./integration.test"; +import { sleep } from "../../utils/utils"; +import { expect } from "chai"; +import * as sinon from "sinon"; + +export default function agentTests(testContext: TestContext) { + return () => { + describe("basic agent operations", () => { + it("can get and create agent store", async () => { + const ad4mClient = testContext.ad4mClient!; + + const agentUpdated = sinon.fake(); + ad4mClient.agent.addAgentStatusChangedListener(agentUpdated); + + const generate = await ad4mClient.agent.generate("passphrase"); + expect(generate.isInitialized).to.be.true; + expect(generate.isUnlocked).to.be.true; + + await sleep(1000); + expect(agentUpdated.calledOnce).to.be.true; + + // //Should be able to create a perspective + // const create = await ad4mClient.perspective.add("test"); + // expect(create.name).to.equal("test"); + + const lockAgent = await ad4mClient.agent.lock("passphrase"); + + expect(lockAgent.isInitialized).to.be.true; + expect(lockAgent.isUnlocked).to.be.false; + + await sleep(1000); + expect(agentUpdated.calledTwice).to.be.true; + + // //Should not be able to create a perspective + // const createLocked = await ad4mClient.perspective.add("test2"); + // console.log(createLocked); + + const unlockAgent = await ad4mClient.agent.unlock("passphrase"); + expect(unlockAgent.isInitialized).to.be.true; + expect(unlockAgent.isUnlocked).to.be.true; + + await sleep(1000); + expect(agentUpdated.calledThrice).to.be.true; + + // //Should be able to create a perspective + // const create = await ad4mClient.perspective.add("test3"); + // expect(create.name).to.equal("test3"); + + const agentDump = await ad4mClient.agent.status(); + expect(agentDump.isInitialized).to.be.true; + expect(agentDump.isUnlocked).to.be.true; + }), + it("can get and create agent expression profile", async () => { + const ad4mClient = testContext.ad4mClient!; + + const agentUpdated = sinon.fake(); + ad4mClient.agent.addUpdatedListener(agentUpdated); + + const currentAgent = await ad4mClient.agent.me(); + expect(currentAgent.perspective).not.to.be.undefined; + expect(currentAgent.perspective!.links.length).to.equal(0); + expect(currentAgent.directMessageLanguage).not.to.be.undefined; + const oldDmLang = currentAgent.directMessageLanguage!; + + let link = new LinkExpression(); + link.author = "did:test"; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: "ad4m://src", + target: "test://target", + predicate: "ad4m://pred", + }); + link.proof = new ExpressionProof("sig", "key"); + const updatePerspective = + await ad4mClient.agent.updatePublicPerspective( + new Perspective([link]), + ); + expect(currentAgent.perspective).not.to.be.undefined; + expect(updatePerspective.perspective!.links.length).to.equal(1); + + await sleep(500); + expect(agentUpdated.calledOnce).to.be.true; + expect(agentUpdated.getCall(0).args[0]).to.deep.equal( + updatePerspective, + ); + + const updatePublicLanguage = + await ad4mClient.agent.updateDirectMessageLanguage("newlang"); + expect(currentAgent.perspective).not.to.be.undefined; + expect(updatePublicLanguage.perspective!.links.length).to.equal(1); + expect(updatePublicLanguage.directMessageLanguage).to.equal( + "newlang", + ); + + await sleep(500); + expect(agentUpdated.calledTwice).to.be.true; + expect(agentUpdated.getCall(1).args[0]).to.deep.equal( + updatePublicLanguage, + ); + + const currentAgentPostUpdate = await ad4mClient.agent.me(); + expect(currentAgent.perspective).not.to.be.undefined; + expect(currentAgentPostUpdate.perspective!.links.length).to.equal(1); + expect(currentAgentPostUpdate.directMessageLanguage).to.equal( + "newlang", + ); + + const getByDid = await ad4mClient.agent.byDID(currentAgent.did); + expect(getByDid.did).to.equal(currentAgent.did); + expect(currentAgent.perspective).not.to.be.undefined; + expect(getByDid.perspective!.links.length).to.equal(1); + expect(getByDid.directMessageLanguage).to.equal("newlang"); + + await ad4mClient.agent.updateDirectMessageLanguage(oldDmLang); + + const getInvalidDid = await ad4mClient.agent.byDID("na"); + expect(getInvalidDid).to.equal(null); + }); + it("can mutate agent public profile", async () => { + const ad4mClient = testContext.ad4mClient!; + + const currentAgent = await ad4mClient.agent.me(); + expect(currentAgent.perspective).not.to.be.undefined; + expect(currentAgent.perspective!.links.length).to.equal(1); + expect(currentAgent.directMessageLanguage).not.to.be.undefined; + + await ad4mClient.agent.mutatePublicPerspective({ + additions: [ + new Link({ + source: "test://source-test", + predicate: "test://predicate-test", + target: "test://target-test", + }), + ], + removals: [], + }); + + const currentAgentPostMutation = await ad4mClient.agent.me(); + expect(currentAgentPostMutation.perspective).not.to.be.undefined; + expect(currentAgentPostMutation.perspective!.links.length).to.equal(2); + const link = currentAgentPostMutation.perspective!.links[0]; + + await ad4mClient.agent.mutatePublicPerspective({ + additions: [], + removals: [link], + }); + + const currentAgentPostDeletion = await ad4mClient.agent.me(); + expect(currentAgentPostDeletion.perspective).not.to.be.undefined; + expect(currentAgentPostDeletion.perspective!.links.length).to.equal(1); + }); + it("can create entanglementProofPreFlight", async () => { + const ad4mClient = testContext.ad4mClient!; + + //Check can generate a preflight key + const preFlight = await ad4mClient.agent.entanglementProofPreFlight( + "ethAddr", + "ethereum", + ); + expect(preFlight.deviceKey).to.equal("ethAddr"); + expect(preFlight.deviceKeyType).to.equal("ethereum"); + expect(preFlight.didSignedByDeviceKey).to.be.null; + + const verify = await ad4mClient.runtime.verifyStringSignedByDid( + preFlight.did, + preFlight.didSigningKeyId, + "ethAddr", + preFlight.deviceKeySignedByDid, + ); + expect(verify).to.be.true; + + //Check can save a entanglement proof + preFlight.didSignedByDeviceKey = "ethSignedDID"; + const addProof = await ad4mClient.agent.addEntanglementProofs([ + preFlight as EntanglementProofInput, + ]); + expect(addProof[0]).to.deep.equal(preFlight); + + //Check can get entanglment proofs + const getProofs = await ad4mClient.agent.getEntanglementProofs(); + expect(getProofs[0]).to.deep.equal(preFlight); + + //Check can delete entanglement proofs + const deleteProofs = await ad4mClient.agent.deleteEntanglementProofs([ + preFlight as EntanglementProofInput, + ]); + expect(deleteProofs.length).to.be.equal(0); + + //Check entanglement proof is deleted on get + const getProofsPostDelete = + await ad4mClient.agent.getEntanglementProofs(); + expect(getProofsPostDelete.length).to.be.equal(0); + }); + it("can signMessage", async () => { + const ad4mClient = testContext.ad4mClient!; + + const signed = await ad4mClient.agent.signMessage("test"); + expect(signed).to.not.be.null; + }); + }); + }; +} diff --git a/tests/js/tests/integration/ai.suite.ts b/tests/js/tests/integration/ai.suite.ts new file mode 100644 index 000000000..7767d7103 --- /dev/null +++ b/tests/js/tests/integration/ai.suite.ts @@ -0,0 +1,785 @@ +import { TestContext } from "./integration.test"; +import { expect } from "chai"; +//@ts-ignore +import ffmpeg from "fluent-ffmpeg"; +import { ModelInput } from "@coasys/ad4m/lib/src/ai/AIResolver"; + +// Helper function to convert audio file to PCM data +async function convertAudioToPCM(audioFilePath: string): Promise { + const pcmData: Buffer = await new Promise((resolve, reject) => { + const chunks: any[] = []; + ffmpeg() + .input(audioFilePath) + .inputFormat("m4a") + .toFormat("f32le") + .audioFrequency(16000) + .audioChannels(1) + .on("error", reject) + .pipe() + .on("data", (chunk: any) => { + chunks.push(chunk); + }) + .on("end", () => { + const finalBuffer = Buffer.concat(chunks); + console.log("Total PCM data size:", finalBuffer.length); + resolve(finalBuffer); + }) + .on("error", reject); + }); + + return new Float32Array( + pcmData.buffer, + pcmData.byteOffset, + pcmData.byteLength / Float32Array.BYTES_PER_ELEMENT, + ); +} + +// Helper function to stream audio data in chunks +async function streamAudioData( + audioData: Float32Array, + streamIds: string | string[], + feedTranscriptionStream: ( + streamIds: string | string[], + audio: number[], + ) => Promise, + chunkSize: number = 8000, +): Promise { + for (let i = 0; i < audioData.length; i += chunkSize) { + let end = i + chunkSize; + if (end > audioData.length) { + end = audioData.length; + } + console.log(`Sending chunk: ${i} - ${end}`); + const chunk = audioData.slice(i, end); + const numberArray = Array.from(chunk); // Convert Float32Array to number[] + await feedTranscriptionStream(streamIds, numberArray); + // Simulate real-time processing by adding a small delay + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} + +// Helper function to wait for transcription results +async function waitForTranscription( + condition: () => boolean, + maxWaitSeconds: number = 60, +): Promise { + let i = 0; + while (!condition() && i < maxWaitSeconds) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + i += 1; + } +} + +export default function aiTests(testContext: TestContext) { + return () => { + describe("AI service", () => { + // This is used in the skipped tests below + // They are skipped for CI, run on local device with GPU + let testModelFileName: string = "llama_3_1_8b_chat"; + let testModelId: string = ""; + + it("can perform Model CRUD operations", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Test adding an API model + const apiModelInput: ModelInput = { + name: "TestApiModel", + api: { + baseUrl: "https://api.example.com/", + apiKey: "test-api-key", + model: "llama", + apiType: "OPEN_AI", + }, + modelType: "LLM", + }; + + const addApiResult = await ad4mClient.ai.addModel(apiModelInput); + expect(addApiResult).to.be.a.string; + + // Test adding a local model + const localModelInput: ModelInput = { + name: "TestLocalModel", + local: { + fileName: "test_model.bin", + tokenizerSource: { + repo: "test-repo", + revision: "main", + fileName: "tokenizer.json", + }, + huggingfaceRepo: "test-repo", + revision: "main", + }, + modelType: "EMBEDDING", + }; + + const addLocalResult = await ad4mClient.ai.addModel(localModelInput); + expect(addLocalResult).to.be.a.string; + + // Test getting models + const models = await ad4mClient.ai.getModels(); + expect(models).to.be.an("array"); + expect(models.length).to.be.at.least(2); + + const addedApiModel = models.find( + (model) => model.name === "TestApiModel", + ); + expect(addedApiModel).to.exist; + expect(addedApiModel?.id).to.equal(addApiResult); + expect(addedApiModel?.api?.baseUrl).to.equal( + "https://api.example.com/", + ); + expect(addedApiModel?.api?.apiKey).to.equal("test-api-key"); + expect(addedApiModel?.api?.model).to.equal("llama"); + expect(addedApiModel?.api?.apiType).to.equal("OPEN_AI"); + + const addedLocalModel = models.find( + (model) => model.name === "TestLocalModel", + ); + expect(addedLocalModel).to.exist; + expect(addedLocalModel?.id).to.equal(addLocalResult); + expect(addedLocalModel?.local?.fileName).to.equal("test_model.bin"); + expect(addedLocalModel?.local?.tokenizerSource?.repo).to.equal( + "test-repo", + ); + expect(addedLocalModel?.local?.tokenizerSource?.revision).to.equal( + "main", + ); + expect(addedLocalModel?.local?.tokenizerSource?.fileName).to.equal( + "tokenizer.json", + ); + expect(addedLocalModel?.local?.huggingfaceRepo).to.equal("test-repo"); + expect(addedLocalModel?.local?.revision).to.equal("main"); + + // Test removing models + const removeApiResult = await ad4mClient.ai.removeModel( + addedApiModel!.id, + ); + expect(removeApiResult).to.be.true; + + const removeLocalResult = await ad4mClient.ai.removeModel( + addedLocalModel!.id, + ); + expect(removeLocalResult).to.be.true; + + // Verify the models were removed + const updatedModels = await ad4mClient.ai.getModels(); + const removedApiModel = updatedModels.find( + (model) => model.name === "TestApiModel", + ); + expect(removedApiModel).to.be.undefined; + + const removedLocalModel = updatedModels.find( + (model) => model.name === "TestLocalModel", + ); + expect(removedLocalModel).to.be.undefined; + }); + + it("can update model", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Create initial API model + const initialModel: ModelInput = { + name: "TestUpdateModel", + api: { + baseUrl: "https://api.example.com/", + apiKey: "initial-key", + model: "llama", + apiType: "OPEN_AI", + }, + modelType: "LLM", + }; + + // Add initial model + const addResult = await ad4mClient.ai.addModel(initialModel); + expect(addResult).to.be.a.string; + + // Get the model to retrieve its ID + const models = await ad4mClient.ai.getModels(); + const addedModel = models.find( + (model) => model.name === "TestUpdateModel", + ); + expect(addedModel).to.exist; + + // Create updated model data + const bogusModelUrls: ModelInput = { + name: "UpdatedModel", + local: { + fileName: "updated_model.bin", + tokenizerSource: { + repo: "updated-repo", + revision: "main", + fileName: "updated_tokenizer.json", + }, + huggingfaceRepo: "updated-repo", + revision: "main", + }, + modelType: "EMBEDDING", + }; + + // Update the model + let updateResult = false; + let error = {} as any; + try { + updateResult = await ad4mClient.ai.updateModel( + addedModel!.id, + bogusModelUrls, + ); + } catch (e) { + error = e; + console.log(error); + } + expect(updateResult).to.be.false; + expect(error).to.have.property("message"); + expect(error.message).to.include("Failed to update model"); + + // Create updated model data + const updatedModel: ModelInput = { + name: "UpdatedModel", + api: { + baseUrl: "https://api.example.com/v2", + apiKey: "updated-api-key", + model: "gpt-4", + apiType: "OPEN_AI", + }, + modelType: "LLM", + }; + + updateResult = await ad4mClient.ai.updateModel( + addedModel!.id, + updatedModel, + ); + + expect(updateResult).to.be.true; + + // Verify the update + const updatedModels = await ad4mClient.ai.getModels(); + const retrievedModel = updatedModels.find( + (model) => model.id === addedModel!.id, + ); + expect(retrievedModel).to.exist; + expect(retrievedModel?.name).to.equal("UpdatedModel"); + expect(retrievedModel?.local).to.be.null; + expect(retrievedModel?.api?.baseUrl).to.equal( + "https://api.example.com/v2", + ); + expect(retrievedModel?.api?.apiKey).to.equal("updated-api-key"); + expect(retrievedModel?.api?.model).to.equal("gpt-4"); + expect(retrievedModel?.api?.apiType).to.equal("OPEN_AI"); + expect(retrievedModel?.modelType).to.equal("LLM"); + + // Clean up + const removeResult = await ad4mClient.ai.removeModel(addedModel!.id); + expect(removeResult).to.be.true; + }); + + it.skip("can update model and verify it works", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Create initial model + const initialModel: ModelInput = { + name: "TestModel", + local: { + fileName: "llama_tiny_1_1b_chat", + }, + modelType: "LLM", + }; + + // Add initial model + const modelId = await ad4mClient.ai.addModel(initialModel); + expect(modelId).to.be.a.string; + + // Wait for model to be loaded + let status; + do { + status = await ad4mClient.ai.modelLoadingStatus(modelId); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second between checks + } while (status.progress < 100); + + testModelId = modelId; + + // Create task using "default" as model_id + const task = await ad4mClient.ai.addTask( + "test-task", + modelId, + "You are a helpful assistant", + [{ input: "Say hi", output: "Hello!" }], + ); + + // Test that initial model works + const prompt = "Say hello"; + const initialResponse = await ad4mClient.ai.prompt(task.taskId, prompt); + expect(initialResponse).to.be.a.string; + expect(initialResponse.length).to.be.greaterThan(0); + + // Create updated model config + const updatedModel: ModelInput = { + name: "UpdatedTestModel", + local: { fileName: testModelFileName }, + modelType: "LLM", + }; + + // Update the model + const updateResult = await ad4mClient.ai.updateModel( + modelId, + updatedModel, + ); + expect(updateResult).to.be.true; + + // Wait for model to be loaded + do { + status = await ad4mClient.ai.modelLoadingStatus(modelId); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second between checks + } while (status.progress < 100); + + // Verify model was updated in DB + const models = await ad4mClient.ai.getModels(); + const retrievedModel = models.find((m) => m.id === modelId); + expect(retrievedModel).to.exist; + expect(retrievedModel?.name).to.equal("UpdatedTestModel"); + expect(retrievedModel?.local?.fileName).to.equal(testModelFileName); + + // Test that updated model still works + const updatedResponse = await ad4mClient.ai.prompt(task.taskId, prompt); + expect(updatedResponse).to.be.a.string; + expect(updatedResponse.length).to.be.greaterThan(0); + + // keep model around for other tests + }); + + it("AI model status", async () => { + const ad4mClient = testContext.ad4mClient!; + const status = await ad4mClient.ai.modelLoadingStatus("bert-id"); + expect(status).to.have.property("model"); + expect(status).to.have.property("status"); + }); + + it("can set and get default model", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Create test models first + const apiModelInput: ModelInput = { + name: "TestDefaultApiModel", + api: { + baseUrl: "https://api.example.com/", + apiKey: "test-api-key", + model: "llama", + apiType: "OPEN_AI", + }, + modelType: "LLM", + }; + + let id = await ad4mClient.ai.addModel(apiModelInput); + + // Set default model + const setResult = await ad4mClient.ai.setDefaultModel("LLM", id); + expect(setResult).to.be.true; + + // Verify default model is set correctly + const defaultModel = await ad4mClient.ai.getDefaultModel("LLM"); + expect(defaultModel.name).to.equal("TestDefaultApiModel"); + expect(defaultModel.api?.baseUrl).to.equal("https://api.example.com/"); + + // Clean up + await ad4mClient.ai.removeModel(id); + }); + + it.skip('can use "default" as model_id in tasks and prompting works', async () => { + const ad4mClient = testContext.ad4mClient!; + await ad4mClient.ai.setDefaultModel("LLM", testModelId); + + // Create task using "default" as model_id + const task = await ad4mClient.ai.addTask( + "default-model-task", + "default", + "You are a helpful assistant. Whatever you say, it will include 'hello'", + [{ input: "Say hi", output: "Hello!" }], + ); + expect(task).to.have.property("taskId"); + expect(task.modelId).to.equal("default"); + + // Test prompting works with the task + const response = await ad4mClient.ai.prompt(task.taskId, "Say hi"); + expect(response).to.be.a("string"); + expect(response.toLowerCase()).to.include("hello"); + + // Create another test model + const newModelInput: ModelInput = { + name: "TestDefaultModel2", + local: { fileName: "llama_3_1_8b_chat" }, + modelType: "LLM", + }; + const newModelId = await ad4mClient.ai.addModel(newModelInput); + + // Wait for new model to be loaded + let newModelStatus; + do { + newModelStatus = await ad4mClient.ai.modelLoadingStatus(newModelId); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } while (newModelStatus.progress < 100); + + // Change default model to new one + await ad4mClient.ai.setDefaultModel("LLM", newModelId); + + // Verify new default model is set + const newDefaultModel = await ad4mClient.ai.getDefaultModel("LLM"); + expect(newDefaultModel.name).to.equal("TestDefaultModel2"); + + // Test that prompting still works with the task using "default" + const response2 = await ad4mClient.ai.prompt(task.taskId, "Say hi"); + expect(response2).to.be.a("string"); + expect(response2.toLowerCase()).to.include("hello"); + + // Clean up + await ad4mClient.ai.removeTask(task.taskId); + await ad4mClient.ai.removeModel(newModelId); + }); + + it.skip("can do Tasks CRUD", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Add a task + const newTask = await ad4mClient.ai.addTask( + "test-name", + testModelId, + "This is a test system prompt", + [{ input: "Test input", output: "Test output" }], + ); + expect(newTask).to.have.property("taskId"); + expect(newTask.name).to.equal("test-name"); + expect(newTask.modelId).to.equal(testModelId); + expect(newTask.systemPrompt).to.equal("This is a test system prompt"); + expect(newTask.promptExamples).to.deep.equal([ + { input: "Test input", output: "Test output" }, + ]); + + // Get all tasks + const tasks = await ad4mClient.ai.tasks(); + expect(tasks).to.be.an("array"); + expect(tasks).to.have.lengthOf.at.least(1); + expect( + tasks.find((task) => task.taskId === newTask.taskId), + ).to.deep.equal(newTask); + + // Update a task + const updatedTask = await ad4mClient.ai.updateTask(newTask.taskId, { + ...newTask, + systemPrompt: "Updated system prompt", + promptExamples: [ + { input: "Updated input", output: "Updated output" }, + ], + }); + expect(updatedTask.taskId).to.equal(newTask.taskId); + expect(updatedTask.systemPrompt).to.equal("Updated system prompt"); + expect(updatedTask.promptExamples).to.deep.equal([ + { input: "Updated input", output: "Updated output" }, + ]); + + // Remove a task + const removedTask = await ad4mClient.ai.removeTask(newTask.taskId); + expect(removedTask).to.deep.equal(updatedTask); + + // Verify task is removed + const tasksAfterRemoval = await ad4mClient.ai.tasks(); + expect(tasksAfterRemoval.find((task) => task.taskId === newTask.taskId)) + .to.be.undefined; + }).timeout(900000); + + it.skip("can prompt a task", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Create a new task + const newTask = await ad4mClient.ai.addTask( + "test-name", + testModelId, + "You are inside a test. Please ALWAYS respond with 'works', plus something else.", + [ + { + input: "What's the capital of France?", + output: "works. Also that is Paris", + }, + { + input: "What's the largets planet in our solar system?", + output: "works. That is Jupiter.", + }, + ], + ); + + expect(newTask).to.have.property("taskId"); + + // Prompt the task + const promptResult = await ad4mClient.ai.prompt( + newTask.taskId, + "What's the largest planet in our solar system?", + ); + + console.log("PROMPT RESULT:", promptResult); + // Check if the result is a non-empty string + expect(promptResult).to.be.a("string"); + expect(promptResult.length).to.be.greaterThan(0); + + // Check if the result mentions Jupiter + expect(promptResult.toLowerCase()).to.include("works"); + + // Clean up: remove the task + await ad4mClient.ai.removeTask(newTask.taskId); + }).timeout(900000); + + it.skip("can prompt several tasks in a row fast", async () => { + const ad4mClient = testContext.ad4mClient!; + + console.log("test 1"); + + // Create a new task + const newTask = await ad4mClient.ai.addTask( + "test-name", + testModelId, + "You are inside a test. Please respond with a short, unique message each time.", + [ + { + input: "Test long 1", + output: + "This is a much longer response that includes various details. It talks about the weather being sunny, the importance of staying hydrated, and even mentions a recipe for chocolate chip cookies. The response goes on to discuss the benefits of regular exercise, the plot of a popular novel, and concludes with a fun fact about the migration patterns of monarch butterflies.", + }, + { + input: "Test long 2", + output: + "This is another much longer response that delves into various topics. It begins by discussing the intricate process of photosynthesis in plants, then transitions to the history of ancient civilizations, touching on the rise and fall of the Roman Empire. The response continues with an explanation of quantum mechanics and its implications for our understanding of the universe. It then explores the evolution of human language, the impact of climate change on global ecosystems, and the potential for artificial intelligence to revolutionize healthcare. The response concludes with a brief overview of the cultural significance of tea ceremonies in different parts of the world.", + }, + { + input: "Test long 3", + output: + "This extensive response covers a wide range of subjects, starting with an in-depth analysis of sustainable urban planning and its impact on modern cities. It then shifts to discuss the evolution of musical instruments throughout history, touching on the development of the piano, guitar, and electronic synthesizers. The text continues with an exploration of the human immune system, detailing how it fights off pathogens and the importance of vaccinations. Next, it delves into the world of astronomy, describing the life cycle of stars and the formation of galaxies. The response also includes a section on the history of cryptography, from ancient ciphers to modern encryption algorithms used in digital security. It concludes with a discussion on the philosophy of ethics, examining various moral frameworks and their applications in contemporary society.", + }, + ], + ); + + console.log("test 2"); + + expect(newTask).to.have.property("taskId"); + + // Create an array of 10 prompts + const prompts = Array.from( + { length: 1 }, + (_, i) => + `This is a much longer test prompt number ${i + 1}. It includes various details to make it more substantial. For instance, it mentions that the sky is blue, grass is green, and water is essential for life. It also touches on the fact that technology is rapidly advancing, climate change is a global concern, and education is crucial for personal growth. Additionally, it notes that music can evoke powerful emotions, reading broadens the mind, and exercise is important for maintaining good health. Lastly, it states that kindness can make a significant difference in someone's day.`, + ); + + console.log("test 3"); + + // Run 10 prompts simultaneously + const promptResults = await Promise.all( + prompts.map((prompt) => ad4mClient.ai.prompt(newTask.taskId, prompt)), + ); + + console.log("test 4", promptResults); + + // Check results + promptResults.forEach((result, index) => { + expect(result).to.be.a("string"); + expect(result.length).to.be.greaterThan(0); + console.log(`Prompt ${index + 1} result:`, result); + }); + + console.log("test 5"); + + // Clean up: remove the task + await ad4mClient.ai.removeTask(newTask.taskId); + + console.log("test 6"); + }); + + it("can embed text to vectors", async () => { + const ad4mClient = testContext.ad4mClient!; + + let vector = await ad4mClient.ai.embed("bert", "Test string"); + expect(typeof vector).to.equal("object"); + expect(Array.isArray(vector)).to.be.true; + expect(vector.length).to.be.greaterThan(300); + }); + + it.skip("can do audio to text transcription", async () => { + const ad4mClient = testContext.ad4mClient!; + const audioData = await convertAudioToPCM("../transcription_test.m4a"); + let transcribedText = ""; + + // These should be the default parameters + const customParams = { + startThreshold: 0.3, + startWindow: 150, + endThreshold: 0.2, + endWindow: 300, + timeBeforeSpeech: 100, + }; + + const streamId = await ad4mClient.ai.openTranscriptionStream( + "Whisper", + (text) => { + console.log("Received transcription:", text); + transcribedText += text; + }, + customParams, + ); + + // Type assertion for the feedTranscriptionStream function with unknown intermediate + const feedStream = ad4mClient.ai.feedTranscriptionStream.bind( + ad4mClient.ai, + ) as unknown as ( + streamIds: string | string[], + audio: number[], + ) => Promise; + await streamAudioData(audioData, streamId, feedStream); + + try { + await ad4mClient.ai.closeTranscriptionStream(streamId); + } catch (e) { + console.log("Error trying to close TranscriptionStream:", e); + } + + await waitForTranscription(() => transcribedText.length > 0); + + // Assertions + expect(transcribedText).to.be.a("string"); + expect(transcribedText.length).to.be.greaterThan(0); + expect(transcribedText).to.include( + "If you can read this, transcription is working.", + ); + console.log("Final transcription:", transcribedText); + }); + + it.skip("can do fast (word-by-word) audio transcription", async () => { + const ad4mClient = testContext.ad4mClient!; + const audioData = await convertAudioToPCM("../transcription_test.m4a"); + let transcribedWords: string[] = []; + + // Configure parameters for word-by-word detection + // Use very short end window and low thresholds to separate words + const wordByWordParams = { + startThreshold: 0.25, // Lower threshold to detect softer speech + startWindow: 100, // Quick start detection + endThreshold: 0.15, // Lower threshold to detect end of words + endWindow: 100, // Short pause between words (100ms) + timeBeforeSpeech: 20, // Include minimal context before speech + }; + + const streamId = await ad4mClient.ai.openTranscriptionStream( + "Whisper", + (text) => { + console.log("Received word:", text); + if (text.trim()) { + // Only add non-empty text + transcribedWords.push(text.trim()); + } + }, + wordByWordParams, + ); + + // Type assertion for the feedTranscriptionStream function with unknown intermediate + const feedStream = ad4mClient.ai.feedTranscriptionStream.bind( + ad4mClient.ai, + ) as unknown as ( + streamIds: string | string[], + audio: number[], + ) => Promise; + await streamAudioData(audioData, streamId, feedStream); + + try { + await ad4mClient.ai.closeTranscriptionStream(streamId); + } catch (e) { + console.log("Error trying to close TranscriptionStream:", e); + } + + await waitForTranscription(() => transcribedWords.length > 0); + + // Assertions + expect(transcribedWords).to.be.an("array"); + expect(transcribedWords.length).to.be.greaterThan(1); + expect(transcribedWords.join(" ")).to.include( + "If you can read this, transcription is working", + ); + + console.log("Transcribed words:", transcribedWords); + }); + + it.skip("can do fast and accurate transcription simultaneously", async () => { + const ad4mClient = testContext.ad4mClient!; + const audioData = await convertAudioToPCM("../transcription_test.m4a"); + let fastTranscription = ""; + let accurateTranscription = ""; + + // Configure parameters for word-by-word detection (fast) + const fastParams = { + startThreshold: 0.25, + startWindow: 100, + endThreshold: 0.15, + endWindow: 100, + timeBeforeSpeech: 20, + }; + + // Configure parameters for accurate transcription + const accurateParams = { + startThreshold: 0.3, + startWindow: 150, + endThreshold: 0.2, + endWindow: 500, + timeBeforeSpeech: 100, + }; + + // Open two streams with different parameters + const fastStreamId = await ad4mClient.ai.openTranscriptionStream( + "whisper_tiny", + (text) => { + console.log("Received fast transcription:", text); + fastTranscription += text; + }, + fastParams, + ); + + const accurateStreamId = await ad4mClient.ai.openTranscriptionStream( + "whisper_small", + (text) => { + console.log("Received accurate transcription:", text); + accurateTranscription += text; + }, + accurateParams, + ); + + // Feed audio to both streams simultaneously + const feedStream = ad4mClient.ai.feedTranscriptionStream.bind( + ad4mClient.ai, + ) as unknown as ( + streamIds: string | string[], + audio: number[], + ) => Promise; + await streamAudioData( + audioData, + [fastStreamId, accurateStreamId], + feedStream, + ); + + try { + await ad4mClient.ai.closeTranscriptionStream(fastStreamId); + await ad4mClient.ai.closeTranscriptionStream(accurateStreamId); + } catch (e) { + console.log("Error trying to close TranscriptionStream:", e); + } + + await waitForTranscription( + () => + fastTranscription.length > 0 && accurateTranscription.length > 0, + ); + + // Assertions + expect(fastTranscription).to.be.a("string"); + expect(accurateTranscription).to.be.a("string"); + expect(fastTranscription.length).to.be.greaterThan(0); + expect(accurateTranscription.length).to.be.greaterThan(0); + expect(fastTranscription).to.include("If you can read this"); + expect(accurateTranscription).to.include( + "If you can read this, transcription is working", + ); + console.log("Fast transcription:", fastTranscription); + console.log("Accurate transcription:", accurateTranscription); + }); + }); + }; +} diff --git a/tests/js/tests/integration/direct-messages.suite.ts b/tests/js/tests/integration/direct-messages.suite.ts new file mode 100644 index 000000000..1765e434d --- /dev/null +++ b/tests/js/tests/integration/direct-messages.suite.ts @@ -0,0 +1,112 @@ +import { + Ad4mClient, + ExpressionProof, + Link, + LinkExpressionInput, + Literal, + Perspective, +} from "@coasys/ad4m"; +import { TestContext } from "./integration.test"; +import { sleep } from "../../utils/utils"; +import { expect } from "chai"; +import * as sinon from "sinon"; + +export default function directMessageTests(testContext: TestContext) { + return () => { + let link = new LinkExpressionInput(); + link.author = "did:test"; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: Literal.from("me").toUrl(), + predicate: Literal.from("thinks").toUrl(), + target: Literal.from("nothing").toUrl(), + }); + link.proof = new ExpressionProof("sig", "key"); + const message = new Perspective([link]); + + it("don't work when we're not friends", async () => { + const alice = testContext.alice!; + const bob = testContext.bob!; + const { did } = await bob.agent.status(); + + let hasThrown = false; + try { + await alice.runtime.friendStatus(did!); + } catch (e) { + hasThrown = true; + } + + expect(hasThrown).to.be.true; + }); + + describe("with Alice and Bob being friends", () => { + let alice: Ad4mClient, bob: Ad4mClient, didAlice: string, didBob: string; + + before(async () => { + alice = testContext.alice!; + didAlice = (await alice.agent.status()).did!; + bob = testContext.bob!; + didBob = (await bob.agent.status()).did!; + + await alice.runtime.addFriends([didBob!]); + await bob.runtime.addFriends([didAlice!]); + }); + + it("Alice can get Bob's status", async () => { + let link = new LinkExpressionInput(); + link.author = "did:test"; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: didBob, + predicate: Literal.from("is").toUrl(), + target: Literal.from("online").toUrl(), + }); + link.proof = new ExpressionProof("sig", "key"); + const statusBob = new Perspective([link]); + await bob.runtime.setStatus(statusBob); + await sleep(1000); + const statusAlice = await alice.runtime.friendStatus(didBob); + expect(statusAlice).not.to.be.undefined; + delete statusAlice.data.links[0].proof.invalid; + delete statusAlice.data.links[0].proof.valid; + expect(statusAlice.data).to.be.eql(statusBob); + }); + + it("Alice can send a message to Bob", async () => { + const bobMessageCallback = sinon.fake(); + await bob.runtime.addMessageCallback(bobMessageCallback); + await alice.runtime.friendSendMessage(didBob, message); + await sleep(1000); + const bobsInbox = await bob.runtime.messageInbox(); + expect(bobsInbox.length).to.be.equal(1); + + expect(bobMessageCallback.calledOnce).to.be.true; + expect(bobMessageCallback.getCall(0).args[0]).to.be.eql(bobsInbox[0]); + + delete bobsInbox[0].data.links[0].proof.invalid; + delete bobsInbox[0].data.links[0].proof.valid; + expect(bobsInbox[0].data).to.be.eql(message); + + expect((await bob.runtime.messageInbox(didAlice)).length).to.be.equal( + 1, + ); + expect( + (await bob.runtime.messageInbox("did:test:other")).length, + ).to.be.equal(0); + }); + + it("Alice finds her sent message in the outbox", async () => { + const outbox = await alice.runtime.messageOutbox(); + expect(outbox.length).to.be.equal(1); + expect(outbox[0].recipient).to.be.equal(didBob); + delete outbox[0].message.data.links[0].proof.invalid; + delete outbox[0].message.data.links[0].proof.valid; + expect(outbox[0].message.data).to.be.eql(message); + + const filteredOutbox = + await alice.runtime.messageOutbox("did:test:other"); + expect(filteredOutbox.length).to.be.equal(0); + }); + }); + }; +} diff --git a/tests/js/tests/integration/expression.suite.ts b/tests/js/tests/integration/expression.suite.ts new file mode 100644 index 000000000..78ba250a0 --- /dev/null +++ b/tests/js/tests/integration/expression.suite.ts @@ -0,0 +1,193 @@ +import { + InteractionCall, + LanguageMetaInput, + Literal, + parseExprUrl, +} from "@coasys/ad4m"; +import { TestContext } from "./integration.test"; +import { expect } from "chai"; + +export default function expressionTests(testContext: TestContext) { + return () => { + describe("Expressions", () => { + let noteLangAddress = ""; + + before(async () => { + const ad4mClient = testContext.ad4mClient!; + + //Publish mocking interactions language so it can be used + const publish = await ad4mClient.languages.publish( + "./languages/note-store/build/bundle.js", + { + name: "note-store", + description: "A test language for saving simple strings", + } as LanguageMetaInput, + ); + + noteLangAddress = publish.address; + }); + + it("can get() my agent expression", async () => { + const ad4mClient = testContext.ad4mClient!; + const me = await ad4mClient.agent.me(); + + const agent = await ad4mClient.expression.get(me.did); + console.warn(agent); + + expect(agent.proof.valid).to.be.true; + expect(agent.proof.invalid).to.be.false; + }); + + it("can getManyExpressions()", async () => { + const ad4mClient = testContext.ad4mClient!; + const me = await ad4mClient.agent.me(); + + const agentAndNull = await ad4mClient.expression.getMany([ + me.did, + "lang://getNull", + me.did, + ]); + expect(agentAndNull.length).to.be.equal(3); + expect(JSON.parse(agentAndNull[0].data).did).to.be.equal(me.did); + expect(agentAndNull[1]).to.be.null; + expect(JSON.parse(agentAndNull[2].data).did).to.be.equal(me.did); + }); + + it("can getRaw() my agent expression", async () => { + const ad4mClient = testContext.ad4mClient!; + const me = await ad4mClient.agent.me(); + + const agent = await ad4mClient.expression.getRaw(me.did); + expect(JSON.parse(agent).data.did).to.be.equal(me.did); + expect(JSON.parse(agent).data.directMessageLanguage).to.be.equal( + me.directMessageLanguage, + ); + }); + + it("can create()", async () => { + const ad4mClient = testContext.ad4mClient!; + let me = await ad4mClient.agent.me(); + + const result = await ad4mClient.expression.create(me, "did"); + expect(result).to.be.equal(me.did); + }); + + it("can create valid signatures", async () => { + const ad4mClient = testContext.ad4mClient!; + + const exprAddr = await ad4mClient.expression.create( + "test note", + noteLangAddress, + ); + expect(exprAddr).not.to.be.undefined; + + const expr = await ad4mClient.expression.get(exprAddr); + expect(expr).not.to.be.undefined; + expect(expr.proof.valid).to.be.true; + }); + + it("can get expression from cache", async () => { + const ad4mClient = testContext.ad4mClient!; + + const exprAddr = await ad4mClient.expression.create( + "test note", + noteLangAddress, + ); + expect(exprAddr).not.to.be.undefined; + + const expr = await ad4mClient.expression.get(exprAddr); + expect(expr).not.to.be.undefined; + expect(expr.proof.valid).to.be.true; + expect(expr.data).to.be.equal('"test note"'); + + const exprCacheHit = await ad4mClient.expression.get(exprAddr); + expect(exprCacheHit).not.to.be.undefined; + expect(exprCacheHit.proof.valid).to.be.true; + expect(exprCacheHit.data).to.be.equal('"test note"'); + + const objExpr = await ad4mClient.expression.create( + { key: "value" }, + noteLangAddress, + ); + expect(objExpr).not.to.be.undefined; + + const exprObj = await ad4mClient.expression.get(objExpr); + expect(exprObj).not.to.be.undefined; + expect(exprObj.proof.valid).to.be.true; + expect(exprObj.data).to.be.equal(JSON.stringify({ key: "value" })); + + const exprObjCacheHit = await ad4mClient.expression.get(objExpr); + expect(exprObjCacheHit).not.to.be.undefined; + expect(exprObjCacheHit.proof.valid).to.be.true; + expect(exprObjCacheHit.data).to.be.equal( + JSON.stringify({ key: "value" }), + ); + }); + + it("can use expression interactions", async () => { + const ad4mClient = testContext.ad4mClient!; + //Publish mocking interactions language so it can be used + const publish = await ad4mClient.languages.publish( + "./languages/test-language/build/bundle.js", + { + name: "test-language", + description: "A test language for interactions", + } as LanguageMetaInput, + ); + + const testLangAddress = publish.address; + + const exprAddr = await ad4mClient.expression.create( + "test note", + testLangAddress, + ); + expect(exprAddr).not.to.be.undefined; + + let expr = await ad4mClient.expression.get(exprAddr); + expect(expr).not.to.be.undefined; + expect(expr.proof.valid).to.be.true; + expect(expr.data).to.be.equal('"test note"'); + + const interactions = await ad4mClient.expression.interactions(exprAddr); + + expect(interactions.length).to.be.equal(1); + expect(interactions[0].name).to.be.equal("modify"); + + const interactionCall = new InteractionCall("modify", { + newValue: "modified note", + }); + const result = await ad4mClient.expression.interact( + exprAddr, + interactionCall, + ); + expect(result).to.be.equal("ok"); + + expr = await ad4mClient.expression.get(exprAddr); + expect(expr).not.to.be.undefined; + expect(expr.proof.valid).to.be.true; + expect(expr.data).to.be.equal('"modified note"'); + }); + + it("Literal language expressions can be created with signature and can get resolved from URL", async () => { + const ad4mClient = testContext.ad4mClient!; + + const TEST_DATA = "Hello World"; + const addr = await ad4mClient.expression.create(TEST_DATA, "literal"); + const exprRef = parseExprUrl(addr); + expect(exprRef.language.address).to.be.equal("literal"); + + const expr = Literal.fromUrl(addr).get(); + + expect(expr.data).to.be.equal(TEST_DATA); + + const expr2Raw = await ad4mClient.expression.getRaw(addr); + const expr2 = JSON.parse(expr2Raw); + console.log(expr2); + + expr.proof.valid = true; + expr.proof.invalid = false; + expect(expr2).to.be.eql(expr); + }); + }); + }; +} diff --git a/tests/js/tests/integration/integration.test.ts b/tests/js/tests/integration/integration.test.ts new file mode 100644 index 000000000..98dd5b2c2 --- /dev/null +++ b/tests/js/tests/integration/integration.test.ts @@ -0,0 +1,269 @@ +import fs from "fs-extra"; +import path from "path"; +import { + Ad4mClient, + ExpressionProof, + Link, + LinkExpression, + Perspective, +} from "@coasys/ad4m"; +import { fileURLToPath } from "url"; +import { expect } from "chai"; +import { + startExecutor, + apolloClient, + runHcLocalServices, + waitForExit, +} from "../../utils/utils"; +import { ChildProcess } from "child_process"; +import { getFreePorts } from "../../helpers/ports"; +import perspectiveTests from "./perspective.suite"; +import agentTests from "./agent.suite"; +import aiTests from "./ai.suite"; +import languageTests from "./language.suite"; +import expressionTests from "./expression.suite"; +import neighbourhoodTests from "./neighbourhood.suite"; +import runtimeTests from "./runtime.suite"; +import agentLanguageTests from "./agent-language.suite"; +import socialDNATests from "./social-dna-flow.suite"; +import tripleAgentTests from "./triple-agent-test.suite"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const TEST_DIR = `${__dirname}/../../tst-tmp`; + +export class TestContext { + #alice: Ad4mClient | undefined; + #bob: Ad4mClient | undefined; + #jim: Ad4mClient | undefined; + + #aliceCore: ChildProcess | undefined; + #bobCore: ChildProcess | undefined; + #jimCore: ChildProcess | undefined; + + get ad4mClient(): Ad4mClient { + return this.#alice!; + } + + get alice(): Ad4mClient { + return this.#alice!; + } + + get bob(): Ad4mClient { + return this.#bob!; + } + + get jim(): Ad4mClient { + return this.#jim!; + } + + set alice(client: Ad4mClient) { + this.#alice = client; + } + + set bob(client: Ad4mClient) { + this.#bob = client; + } + + set jim(client: Ad4mClient) { + this.#jim = client; + } + + set aliceCore(aliceCore: ChildProcess) { + this.#aliceCore = aliceCore; + } + + set bobCore(bobCore: ChildProcess) { + this.#bobCore = bobCore; + } + + set jimCore(jimCore: ChildProcess) { + this.#jimCore = jimCore; + } + + async makeAllNodesKnown() { + const aliceAgentInfo = await this.#alice!.runtime.hcAgentInfos(); + const bobAgentInfo = await this.#bob!.runtime.hcAgentInfos(); + + await this.#alice!.runtime.hcAddAgentInfos(bobAgentInfo); + await this.#bob!.runtime.hcAddAgentInfos(aliceAgentInfo); + } + + async makeAllThreeNodesKnown() { + const aliceAgentInfo = await this.#alice!.runtime.hcAgentInfos(); + const bobAgentInfo = await this.#bob!.runtime.hcAgentInfos(); + const jimAgentInfo = await this.#jim!.runtime.hcAgentInfos(); + + await this.#alice!.runtime.hcAddAgentInfos(bobAgentInfo); + await this.#alice!.runtime.hcAddAgentInfos(jimAgentInfo); + await this.#bob!.runtime.hcAddAgentInfos(aliceAgentInfo); + await this.#bob!.runtime.hcAddAgentInfos(jimAgentInfo); + await this.#jim!.runtime.hcAddAgentInfos(aliceAgentInfo); + await this.#jim!.runtime.hcAddAgentInfos(bobAgentInfo); + } +} +let testContext: TestContext = new TestContext(); + +describe("Integration tests", function () { + this.timeout(660000); + const appDataPath = path.join(TEST_DIR, "agents", "alice"); + const bootstrapSeedPath = path.join(`${__dirname}/../../bootstrapSeed.json`); + let gqlPort: number; + let hcAdminPort: number; + let hcAppPort: number; + + let executorProcess: ChildProcess | null = null; + + let proxyUrl: string | null = null; + let bootstrapUrl: string | null = null; + let localServicesProcess: ChildProcess | null = null; + let relayUrl: string | null = null; + + before(async () => { + [gqlPort, hcAdminPort, hcAppPort] = await getFreePorts(3); + + if (!fs.existsSync(TEST_DIR)) { + throw Error( + "Please ensure that prepare-test is run before running tests!", + ); + } + if (!fs.existsSync(path.join(TEST_DIR, "agents"))) + fs.mkdirSync(path.join(TEST_DIR, "agents")); + if (!fs.existsSync(appDataPath)) fs.mkdirSync(appDataPath); + + let localServices = await runHcLocalServices(); + proxyUrl = localServices.proxyUrl; + bootstrapUrl = localServices.bootstrapUrl; + localServicesProcess = localServices.process; + relayUrl = localServices.relayUrl; + + executorProcess = await startExecutor( + appDataPath, + bootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + relayUrl!, + ); + + testContext.alice = new Ad4mClient(apolloClient(gqlPort)); + testContext.aliceCore = executorProcess; + }); + + after(async () => { + await waitForExit(executorProcess); + await waitForExit(localServicesProcess); + }); + + describe("Agent / Agent-Setup", agentTests(testContext)); + describe("Artificial Intelligence", aiTests(testContext)); + describe("Runtime", runtimeTests(testContext)); + describe("Expression", expressionTests(testContext)); + describe("Perspective", perspectiveTests(testContext)); + describe("Social DNA", socialDNATests(testContext)); + + describe("with Alice and Bob", () => { + let bobExecutorProcess: ChildProcess | null = null; + before(async () => { + const bobAppDataPath = path.join(TEST_DIR, "agents", "bob"); + const bobBootstrapSeedPath = path.join( + `${__dirname}/../../bootstrapSeed.json`, + ); + const [bobGqlPort, bobHcAdminPort, bobHcAppPort] = await getFreePorts(3); + + if (!fs.existsSync(path.join(TEST_DIR, "agents"))) + fs.mkdirSync(path.join(TEST_DIR, "agents")); + if (!fs.existsSync(bobAppDataPath)) fs.mkdirSync(bobAppDataPath); + + bobExecutorProcess = await startExecutor( + bobAppDataPath, + bobBootstrapSeedPath, + bobGqlPort, + bobHcAdminPort, + bobHcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + relayUrl!, + ); + + testContext.bob = new Ad4mClient(apolloClient(bobGqlPort)); + testContext.bobCore = bobExecutorProcess; + await testContext.bob.agent.generate("passphrase"); + + const status = await testContext.bob.agent.status(); + + expect(status.isInitialized).to.be.true; + expect(status.isUnlocked).to.be.true; + + let link = new LinkExpression(); + link.author = "did:test"; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: "ad4m://src", + target: "test://target", + predicate: "ad4m://pred", + }); + link.proof = new ExpressionProof("sig", "key"); + + await testContext.bob.agent.updatePublicPerspective( + new Perspective([link]), + ); + + await testContext.makeAllNodesKnown(); + }); + + after(async () => { + await waitForExit(bobExecutorProcess); + }); + + describe("Agent Language", agentLanguageTests(testContext)); + describe("Language", languageTests(testContext)); + describe("Neighbourhood", neighbourhoodTests(testContext)); + //describe('Direct Messages', directMessageTests(testContext)) + + describe("with Alice, Bob and Jim", () => { + let jimExecutorProcess: ChildProcess | null = null; + before(async () => { + const jimAppDataPath = path.join(TEST_DIR, "agents", "jim"); + const [jimGqlPort, jimHcAdminPort, jimHcAppPort] = + await getFreePorts(3); + + if (!fs.existsSync(jimAppDataPath)) fs.mkdirSync(jimAppDataPath); + + jimExecutorProcess = await startExecutor( + jimAppDataPath, + bootstrapSeedPath, + jimGqlPort, + jimHcAdminPort, + jimHcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + relayUrl!, + ); + + testContext.jim = new Ad4mClient(apolloClient(jimGqlPort)); + testContext.jimCore = jimExecutorProcess; + await testContext.jim.agent.generate("passphrase"); + + const status = await testContext.jim.agent.status(); + expect(status.isInitialized).to.be.true; + expect(status.isUnlocked).to.be.true; + }); + + after(async () => { + await waitForExit(jimExecutorProcess); + }); + + describe("Triple Agent", tripleAgentTests(testContext)); + }); + }); +}); diff --git a/tests/js/tests/integration/language.suite.ts b/tests/js/tests/integration/language.suite.ts new file mode 100644 index 000000000..de92c39df --- /dev/null +++ b/tests/js/tests/integration/language.suite.ts @@ -0,0 +1,310 @@ +import { TestContext } from "./integration.test"; +import path from "path"; +import fs from "fs"; +import { sleep } from "../../utils/utils"; +import { Ad4mClient, LanguageMetaInput, LanguageRef } from "@coasys/ad4m"; +import { expect } from "chai"; +import { fileURLToPath } from "url"; +import stringify from "json-stable-stringify"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default function languageTests(testContext: TestContext) { + return () => { + describe("with a perspective-diff-sync templated by Alice", () => { + let ad4mClient: Ad4mClient; + let bobAd4mClient: Ad4mClient; + let sourceLanguage: LanguageRef = new LanguageRef(); + let nonHCSourceLanguage: LanguageRef = new LanguageRef(); + let sourceLanguageMeta: LanguageMetaInput = new LanguageMetaInput( + "Newly published perspective-diff-sync", + "..here for you template", + ); + sourceLanguageMeta.possibleTemplateParams = [ + "uid", + "description", + "name", + ]; + + before(async () => { + ad4mClient = testContext.ad4mClient; + bobAd4mClient = testContext.bob; + + //First edit bundle for perspective-diff-sync so we get a unique hash which does not clash with existing loaded perspective-diff-sync object in LanguageController + let socialContextData = fs + .readFileSync( + "./tst-tmp/languages/perspective-diff-sync/build/bundle.js", + ) + .toString(); + socialContextData = socialContextData + "\n//Test"; + fs.writeFileSync( + "./tst-tmp/languages/perspective-diff-sync/build/bundle.js", + socialContextData, + ); + + //Publish a source language to start working from + sourceLanguage = await ad4mClient.languages.publish( + path + .join( + __dirname, + "../../tst-tmp/languages/perspective-diff-sync/build/bundle.js", + ) + .replace(/\\/g, "/"), + sourceLanguageMeta, + ); + expect(sourceLanguage.name).to.be.equal(sourceLanguageMeta.name); + // @ts-ignore + sourceLanguageMeta.address = sourceLanguage.address; + }); + + it("Alice can get the source of her own templated language", async () => { + const sourceFromAd4m = await ad4mClient.languages.source( + sourceLanguage.address, + ); + const sourceFromFile = fs + .readFileSync( + path.join( + __dirname, + "../../tst-tmp/languages/perspective-diff-sync/build/bundle.js", + ), + ) + .toString(); + expect(sourceFromAd4m).to.be.equal(sourceFromFile); + }); + + it("Alice can install her own published language", async () => { + const install = await ad4mClient.languages.byAddress( + sourceLanguage.address, + ); + expect(install.address).not.to.be.undefined; + expect(install.constructorIcon).to.be.null; + expect(install.settingsIcon).not.to.be.undefined; + }); + + it("Alice can install her own non HC published language", async () => { + let sourceLanguageMeta: LanguageMetaInput = new LanguageMetaInput( + "Newly published perspective-language", + "..here for you template", + ); + let socialContextData = fs + .readFileSync( + "./tst-tmp/languages/perspective-language/build/bundle.js", + ) + .toString(); + socialContextData = socialContextData + "\n//Test"; + fs.writeFileSync( + "./tst-tmp/languages/perspective-language/build/bundle.js", + socialContextData, + ); + + //Publish a source language to start working from + nonHCSourceLanguage = await ad4mClient.languages.publish( + path + .join( + __dirname, + "../../tst-tmp/languages/perspective-language/build/bundle.js", + ) + .replace(/\\/g, "/"), + sourceLanguageMeta, + ); + expect(nonHCSourceLanguage.name).to.be.equal(nonHCSourceLanguage.name); + + const install = await ad4mClient.languages.byAddress( + nonHCSourceLanguage.address, + ); + expect(install.address).not.to.be.undefined; + expect(install.constructorIcon).not.to.be.null; + expect(install.icon).not.to.be.null; + expect(install.settingsIcon).to.be.null; + }); + + it("Alice can use language.meta() to get meta info of her Language", async () => { + const meta = await ad4mClient.languages.meta(sourceLanguage.address); + expect(meta.address).to.be.equal(sourceLanguage.address); + expect(meta.name).to.be.equal(sourceLanguageMeta.name); + expect(meta.description).to.be.equal(sourceLanguageMeta.description); + expect(meta.author).to.be.equal((await ad4mClient.agent.status()).did); + expect(meta.templated).to.be.false; + }); + + it("Alice can get her own templated perspective-diff-sync and it provides correct meta data", async () => { + //Get the meta of the source language and make sure it is correct + const foundSourceLanguageMeta = await ad4mClient.expression.get( + `lang://${sourceLanguage.address}`, + ); + expect(foundSourceLanguageMeta.proof.valid).to.be.true; + const sourceLanguageMetaData = JSON.parse(foundSourceLanguageMeta.data); + expect(sourceLanguageMetaData.name).to.be.equal( + sourceLanguageMeta.name, + ); + expect(sourceLanguageMetaData.description).to.be.equal( + sourceLanguageMeta.description, + ); + }); + + it("can publish and template a non-Holochain language and provide correct meta data", async () => { + const noteMetaInfo = new LanguageMetaInput( + "Newly published note language", + "Just to test non-HC language work as well", + ); + //Publish a source language without a holochain DNA + const canPublishNonHolochainLang = await ad4mClient.languages.publish( + path + .join(__dirname, "../../languages/note-store/build/bundle.js") + .replace(/\\/g, "/"), + noteMetaInfo, + ); + expect(canPublishNonHolochainLang.name).to.be.equal(noteMetaInfo.name); + //TODO/NOTE: this will break if the note language version is changed + expect(canPublishNonHolochainLang.address).to.be.equal( + "QmzSYwdiTHLZtzCPBq384QqeyKT4P2JvqqXH8So4MB4axfftLHA", + ); + + //Get meta for source language above and make sure it is correct + const sourceLanguageMetaNonHC = await ad4mClient.expression.get( + `lang://${canPublishNonHolochainLang.address}`, + ); + expect(sourceLanguageMetaNonHC.proof.valid).to.be.true; + const sourceLanguageMetaNonHCData = JSON.parse( + sourceLanguageMetaNonHC.data, + ); + expect(sourceLanguageMetaNonHCData.name).to.be.equal(noteMetaInfo.name); + expect(sourceLanguageMetaNonHCData.description).to.be.equal( + noteMetaInfo.description, + ); + expect(sourceLanguageMetaNonHCData.address).to.be.equal( + "QmzSYwdiTHLZtzCPBq384QqeyKT4P2JvqqXH8So4MB4axfftLHA", + ); + }); + + it("Bob can not get/install Alice's language since he doesn't trust her yet", async () => { + // Make sure all new Holochain cells get connected.. + await testContext.makeAllNodesKnown(); + // .. and have time to gossip inside the Language Language, + // so Bob sees the languages created above by Alice + await sleep(1000); + + //Test that bob cannot install source language which alice created since she is not in his trusted agents + let error; + try { + await bobAd4mClient.languages.byAddress(sourceLanguage.address); + } catch (e) { + error = e; + } + + console.log("Response for non trust got error", error); + + //@ts-ignore + sourceLanguageMeta.sourceCodeLink = null; + + //@ts-ignore + expect(error.toString()).to.contain( + `ApolloError: Language not created by trusted agent: ${(await ad4mClient.agent.me()).did} and is not templated... aborting language install. Language metadata: ${stringify(sourceLanguageMeta)}`, + ); + }); + + describe("with Bob having added Alice to list of trusted agents", () => { + let applyTemplateFromSource: LanguageRef = new LanguageRef(); + + before(async () => { + //Add alice as trusted agent for bob + const aliceDid = await ad4mClient.agent.me(); + const aliceTrusted = await bobAd4mClient.runtime.addTrustedAgents([ + aliceDid.did, + ]); + console.warn("Got result when adding trusted agent", aliceTrusted); + }); + + it("Bob can template Alice's perspective-diff-sync, and alice can install", async () => { + //Apply template on above holochain language + applyTemplateFromSource = + await bobAd4mClient.languages.applyTemplateAndPublish( + sourceLanguage.address, + JSON.stringify({ + uid: "2eebb82b-9db1-401b-ba04-1e8eb78ac84c", + name: "Bob's templated perspective-diff-sync", + }), + ); + expect(applyTemplateFromSource.name).to.be.equal( + "Bob's templated perspective-diff-sync", + ); + expect(applyTemplateFromSource.address).not.be.equal( + sourceLanguage.address, + ); + + //Get language meta for above language and make sure it is correct + const langExpr = await bobAd4mClient.expression.get( + `lang://${applyTemplateFromSource.address}`, + ); + expect(langExpr.proof.valid).to.be.true; + const meta = await bobAd4mClient.languages.meta( + applyTemplateFromSource.address, + ); + expect(meta.name).to.be.equal( + "Bob's templated perspective-diff-sync", + ); + expect(meta.description).to.be.equal("..here for you template"); + expect(meta.author).to.be.equal( + (await bobAd4mClient.agent.status()).did, + ); + expect(meta.templateAppliedParams).to.be.equal( + JSON.stringify({ + name: "Bob's templated perspective-diff-sync", + uid: "2eebb82b-9db1-401b-ba04-1e8eb78ac84c", + }), + ); + expect(meta.templateSourceLanguageAddress).to.be.equal( + sourceLanguage.address, + ); + + await ad4mClient.runtime.addTrustedAgents([ + (await bobAd4mClient.agent.me()).did, + ]); + + const installGetLanguage = await ad4mClient.languages.byAddress( + applyTemplateFromSource.address, + ); + expect(installGetLanguage.address).to.be.equal( + applyTemplateFromSource.address, + ); + expect(installGetLanguage.name).to.be.equal(meta.name); + }); + + it("Bob can install Alice's perspective-diff-sync", async () => { + //Test that bob can install source language when alice is in trusted agents + const installSourceTrusted = await bobAd4mClient.languages.byAddress( + sourceLanguage.address, + ); + expect(installSourceTrusted.address).to.be.equal( + sourceLanguage.address, + ); + expect(installSourceTrusted.constructorIcon).not.to.be.undefined; + expect(installSourceTrusted.settingsIcon).not.to.be.undefined; + }); + + it("Bob can install his own templated language", async () => { + //Test that bob can install language which is templated from source since source language was created by alice who is now a trusted agent + const installTemplated = await bobAd4mClient.languages.byAddress( + applyTemplateFromSource.address, + ); + expect(installTemplated.address).to.be.equal( + applyTemplateFromSource.address, + ); + expect(installTemplated.name).to.be.equal( + "Bob's templated perspective-diff-sync", + ); + expect(installTemplated.constructorIcon).not.to.be.undefined; + expect(installTemplated.settingsIcon).not.to.be.undefined; + }); + + it("Bob can delete a language", async () => { + const deleteLanguage = await bobAd4mClient.languages.remove( + sourceLanguage.address, + ); + expect(deleteLanguage).to.be.true; + }); + }); + }); + }; +} diff --git a/tests/js/tests/integration/neighbourhood.suite.ts b/tests/js/tests/integration/neighbourhood.suite.ts new file mode 100644 index 000000000..f9297c66d --- /dev/null +++ b/tests/js/tests/integration/neighbourhood.suite.ts @@ -0,0 +1,739 @@ +import { + Link, + Perspective, + LinkExpression, + ExpressionProof, + LinkQuery, + PerspectiveState, + NeighbourhoodProxy, + PerspectiveUnsignedInput, + PerspectiveProxy, + PerspectiveHandle, +} from "@coasys/ad4m"; +import { TestContext } from "./integration.test"; +import { sleep } from "../../utils/utils"; +import fs from "fs"; +import { v4 as uuidv4 } from "uuid"; +import { expect } from "chai"; + +const DIFF_SYNC_OFFICIAL = fs + .readFileSync("./scripts/perspective-diff-sync-hash") + .toString(); +let aliceP1: null | PerspectiveProxy = null; +let bobP1: null | PerspectiveHandle = null; + +export default function neighbourhoodTests(testContext: TestContext) { + return () => { + describe("Neighbourhood", () => { + it("can publish and join locally @alice", async () => { + const ad4mClient = testContext.alice!; + + const create = await ad4mClient!.perspective.add("publish-test"); + expect(create.name).to.be.equal("publish-test"); + expect(create.neighbourhood).to.be.null; + expect(create.state).to.be.equal(PerspectiveState.Private); + + //Create unique perspective-diff-sync to simulate real scenario + const socialContext = + await ad4mClient.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's perspective-diff-sync", + }), + ); + expect(socialContext.name).to.be.equal("Alice's perspective-diff-sync"); + + let link = new LinkExpression(); + link.author = "did:test"; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: "ad4m://src", + target: "test://target", + predicate: "ad4m://pred", + }); + link.proof = new ExpressionProof("sig", "key"); + const publishPerspective = + await ad4mClient.neighbourhood.publishFromPerspective( + create.uuid, + socialContext.address, + new Perspective([link]), + ); + + //Check that we got an ad4m url back + expect(publishPerspective.split("://").length).to.be.equal(2); + + const perspective = await ad4mClient.perspective.byUUID(create.uuid); + expect(perspective?.neighbourhood).not.to.be.undefined; + expect(perspective?.neighbourhood!.data.linkLanguage).to.be.equal( + socialContext.address, + ); + expect(perspective?.neighbourhood!.data.meta.links.length).to.be.equal( + 1, + ); + + // The perspective should start in NeighbourhoodCreationInitiated state + expect(perspective?.state).to.be.equal( + PerspectiveState.NeighboudhoodCreationInitiated, + ); + + // Wait for the perspective to transition to Synced state + let tries = 0; + const maxTries = 10; + let currentPerspective = perspective; + while ( + currentPerspective?.state !== PerspectiveState.Synced && + tries < maxTries + ) { + await sleep(1000); + currentPerspective = await ad4mClient.perspective.byUUID(create.uuid); + tries++; + } + expect(currentPerspective?.state).to.be.equal(PerspectiveState.Synced); + }); + + it("can be created by Alice and joined by Bob", async () => { + const alice = testContext.alice; + const bob = testContext.bob; + + const aliceP1 = await alice.perspective.add("friends"); + const socialContext = await alice.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's neighbourhood with Bob", + }), + ); + expect(socialContext.name).to.be.equal( + "Alice's neighbourhood with Bob", + ); + const neighbourhoodUrl = + await alice.neighbourhood.publishFromPerspective( + aliceP1.uuid, + socialContext.address, + new Perspective(), + ); + + let bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + + await testContext.makeAllNodesKnown(); + + expect(bobP1!.name).not.to.be.undefined; + expect(bobP1!.sharedUrl).to.be.equal(neighbourhoodUrl); + expect(bobP1!.neighbourhood).not.to.be.undefined; + expect(bobP1!.neighbourhood!.data.linkLanguage).to.be.equal( + socialContext.address, + ); + expect(bobP1!.neighbourhood!.data.meta.links.length).to.be.equal(0); + }); + + it("shared link created by Alice received by Bob", async () => { + const alice = testContext.alice; + const bob = testContext.bob; + + const aliceP1 = await alice.perspective.add("friends"); + const socialContext = await alice.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's neighbourhood with Bob test shared links", + }), + ); + const neighbourhoodUrl = + await alice.neighbourhood.publishFromPerspective( + aliceP1.uuid, + socialContext.address, + new Perspective(), + ); + + let bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + + await testContext.makeAllNodesKnown(); + expect(bobP1!.state).to.be.oneOf([ + PerspectiveState.LinkLanguageInstalledButNotSynced, + PerspectiveState.Synced, + ]); + + await sleep(1000); + + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + + await sleep(1000); + + let bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + let tries = 1; + + while (bobLinks.length < 1 && tries < 60) { + console.log("Bob retrying getting links..."); + await sleep(1000); + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + + expect(bobLinks.length).to.be.equal(1); + expect(bobLinks[0].data.target).to.be.equal("test://test"); + expect(bobLinks[0].proof.valid).to.be.true; + }); + + it("local link created by Alice NOT received by Bob", async () => { + const alice = testContext.alice; + const bob = testContext.bob; + + aliceP1 = await alice.perspective.add("friends"); + const socialContext = await alice.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's neighbourhood with Bob test local links", + }), + ); + const neighbourhoodUrl = + await alice.neighbourhood.publishFromPerspective( + aliceP1.uuid, + socialContext.address, + new Perspective(), + ); + console.log("neighbourhoodUrl", neighbourhoodUrl); + bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + + await testContext.makeAllNodesKnown(); + + await sleep(1000); + + await alice.perspective.addLink( + aliceP1.uuid, + { source: "ad4m://root", target: "test://test" }, + "local", + ); + + await sleep(1000); + + let bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + let tries = 1; + + while (bobLinks.length < 1 && tries < 5) { + console.log("Bob retrying getting NOT received links..."); + await sleep(1000); + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + + expect(bobLinks.length).to.be.equal(0); + }); + + it("stress test - Bob receives 1500 links created rapidly by Alice", async () => { + const alice = testContext.alice; + const bob = testContext.bob; + + aliceP1 = await alice.perspective.add("friends"); + const socialContext = await alice.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's neighbourhood with Bob stress test", + }), + ); + const neighbourhoodUrl = + await alice.neighbourhood.publishFromPerspective( + aliceP1.uuid, + socialContext.address, + new Perspective(), + ); + console.log("neighbourhoodUrl", neighbourhoodUrl); + bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + + await testContext.makeAllNodesKnown(); + + await sleep(1000); + + // Create 1500 links as fast as possible + //const linkPromises = [] + for (let i = 0; i < 1500; i++) { + console.log("Alice adding link ", i); + const link = await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: `test://test/${i}`, + }); + console.log("Link expression:", link); + } + //await Promise.all(linkPromises) + + console.log("wait 15s for initial sync"); + await sleep(15000); + + let bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + let tries = 1; + const maxTries = 180; // 3 minutes with 1 second sleep (increased for fallback sync) + + while (bobLinks.length < 1500 && tries < maxTries) { + console.log( + `Bob retrying getting links... Got ${bobLinks.length}/1500`, + ); + await sleep(1000); + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + + expect(bobLinks.length).to.be.equal(1500); + // Verify a few random links to ensure data integrity + expect(bobLinks.some((link) => link.data.target === "test://test/0")).to + .be.true; + expect(bobLinks.some((link) => link.data.target === "test://test/749")) + .to.be.true; + expect(bobLinks.some((link) => link.data.target === "test://test/1499")) + .to.be.true; + bobLinks.forEach((link) => { + expect(link.proof.valid).to.be.true; + }); + + // make sure we're getting out of burst mode again + await sleep(11000); + + // Alice creates some links + console.log("Alice creating links..."); + await testContext.alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://alice", + target: "test://alice/1", + }); + await testContext.alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://alice", + target: "test://alice/2", + }); + await testContext.alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://alice", + target: "test://alice/3", + }); + + // Wait for sync with retry loop + bobLinks = await testContext.bob.perspective.queryLinks( + bobP1.uuid, + new LinkQuery({ source: "ad4m://alice" }), + ); + let bobTries = 1; + const maxTriesBob = 20; // 20 tries with 2 second sleep = 40 seconds max + + while (bobLinks.length < 3 && bobTries < maxTriesBob) { + console.log( + `Bob retrying getting Alice's links... Got ${bobLinks.length}/3`, + ); + await sleep(2000); + bobLinks = await testContext.bob.perspective.queryLinks( + bobP1.uuid, + new LinkQuery({ source: "ad4m://alice" }), + ); + bobTries++; + } + + // Verify Bob received Alice's links + expect(bobLinks.length).to.equal(3); + expect(bobLinks.some((link) => link.data.target === "test://alice/1")) + .to.be.true; + expect(bobLinks.some((link) => link.data.target === "test://alice/2")) + .to.be.true; + expect(bobLinks.some((link) => link.data.target === "test://alice/3")) + .to.be.true; + + // Bob creates some links + console.log("Bob creating links..."); + await testContext.bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://bob", + target: "test://bob/1", + }); + await testContext.bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://bob", + target: "test://bob/2", + }); + await testContext.bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://bob", + target: "test://bob/3", + }); + + // Wait for sync with retry loop + let aliceLinks = await testContext.alice.perspective.queryLinks( + aliceP1.uuid, + new LinkQuery({ source: "ad4m://bob" }), + ); + tries = 1; + const maxTriesAlice = 20; // 2 minutes with 1 second sleep + + while (aliceLinks.length < 3 && tries < maxTriesAlice) { + console.log( + `Alice retrying getting links... Got ${aliceLinks.length}/3`, + ); + await sleep(2000); + aliceLinks = await testContext.alice.perspective.queryLinks( + aliceP1.uuid, + new LinkQuery({ source: "ad4m://bob" }), + ); + tries++; + } + + // Verify Alice received Bob's links + //let aliceLinks = await testContext.alice.perspective.queryLinks(aliceP1.uuid, new LinkQuery({source: 'bob'})) + expect(aliceLinks.length).to.equal(3); + expect(aliceLinks.some((link) => link.data.target === "test://bob/1")) + .to.be.true; + expect(aliceLinks.some((link) => link.data.target === "test://bob/2")) + .to.be.true; + expect(aliceLinks.some((link) => link.data.target === "test://bob/3")) + .to.be.true; + }); + + it("can delete neighbourhood", async () => { + const alice = testContext.alice; + const bob = testContext.bob; + + const deleteNeighbourhood = await alice.perspective.remove( + aliceP1!.uuid, + ); + expect(deleteNeighbourhood.perspectiveRemove).to.be.true; + + const bobDeleteNeighbourhood = await bob.perspective.remove( + bobP1!.uuid, + ); + expect(bobDeleteNeighbourhood.perspectiveRemove).to.be.true; + + const perspectives = await alice.perspective.all(); + }); + + // it('can get the correct state change signals', async () => { + // const aliceP1 = await testContext.alice.perspective.add("state-changes") + // expect(aliceP1.state).to.be.equal(PerspectiveState.Private); + + // const socialContext = await testContext.alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob"})); + // expect(socialContext.name).to.be.equal("Alice's neighbourhood with Bob"); + // const neighbourhoodUrl = await testContext.alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) + + // let aliceSyncChangeCalls = 0; + // let aliceSyncChangeData = null; + // const aliceSyncChangeHandler = (payload: PerspectiveState) => { + // aliceSyncChangeCalls += 1; + // //@ts-ignore + // aliceSyncChangeData = payload; + // return null; + // }; + + // aliceP1.addSyncStateChangeListener(aliceSyncChangeHandler); + + // await testContext.alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) + + // let bobSyncChangeCalls = 0; + // let bobSyncChangeData = null; + // const bobSyncChangeHandler = (payload: PerspectiveState) => { + // console.log("bob got new state", payload); + // bobSyncChangeCalls += 1; + // //@ts-ignore + // bobSyncChangeData = payload; + // return null; + // }; + + // let bobHandler = await testContext.bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + // let bobP1 = await testContext.bob.perspective.byUUID(bobHandler.uuid); + // expect(bobP1?.state).to.be.equal(PerspectiveState.LinkLanguageInstalledButNotSynced); + + // await bobP1!.addSyncStateChangeListener(bobSyncChangeHandler); + + // //These next assertions are flaky since they depend on holochain not syncing right away, which most of the time is the case + + // let bobLinks = await testContext.bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) + // let tries = 1 + + // while(bobLinks.length < 1 && tries < 300) { + // await sleep(1000) + // bobLinks = await testContext.bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) + // tries++ + // } + + // expect(bobLinks.length).to.be.equal(1) + + // await sleep(5000); + + // // expect(aliceSyncChangeCalls).to.be.equal(2); + // // expect(aliceSyncChangeData).to.be.equal(PerspectiveState.Synced); + + // expect(bobSyncChangeCalls).to.be.equal(1); + // expect(bobSyncChangeData).to.be.equal(PerspectiveState.Synced); + // }) + + describe("with set up and joined NH for Telepresence", async () => { + let aliceNH: NeighbourhoodProxy | undefined; + let bobNH: NeighbourhoodProxy | undefined; + let aliceDID: string | undefined; + let bobDID: string | undefined; + + before(async () => { + const alice = testContext.alice; + const bob = testContext.bob; + + const aliceP1 = await alice.perspective.add("telepresence"); + const linkLang = await alice.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's neighbourhood for Telepresence", + }), + ); + const neighbourhoodUrl = + await alice.neighbourhood.publishFromPerspective( + aliceP1.uuid, + linkLang.address, + new Perspective(), + ); + await sleep(5000); + const bobP1Handle = + await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + const bobP1 = await bob.perspective.byUUID(bobP1Handle.uuid); + await testContext.makeAllNodesKnown(); + + aliceNH = aliceP1.getNeighbourhoodProxy(); + bobNH = bobP1!.getNeighbourhoodProxy(); + aliceDID = (await alice.agent.me()).did; + bobDID = (await bob.agent.me()).did; + await sleep(5000); + }); + + it("they see each other in `otherAgents`", async () => { + // Wait for agents to discover each other with retry loop + let aliceAgents = await aliceNH!.otherAgents(); + let bobAgents = await bobNH!.otherAgents(); + let tries = 1; + const maxTries = 60; // 60 tries with 1 second sleep = 1 minute max + + while ( + (aliceAgents.length < 1 || bobAgents.length < 1) && + tries < maxTries + ) { + console.log( + `Waiting for agents to discover each other... Alice: ${aliceAgents.length}, Bob: ${bobAgents.length}`, + ); + await sleep(1000); + aliceAgents = await aliceNH!.otherAgents(); + bobAgents = await bobNH!.otherAgents(); + tries++; + } + + console.log("alice agents", aliceAgents); + console.log("bob agents", bobAgents); + expect(aliceAgents.length).to.be.equal(1); + expect(aliceAgents[0]).to.be.equal(bobDID); + expect(bobAgents.length).to.be.equal(1); + expect(bobAgents[0]).to.be.equal(aliceDID); + }); + + it("they can set their online status and see each others online status in `onlineAgents`", async () => { + let link = new LinkExpression(); + link.author = "did:test"; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: "ad4m://src", + target: "test://target", + predicate: "ad4m://pred", + }); + link.proof = new ExpressionProof("sig", "key"); + link.proof.invalid = true; + link.proof.valid = false; + const testPerspective = new Perspective([link]); + await aliceNH!.setOnlineStatus(testPerspective); + await bobNH!.setOnlineStatus(testPerspective); + + const aliceOnline = await aliceNH!.onlineAgents(); + const bobOnline = await bobNH!.onlineAgents(); + expect(aliceOnline.length).to.be.equal(1); + expect(aliceOnline[0].did).to.be.equal(bobDID); + console.log(aliceOnline[0].status); + expect(aliceOnline[0].status.data.links).to.deep.equal( + testPerspective.links, + ); + + expect(bobOnline.length).to.be.equal(1); + expect(bobOnline[0].did).to.be.equal(aliceDID); + expect(bobOnline[0].status.data.links).to.deep.equal( + testPerspective.links, + ); + + await aliceNH!.setOnlineStatusU( + PerspectiveUnsignedInput.fromLink( + new Link({ + source: "test://source", + target: "test://target", + }), + ), + ); + + const bobOnline2 = await bobNH!.onlineAgents(); + + expect(bobOnline2.length).to.be.equal(1); + expect(bobOnline2[0].did).to.be.equal(aliceDID); + expect(bobOnline2[0].status.data.links[0].data.source).to.equal( + "test://source", + ); + expect(bobOnline2[0].status.data.links[0].data.target).to.equal( + "test://target", + ); + expect(bobOnline2[0].status.data.links[0].proof.valid).to.be.true; + // TODO: Signature check for the whole perspective is broken + // Got to fix that and add back this assertion + //expect(bobOnline2[0].status.proof.valid).to.be.true + }); + + it("they can send signals via `sendSignal` and receive callbacks via `addSignalHandler`", async () => { + let aliceCalls = 0; + let aliceData = null; + const aliceHandler = async (payload: Perspective) => { + aliceCalls += 1; + aliceData = payload; + }; + aliceNH!.addSignalHandler(aliceHandler); + + let bobCalls = 0; + let bobData = null; + const bobHandler = async (payload: Perspective) => { + bobCalls += 1; + bobData = payload; + }; + bobNH!.addSignalHandler(bobHandler); + + let link = new LinkExpression(); + link.author = aliceDID; + link.timestamp = new Date().toISOString(); + link.data = new Link({ + source: "alice", + target: "bob", + predicate: "signal", + }); + link.proof = new ExpressionProof("sig", "key"); + const aliceSignal = new Perspective([link]); + + await aliceNH!.sendSignal(bobDID!, aliceSignal); + + await sleep(1000); + + expect(bobCalls).to.be.equal(1); + expect(aliceCalls).to.be.equal(0); + + link.proof.invalid = true; + link.proof.valid = false; + //@ts-ignore + expect(bobData.data.links).to.deep.equal(aliceSignal.links); + + let link2 = new Link({ + source: "bob", + target: "alice", + predicate: "signal", + }); + const bobSignal = new PerspectiveUnsignedInput([link2]); + + await bobNH!.sendBroadcastU(bobSignal); + + await sleep(1000); + + expect(aliceCalls).to.be.equal(1); + + //@ts-ignore + expect(aliceData.data.links[0].data).to.deep.equal(link2); + }); + + it("supports loopback functionality for broadcasts", async () => { + let aliceCalls = 0; + let aliceData = null; + const aliceHandler = async (payload: Perspective) => { + aliceCalls += 1; + //@ts-ignore + aliceData = payload; + }; + aliceNH!.addSignalHandler(aliceHandler); + + let bobCalls = 0; + let bobData = null; + const bobHandler = async (payload: Perspective) => { + bobCalls += 1; + //@ts-ignore + bobData = payload; + }; + bobNH!.addSignalHandler(bobHandler); + + // Test broadcast without loopback + let link = new Link({ + source: "alice", + target: "broadcast", + predicate: "test", + }); + const aliceSignal = new PerspectiveUnsignedInput([link]); + await aliceNH!.sendBroadcastU(aliceSignal); + + await sleep(1000); + + expect(bobCalls).to.be.equal(1); + expect(aliceCalls).to.be.equal(0); // Alice shouldn't receive her own broadcast + //@ts-ignore + expect(bobData.data.links[0].data).to.deep.equal(link); + + // Reset counters + bobCalls = 0; + aliceCalls = 0; + bobData = null; + aliceData = null; + + // Test broadcast with loopback enabled + let link2 = new Link({ + source: "alice", + target: "broadcast-loopback", + predicate: "test", + }); + const aliceSignal2 = new PerspectiveUnsignedInput([link2]); + // @ts-ignore - Ignoring the type error since we know the implementation supports loopback + await aliceNH!.sendBroadcastU(aliceSignal2, true); + + await sleep(1000); + + expect(bobCalls).to.be.equal(1); + expect(aliceCalls).to.be.equal(1); // Alice should receive her own broadcast + //@ts-ignore + expect(bobData.data.links[0].data).to.deep.equal(link2); + //@ts-ignore + expect(aliceData.data.links[0].data).to.deep.equal(link2); + + // Test Bob's broadcast with loopback + let link3 = new Link({ + source: "bob", + target: "broadcast-loopback", + predicate: "test", + }); + const bobSignal = new PerspectiveUnsignedInput([link3]); + // @ts-ignore - Ignoring the type error since we know the implementation supports loopback + await bobNH!.sendBroadcastU(bobSignal, true); + + await sleep(1000); + + expect(bobCalls).to.be.equal(2); // Bob should receive his own broadcast + expect(aliceCalls).to.be.equal(2); // Alice should receive Bob's broadcast + //@ts-ignore + expect(bobData.data.links[0].data).to.deep.equal(link3); + //@ts-ignore + expect(aliceData.data.links[0].data).to.deep.equal(link3); + }); + }); + }); + }; +} diff --git a/tests/js/tests/integration/perspective.suite.ts b/tests/js/tests/integration/perspective.suite.ts new file mode 100644 index 000000000..a0685a85b --- /dev/null +++ b/tests/js/tests/integration/perspective.suite.ts @@ -0,0 +1,1236 @@ +import { + Ad4mClient, + Link, + LinkQuery, + PerspectiveProxy, + PerspectiveState, +} from "@coasys/ad4m"; +import { TestContext } from "./integration.test"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import { sleep } from "../../utils/utils"; + +export default function perspectiveTests(testContext: TestContext) { + return () => { + describe("Perspectives", () => { + it("can create, get & delete perspective", async () => { + const ad4mClient = testContext.ad4mClient!; + + let perspectiveCount = (await ad4mClient.perspective.all()).length; + + const create = await ad4mClient.perspective.add("test"); + expect(create.name).to.equal("test"); + + const get = await ad4mClient!.perspective.byUUID(create.uuid); + expect(get!.name).to.equal("test"); + + const update = await ad4mClient!.perspective.update( + create.uuid, + "updated-test", + ); + expect(update.name).to.equal("updated-test"); + + const getUpdated = await ad4mClient!.perspective.byUUID(update.uuid); + expect(getUpdated!.name).to.equal("updated-test"); + + const perspectives = await ad4mClient.perspective.all(); + expect(perspectives.length).to.equal(perspectiveCount + 1); + + const perspectiveSnaphot = await ad4mClient.perspective.snapshotByUUID( + update.uuid, + ); + expect(perspectiveSnaphot!.links.length).to.equal(0); + + const deletePerspective = await ad4mClient!.perspective.remove( + update.uuid, + ); + expect(deletePerspective.perspectiveRemove).to.be.true; + + const getDeleted = await ad4mClient!.perspective.byUUID(update.uuid); + expect(getDeleted).to.be.null; + }); + + it("can CRUD local perspective links", async () => { + const ad4mClient = testContext.ad4mClient!; + + const create = await ad4mClient.perspective.add("test-crud"); + expect(create.name).to.equal("test-crud"); + + const linkAdd = await create.add( + new Link({ + source: "test://test-source", + predicate: "test://test-predicate", + target: "test://test-target", + }), + ); + + const links = await create.get({} as LinkQuery); + expect(links.length).to.equal(1); + + await create.remove(linkAdd); + + const linksPostDelete = await create.get({} as LinkQuery); + expect(linksPostDelete.length).to.equal(0); + + const snapshot = await create.snapshot(); + expect(snapshot.links.length).to.equal(0); + }); + + it("can CRUD local perspective links with local link method", async () => { + const ad4mClient = testContext.ad4mClient!; + + const create = await ad4mClient.perspective.add("test-crud"); + expect(create.name).to.equal("test-crud"); + + const linkAdd = await create.add( + new Link({ + source: "test://test-source", + predicate: "test://test-predicate", + target: "test://test-target", + }), + "local", + ); + + const links = await create.get({} as LinkQuery); + expect(links.length).to.equal(1); + expect(links[0].status).to.equal("LOCAL"); + + await create.remove(linkAdd); + + const linksPostDelete = await create.get({} as LinkQuery); + expect(linksPostDelete.length).to.equal(0); + + const snapshot = await create.snapshot(); + expect(snapshot.links.length).to.equal(0); + }); + + it("can make mutations using perspective addLinks(), removeLinks() & linkMutations()", async () => { + const ad4mClient = testContext.ad4mClient!; + + const create = await ad4mClient.perspective.add("test-mutations"); + expect(create.name).to.equal("test-mutations"); + + const links = [ + new Link({ + source: "test://test-source", + predicate: "test://test-predicate", + target: "test://test-target", + }), + new Link({ + source: "test://test-source2", + predicate: "test://test-predicate2", + target: "test://test-target2", + }), + ]; + const linkAdds = await create.addLinks(links); + expect(linkAdds.length).to.equal(2); + + const linksPostAdd = await create.get({} as LinkQuery); + expect(linksPostAdd.length).to.equal(2); + + const linkRemoves = await create.removeLinks(linkAdds); + expect(linkRemoves.length).to.equal(2); + + const linksPostRemove = await create.get({} as LinkQuery); + expect(linksPostRemove.length).to.equal(0); + + const addTwoMore = await create.addLinks(links); + + const linkMutation = { + additions: links, + removals: addTwoMore, + }; + const linkMutations = await create.linkMutations(linkMutation); + expect(linkMutations.additions.length).to.equal(2); + expect(linkMutations.removals.length).to.equal(2); + + const linksPostMutation = await create.get({} as LinkQuery); + expect(linksPostMutation.length).to.equal(2); + }); + + it(`doesn't error when duplicate entries passed to removeLinks`, async () => { + const ad4mClient = testContext.ad4mClient!; + const perspective = await ad4mClient.perspective.add( + "test-duplicate-link-removal", + ); + expect(perspective.name).to.equal("test-duplicate-link-removal"); + + // create link + const link = { + source: "ad4m://root", + predicate: "ad4m://p", + target: "test://abc", + }; + const addLink = await perspective.add(link); + expect(addLink.data.target).to.equal("test://abc"); + + // get link expression + const linkExpression = (await perspective.get(new LinkQuery(link)))[0]; + expect(linkExpression.data.target).to.equal("test://abc"); + + // attempt to remove link twice (currently errors and prevents further execution of code) + await perspective.removeLinks([linkExpression, linkExpression]); + + // check link is removed + const links = await perspective.get(new LinkQuery(link)); + expect(links.length).to.equal(0); + }); + + it("test local perspective links - time query", async () => { + const ad4mClient = testContext.ad4mClient!; + + const create = await ad4mClient!.perspective.add("test-links-time"); + expect(create.name).to.equal("test-links-time"); + + let addLink = await ad4mClient!.perspective.addLink( + create.uuid, + new Link({ + source: "lang://test", + target: "lang://test-target", + predicate: "lang://predicate", + }), + ); + await sleep(10); + let addLink2 = await ad4mClient!.perspective.addLink( + create.uuid, + new Link({ + source: "lang://test", + target: "lang://test-target2", + predicate: "lang://predicate", + }), + ); + await sleep(10); + let addLink3 = await ad4mClient!.perspective.addLink( + create.uuid, + new Link({ + source: "lang://test", + target: "lang://test-target3", + predicate: "lang://predicate", + }), + ); + await sleep(10); + let addLink4 = await ad4mClient!.perspective.addLink( + create.uuid, + new Link({ + source: "lang://test", + target: "lang://test-target4", + predicate: "lang://predicate", + }), + ); + await sleep(10); + let addLink5 = await ad4mClient!.perspective.addLink( + create.uuid, + new Link({ + source: "lang://test", + target: "lang://test-target5", + predicate: "lang://predicate", + }), + ); + + // Get all the links + let queryLinksAll = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ + source: "lang://test", + fromDate: new Date(new Date(addLink.timestamp).getTime()), + untilDate: new Date(), + }), + ); + expect(queryLinksAll.length).to.equal(5); + + // Get 3 of the links in descending order + let queryLinksAsc = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ + source: "lang://test", + fromDate: new Date(), + untilDate: new Date("August 19, 1975 23:15:30"), + limit: 3, + }), + ); + expect(queryLinksAsc.length).to.equal(3); + expect(queryLinksAsc[0].data.target).to.equal(addLink5.data.target); + expect(queryLinksAsc[1].data.target).to.equal(addLink4.data.target); + expect(queryLinksAsc[2].data.target).to.equal(addLink3.data.target); + + // Get 3 of the links in descending order + let queryLinksDesc = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ + source: "lang://test", + fromDate: new Date("August 19, 1975 23:15:30"), + untilDate: new Date(), + limit: 3, + }), + ); + expect(queryLinksDesc.length).to.equal(3); + expect(queryLinksDesc[0].data.target).to.equal(addLink.data.target); + expect(queryLinksDesc[1].data.target).to.equal(addLink2.data.target); + expect(queryLinksDesc[2].data.target).to.equal(addLink3.data.target); + + //Test can get all links but first by querying from second timestamp + let queryLinks = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ + source: "lang://test", + fromDate: new Date(new Date(addLink2.timestamp).getTime() - 1), + untilDate: new Date(), + }), + ); + expect(queryLinks.length).to.equal(4); + + //Test can get links limited + let queryLinksLimited = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ + source: "lang://test", + fromDate: new Date(new Date(addLink2.timestamp).getTime() - 1), + untilDate: new Date(), + limit: 3, + }), + ); + expect(queryLinksLimited.length).to.equal(3); + + //Test can get only the first link + let queryLinksFirst = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ + source: "lang://test", + fromDate: new Date(addLink.timestamp), + untilDate: new Date(new Date(addLink2.timestamp).getTime() - 1), + }), + ); + expect(queryLinksFirst.length).to.equal(1); + expect(queryLinksFirst[0].data.target).to.equal("lang://test-target"); + }); + + it("test local perspective links", async () => { + const ad4mClient = testContext.ad4mClient!; + + const create = await ad4mClient!.perspective.add("test-links"); + expect(create.name).to.equal("test-links"); + + let addLink = await ad4mClient!.perspective.addLink( + create.uuid, + new Link({ + source: "lang://test", + target: "lang://test-target", + predicate: "lang://predicate", + }), + ); + expect(addLink.data.target).to.equal("lang://test-target"); + expect(addLink.data.source).to.equal("lang://test"); + + //Test can get by source, target, predicate + let queryLinks = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ source: "lang://test" }), + ); + expect(queryLinks.length).to.equal(1); + expect(queryLinks[0].data.target).to.equal("lang://test-target"); + expect(queryLinks[0].data.source).to.equal("lang://test"); + + let queryLinksTarget = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ target: "lang://test-target" }), + ); + expect(queryLinksTarget.length).to.equal(1); + expect(queryLinksTarget[0].data.target).to.equal("lang://test-target"); + expect(queryLinksTarget[0].data.source).to.equal("lang://test"); + + let queryLinksPredicate = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ predicate: "lang://predicate" }), + ); + expect(queryLinksPredicate.length).to.equal(1); + expect(queryLinksPredicate[0].data.target).to.equal( + "lang://test-target", + ); + expect(queryLinksPredicate[0].data.source).to.equal("lang://test"); + + const perspectiveSnaphot = await ad4mClient.perspective.snapshotByUUID( + create.uuid, + ); + expect(perspectiveSnaphot!.links.length).to.equal(1); + + //Update the link to new link + const updateLink = await ad4mClient.perspective.updateLink( + create.uuid, + addLink, + new Link({ + source: "lang://test2", + target: "lang://test-target2", + predicate: "lang://predicate2", + }), + ); + expect(updateLink.data.target).to.equal("lang://test-target2"); + expect(updateLink.data.source).to.equal("lang://test2"); + + const perspectiveSnaphotLinkUpdate = + await ad4mClient.perspective.snapshotByUUID(create.uuid); + expect(perspectiveSnaphotLinkUpdate!.links.length).to.equal(1); + + //Test cannot get old link + let queryLinksOld = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ source: "lang://test" }), + ); + expect(queryLinksOld.length).to.equal(0); + + //Test can get new link + let queryLinksUpdated = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ source: "lang://test2" }), + ); + expect(queryLinksUpdated.length).to.equal(1); + + const deleteLink = await ad4mClient!.perspective.removeLink( + create.uuid, + updateLink, + ); + expect(deleteLink).to.equal(true); + + let queryLinksDeleted = await ad4mClient!.perspective.queryLinks( + create.uuid, + new LinkQuery({ source: "lang://test2" }), + ); + expect(queryLinksDeleted.length).to.equal(0); + }); + + it("subscriptions", async () => { + const ad4mClient: Ad4mClient = testContext.ad4mClient!; + + const perspectiveAdded = sinon.fake(); + ad4mClient.perspective.addPerspectiveAddedListener(perspectiveAdded); + const perspectiveUpdated = sinon.fake(); + ad4mClient.perspective.addPerspectiveUpdatedListener( + perspectiveUpdated, + ); + const perspectiveRemoved = sinon.fake(); + ad4mClient.perspective.addPerspectiveRemovedListener( + perspectiveRemoved, + ); + + const name = "Subscription Test Perspective"; + const p = await ad4mClient.perspective.add(name); + await sleep(1000); + expect(perspectiveAdded.calledOnce).to.be.true; + const pSeenInAddCB = perspectiveAdded.getCall(0).args[0]; + expect(pSeenInAddCB.uuid).to.equal(p.uuid); + expect(pSeenInAddCB.name).to.equal(p.name); + + const p1 = await ad4mClient.perspective.update(p.uuid, "New Name"); + await sleep(1000); + expect(perspectiveUpdated.calledOnce).to.be.true; + const pSeenInUpdateCB = perspectiveUpdated.getCall(0).args[0]; + expect(pSeenInUpdateCB.uuid).to.equal(p1.uuid); + expect(pSeenInUpdateCB.name).to.equal(p1.name); + expect(pSeenInUpdateCB.state).to.equal(PerspectiveState.Private); + + const linkAdded = sinon.fake(); + await ad4mClient.perspective.addPerspectiveLinkAddedListener(p1.uuid, [ + linkAdded, + ]); + const linkRemoved = sinon.fake(); + await ad4mClient.perspective.addPerspectiveLinkRemovedListener( + p1.uuid, + [linkRemoved], + ); + const linkUpdated = sinon.fake(); + await ad4mClient.perspective.addPerspectiveLinkUpdatedListener( + p1.uuid, + [linkUpdated], + ); + + const linkExpression = await ad4mClient.perspective.addLink(p1.uuid, { + source: "ad4m://root", + target: "lang://123", + }); + await sleep(1000); + expect(linkAdded.called).to.be.true; + expect(linkAdded.getCall(0).args[0]).to.eql(linkExpression); + + const updatedLinkExpression = await ad4mClient.perspective.updateLink( + p1.uuid, + linkExpression, + { source: "ad4m://root", target: "lang://456" }, + ); + await sleep(1000); + expect(linkUpdated.called).to.be.true; + expect(linkUpdated.getCall(0).args[0].newLink).to.eql( + updatedLinkExpression, + ); + + const copiedUpdatedLinkExpression = { ...updatedLinkExpression }; + + await ad4mClient.perspective.removeLink(p1.uuid, updatedLinkExpression); + await sleep(1000); + expect(linkRemoved.called).to.be.true; + //expect(linkRemoved.getCall(0).args[0]).to.eql(copiedUpdatedLinkExpression) + }); + + // SdnaOnly doesn't load links into prolog engine + it.skip("shares subscription between identical prolog queries", async () => { + const ad4mClient: Ad4mClient = testContext.ad4mClient!; + const p = await ad4mClient.perspective.add("Subscription test"); + + const query = 'triple(X, _, "test://target").'; + + // Create first subscription + const sub1 = await p.subscribeInfer(query); + const sub1Id = sub1.id; + const callback1 = sinon.fake(); + sub1.onResult(callback1); + + // Create second subscription with same query + const sub2 = await p.subscribeInfer(query); + const sub2Id = sub2.id; + const callback2 = sinon.fake(); + sub2.onResult(callback2); + + // Assert they got same subscription ID + expect(sub1Id).to.equal(sub2Id); + + // Wait for the subscriptions to be established + // it's sending the initial result a couple of times + // to allow clients to wait and ensure for the subscription to be established + await sleep(1000); + + // Add a link that matches the query + await p.add( + new Link({ + source: "test://source", + target: "test://target", + }), + ); + + await sleep(1000); + + // Verify both callbacks were called + expect(callback1.called).to.be.true; + expect(callback2.called).to.be.true; + + // Verify both got same result + const result1 = callback1.getCall(callback1.callCount - 1).args[0]; + const result2 = callback2.getCall(callback2.callCount - 1).args[0]; + console.log("result1", result1); + expect(result1).to.deep.equal(result2); + expect(result1[0].X).to.equal("test://source"); + }); + + // SdnaOnly doesn't load links into prolog engine + it.skip("can run Prolog queries", async () => { + const ad4mClient: Ad4mClient = testContext.ad4mClient!; + const p = await ad4mClient.perspective.add("Prolog test"); + await p.add( + new Link({ + source: "ad4m://root", + target: "note-ipfs://Qm123", + }), + ); + await p.add( + new Link({ + source: "note-ipfs://Qm123", + target: "todo-ontology://is-todo", + }), + ); + + const result = await p.infer( + 'triple(X, _, "todo-ontology://is-todo").', + ); + expect(result).not.to.be.false; + expect(result.length).to.equal(1); + expect(result[0].X).to.equal("note-ipfs://Qm123"); + + expect( + await p.infer('reachable("ad4m://root", "todo-ontology://is-todo")'), + ).to.be.true; + + const linkResult = await p.infer( + 'link(X, _, "todo-ontology://is-todo", Timestamp, Author).', + ); + expect(linkResult).not.to.be.false; + expect(linkResult.length).to.equal(1); + expect(linkResult[0].X).to.equal("note-ipfs://Qm123"); + expect(linkResult[0].Timestamp).not.to.be.null; + expect(linkResult[0].Author).not.to.be.null; + }); + }); + + describe.skip("Batch Operations", () => { + let proxy: PerspectiveProxy; + let ad4mClient: Ad4mClient; + + beforeEach(async () => { + ad4mClient = testContext.ad4mClient!; + proxy = await ad4mClient.perspective.add("batch test"); + }); + + it("can create and commit empty batch", async () => { + const batchId = await proxy.createBatch(); + expect(batchId).to.be.a("string"); + + const result = await proxy.commitBatch(batchId); + expect(result.additions).to.be.an("array"); + expect(result.additions.length).to.equal(0); + expect(result.removals).to.be.an("array"); + expect(result.removals.length).to.equal(0); + }); + + it("can add links in batch", async () => { + const batchId = await proxy.createBatch(); + + const link1 = new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }); + const link2 = new Link({ + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }); + + // Add links to batch + await proxy.add(link1, "shared", batchId); + await proxy.add(link2, "shared", batchId); + + // Links should not be visible before commit + let links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(0); + + // Commit batch + const result = await proxy.commitBatch(batchId); + expect(result.additions.length).to.equal(2); + expect(result.removals.length).to.equal(0); + + // Verify links are now visible + links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(2); + expect(links.map((l) => l.data.target)).to.include("test://target1"); + expect(links.map((l) => l.data.target)).to.include("test://target2"); + }); + + it("can remove links in batch", async () => { + // Add some initial links + const link1 = new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }); + const link2 = new Link({ + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }); + + const expr1 = await proxy.add(link1); + const expr2 = await proxy.add(link2); + + // Create batch for removals + const batchId = await proxy.createBatch(); + + // Remove links in batch + await proxy.remove(expr1, batchId); + await proxy.remove(expr2, batchId); + + // Links should still be visible before commit + let links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(2); + + // Commit batch + const result = await proxy.commitBatch(batchId); + expect(result.additions.length).to.equal(0); + expect(result.removals.length).to.equal(2); + + // Verify links are now removed + links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(0); + }); + + it("can mix additions and removals in batch", async () => { + // Add an initial link + const link1 = new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }); + const expr1 = await proxy.add(link1); + + // Create batch + const batchId = await proxy.createBatch(); + + // Remove existing link and add new one in batch + await proxy.remove(expr1, batchId); + const link2 = new Link({ + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }); + await proxy.add(link2, "shared", batchId); + + // Original state should be unchanged before commit + let links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(1); + expect(links[0].data.target).to.equal("test://target1"); + + // Commit batch + const result = await proxy.commitBatch(batchId); + expect(result.additions.length).to.equal(1); + expect(result.removals.length).to.equal(1); + + // Verify final state + links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(1); + expect(links[0].data.target).to.equal("test://target2"); + }); + + it("can update links in batch", async () => { + // Add an initial link + const link1 = new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }); + const expr1 = await proxy.add(link1); + + // Create batch + const batchId = await proxy.createBatch(); + + // Update link in batch + const newLink = new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://updated-target", + }); + await proxy.update(expr1, newLink, batchId); + + // Original state should be unchanged before commit + let links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(1); + expect(links[0].data.target).to.equal("test://target1"); + + // Commit batch + const result = await proxy.commitBatch(batchId); + expect(result.additions.length).to.equal(1); + expect(result.removals.length).to.equal(1); + + // Verify final state + links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(1); + expect(links[0].data.target).to.equal("test://updated-target"); + }); + + it("can handle multiple batches concurrently", async () => { + // Create two batches + const batchId1 = await proxy.createBatch(); + const batchId2 = await proxy.createBatch(); + + // Add different links to each batch + const link1 = new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }); + const link2 = new Link({ + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }); + + await proxy.add(link1, "shared", batchId1); + await proxy.add(link2, "shared", batchId2); + + // Verify no links are visible yet + let links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(0); + + // Commit first batch + const result1 = await proxy.commitBatch(batchId1); + expect(result1.additions.length).to.equal(1); + expect(result1.removals.length).to.equal(0); + + // Verify only first link is visible + links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(1); + expect(links[0].data.target).to.equal("test://target1"); + + // Commit second batch + const result2 = await proxy.commitBatch(batchId2); + expect(result2.additions.length).to.equal(1); + expect(result2.removals.length).to.equal(0); + + // Verify both links are now visible + links = await proxy.get({} as LinkQuery); + expect(links.length).to.equal(2); + expect(links.map((l) => l.data.target)).to.include("test://target1"); + expect(links.map((l) => l.data.target)).to.include("test://target2"); + }); + + it("handles batch operations with addLinks and removeLinks", async () => { + const batchId = await proxy.createBatch(); + + // Add multiple links in one call + const links = [ + new Link({ + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }), + new Link({ + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }), + ]; + + await proxy.addLinks(links, "shared", batchId); + + // Verify links are not visible yet + let currentLinks = await proxy.get({} as LinkQuery); + expect(currentLinks.length).to.equal(0); + + // Commit batch + const result = await proxy.commitBatch(batchId); + expect(result.additions.length).to.equal(2); + expect(result.removals.length).to.equal(0); + + // Verify links are now visible + currentLinks = await proxy.get({} as LinkQuery); + expect(currentLinks.length).to.equal(2); + + // Create new batch for removal + const removeBatchId = await proxy.createBatch(); + + // Remove multiple links in one call + await proxy.removeLinks(currentLinks, removeBatchId); + + // Verify links are still visible before commit + currentLinks = await proxy.get({} as LinkQuery); + expect(currentLinks.length).to.equal(2); + + // Commit removal batch + const removeResult = await proxy.commitBatch(removeBatchId); + expect(removeResult.additions.length).to.equal(0); + expect(removeResult.removals.length).to.equal(2); + + // Verify all links are removed + currentLinks = await proxy.get({} as LinkQuery); + expect(currentLinks.length).to.equal(0); + }); + + it("should support batch operations with executeCommands", async () => { + const perspective = await ad4mClient.perspective.add( + "test-batch-execute-commands", + ); + const batchId = await perspective.createBatch(); + + // Execute commands in batch + await perspective.executeAction( + [ + { + action: "addLink", + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }, + { + action: "addLink", + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }, + ], + "test://expression", + [], + batchId, + ); + + // Verify links are not visible before commit + let links = await perspective.get(new LinkQuery({})); + expect(links.length).to.equal(0); + + // Commit batch and verify links are now visible + const diff = await perspective.commitBatch(batchId); + expect(diff.additions.length).to.equal(2); + expect(diff.removals.length).to.equal(0); + + links = await perspective.get({ isMatch: () => true }); + expect(links.length).to.equal(2); + + // Verify link contents + const link1 = links.find((l) => l.data.source === "test://source1"); + if (!link1) throw new Error("Expected to find link1"); + expect(link1.data.predicate).to.equal("test://predicate1"); + expect(link1.data.target).to.equal("test://target1"); + + const link2 = links.find((l) => l.data.source === "test://source2"); + if (!link2) throw new Error("Expected to find link2"); + expect(link2.data.predicate).to.equal("test://predicate2"); + expect(link2.data.target).to.equal("test://target2"); + }); + + it("should support batch operations with multiple commands", async () => { + const perspective = await ad4mClient.perspective.add( + "test-batch-multiple-commands", + ); + + // Add a link outside the batch first + await perspective.executeAction( + [ + { + action: "addLink", + source: "test://source0", + predicate: "test://predicate0", + target: "test://target0", + }, + ], + "test://expression", + [], + ); + + const batchId = await perspective.createBatch(); + + // Execute multiple commands in batch including a remove + await perspective.executeAction( + [ + { + action: "removeLink", + source: "test://source0", + predicate: "test://predicate0", + target: "test://target0", + }, + { + action: "addLink", + source: "test://source1", + predicate: "test://predicate1", + target: "test://target1", + }, + { + action: "setSingleTarget", + source: "test://source2", + predicate: "test://predicate2", + target: "test://target2", + }, + ], + "test://expression", + [], + batchId, + ); + + // Verify state before commit + let links = await perspective.get(new LinkQuery({})); + expect(links.length).to.equal(1); // Only the initial link + expect(links[0].data.source).to.equal("test://source0"); + + // Commit batch and verify final state + const diff = await perspective.commitBatch(batchId); + expect(diff.additions.length).to.equal(2); // New links + expect(diff.removals.length).to.equal(1); // Removed initial link + + links = await perspective.get(new LinkQuery({})); + expect(links.length).to.equal(2); // Two new links + + // Verify final link contents + const link1 = links.find((l) => l.data.source === "test://source1"); + if (!link1) throw new Error("Expected to find link1"); + expect(link1.data.predicate).to.equal("test://predicate1"); + expect(link1.data.target).to.equal("test://target1"); + + const link2 = links.find((l) => l.data.source === "test://source2"); + if (!link2) throw new Error("Expected to find link2"); + expect(link2.data.predicate).to.equal("test://predicate2"); + expect(link2.data.target).to.equal("test://target2"); + + // Verify removed link is gone + const removedLink = links.find( + (l) => l.data.source === "test://source0", + ); + expect(removedLink).to.be.undefined; + }); + }); + + describe("PerspectiveProxy", () => { + let proxy: PerspectiveProxy; + let ad4mClient: Ad4mClient; + before(async () => { + ad4mClient = testContext.ad4mClient!; + proxy = await ad4mClient.perspective.add("proxy test"); + }); + + it("can do link CRUD", async () => { + const all = new LinkQuery({}); + const testLink = new Link({ + source: "test://source", + predicate: "test://predicate", + target: "test://target", + }); + + expect(await proxy.get(all)).to.eql([]); + + await proxy.add(testLink); + let links = await proxy.get(all); + expect(links.length).to.equal(1); + + let link = new Link(links[0].data); + expect(link).to.eql(testLink); + + const updatedLink = new Link({ + source: link.source, + predicate: link.predicate, + target: "test://new_target", + }); + await proxy.update(links[0], updatedLink); + + links = await proxy.get(all); + expect(links.length).to.equal(1); + link = new Link(links[0].data); + expect(link).to.eql(updatedLink); + + await proxy.remove(links[0]); + expect(await proxy.get(all)).to.eql([]); + }); + + it("can do singleTarget operations", async () => { + const all = new LinkQuery({}); + + expect(await proxy.get(all)).to.eql([]); + const link1 = new Link({ + source: "test://source", + predicate: "test://predicate", + target: "test://target1", + }); + + await proxy.setSingleTarget(link1); + const result1 = (await proxy.get(all))[0].data; + expect(result1.source).to.equal(link1.source); + expect(result1.predicate).to.equal(link1.predicate); + expect(result1.target).to.equal(link1.target); + expect(await proxy.getSingleTarget(new LinkQuery(link1))).to.equal( + "test://target1", + ); + + const link2 = new Link({ + source: "test://source", + predicate: "test://predicate", + target: "test://target2", + }); + + await proxy.setSingleTarget(link2); + + const result2 = (await proxy.get(all))[0].data; + expect(result2.source).to.equal(link2.source); + expect(result2.predicate).to.equal(link2.predicate); + expect(result2.target).to.equal(link2.target); + expect(await proxy.getSingleTarget(new LinkQuery(link1))).to.equal( + "test://target2", + ); + }); + + // SdnaOnly doesn't load links into prolog engine + it.skip("can subscribe to Prolog query results", async () => { + // Add some test data + await proxy.add( + new Link({ + source: "ad4m://root", + target: "note-ipfs://Qm123", + }), + ); + await proxy.add( + new Link({ + source: "note-ipfs://Qm123", + target: "todo-ontology://is-todo", + }), + ); + + // Create subscription + const subscription = await (proxy as any).subscribeInfer( + 'triple(X, _, "todo-ontology://is-todo").', + ); + + // Check initial result + const initialResult = subscription.result; + expect(initialResult).to.be.an("array"); + expect(initialResult.length).to.equal(1); + expect(initialResult[0].X).to.equal("note-ipfs://Qm123"); + + // Set up callback for updates + const updates: any[] = []; + const unsubscribe = subscription.onResult((result: any) => { + updates.push(result); + }); + + // Add another link that should trigger an update + await proxy.add( + new Link({ + source: "note-ipfs://Qm456", + target: "todo-ontology://is-todo", + }), + ); + + // Wait for subscription update + await sleep(1000); + + // Verify we got an update + expect(updates.length).to.be.greaterThan(0); + const latestResult = updates[updates.length - 1]; + expect(latestResult).to.be.an("array"); + expect(latestResult.length).to.equal(2); + expect(latestResult.map((r: any) => r.X)).to.include( + "note-ipfs://Qm123", + ); + expect(latestResult.map((r: any) => r.X)).to.include( + "note-ipfs://Qm456", + ); + + // Clean up subscription + unsubscribe(); + subscription.dispose(); + }); + }); + + describe("SurrealDB Queries", () => { + it("should execute basic SurrealQL SELECT query", async () => { + const ad4mClient = testContext.ad4mClient!; + const perspective = await ad4mClient.perspective.add( + "test-surrealdb-basic", + ); + + // Add sample links + const link1 = new Link({ + source: "test://source1", + predicate: "test://follows", + target: "test://target1", + }); + const link2 = new Link({ + source: "test://source2", + predicate: "test://likes", + target: "test://target2", + }); + + await perspective.addLinks([link1, link2]); + + // Execute SurrealQL query + const result = await perspective.querySurrealDB("SELECT * FROM link"); + + // Verify results + expect(result).to.be.an("array"); + expect(result.length).to.be.greaterThanOrEqual(2); + + // Check that our links are present + const sources = result.map((r: any) => r.source); + expect(sources).to.include("test://source1"); + expect(sources).to.include("test://source2"); + + // Clean up + await ad4mClient.perspective.remove(perspective.uuid); + }); + + it("should handle SurrealQL query with WHERE clause", async () => { + const ad4mClient = testContext.ad4mClient!; + const perspective = await ad4mClient.perspective.add( + "test-surrealdb-where", + ); + + // Add links with different predicates + const followsLink = new Link({ + source: "test://alice", + predicate: "test://follows", + target: "test://bob", + }); + const likesLink = new Link({ + source: "test://alice", + predicate: "test://likes", + target: "test://post123", + }); + + await perspective.addLinks([followsLink, likesLink]); + + // Query with WHERE clause + const result = await perspective.querySurrealDB( + "SELECT * FROM link WHERE predicate = 'test://follows'", + ); + + // Verify filtered results + expect(result).to.be.an("array"); + expect(result.length).to.be.greaterThanOrEqual(1); + + // All results should have the 'follows' predicate + result.forEach((link: any) => { + if (link.source === "test://alice") { + expect(link.predicate).to.equal("test://follows"); + } + }); + + // Clean up + await ad4mClient.perspective.remove(perspective.uuid); + }); + + it("should return empty array for query with no matches", async () => { + const ad4mClient = testContext.ad4mClient!; + const perspective = await ad4mClient.perspective.add( + "test-surrealdb-empty", + ); + + // Add a link + await perspective.add( + new Link({ + source: "test://source", + predicate: "test://predicate", + target: "test://target", + }), + ); + + // Query for something that doesn't exist + const result = await perspective.querySurrealDB( + "SELECT * FROM link WHERE predicate = 'test://nonexistent'", + ); + + // Should return empty array + expect(result).to.be.an("array"); + expect(result.length).to.equal(0); + + // Clean up + await ad4mClient.perspective.remove(perspective.uuid); + }); + + it("should integrate with link mutations", async () => { + const ad4mClient = testContext.ad4mClient!; + const perspective = await ad4mClient.perspective.add( + "test-surrealdb-mutations", + ); + + // Add a link + const link = new Link({ + source: "test://mutation-source", + predicate: "test://mutation-predicate", + target: "test://mutation-target", + }); + + const addedLink = await perspective.add(link); + + // Query to verify it's there + let result = await perspective.querySurrealDB( + "SELECT * FROM link WHERE source = 'test://mutation-source'", + ); + + expect(result).to.be.an("array"); + expect(result.length).to.be.greaterThanOrEqual(1); + + // Remove the link + await perspective.removeLinks([addedLink]); + + // Query again and verify it's gone + result = await perspective.querySurrealDB( + "SELECT * FROM link WHERE source = 'test://mutation-source'", + ); + + expect(result).to.be.an("array"); + expect(result.length).to.equal(0); + + // Clean up + await ad4mClient.perspective.remove(perspective.uuid); + }); + }); + }; +} diff --git a/tests/js/tests/integration/runtime.suite.ts b/tests/js/tests/integration/runtime.suite.ts new file mode 100644 index 000000000..7bbb91ba1 --- /dev/null +++ b/tests/js/tests/integration/runtime.suite.ts @@ -0,0 +1,739 @@ +import { TestContext } from "./integration.test"; +import fs from "fs"; +import { expect } from "chai"; +import { + NotificationInput, + TriggeredNotification, +} from "@coasys/ad4m/lib/src/runtime/RuntimeResolver"; +import sinon from "sinon"; +import { sleep } from "../../utils/utils"; +import { ExceptionType, Link } from "@coasys/ad4m"; +// Imports needed for webhook tests: +// (deactivated for now because these imports break the test suite on CI) +// (( local execution works - I leave this here for manualy local testing )) +//import express from 'express'; +//import bodyParser from 'body-parser'; +//import { Server } from 'http'; + +const PERSPECT3VISM_AGENT = + "did:key:zQ3shkkuZLvqeFgHdgZgFMUx8VGkgVWsLA83w2oekhZxoCW2n"; +const DIFF_SYNC_OFFICIAL = fs + .readFileSync("./scripts/perspective-diff-sync-hash") + .toString(); +// PUBLISHING_AGENT is read lazily inside the suite (not at module load time) +// because the file is created by injectPublishingAgent.js during test setup, +// which runs after Mocha has already imported all suite modules. +let PUBLISHING_AGENT: string; + +export default function runtimeTests(testContext: TestContext) { + return () => { + before(() => { + PUBLISHING_AGENT = JSON.parse( + fs.readFileSync("./tst-tmp/agents/p/ad4m/agent.json").toString(), + )["did"]; + }); + + it("Trusted Agents CRUD", async () => { + const ad4mClient = testContext.ad4mClient!; + const { did } = await ad4mClient.agent.status(); + + const initalAgents = await ad4mClient.runtime.getTrustedAgents(); + console.warn(initalAgents); + console.warn([did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT]); + expect(initalAgents).to.eql( + [did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT].sort(), + ); + + const addAgents = await ad4mClient.runtime.addTrustedAgents([ + "agentPubKey", + "agentPubKey2", + ]); + expect(addAgents).to.eql( + [ + did, + PERSPECT3VISM_AGENT, + PUBLISHING_AGENT, + "agentPubKey", + "agentPubKey2", + ].sort(), + ); + + //Add the agents again to be sure we cannot get any duplicates + const addAgentsDuplicate = await ad4mClient.runtime.addTrustedAgents([ + "agentPubKey", + "agentPubKey2", + ]); + expect(addAgentsDuplicate).to.eql( + [ + did, + PERSPECT3VISM_AGENT, + PUBLISHING_AGENT, + "agentPubKey", + "agentPubKey2", + ].sort(), + ); + + const getAgents = await ad4mClient.runtime.getTrustedAgents(); + expect(getAgents).to.eql( + [ + did, + PERSPECT3VISM_AGENT, + PUBLISHING_AGENT, + "agentPubKey", + "agentPubKey2", + ].sort(), + ); + + const deleteAgents1 = await ad4mClient.runtime.deleteTrustedAgents([ + "agentPubKey2", + ]); + expect(deleteAgents1).to.eql( + [did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, "agentPubKey"].sort(), + ); + + const deleteAgents2 = await ad4mClient.runtime.deleteTrustedAgents([ + "agentPubKey", + "agentPubKey2", + ]); + expect(deleteAgents2).to.eql( + [did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT].sort(), + ); + + const getAgentsPostDelete = await ad4mClient.runtime.getTrustedAgents(); + expect(getAgentsPostDelete).to.eql( + [did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT].sort(), + ); + }); + + it("CRUD for known LinkLanguage templates", async () => { + const ad4mClient = testContext.ad4mClient!; + + const addresses = await ad4mClient.runtime.knownLinkLanguageTemplates(); + expect(addresses).to.eql([DIFF_SYNC_OFFICIAL]); + + const addAddresses = + await ad4mClient.runtime.addKnownLinkLanguageTemplates([ + "Qm123", + "Qmabc", + ]); + expect(addAddresses).to.eql( + [DIFF_SYNC_OFFICIAL, "Qm123", "Qmabc"].sort(), + ); + + //Add the agents again to be sure we cannot get any duplicates + const addDuplicate = + await ad4mClient.runtime.addKnownLinkLanguageTemplates([ + "Qm123", + "Qmabc", + ]); + expect(addDuplicate).to.eql( + [DIFF_SYNC_OFFICIAL, "Qm123", "Qmabc"].sort(), + ); + + const get = await ad4mClient.runtime.knownLinkLanguageTemplates(); + expect(get).to.eql([DIFF_SYNC_OFFICIAL, "Qm123", "Qmabc"].sort()); + + const deleted = await ad4mClient.runtime.removeKnownLinkLanguageTemplates( + ["Qm123"], + ); + expect(deleted).to.eql([DIFF_SYNC_OFFICIAL, "Qmabc"].sort()); + + const deleted2 = + await ad4mClient.runtime.removeKnownLinkLanguageTemplates([ + "Qm123", + "Qmabc", + ]); + expect(deleted2).to.eql([DIFF_SYNC_OFFICIAL]); + + const getPostDelete = + await ad4mClient.runtime.knownLinkLanguageTemplates(); + expect(getPostDelete).to.eql([DIFF_SYNC_OFFICIAL]); + }); + + it("CRUD for friends", async () => { + const ad4mClient = testContext.ad4mClient!; + + const dids = await ad4mClient.runtime.friends(); + expect(dids).to.eql([]); + + const added = await ad4mClient.runtime.addFriends([ + "did:test:1", + "did:test:2", + ]); + expect(added).to.eql(["did:test:1", "did:test:2"]); + + //Add the agents again to be sure we cannot get any duplicates + const addDuplicate = await ad4mClient.runtime.addFriends([ + "did:test:1", + "did:test:2", + ]); + expect(addDuplicate).to.eql(["did:test:1", "did:test:2"]); + + const get = await ad4mClient.runtime.friends(); + expect(get).to.eql(["did:test:1", "did:test:2"]); + + const deleted = await ad4mClient.runtime.removeFriends(["did:test:1"]); + expect(deleted).to.eql(["did:test:2"]); + + const deleted2 = await ad4mClient.runtime.removeFriends([ + "did:test:1", + "did:test:2", + ]); + expect(deleted2).to.eql([]); + + const getPostDelete = await ad4mClient.runtime.friends(); + expect(getPostDelete).to.eql([]); + }); + + it("doesn't mix up stores", async () => { + const ad4mClient = testContext.ad4mClient!; + const { did } = await ad4mClient.agent.status(); + + await ad4mClient.runtime.addFriends(["did:test:1", "did:test:2"]); + + const addresses = await ad4mClient.runtime.knownLinkLanguageTemplates(); + expect(addresses).to.eql([DIFF_SYNC_OFFICIAL]); + + const initalAgents = await ad4mClient.runtime.getTrustedAgents(); + expect(initalAgents).to.eql( + [did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT].sort(), + ); + + const addAddresses = + await ad4mClient.runtime.addKnownLinkLanguageTemplates([ + "Qm123", + "Qmabc", + ]); + expect(addAddresses).to.eql( + [DIFF_SYNC_OFFICIAL, "Qm123", "Qmabc"].sort(), + ); + + const addAgents = await ad4mClient.runtime.addTrustedAgents([ + "agentPubKey", + "agentPubKey2", + ]); + expect(addAgents).to.eql( + [ + did, + PERSPECT3VISM_AGENT, + PUBLISHING_AGENT, + "agentPubKey", + "agentPubKey2", + ].sort(), + ); + + const dids = await ad4mClient.runtime.friends(); + expect(dids).to.eql(["did:test:1", "did:test:2"].sort()); + + const deleted = await ad4mClient.runtime.removeFriends([ + "did:test:1", + "agentPubKey", + "Qm123", + ]); + expect(deleted).to.eql(["did:test:2"]); + + const postDeleteAddresses = + await ad4mClient.runtime.knownLinkLanguageTemplates(); + expect(postDeleteAddresses).to.eql( + [DIFF_SYNC_OFFICIAL, "Qm123", "Qmabc"].sort(), + ); + + const postDeleteAgents = await ad4mClient.runtime.getTrustedAgents(); + expect(postDeleteAgents).to.eql( + [ + did, + PERSPECT3VISM_AGENT, + PUBLISHING_AGENT, + "agentPubKey", + "agentPubKey2", + ].sort(), + ); + }); + + it("can deal with Holochain's agent_infos", async () => { + const ad4mClient = testContext.ad4mClient!; + // @ts-ignore + const agentInfos = await ad4mClient.runtime.hcAgentInfos(); + // @ts-ignore + expect(await ad4mClient.runtime.hcAddAgentInfos(agentInfos)).to.be.true; + }); + + it("can get runtimeInfo", async () => { + const ad4mClient = testContext.ad4mClient!; + const runtimeInfo = await ad4mClient.runtime.info(); + expect(runtimeInfo.ad4mExecutorVersion).to.be.equal( + process.env.npm_package_version, + ); + expect(runtimeInfo.isUnlocked).to.be.true; + expect(runtimeInfo.isInitialized).to.be.true; + }); + + it("can handle notifications", async () => { + const ad4mClient = testContext.ad4mClient!; + + const notification: NotificationInput = { + description: "Test Description", + appName: "Test App Name", + appUrl: "Test App URL", + appIconPath: "Test App Icon Path", + trigger: "SELECT * FROM link WHERE predicate = 'test://never-matches'", + perspectiveIds: ["Test Perspective ID"], + webhookUrl: "Test Webhook URL", + webhookAuth: "Test Webhook Auth", + }; + + const mockFunction = sinon.stub(); + + let ignoreRequest = false; + + // Setup the stub to automatically resolve when called + mockFunction.callsFake((exception) => { + if (ignoreRequest) return; + + if (exception.type === ExceptionType.InstallNotificationRequest) { + const requestedNotification = JSON.parse(exception.addon); + + // Only check assertions for THIS test's notification + if (requestedNotification.description === notification.description) { + expect(requestedNotification.appName).to.equal( + notification.appName, + ); + expect(requestedNotification.appUrl).to.equal(notification.appUrl); + expect(requestedNotification.appIconPath).to.equal( + notification.appIconPath, + ); + expect(requestedNotification.trigger).to.equal( + notification.trigger, + ); + expect(requestedNotification.perspectiveIds).to.eql( + notification.perspectiveIds, + ); + expect(requestedNotification.webhookUrl).to.equal( + notification.webhookUrl, + ); + expect(requestedNotification.webhookAuth).to.equal( + notification.webhookAuth, + ); + } + // Automatically resolve without needing to manually manage a Promise + return null; + } + }); + + await ad4mClient.runtime.addExceptionCallback(mockFunction); + + // Request to install a new notification + const notificationId = + await ad4mClient.runtime.requestInstallNotification(notification); + + await sleep(2000); + + // Use sinon's assertions to wait for the stub to be called + await sinon.assert.calledOnce(mockFunction); + ignoreRequest = true; + + // Check if the notification is in the list of notifications + const notificationsBeforeGrant = await ad4mClient.runtime.notifications(); + expect(notificationsBeforeGrant.length).to.equal(1); + const notificationInList = notificationsBeforeGrant[0]; + expect(notificationInList).to.exist; + expect(notificationInList?.granted).to.be.false; + + // Grant the notification + const granted = + await ad4mClient.runtime.grantNotification(notificationId); + expect(granted).to.be.true; + + // Check if the notification is updated + const updatedNotification: NotificationInput = { + description: "Update Test Description", + appName: "Test App Name", + appUrl: "Test App URL", + appIconPath: "Test App Icon Path", + trigger: "SELECT * FROM link WHERE predicate = 'test://updated'", + perspectiveIds: ["Test Perspective ID"], + webhookUrl: "Test Webhook URL", + webhookAuth: "Test Webhook Auth", + }; + const updated = await ad4mClient.runtime.updateNotification( + notificationId, + updatedNotification, + ); + expect(updated).to.be.true; + + const updatedNotificationCheck = await ad4mClient.runtime.notifications(); + const updatedNotificationInList = updatedNotificationCheck.find( + (n) => n.id === notificationId, + ); + expect(updatedNotificationInList).to.exist; + // after changing a notification it needs to be granted again + expect(updatedNotificationInList?.granted).to.be.false; + expect(updatedNotificationInList?.description).to.equal( + updatedNotification.description, + ); + + // Check if the notification is removed + const removed = + await ad4mClient.runtime.removeNotification(notificationId); + expect(removed).to.be.true; + }); + + it("can trigger notifications", async () => { + const ad4mClient = testContext.ad4mClient!; + + let triggerPredicate = "ad4m://notification"; + + let notificationPerspective = await ad4mClient.perspective.add( + "notification test perspective", + ); + let otherPerspective = + await ad4mClient.perspective.add("other perspective"); + + const notification: NotificationInput = { + description: "ad4m://notification predicate used", + appName: "ADAM tests", + appUrl: "Test App URL", + appIconPath: "Test App Icon Path", + trigger: `SELECT source, target, predicate FROM link WHERE predicate = '${triggerPredicate}'`, + perspectiveIds: [notificationPerspective.uuid], + webhookUrl: "Test Webhook URL", + webhookAuth: "Test Webhook Auth", + }; + + // Request to install a new notification + const notificationId = + await ad4mClient.runtime.requestInstallNotification(notification); + sleep(1000); + // Grant the notification + const granted = + await ad4mClient.runtime.grantNotification(notificationId); + expect(granted).to.be.true; + + const mockFunction = sinon.stub(); + await ad4mClient.runtime.addNotificationTriggeredCallback(mockFunction); + + // Ensuring no false positives + await notificationPerspective.add( + new Link({ source: "control://source", target: "control://target" }), + ); + await sleep(1000); + expect(mockFunction.called).to.be.false; + + // Ensuring only selected perspectives will trigger + await otherPerspective.add( + new Link({ + source: "control://source", + predicate: triggerPredicate, + target: "control://target", + }), + ); + await sleep(1000); + expect(mockFunction.called).to.be.false; + + // Happy path + await notificationPerspective.add( + new Link({ + source: "test://source", + predicate: triggerPredicate, + target: "test://target1", + }), + ); + await sleep(7000); + expect(mockFunction.called).to.be.true; + let triggeredNotification = mockFunction.getCall(0) + .args[0] as TriggeredNotification; + expect(triggeredNotification.notification.description).to.equal( + notification.description, + ); + let triggerMatch = JSON.parse(triggeredNotification.triggerMatch); + expect(triggerMatch.length).to.equal(1); + let match = triggerMatch[0]; + //@ts-ignore + expect(match.source).to.equal("test://source"); + //@ts-ignore + expect(match.target).to.equal("test://target1"); + + // Ensuring we don't get old data on a new trigger + await notificationPerspective.add( + new Link({ + source: "test://source", + predicate: triggerPredicate, + target: "test://target2", + }), + ); + await sleep(7000); + expect(mockFunction.callCount).to.equal(2); + triggeredNotification = mockFunction.getCall(1) + .args[0] as TriggeredNotification; + triggerMatch = JSON.parse(triggeredNotification.triggerMatch); + expect(triggerMatch.length).to.equal(1); + match = triggerMatch[0]; + //@ts-ignore + expect(match.source).to.equal("test://source"); + //@ts-ignore + expect(match.target).to.equal("test://target2"); + }); + + it("can detect mentions in notifications (Flux example)", async () => { + const ad4mClient = testContext.ad4mClient!; + const agentStatus = await ad4mClient.agent.status(); + const agentDid = agentStatus.did; + + let notificationPerspective = + await ad4mClient.perspective.add("flux mention test"); + + const notification: NotificationInput = { + description: "You were mentioned in a message", + appName: "Flux Mentions", + appUrl: "https://flux.app", + appIconPath: "/flux-icon.png", + // Extract multiple data points from the match + trigger: `SELECT + source as message_id, + fn::parse_literal(target) as message_content, + fn::strip_html(fn::parse_literal(target)) as plain_text, + $agentDid as mentioned_agent, + $perspectiveId as perspective_id + FROM link + WHERE predicate = 'rdf://content' + AND fn::contains(fn::parse_literal(target), $agentDid)`, + perspectiveIds: [notificationPerspective.uuid], + webhookUrl: "https://test.webhook", + webhookAuth: "test-auth", + }; + + const notificationId = + await ad4mClient.runtime.requestInstallNotification(notification); + await sleep(1000); + const granted = + await ad4mClient.runtime.grantNotification(notificationId); + expect(granted).to.be.true; + + const mockFunction = sinon.stub(); + await ad4mClient.runtime.addNotificationTriggeredCallback(mockFunction); + + // Add a message that doesn't mention the agent + await notificationPerspective.add( + new Link({ + source: "message://1", + predicate: "rdf://content", + target: "literal://string:Hello%20world", + }), + ); + await sleep(2000); + expect(mockFunction.called).to.be.false; + + // Add a message that mentions the agent (with HTML formatting) + const messageWithMention = `

Hey ${agentDid!}, how are you?

`; + await notificationPerspective.add( + new Link({ + source: "message://2", + predicate: "rdf://content", + target: `literal://string:${encodeURIComponent(messageWithMention)}`, + }), + ); + await sleep(7000); + expect(mockFunction.called).to.be.true; + + let triggeredNotification = mockFunction.getCall(0) + .args[0] as TriggeredNotification; + expect(triggeredNotification.notification.description).to.equal( + notification.description, + ); + let triggerMatch = JSON.parse(triggeredNotification.triggerMatch); + expect(triggerMatch.length).to.equal(1); + + // Verify all extracted data points + //@ts-ignore + expect(triggerMatch[0].message_id).to.equal("message://2"); + //@ts-ignore + expect(triggerMatch[0].message_content).to.include(agentDid); + //@ts-ignore + expect(triggerMatch[0].message_content).to.include(""); + //@ts-ignore + expect(triggerMatch[0].plain_text).to.include(agentDid); + //@ts-ignore + expect(triggerMatch[0].plain_text).to.not.include(""); + //@ts-ignore + expect(triggerMatch[0].mentioned_agent).to.equal(agentDid); + //@ts-ignore + expect(triggerMatch[0].perspective_id).to.equal( + notificationPerspective.uuid, + ); + }); + + it("can export and import database", async () => { + const ad4mClient = testContext.ad4mClient!; + const exportPath = "./tst-tmp/db_export.json"; + const importPath = "./tst-tmp/db_import.json"; + + // Add some test data + await ad4mClient.runtime.addTrustedAgents([ + "test-agent-1", + "test-agent-2", + ]); + await ad4mClient.runtime.addFriends(["test-friend-1", "test-friend-2"]); + + // Export the database + const exported = await ad4mClient.runtime.exportDb(exportPath); + expect(exported).to.be.true; + + // Verify export file exists + expect(fs.existsSync(exportPath)).to.be.true; + + // Clear some data + await ad4mClient.runtime.removeFriends([ + "test-friend-1", + "test-friend-2", + ]); + await ad4mClient.runtime.deleteTrustedAgents([ + "test-agent-1", + "test-agent-2", + ]); + + // Import the database + const imported = await ad4mClient.runtime.importDb(exportPath); + expect(imported).to.have.property("perspectives"); + expect(imported).to.have.property("links"); + expect(imported).to.have.property("expressions"); + expect(imported).to.have.property("perspectiveDiffs"); + expect(imported).to.have.property("notifications"); + expect(imported).to.have.property("models"); + expect(imported).to.have.property("defaultModels"); + expect(imported).to.have.property("tasks"); + expect(imported).to.have.property("friends"); + expect(imported).to.have.property("trustedAgents"); + expect(imported).to.have.property("knownLinkLanguages"); + + // Each property should have the ImportStats structure + const checkImportStats = (stats: any) => { + expect(stats).to.have.property("total"); + expect(stats).to.have.property("imported"); + expect(stats).to.have.property("failed"); + expect(stats).to.have.property("omitted"); + expect(stats).to.have.property("errors"); + expect(stats.errors).to.be.an("array"); + }; + + Object.values(imported).forEach(checkImportStats); + + // Verify data was restored + const trustedAgents = await ad4mClient.runtime.getTrustedAgents(); + expect(trustedAgents).to.include.members([ + "test-agent-1", + "test-agent-2", + ]); + + const friends = await ad4mClient.runtime.friends(); + expect(friends).to.include.members(["test-friend-1", "test-friend-2"]); + + // Clean up test files + fs.unlinkSync(exportPath); + }); + + // See comments on the imports at the top + // breaks CI for some reason but works locally + // leaving this here for manual local testing + /* + it("should trigger a notification and call the webhook", async () => { + const ad4mClient = testContext.ad4mClient! + const webhookUrl = 'http://localhost:8080/webhook'; + const webhookAuth = 'Test Webhook Auth' + // Setup Express server + const app = express(); + app.use(bodyParser.json()); + + let webhookCalled = false; + let webhookGotAuth = "" + let webhookGotBody = null + + app.post('/webhook', (req, res) => { + webhookCalled = true; + webhookGotAuth = req.headers['authorization']?.substring("Bearer ".length)||""; + webhookGotBody = req.body; + res.status(200).send({ success: true }); + }); + + let server: Server|void + let serverRunning = new Promise((done) => { + server = app.listen(8080, () => { + console.log('Test server running on port 8080'); + done() + }); + }) + + await serverRunning + + + let triggerPredicate = "ad4m://notification_webhook" + let notificationPerspective = await ad4mClient.perspective.add("notification test perspective") + let otherPerspective = await ad4mClient.perspective.add("other perspective") + + const notification: NotificationInput = { + description: "ad4m://notification predicate used", + appName: "ADAM tests", + appUrl: "Test App URL", + appIconPath: "Test App Icon Path", + trigger: `triple(Source, "${triggerPredicate}", Target)`, + perspectiveIds: [notificationPerspective.uuid], + webhookUrl: webhookUrl, + webhookAuth: webhookAuth + } + + // Request to install a new notification + const notificationId = await ad4mClient.runtime.requestInstallNotification(notification); + sleep(1000) + // Grant the notification + const granted = await ad4mClient.runtime.grantNotification(notificationId) + expect(granted).to.be.true + + // Ensuring no false positives + await notificationPerspective.add(new Link({source: "control://source", target: "control://target"})) + await sleep(1000) + expect(webhookCalled).to.be.false + + // Ensuring only selected perspectives will trigger + await otherPerspective.add(new Link({source: "control://source", predicate: triggerPredicate, target: "control://target"})) + await sleep(1000) + expect(webhookCalled).to.be.false + + // Happy path + await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target1"})) + await sleep(1000) + expect(webhookCalled).to.be.true + expect(webhookGotAuth).to.equal(webhookAuth) + expect(webhookGotBody).to.be.not.be.null + let triggeredNotification = webhookGotBody as unknown as TriggeredNotification + let triggerMatch = JSON.parse(triggeredNotification.triggerMatch) + expect(triggerMatch.length).to.equal(1) + let match = triggerMatch[0] + //@ts-ignore + expect(match.Source).to.equal("test://source") + //@ts-ignore + expect(match.Target).to.equal("test://target1") + + // Reset webhookCalled for the next test + webhookCalled = false; + webhookGotAuth = "" + webhookGotBody = null + + await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target2"})) + await sleep(1000) + expect(webhookCalled).to.be.true + expect(webhookGotAuth).to.equal(webhookAuth) + triggeredNotification = webhookGotBody as unknown as TriggeredNotification + triggerMatch = JSON.parse(triggeredNotification.triggerMatch) + expect(triggerMatch.length).to.equal(1) + match = triggerMatch[0] + //@ts-ignore + expect(match.Source).to.equal("test://source") + //@ts-ignore + expect(match.Target).to.equal("test://target2") + + // Close the server after the test + //@ts-ignore + server!.close() + }) + */ + }; +} diff --git a/tests/js/tests/integration/social-dna-flow.suite.ts b/tests/js/tests/integration/social-dna-flow.suite.ts new file mode 100644 index 000000000..fa461cfaf --- /dev/null +++ b/tests/js/tests/integration/social-dna-flow.suite.ts @@ -0,0 +1,316 @@ +import { Link, LinkQuery, Literal, SHACLFlow } from "@coasys/ad4m"; +import { TestContext } from "./integration.test"; +import { expect } from "chai"; +import { sleep } from "../../utils/utils"; + +export default function socialDNATests(testContext: TestContext) { + return () => { + describe("There is a SDNA test exercising an example TODO SDNA", () => { + // SdnaOnly doesn't load links into prolog engine + it.skip("can add social DNA to perspective and go through flow", async () => { + const sdna = [ + // The name of our SDNA flow: "TODO" + 'register_sdna_flow("TODO", t).', + + // What expressions can be used to start this flow? -> all + "flowable(_, t).", + + // This Flow has 3 states (0=ready, 0.5=doing, 1=done), + // which are represented by links with predicate 'todo://state' + 'flow_state(ExprAddr, 0, t) :- triple(ExprAddr, "todo://state", "todo://ready").', + 'flow_state(ExprAddr, 0.5, t) :- triple(ExprAddr, "todo://state", "todo://doing").', + 'flow_state(ExprAddr, 1, t) :- triple(ExprAddr, "todo://state", "todo://done").', + + // Initial action renders any expression into a todo item by adding a state link to 'ready' + `start_action('[{action: "addLink", source: "this", predicate: "todo://state", target: "todo://ready"}]', t).`, + // A ready todo can be 'started' = commencing work on it. Removes 'ready' link and replaces it by 'doing' link + `action(0, "Start", 0.5, '[{action: "addLink", source: "this", predicate: "todo://state", target: "todo://doing"}, {action: "removeLink", source: "this", predicate: "todo://state", target: "todo://ready"}]').`, + // A todo in doing can be 'finished' + `action(0.5, "Finish", 1, '[{action: "addLink", source: "this", predicate: "todo://state", target: "todo://done"}, {action: "removeLink", source: "this", predicate: "todo://state", target: "todo://doing"}]').`, + ]; + + const ad4mClient = testContext.ad4mClient!; + + const perspective = await ad4mClient.perspective.add("sdna-test"); + expect(perspective.name).to.be.equal("sdna-test"); + + await perspective.addSdna("Todo", sdna.join("\n"), "flow"); + + let sDNAFacts = await ad4mClient!.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "ad4m://self", + predicate: "ad4m://has_flow", + }), + ); + expect(sDNAFacts.length).to.be.equal(1); + let flows = await perspective.sdnaFlows(); + expect(flows[0]).to.be.equal("TODO"); + + await perspective.add( + new Link({ source: "ad4m://self", target: "test-lang://1234" }), + ); + let availableFlows = + await perspective.availableFlows("test-lang://1234"); + expect(availableFlows.length).to.be.equal(1); + expect(availableFlows[0]).to.be.equal("TODO"); + let startAction = await perspective.infer( + `start_action(Action, F), register_sdna_flow("TODO", F)`, + ); + await perspective.startFlow("TODO", "test-lang://1234"); + + let flowLinks = await ad4mClient!.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "test-lang://1234", + predicate: "todo://state", + }), + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal("todo://ready"); + + let todoState = await perspective.flowState("TODO", "test-lang://1234"); + expect(todoState).to.be.equal(0); + + let expressionsInTodo = await perspective.expressionsInFlowState( + "TODO", + 0, + ); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal("test-lang://1234"); + + // continue flow + let flowActions = await perspective.flowActions( + "TODO", + "test-lang://1234", + ); + expect(flowActions.length).to.be.equal(1); + expect(flowActions[0]).to.be.equal("Start"); + + await perspective.runFlowAction("TODO", "test-lang://1234", "Start"); + await sleep(100); + todoState = await perspective.flowState("TODO", "test-lang://1234"); + expect(todoState).to.be.equal(0.5); + + flowLinks = await ad4mClient!.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "test-lang://1234", + predicate: "todo://state", + }), + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal("todo://doing"); + + expressionsInTodo = await perspective.expressionsInFlowState( + "TODO", + 0.5, + ); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal("test-lang://1234"); + + // continue flow + flowActions = await perspective.flowActions("TODO", "test-lang://1234"); + expect(flowActions.length).to.be.equal(1); + expect(flowActions[0]).to.be.equal("Finish"); + + await perspective.runFlowAction("TODO", "test-lang://1234", "Finish"); + await sleep(100); + todoState = await perspective.flowState("TODO", "test-lang://1234"); + expect(todoState).to.be.equal(1); + + flowLinks = await ad4mClient!.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "test-lang://1234", + predicate: "todo://state", + }), + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal("todo://done"); + expressionsInTodo = await perspective.expressionsInFlowState("TODO", 1); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal("test-lang://1234"); + }); + }); + + describe("SHACL-based TODO flow", () => { + it("can add SHACL flow and go through full TODO workflow", async () => { + const ad4mClient = testContext.ad4mClient!; + + // Create perspective + const perspective = await ad4mClient.perspective.add("shacl-flow-test"); + expect(perspective.name).to.be.equal("shacl-flow-test"); + + // Create a SHACLFlow for TODO workflow + const todoFlow = new SHACLFlow("TODO", "todo://"); + todoFlow.flowable = "any"; + + // Define states + todoFlow.addState({ + name: "ready", + value: 0, + stateCheck: { predicate: "todo://state", target: "todo://ready" }, + }); + todoFlow.addState({ + name: "doing", + value: 0.5, + stateCheck: { predicate: "todo://state", target: "todo://doing" }, + }); + todoFlow.addState({ + name: "done", + value: 1, + stateCheck: { predicate: "todo://state", target: "todo://done" }, + }); + + // Define start action + todoFlow.startAction = [ + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, + ]; + + // Define transitions + todoFlow.addTransition({ + actionName: "Start", + fromState: "ready", + toState: "doing", + actions: [ + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://doing", + }, + { + action: "removeLink", + source: "this", + predicate: "todo://state", + target: "todo://ready", + }, + ], + }); + todoFlow.addTransition({ + actionName: "Finish", + fromState: "doing", + toState: "done", + actions: [ + { + action: "addLink", + source: "this", + predicate: "todo://state", + target: "todo://done", + }, + { + action: "removeLink", + source: "this", + predicate: "todo://state", + target: "todo://doing", + }, + ], + }); + + // Register the flow + await perspective.addFlow("TODO", todoFlow); + + // Test sdnaFlows() returns the flow name + let flows = await perspective.sdnaFlows(); + expect(flows).to.include("TODO"); + + // Add an expression and test availableFlows() + await perspective.add( + new Link({ source: "ad4m://self", target: "test-lang://1234" }), + ); + let availableFlows = + await perspective.availableFlows("test-lang://1234"); + expect(availableFlows.length).to.be.equal(1); + expect(availableFlows[0]).to.be.equal("TODO"); + + // Test startFlow() creates the right links + await perspective.startFlow("TODO", "test-lang://1234"); + + let flowLinks = await ad4mClient.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "test-lang://1234", + predicate: "todo://state", + }), + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal("todo://ready"); + + // Test flowState() returns correct state + let todoState = await perspective.flowState("TODO", "test-lang://1234"); + expect(todoState).to.be.equal(0); + + // Test expressionsInFlowState() finds expressions + let expressionsInTodo = await perspective.expressionsInFlowState( + "TODO", + 0, + ); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal("test-lang://1234"); + + // Test flowActions() returns available actions + let flowActions = await perspective.flowActions( + "TODO", + "test-lang://1234", + ); + expect(flowActions.length).to.be.equal(1); + expect(flowActions[0]).to.be.equal("Start"); + + // Test runFlowAction() transitions state: ready -> doing + await perspective.runFlowAction("TODO", "test-lang://1234", "Start"); + await sleep(100); + + todoState = await perspective.flowState("TODO", "test-lang://1234"); + expect(todoState).to.be.equal(0.5); + + flowLinks = await ad4mClient.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "test-lang://1234", + predicate: "todo://state", + }), + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal("todo://doing"); + + expressionsInTodo = await perspective.expressionsInFlowState( + "TODO", + 0.5, + ); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal("test-lang://1234"); + + // Test transition: doing -> done + flowActions = await perspective.flowActions("TODO", "test-lang://1234"); + expect(flowActions.length).to.be.equal(1); + expect(flowActions[0]).to.be.equal("Finish"); + + await perspective.runFlowAction("TODO", "test-lang://1234", "Finish"); + await sleep(100); + + todoState = await perspective.flowState("TODO", "test-lang://1234"); + expect(todoState).to.be.equal(1); + + flowLinks = await ad4mClient.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ + source: "test-lang://1234", + predicate: "todo://state", + }), + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal("todo://done"); + + expressionsInTodo = await perspective.expressionsInFlowState("TODO", 1); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal("test-lang://1234"); + }); + }); + }; +} diff --git a/tests/js/tests/integration/triple-agent-test.suite.ts b/tests/js/tests/integration/triple-agent-test.suite.ts new file mode 100644 index 000000000..be91dedca --- /dev/null +++ b/tests/js/tests/integration/triple-agent-test.suite.ts @@ -0,0 +1,384 @@ +import { Perspective, LinkQuery } from "@coasys/ad4m"; +import fs from "fs"; +import { TestContext } from "./integration.test"; +import { sleep } from "../../utils/utils"; +import { expect } from "chai"; +import { v4 as uuidv4 } from "uuid"; + +const DIFF_SYNC_OFFICIAL = fs + .readFileSync("./scripts/perspective-diff-sync-hash") + .toString(); + +export default function tripleAgentTests(testContext: TestContext) { + return () => { + it("three agents can join and use a neighbourhood", async () => { + const alice = testContext.alice; + const bob = testContext.bob; + const jim = testContext.jim; + + const aliceP1 = await alice.perspective.add("three-agents"); + const socialContext = await alice.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Alice's neighbourhood with Bob", + }), + ); + expect(socialContext.name).to.be.equal("Alice's neighbourhood with Bob"); + const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective( + aliceP1.uuid, + socialContext.address, + new Perspective(), + ); + + let bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); + let jimP1 = await jim.neighbourhood.joinFromUrl(neighbourhoodUrl); + + await testContext.makeAllThreeNodesKnown(); + + expect(bobP1!.name).not.to.be.undefined; + expect(bobP1!.sharedUrl).to.be.equal(neighbourhoodUrl); + expect(bobP1!.neighbourhood).not.to.be.undefined; + expect(bobP1!.neighbourhood!.data!.linkLanguage).to.be.equal( + socialContext.address, + ); + expect(bobP1!.neighbourhood!.data!.meta.links.length).to.be.equal(0); + + expect(jimP1!.name).not.to.be.undefined; + expect(jimP1!.sharedUrl).to.be.equal(neighbourhoodUrl); + expect(jimP1!.neighbourhood).not.to.be.undefined; + expect(jimP1!.neighbourhood!.data!.linkLanguage).to.be.equal( + socialContext.address, + ); + expect(jimP1!.neighbourhood!.data!.meta.links.length).to.be.equal(0); + + await sleep(1000); + + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + + await sleep(1000); + + let bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + // Increase retries and sleep for CI robustness + const MAX_RETRIES = process.env.CI ? 80 : 40; + const SLEEP_MS = process.env.CI ? 4000 : 3000; + + let tries = 1; + while (bobLinks.length < 10 && tries < MAX_RETRIES) { + console.log( + `Bob retrying getting links (attempt ${tries}/${MAX_RETRIES}, have ${bobLinks.length}/10)...`, + ); + await sleep(SLEEP_MS); + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + if (bobLinks.length !== 10) { + console.error( + `Bob final: got ${bobLinks.length}/10 links after ${tries} tries`, + ); + } + expect(bobLinks.length).to.be.equal( + 10, + `Bob saw ${bobLinks.length}/10 links after ${tries} tries`, + ); + + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + + // Re-exchange agent infos so Jim's DHT routing table is fresh after Alice + // and Bob have made many new Holochain commits. Without this, Jim's node + // can accumulate "Could not find entry" failures because it doesn't know + // which peers hold the new entries. + await testContext.makeAllThreeNodesKnown(); + + // Give the DHT time to gossip the new entries to Jim before the first check. + await sleep(10000); + let jimLinks = await jim.perspective.queryLinks( + jimP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + let jimRetries = 1; + while (jimLinks.length < 20 && jimRetries < MAX_RETRIES) { + console.log( + `Jim retrying getting links (attempt ${jimRetries}/${MAX_RETRIES}, have ${jimLinks.length}/20)...`, + ); + await sleep(SLEEP_MS); + jimLinks = await jim.perspective.queryLinks( + jimP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + jimRetries++; + } + if (jimLinks.length !== 20) { + console.error( + `Jim final: got ${jimLinks.length}/20 links after ${jimRetries} tries`, + ); + } + expect(jimLinks.length).to.be.equal( + 20, + `Jim saw ${jimLinks.length}/20 links after ${jimRetries} tries`, + ); + + // Refresh routing tables again now that Jim has caught up, so that Jim's + // phase-3 writes will propagate back to Alice and Bob (Jim was a slow peer + // throughout phase 2 — without a fresh exchange his new entries may not + // reach the other nodes). + await testContext.makeAllThreeNodesKnown(); + await sleep(10000); + + // Verify Alice also sees all 20 links before phase 3 begins — she may be + // missing Bob's phase-2 contributions if the DHT was slow while Jim was + // catching up. + let aliceLinks = await alice.perspective.queryLinks( + aliceP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries = 1; + while (aliceLinks.length < 20 && tries < MAX_RETRIES) { + console.log( + `Alice pre-phase3 sync (attempt ${tries}/20, have ${aliceLinks.length}/20)...`, + ); + await sleep(SLEEP_MS); + aliceLinks = await alice.perspective.queryLinks( + aliceP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + if (aliceLinks.length !== 20) { + console.error( + `Alice final: got ${aliceLinks.length}/20 links after ${tries} tries`, + ); + } + expect(aliceLinks.length).to.be.equal( + 20, + `Alice saw ${aliceLinks.length}/20 links after ${tries} tries`, + ); + + // Verify Bob also sees all 20 links before phase 3 begins. + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries = 1; + while (bobLinks.length < 20 && tries < MAX_RETRIES) { + console.log( + `Bob pre-phase3 sync (attempt ${tries}/20, have ${bobLinks.length}/20)...`, + ); + await sleep(SLEEP_MS); + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + if (bobLinks.length !== 20) { + console.error( + `Bob final: got ${bobLinks.length}/20 links after ${tries} tries`, + ); + } + expect(bobLinks.length).to.be.equal( + 20, + `Bob saw ${bobLinks.length}/20 links after ${tries} tries`, + ); + + //Alice bob and jim all collectively add 10 links and then check can be received by all agents + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await jim.perspective.addLink(jimP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await jim.perspective.addLink(jimP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await alice.perspective.addLink(aliceP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await bob.perspective.addLink(bobP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await jim.perspective.addLink(jimP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + await jim.perspective.addLink(jimP1.uuid, { + source: "ad4m://root", + target: "test://test", + }); + + aliceLinks = await alice.perspective.queryLinks( + aliceP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries = 1; + + while (aliceLinks.length < 30 && tries < MAX_RETRIES) { + console.log( + `Alice retrying getting links (attempt ${tries}/${MAX_RETRIES}, have ${aliceLinks.length}/30)...`, + ); + await sleep(SLEEP_MS); + aliceLinks = await alice.perspective.queryLinks( + aliceP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + if (aliceLinks.length !== 30) { + console.error( + `Alice final: got ${aliceLinks.length}/30 links after ${tries} tries`, + ); + } + expect(aliceLinks.length).to.be.equal( + 30, + `Alice saw ${aliceLinks.length}/30 links after ${tries} tries`, + ); + + // Bob waits for 30 links + tries = 1; + while (bobLinks.length < 30 && tries < MAX_RETRIES) { + console.log( + `Bob retrying getting links (attempt ${tries}/${MAX_RETRIES}, have ${bobLinks.length}/30)...`, + ); + await sleep(SLEEP_MS); + bobLinks = await bob.perspective.queryLinks( + bobP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + if (bobLinks.length !== 30) { + console.error( + `Bob final: got ${bobLinks.length}/30 links after ${tries} tries`, + ); + } + expect(bobLinks.length).to.be.equal( + 30, + `Bob saw ${bobLinks.length}/30 links after ${tries} tries`, + ); + + // Jim waits for 30 links + tries = 1; + while (jimLinks.length < 30 && tries < MAX_RETRIES) { + console.log( + `Jim retrying getting links (attempt ${tries}/${MAX_RETRIES}, have ${jimLinks.length}/30)...`, + ); + await sleep(SLEEP_MS); + jimLinks = await jim.perspective.queryLinks( + jimP1!.uuid, + new LinkQuery({ source: "ad4m://root" }), + ); + tries++; + } + if (jimLinks.length !== 30) { + console.error( + `Jim final: got ${jimLinks.length}/30 links after ${tries} tries`, + ); + } + expect(jimLinks.length).to.be.equal( + 30, + `Jim saw ${jimLinks.length}/30 links after ${tries} tries`, + ); + }); + }; +} diff --git a/tests/js/tests/language.ts b/tests/js/tests/language.ts deleted file mode 100644 index a79f0b690..000000000 --- a/tests/js/tests/language.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { TestContext } from './integration.test' -import path from "path"; -import fs from "fs"; -import { sleep } from '../utils/utils'; -import { Ad4mClient, LanguageMetaInput, LanguageRef } from '@coasys/ad4m'; -import { expect } from "chai"; -import { fileURLToPath } from 'url'; -import stringify from 'json-stable-stringify' - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export default function languageTests(testContext: TestContext) { - return () => { - describe('with a perspective-diff-sync templated by Alice', () => { - let ad4mClient: Ad4mClient - let bobAd4mClient: Ad4mClient - let sourceLanguage: LanguageRef = new LanguageRef() - let nonHCSourceLanguage: LanguageRef = new LanguageRef() - let sourceLanguageMeta: LanguageMetaInput = new LanguageMetaInput("Newly published perspective-diff-sync", "..here for you template"); - sourceLanguageMeta.possibleTemplateParams = ["uid", "description", "name"]; - - before(async () => { - ad4mClient = testContext.ad4mClient; - bobAd4mClient = testContext.bob; - - //First edit bundle for perspective-diff-sync so we get a unique hash which does not clash with existing loaded perspective-diff-sync object in LanguageController - let socialContextData = fs.readFileSync("./tst-tmp/languages/perspective-diff-sync/build/bundle.js").toString(); - socialContextData = socialContextData + "\n//Test"; - fs.writeFileSync("./tst-tmp/languages/perspective-diff-sync/build/bundle.js", socialContextData); - - //Publish a source language to start working from - sourceLanguage = await ad4mClient.languages.publish( - path.join(__dirname, "../tst-tmp/languages/perspective-diff-sync/build/bundle.js").replace(/\\/g, "/"), - sourceLanguageMeta - ) - expect(sourceLanguage.name).to.be.equal(sourceLanguageMeta.name); - // @ts-ignore - sourceLanguageMeta.address = sourceLanguage.address; - }) - - it('Alice can get the source of her own templated language', async () => { - const sourceFromAd4m = await ad4mClient.languages.source(sourceLanguage.address) - const sourceFromFile = fs.readFileSync(path.join(__dirname, "../tst-tmp/languages/perspective-diff-sync/build/bundle.js")).toString() - expect(sourceFromAd4m).to.be.equal(sourceFromFile) - }) - - it('Alice can install her own published language', async () => { - const install = await ad4mClient.languages.byAddress(sourceLanguage.address); - expect(install.address).not.to.be.undefined; - expect(install.constructorIcon).to.be.null; - expect(install.settingsIcon).not.to.be.undefined; - }) - - it('Alice can install her own non HC published language', async () => { - let sourceLanguageMeta: LanguageMetaInput = new LanguageMetaInput("Newly published perspective-language", "..here for you template"); - let socialContextData = fs.readFileSync("./tst-tmp/languages/perspective-language/build/bundle.js").toString(); - socialContextData = socialContextData + "\n//Test"; - fs.writeFileSync("./tst-tmp/languages/perspective-language/build/bundle.js", socialContextData); - - //Publish a source language to start working from - nonHCSourceLanguage = await ad4mClient.languages.publish( - path.join(__dirname, "../tst-tmp/languages/perspective-language/build/bundle.js").replace(/\\/g, "/"), - sourceLanguageMeta - ) - expect(nonHCSourceLanguage.name).to.be.equal(nonHCSourceLanguage.name); - - const install = await ad4mClient.languages.byAddress(nonHCSourceLanguage.address); - expect(install.address).not.to.be.undefined; - expect(install.constructorIcon).not.to.be.null; - expect(install.icon).not.to.be.null; - expect(install.settingsIcon).to.be.null; - }) - - it('Alice can use language.meta() to get meta info of her Language', async() => { - const meta = await ad4mClient.languages.meta(sourceLanguage.address) - expect(meta.address).to.be.equal(sourceLanguage.address) - expect(meta.name).to.be.equal(sourceLanguageMeta.name) - expect(meta.description).to.be.equal(sourceLanguageMeta.description) - expect(meta.author).to.be.equal((await ad4mClient.agent.status()).did) - expect(meta.templated).to.be.false; - }) - - it('Alice can get her own templated perspective-diff-sync and it provides correct meta data', async () => { - //Get the meta of the source language and make sure it is correct - const foundSourceLanguageMeta = await ad4mClient.expression.get(`lang://${sourceLanguage.address}`); - expect(foundSourceLanguageMeta.proof.valid).to.be.true; - const sourceLanguageMetaData = JSON.parse(foundSourceLanguageMeta.data); - expect(sourceLanguageMetaData.name).to.be.equal(sourceLanguageMeta.name) - expect(sourceLanguageMetaData.description).to.be.equal(sourceLanguageMeta.description) - }) - - it('can publish and template a non-Holochain language and provide correct meta data', async() => { - const noteMetaInfo = new LanguageMetaInput("Newly published note language", "Just to test non-HC language work as well"); - //Publish a source language without a holochain DNA - const canPublishNonHolochainLang = await ad4mClient.languages.publish( - path.join(__dirname, "../languages/note-store/build/bundle.js").replace(/\\/g, "/"), - noteMetaInfo - ); - expect(canPublishNonHolochainLang.name).to.be.equal(noteMetaInfo.name); - //TODO/NOTE: this will break if the note language version is changed - expect(canPublishNonHolochainLang.address).to.be.equal("QmzSYwdiTHLZtzCPBq384QqeyKT4P2JvqqXH8So4MB4axfftLHA"); - - //Get meta for source language above and make sure it is correct - const sourceLanguageMetaNonHC = await ad4mClient.expression.get(`lang://${canPublishNonHolochainLang.address}`); - expect(sourceLanguageMetaNonHC.proof.valid).to.be.true; - const sourceLanguageMetaNonHCData = JSON.parse(sourceLanguageMetaNonHC.data); - expect(sourceLanguageMetaNonHCData.name).to.be.equal(noteMetaInfo.name) - expect(sourceLanguageMetaNonHCData.description).to.be.equal(noteMetaInfo.description) - expect(sourceLanguageMetaNonHCData.address).to.be.equal("QmzSYwdiTHLZtzCPBq384QqeyKT4P2JvqqXH8So4MB4axfftLHA") - }) - - - it("Bob can not get/install Alice's language since he doesn't trust her yet", async () => { - // Make sure all new Holochain cells get connected.. - await testContext.makeAllNodesKnown() - // .. and have time to gossip inside the Language Language, - // so Bob sees the languages created above by Alice - await sleep(1000); - - - //Test that bob cannot install source language which alice created since she is not in his trusted agents - let error - try { - await bobAd4mClient.languages.byAddress(sourceLanguage.address); - } catch(e) { - error = e - } - - console.log("Response for non trust got error", error); - - //@ts-ignore - sourceLanguageMeta.sourceCodeLink = null; - - //@ts-ignore - expect(error.toString()).to.contain(`ApolloError: Language not created by trusted agent: ${(await ad4mClient.agent.me()).did} and is not templated... aborting language install. Language metadata: ${stringify(sourceLanguageMeta)}`) - }) - - describe('with Bob having added Alice to list of trusted agents', () => { - let applyTemplateFromSource: LanguageRef = new LanguageRef() - - before(async () => { - //Add alice as trusted agent for bob - const aliceDid = await ad4mClient.agent.me(); - const aliceTrusted = await bobAd4mClient.runtime.addTrustedAgents([aliceDid.did]); - console.warn("Got result when adding trusted agent", aliceTrusted); - }) - - it("Bob can template Alice's perspective-diff-sync, and alice can install", async () => { - //Apply template on above holochain language - applyTemplateFromSource = await bobAd4mClient.languages.applyTemplateAndPublish(sourceLanguage.address, JSON.stringify({uid: "2eebb82b-9db1-401b-ba04-1e8eb78ac84c", name: "Bob's templated perspective-diff-sync"})) - expect(applyTemplateFromSource.name).to.be.equal("Bob's templated perspective-diff-sync"); - expect(applyTemplateFromSource.address).not.be.equal(sourceLanguage.address); - - //Get language meta for above language and make sure it is correct - const langExpr = await bobAd4mClient.expression.get(`lang://${applyTemplateFromSource.address}`); - expect(langExpr.proof.valid).to.be.true; - const meta = await bobAd4mClient.languages.meta(applyTemplateFromSource.address); - expect(meta.name).to.be.equal("Bob's templated perspective-diff-sync") - expect(meta.description).to.be.equal("..here for you template") - expect(meta.author).to.be.equal((await bobAd4mClient.agent.status()).did) - expect(meta.templateAppliedParams).to.be.equal(JSON.stringify({ - "name": "Bob's templated perspective-diff-sync", - "uid":"2eebb82b-9db1-401b-ba04-1e8eb78ac84c" - })) - expect(meta.templateSourceLanguageAddress).to.be.equal(sourceLanguage.address) - - await ad4mClient.runtime.addTrustedAgents([(await bobAd4mClient.agent.me()).did]); - - const installGetLanguage = await ad4mClient.languages.byAddress(applyTemplateFromSource.address); - expect(installGetLanguage.address).to.be.equal(applyTemplateFromSource.address); - expect(installGetLanguage.name).to.be.equal(meta.name); - }) - - it("Bob can install Alice's perspective-diff-sync", async () => { - //Test that bob can install source language when alice is in trusted agents - const installSourceTrusted = await bobAd4mClient.languages.byAddress(sourceLanguage.address); - expect(installSourceTrusted.address).to.be.equal(sourceLanguage.address); - expect(installSourceTrusted.constructorIcon).not.to.be.undefined; - expect(installSourceTrusted.settingsIcon).not.to.be.undefined; - }) - - it("Bob can install his own templated language", async () => { - //Test that bob can install language which is templated from source since source language was created by alice who is now a trusted agent - const installTemplated = await bobAd4mClient.languages.byAddress(applyTemplateFromSource.address); - expect(installTemplated.address).to.be.equal(applyTemplateFromSource.address); - expect(installTemplated.name).to.be.equal("Bob's templated perspective-diff-sync"); - expect(installTemplated.constructorIcon).not.to.be.undefined; - expect(installTemplated.settingsIcon).not.to.be.undefined; - }) - - it('Bob can delete a language', async () => { - const deleteLanguage = await bobAd4mClient.languages.remove(sourceLanguage.address); - expect(deleteLanguage).to.be.true; - }) - }) - }) - } -} diff --git a/tests/js/tests/model/hooks.ts b/tests/js/tests/model/hooks.ts new file mode 100644 index 000000000..9a751a323 --- /dev/null +++ b/tests/js/tests/model/hooks.ts @@ -0,0 +1,51 @@ +/** + * Mocha Root Hooks Plugin — model test suite shared executor + * + * Loaded via --require in the test-model script. Starts a single HC executor + * before the first model test file runs and tears it down after the last, + * replacing per-file startAgent/stop pairs with one HC startup for the + * entire model suite (8 files → 1 holochain conductor boot instead of 8). + * + * Each test file calls getSharedAgent() in its before() hook. If it returns + * null the file was run in isolation (without --require hooks.ts) and it + * falls back to starting its own executor — so individual files stay runnable + * standalone for debugging. + * + * When adding a new test group that should share one executor, follow the + * same pattern: add a hooks.ts alongside the files, export mochaHooks and + * getSharedAgent(), and add --require to the npm script. + */ + +import fetch from "node-fetch"; +import { startAgent } from "../../helpers/index.js"; +import type { AgentHandle } from "../../helpers/executor.js"; + +// @ts-ignore +global.fetch = fetch; + +let _sharedAgent: AgentHandle | null = null; + +/** + * Returns the AgentHandle started by the Root Hooks Plugin, or null when a + * test file is run in isolation (without --require hooks.ts). + */ +export function getSharedAgent(): AgentHandle | null { + return _sharedAgent; +} + +/** + * Root Hooks — called by Mocha once around the full test run. + */ +export const mochaHooks = { + async beforeAll(this: Mocha.Context) { + this.timeout(120_000); + _sharedAgent = await startAgent("model-suite"); + }, + + async afterAll() { + if (_sharedAgent) { + await _sharedAgent.stop(); + _sharedAgent = null; + } + }, +}; diff --git a/tests/js/tests/model/model-core.test.ts b/tests/js/tests/model/model-core.test.ts new file mode 100644 index 000000000..a18fa122d --- /dev/null +++ b/tests/js/tests/model/model-core.test.ts @@ -0,0 +1,482 @@ +/** + * Ad4mModel — core CRUD integration tests + * + * Covers: save() / create() / get() / findAll() / findOne() / delete() + * and the @Flag / @Property decorator round-trip. + * + * Ported from playground scenarios 01 (Basic CRUD) and 08 (Decorator API) + * with all six decorator types covered. + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --exit tests/model/model-core.test.ts + */ + +import { expect } from "chai"; +import { Ad4mClient, LinkQuery, PerspectiveProxy } from "@coasys/ad4m"; +import { startAgent, waitUntil } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { wipePerspective } from "../../utils/utils.js"; +import { TestComment, TestPost, TestTag } from "./models.js"; + + +describe("Ad4mModel — Core CRUD", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-core"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-core-test"); + await TestPost.register(perspective); + await TestComment.register(perspective); + await TestTag.register(perspective); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + await wipePerspective(perspective); + await TestPost.register(perspective); + await TestComment.register(perspective); + await TestTag.register(perspective); + }); + + // ── save() / id ──────────────────────────────────────────────────────────── + + it("save() populates a non-empty id", async () => { + const post = new TestPost(perspective); + post.title = "CRUD Test"; + post.body = "body"; + await post.save(); + expect(post.id).to.not.equal(""); + }); + + it("create() constructs, assigns and saves in one call", async () => { + const post = await TestPost.create(perspective, { + title: "Created", + body: "via create", + }); + expect(post.id).to.not.equal(""); + expect(post.title).to.equal("Created"); + expect(post.body).to.equal("via create"); + + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(found).to.not.be.null; + expect(found!.title).to.equal("Created"); + }); + + // ── get() ────────────────────────────────────────────────────────────────── + + it("get() re-reads persisted values from the perspective", async () => { + const post = await TestPost.create(perspective, { + title: "getData Target", + body: "snapshot body", + }); + const data = await post.get(); + expect(data).to.be.an("object"); + expect(data.title).to.equal("getData Target"); + expect(data.body).to.equal("snapshot body"); + }); + + // ── findAll() ────────────────────────────────────────────────────────────── + + it("findAll() returns all saved instances", async () => { + const post = await TestPost.create(perspective, { + title: "Count Test", + body: "", + }); + const results = await TestPost.findAll(perspective, { + where: { id: post.id }, + }); + expect(results).to.have.length(1); + expect(results[0].title).to.equal("Count Test"); + }); + + // ── @Flag ────────────────────────────────────────────────────────────────── + + it("@Flag — findAll() returns only TestPost instances", async () => { + await TestPost.create(perspective, { title: "Flag Check", body: "" }); + await TestComment.create(perspective, { body: "not a post" }); + + let posts: TestPost[] = []; + await waitUntil( + async () => { + posts = await TestPost.findAll(perspective); + return posts.length > 0; + }, + 8000, + "first post appears in findAll()", + ); + + expect(posts.every((p) => p instanceof TestPost)).to.be.true; + // Comments should not appear in TestPost.findAll() — different @Flag + expect( + posts.some( + (p) => + (p as any).body !== undefined && (p as any).type === "test://comment", + ), + ).to.be.false; + }); + + it("@Flag — flag value survives re-save (immutable after creation)", async () => { + const post = new TestPost(perspective); + post.title = "Flag Immutability"; + post.body = ""; + await post.save(); + + post.title = "Updated Title"; + await post.save(); + + const found = await TestPost.findAll(perspective, { + where: { id: post.id }, + }); + expect(found).to.have.length(1); + expect(found[0].title).to.equal("Updated Title"); + expect(found[0].type).to.equal("test://post"); + }); + + // ── @Property round-trip ─────────────────────────────────────────────────── + + it("@Property — fields round-trip correctly through save/findOne", async () => { + const post = await TestPost.create(perspective, { + title: "Round Trip", + body: "body text", + }); + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(found).to.not.be.null; + expect(found!.title).to.equal("Round Trip"); + expect(found!.body).to.equal("body text"); + }); + + // ── re-save / update ─────────────────────────────────────────────────────── + + it("save() on existing instance updates without creating a duplicate", async () => { + const post = new TestPost(perspective); + post.title = "Original"; + post.body = ""; + await post.save(); + const id = post.id; + + post.title = "Updated"; + await post.save(); + + const all = await TestPost.findAll(perspective, { where: { id } }); + expect(all).to.have.length(1); + expect(all[0].title).to.equal("Updated"); + }); + + // ── findOne ──────────────────────────────────────────────────────────────── + + it("findOne() returns matching instance", async () => { + const post = await TestPost.create(perspective, { + title: "FindOne Target", + body: "", + }); + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(found).to.not.be.null; + expect(found).to.be.instanceOf(TestPost); + expect(found!.id).to.equal(post.id); + expect(found!.title).to.equal("FindOne Target"); + }); + + it("findOne() returns null for non-existent id", async () => { + const missing = await TestPost.findOne(perspective, { + where: { id: "literal://string:no-such-id" }, + }); + expect(missing).to.be.null; + }); + + // ── delete ───────────────────────────────────────────────────────────────── + + it("delete() removes the instance from the perspective", async () => { + const post = await TestPost.create(perspective, { + title: "Delete Target", + body: "", + }); + const id = post.id; + await post.delete(); + const found = await TestPost.findAll(perspective, { where: { id } }); + expect(found).to.have.length(0); + }); + + // ── static delete() ──────────────────────────────────────────────────────── + + it("TestPost.delete(perspective, id) removes the instance", async () => { + const post = await TestPost.create(perspective, { title: "Static Delete", body: "" }); + const id = post.id; + await TestPost.delete(perspective, id); + const found = await TestPost.findAll(perspective, { where: { id } }); + expect(found).to.have.length(0); + }); + + // ── static update() ──────────────────────────────────────────────────────── + + it("TestPost.update() mutates only the specified field and leaves others intact", async () => { + const post = await TestPost.create(perspective, { title: "Before", body: "Keep this" }); + + await TestPost.update(perspective, post.id, { title: "After" }); + + const found = await TestPost.findOne(perspective, { where: { id: post.id } }); + expect(found).to.not.be.null; + expect(found!.title).to.equal("After"); + expect(found!.body).to.equal("Keep this"); + }); + + it("TestPost.update() returns the updated instance", async () => { + const post = await TestPost.create(perspective, { title: "Original", body: "" }); + const updated = await TestPost.update(perspective, post.id, { title: "Returned" }); + expect(updated).to.be.instanceOf(TestPost); + expect(updated.id).to.equal(post.id); + expect(updated.title).to.equal("Returned"); + }); + + it("TestPost.update() with multiple fields updates all of them", async () => { + const post = await TestPost.create(perspective, { title: "Old Title", body: "Old Body" }); + await TestPost.update(perspective, post.id, { title: "New Title", body: "New Body" }); + const found = await TestPost.findOne(perspective, { where: { id: post.id } }); + expect(found!.title).to.equal("New Title"); + expect(found!.body).to.equal("New Body"); + }); + + + // ── @HasMany — addComments ───────────────────────────────────────────────── + + it("@HasMany — addComments() links comment to post", async () => { + const post = await TestPost.create(perspective, { + title: "Post With Comment", + body: "", + }); + const comment = await TestComment.create(perspective, { + body: "Nice post!", + }); + await post.addComments(comment); + const updated = await TestPost.findOne(perspective, { + where: { id: post.id }, + include: { comments: true }, + }); + expect(updated).to.not.be.null; + expect(updated!.comments.some((c) => c.id === comment.id)).to.be.true; + }); + + it("@HasMany — removeComments() unlinks a comment from a post", async () => { + const post = await TestPost.create(perspective, { + title: "Post For Remove", + body: "", + }); + const c1 = await TestComment.create(perspective, { body: "To keep" }); + const c2 = await TestComment.create(perspective, { body: "To remove" }); + await post.addComments(c1); + await post.addComments(c2); + await post.removeComments(c2); + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + include: { comments: true }, + }); + expect(found).to.not.be.null; + expect(found!.comments.some((c) => c.id === c1.id)).to.be.true; + expect(found!.comments.some((c) => c.id === c2.id)).to.be.false; + }); + + it("@HasMany — setComments() replaces entire relation set atomically", async () => { + const post = await TestPost.create(perspective, { + title: "Post For Set", + body: "", + }); + const c1 = await TestComment.create(perspective, { body: "Initial A" }); + const c2 = await TestComment.create(perspective, { body: "Initial B" }); + await post.addComments(c1); + await post.addComments(c2); + const c3 = await TestComment.create(perspective, { body: "Replacement" }); + await post.setComments([c3]); + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + include: { comments: true }, + }); + expect(found).to.not.be.null; + expect(found!.comments.some((c) => c.id === c1.id)).to.be.false; + expect(found!.comments.some((c) => c.id === c2.id)).to.be.false; + expect(found!.comments.some((c) => c.id === c3.id)).to.be.true; + expect(found!.comments).to.have.length(1); + }); + + // ── @HasOne ──────────────────────────────────────────────────────────────── + + it("@HasOne — pinnedComment hydrates to a TestComment instance", async () => { + const post = await TestPost.create(perspective, { + title: "Post With Pin", + body: "", + }); + const comment = await TestComment.create(perspective, { body: "Pinned!" }); + await post.addPinnedComment(comment); + const updated = await TestPost.findOne(perspective, { + where: { id: post.id }, + include: { pinnedComment: true }, + }); + expect(updated).to.not.be.null; + expect(updated!.pinnedComment).to.be.instanceOf(TestComment); + expect((updated!.pinnedComment as TestComment).id).to.equal(comment.id); + }); + + // ── @BelongsToOne ────────────────────────────────────────────────────────── + + it("@BelongsToOne — comment.post resolves to a TestPost instance", async () => { + const post = await TestPost.create(perspective, { + title: "Parent Post", + body: "", + }); + const comment = await TestComment.create(perspective, { + body: "Reverse traversal test", + }); + await post.addComments(comment); + const found = await TestComment.findOne(perspective, { + where: { id: comment.id }, + include: { post: true }, + }); + expect(found).to.not.be.null; + expect(found!.post).to.be.instanceOf(TestPost); + expect((found!.post as TestPost).id).to.equal(post.id); + }); + + it("@BelongsToOne — comment.pinnedBy resolves to the post that pinned it", async () => { + const post = await TestPost.create(perspective, { + title: "Pinning Post", + body: "", + }); + const comment = await TestComment.create(perspective, { + body: "I am the pinned comment", + }); + await post.addPinnedComment(comment); + const found = await TestComment.findOne(perspective, { + where: { id: comment.id }, + include: { pinnedBy: true }, + }); + expect(found).to.not.be.null; + expect(found!.pinnedBy).to.be.instanceOf(TestPost); + expect((found!.pinnedBy as TestPost).id).to.equal(post.id); + + const unpinned = await TestComment.create(perspective, { + body: "Not pinned", + }); + const foundUnpinned = await TestComment.findOne(perspective, { + where: { id: unpinned.id }, + include: { pinnedBy: true }, + }); + expect(foundUnpinned!.pinnedBy).to.be.null; + }); + + // ── @BelongsToMany ───────────────────────────────────────────────────────── + + it("@BelongsToMany — tag.posts contains all posts that use the tag", async () => { + const tag = await TestTag.create(perspective, { label: "belongs-many" }); + const post1 = await TestPost.create(perspective, { + title: "Tagged 1", + body: "", + }); + const post2 = await TestPost.create(perspective, { + title: "Tagged 2", + body: "", + }); + await post1.addTags(tag.id); + await post2.addTags(tag.id); + const found = await TestTag.findOne(perspective, { where: { id: tag.id } }); + expect(found).to.not.be.null; + const postIds = found!.posts as unknown as string[]; + expect(postIds).to.include(post1.id); + expect(postIds).to.include(post2.id); + }); + + // ── relation links are visible in the raw perspective ───────────────────── + + it("relation links are visible via perspective.get() after add*()", async () => { + const post = await TestPost.create(perspective, { + title: "Link Visibility", + body: "", + }); + const c = await TestComment.create(perspective, { body: "visible" }); + await post.addComments(c.id); + const links = await perspective.get( + new LinkQuery({ predicate: "test://has_comment", source: post.id }), + ); + expect(links.length).to.be.at.least(1); + expect(links.some((l) => l.data.target === c.id)).to.be.true; + }); + + // ── createdAt / updatedAt / author ───────────────────────────────────────── + + it("findOne() populates createdAt, updatedAt, and author after save", async () => { + const post = await TestPost.create(perspective, { + title: "Meta Fields", + body: "", + }); + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(found).to.not.be.null; + expect(found!.createdAt).to.not.be.undefined; + expect(found!.updatedAt).to.not.be.undefined; + expect(found!.author).to.be.a("string").and.not.equal(""); + // createdAt ≤ updatedAt always holds (equal when nothing changed) + expect(Number(found!.createdAt)).to.be.at.most(Number(found!.updatedAt)); + }); + + it("updatedAt advances past createdAt after a re-save", async () => { + const post = await TestPost.create(perspective, { + title: "Timestamp Advance", + body: "", + }); + // Small pause — ensures the re-save link gets a strictly later timestamp + await new Promise((r) => setTimeout(r, 100)); + + post.body = "updated body"; + await post.save(); + + const found = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(found).to.not.be.null; + expect(Number(found!.updatedAt)).to.be.greaterThan( + Number(found!.createdAt), + ); + }); + + // ── get(include) ─────────────────────────────────────────────────────────── + + it("get(include) hydrates relations on a bare-id instance", async () => { + const post = await TestPost.create(perspective, { + title: "Get Include Post", + body: "", + }); + const comment = await TestComment.create(perspective, { + body: "populated via get", + }); + await post.addComments(comment.id); + + // Construct a fresh instance with only the id — nothing loaded yet + const bare = new TestPost(perspective, post.id); + await bare.get({ comments: true }); + + expect(bare.comments).to.be.an("array").with.length(1); + expect(bare.comments[0]).to.be.instanceOf(TestComment); + expect((bare.comments[0] as TestComment).id).to.equal(comment.id); + expect((bare.comments[0] as TestComment).body).to.equal( + "populated via get", + ); + }); +}); diff --git a/tests/js/tests/model/model-from-json-schema.test.ts b/tests/js/tests/model/model-from-json-schema.test.ts new file mode 100644 index 000000000..531295c40 --- /dev/null +++ b/tests/js/tests/model/model-from-json-schema.test.ts @@ -0,0 +1,586 @@ +/** + * Ad4mModel.fromJSONSchema — integration tests + * + * Tests for the fromJSONSchema factory method: creating Ad4mModel subclasses + * from JSON Schema definitions with explicit config, x-ad4m metadata, + * title-based namespace inference, and error handling. + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 1200000 --serial --exit tests/model/model-from-json-schema.test.ts + */ + +import { expect } from "chai"; +import { Ad4mClient, PerspectiveProxy, Ad4mModel } from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; + +describe("Ad4mModel.fromJSONSchema", () => { + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy | null = null; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-from-json-schema"); + ad4m = agent.client; + ownStop = agent.stop; + } + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + perspective = await ad4m.perspective.add("json-schema-test"); + }); + + describe("with explicit configuration", () => { + it("should create Ad4mModel class from JSON Schema with explicit namespace", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Person", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string" }, + }, + required: ["name"], + }; + + const PersonClass = Ad4mModel.fromJSONSchema(schema, { + name: "Person", + namespace: "person://", + }) as any; + + expect(PersonClass).to.be.a("function"); + expect(PersonClass.className).to.equal("Person"); + + // Test instance creation + const person = new PersonClass(perspective!); + expect(person).to.be.instanceOf(Ad4mModel); + expect(person.id).to.be.a("string"); + + // Test property assignment + person.name = "Alice Johnson"; + person.age = 30; + person.email = "alice.johnson@example.com"; + + await perspective!.ensureSDNASubjectClass(PersonClass); + await person.save(); + + // Create a second person to test multiple instances + const person2 = new PersonClass(perspective!); + person2.name = "Bob Smith"; + person2.age = 25; + person2.email = "bob.smith@example.com"; + await person2.save(); + + // Verify data was saved and can be retrieved + const savedPeople = await PersonClass.findAll(perspective!); + expect(savedPeople).to.have.lengthOf(2); + + // Find Alice + const alice = savedPeople.find((p: any) => p.name === "Alice Johnson"); + expect(alice).to.exist; + expect(alice!.name).to.equal("Alice Johnson"); + expect(alice!.age).to.equal(30); + expect(alice!.email).to.equal("alice.johnson@example.com"); + + // Find Bob + const bob = savedPeople.find((p: any) => p.name === "Bob Smith"); + expect(bob).to.exist; + expect(bob!.age).to.equal(25); + + // Test querying with where clauses + const adults = await PersonClass.findAll(perspective!, { + where: { age: { gt: 28 } }, + }); + expect(adults).to.have.lengthOf(1); + expect(adults[0].name).to.equal("Alice Johnson"); + }); + + it("should support property mapping overrides", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Contact", + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name"], + }; + + const ContactClass = Ad4mModel.fromJSONSchema(schema, { + name: "Contact", + namespace: "contact://", + propertyMapping: { + name: "foaf://name", + email: "foaf://mbox", + }, + }) as any; + expect(ContactClass.className).to.equal("Contact"); + + // Test that custom predicates are used + const contact = new ContactClass(perspective!); + contact.name = "Bob Wilson"; + contact.email = "bob.wilson@company.com"; + + await perspective!.ensureSDNASubjectClass(ContactClass); + await contact.save(); + + // Create second contact to test multiple instances + const contact2 = new ContactClass(perspective!); + contact2.name = "Carol Davis"; + contact2.email = "carol.davis@company.com"; + await contact2.save(); + + // Verify data retrieval works with custom predicates + const savedContacts = await ContactClass.findAll(perspective!); + expect(savedContacts).to.have.lengthOf(2); + const bob = savedContacts.find((c: any) => c.name === "Bob Wilson"); + expect(bob).to.exist; + expect(bob!.email).to.equal("bob.wilson@company.com"); + + // Verify the custom predicates were used by checking the generated SHACL + const { shape: contactShape } = ContactClass.generateSHACL(); + expect(contactShape.properties.some((p: any) => p.path === "foaf://name")) + .to.be.true; + expect(contactShape.properties.some((p: any) => p.path === "foaf://mbox")) + .to.be.true; + + // Test querying works with custom predicates + const bobQuery = await ContactClass.findAll(perspective!, { + where: { name: "Bob Wilson" }, + }); + expect(bobQuery).to.have.lengthOf(1); + expect(bobQuery[0].email).to.equal("bob.wilson@company.com"); + }); + }); + + describe("with JSON Schema x-ad4m metadata", () => { + it("should use x-ad4m metadata when available", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Product", + type: "object", + "x-ad4m": { + namespace: "product://", + className: "Product", + }, + properties: { + name: { + type: "string", + "x-ad4m": { + through: "product://title", + }, + }, + price: { + type: "number", + "x-ad4m": { + through: "product://cost", + }, + }, + description: { + type: "string", + "x-ad4m": {}, + }, + }, + required: ["name"], + }; + + const ProductClass = Ad4mModel.fromJSONSchema(schema, { + name: "ProductOverride", // This should take precedence + }) as any; + expect(ProductClass.className).to.equal("ProductOverride"); + + const product = new ProductClass(perspective!); + product.name = "Gaming Laptop"; + product.price = 1299.99; + product.description = + "A high-performance gaming laptop with RTX graphics"; + + await perspective!.ensureSDNASubjectClass(ProductClass); + await product.save(); + + // Create a second product with different pricing + const product2 = new ProductClass(perspective!); + product2.name = "Office Laptop"; + product2.price = 799.99; + product2.description = "A reliable laptop for office work"; + await product2.save(); + + // Test data retrieval and validation + const savedProducts = await ProductClass.findAll(perspective!); + expect(savedProducts).to.have.lengthOf(2); + + // Verify x-ad4m custom predicates work for data retrieval + const gamingLaptop = savedProducts.find( + (p: any) => p.name === "Gaming Laptop", + ); + expect(gamingLaptop).to.exist; + expect(gamingLaptop!.price).to.equal(1299.99); + expect(gamingLaptop!.description).to.equal( + "A high-performance gaming laptop with RTX graphics", + ); + + // Test querying with price ranges + const expensiveProducts = await ProductClass.findAll(perspective!, { + where: { price: { gt: 1000 } }, + }); + expect(expensiveProducts).to.have.lengthOf(1); + expect(expensiveProducts[0].name).to.equal("Gaming Laptop"); + + // Verify custom predicates from x-ad4m were used + const { shape: productShape } = ProductClass.generateSHACL(); + expect( + productShape.properties.some((p: any) => p.path === "product://title"), + ).to.be.true; // custom predicate for name + expect( + productShape.properties.some((p: any) => p.path === "product://cost"), + ).to.be.true; // custom predicate for price + expect( + productShape.properties.some( + (p: any) => p.path === "product://description", + ), + ).to.be.true; // inferred from namespace + property + }); + }); + + describe("with title-based inference", () => { + it("should infer namespace from schema title when no explicit config", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Book", + type: "object", + properties: { + title: { type: "string" }, + // Avoid reserved top-level "author" which conflicts with Ad4mModel built-in + writer: { type: "string" }, + isbn: { type: "string" }, + }, + required: ["title"], + }; + + const BookClass = Ad4mModel.fromJSONSchema(schema, { + name: "Book", + }) as any; + expect(BookClass.className).to.equal("Book"); + + const book = new BookClass(perspective!); + book.title = "The Great Gatsby"; + book.writer = "F. Scott Fitzgerald"; + book.isbn = "978-0-7432-7356-5"; + + await perspective!.ensureSDNASubjectClass(BookClass); + await book.save(); + + // Add a second book + const book2 = new BookClass(perspective!); + book2.title = "To Kill a Mockingbird"; + book2.writer = "Harper Lee"; + book2.isbn = "978-0-06-112008-4"; + await book2.save(); + + // Test data retrieval with inferred predicates + const savedBooks = await BookClass.findAll(perspective!); + expect(savedBooks).to.have.lengthOf(2); + const gatsby = savedBooks.find( + (b: any) => b.title === "The Great Gatsby", + ); + expect(gatsby).to.exist; + expect(gatsby!.writer).to.equal("F. Scott Fitzgerald"); + expect(gatsby!.isbn).to.equal("978-0-7432-7356-5"); + + // Test querying by author + const fitzgeraldBooks = await BookClass.findAll(perspective!, { + where: { writer: "F. Scott Fitzgerald" }, + }); + expect(fitzgeraldBooks).to.have.lengthOf(1); + expect(fitzgeraldBooks[0].title).to.equal("The Great Gatsby"); + + // Verify inferred predicates (should be book://title, book://author, etc.) + const { shape: bookShape } = BookClass.generateSHACL(); + expect(bookShape.properties.some((p: any) => p.path === "book://title")) + .to.be.true; + expect(bookShape.properties.some((p: any) => p.path === "book://writer")) + .to.be.true; + expect(bookShape.properties.some((p: any) => p.path === "book://isbn")).to + .be.true; + }); + }); + + describe("error handling", () => { + it("should throw error when no title and no namespace provided", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + value: { type: "string" }, + }, + required: ["value"], // Add required property to avoid constructor error + }; + + expect(() => { + Ad4mModel.fromJSONSchema(schema, { name: "Test" }); + }).to.throw(/Cannot infer namespace/); + }); + + it("should automatically add type flag when no required properties are provided", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "OptionalOnly", + type: "object", + properties: { + optionalValue: { type: "string" }, + anotherOptional: { type: "number" }, + }, + // No required array - all properties are optional + }; + + // Should not throw error - instead adds automatic type flag + const OptionalClass = Ad4mModel.fromJSONSchema(schema, { + name: "OptionalOnly", + namespace: "test://", + }) as any; + + expect(OptionalClass).to.be.a("function"); + expect(OptionalClass.className).to.equal("OptionalOnly"); + + // Should have automatic type flag + const instance = new OptionalClass(perspective!); + expect(instance.__ad4m_type).to.equal("test://instance"); + + // Verify SHACL includes the automatic type flag + const { shape: optionalShape } = OptionalClass.generateSHACL(); + expect( + optionalShape.properties.some((p: any) => p.path === "ad4m://type"), + ).to.be.true; + expect( + optionalShape.properties.some( + (p: any) => p.hasValue === "test://instance", + ), + ).to.be.true; + }); + + it("should work when properties have explicit initial values even if not required", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "WithInitials", + type: "object", + properties: { + status: { type: "string" }, + count: { type: "number" }, + }, + // No required array, but we'll provide initial values + }; + + // This should work because we provide initial values + const TestClass = Ad4mModel.fromJSONSchema(schema, { + name: "WithInitials", + namespace: "test://", + propertyOptions: { + status: { initial: "test://active" }, + count: { initial: "literal://number:0" }, + }, + }) as any; + + expect(TestClass).to.be.a("function"); + expect(TestClass.className).to.equal("WithInitials"); + + // Verify SHACL has constructor actions + const { shape: testShape } = TestClass.generateSHACL(); + expect(testShape.constructor_actions).to.exist; + expect(testShape.constructor_actions!.length).to.be.greaterThan(0); + expect( + testShape.constructor_actions!.some( + (a: any) => a.target === "test://active", + ), + ).to.be.true; + expect( + testShape.constructor_actions!.some( + (a: any) => a.target === "literal://number:0", + ), + ).to.be.true; + }); + + it("should handle complex property types with full data storage and retrieval", async () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "BlogPost", + type: "object", + properties: { + title: { type: "string" }, + tags: { + type: "array", + items: { type: "string" }, + }, + metadata: { + type: "object", + properties: { + created: { type: "string" }, + author: { type: "string" }, + views: { type: "number" }, + }, + }, + categories: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["title"], + }; + + const BlogPostClass = Ad4mModel.fromJSONSchema(schema, { + name: "BlogPost", + }) as any; + expect(BlogPostClass.className).to.equal("BlogPost"); + + await perspective!.ensureSDNASubjectClass(BlogPostClass); + + // Create a blog post with complex data + const post1 = new BlogPostClass(perspective!); + post1.title = "Getting Started with AD4M"; + + // Test array/collection handling + post1.tags = ["tag://ad4m", "tag://tutorial", "tag://blockchain"]; + post1.categories = ["category://technology", "category://development"]; + + // Test complex object handling (should be stored as JSON) + post1.metadata = { + created: "2025-09-22T10:00:00Z", + author: "Alice", + views: 42, + }; + + await post1.save(); + + // Create a second post + const post2 = new BlogPostClass(perspective!); + post2.title = "Advanced AD4M Patterns"; + post2.tags = ["tag://ad4m", "tag://advanced", "tag://patterns"]; + post2.categories = ["category://technology"]; + post2.metadata = { + created: "2025-09-22T11:00:00Z", + author: "Bob", + views: 15, + }; + await post2.save(); + + // Test data retrieval + const savedPosts = await BlogPostClass.findAll(perspective!); + expect(savedPosts).to.have.lengthOf(2); + + // Verify complex object data is preserved + const tutorialPost = savedPosts.find( + (p: any) => p.title === "Getting Started with AD4M", + ); + expect(tutorialPost).to.exist; + expect(tutorialPost!.tags).to.be.an("array"); + expect(tutorialPost!.tags).to.include.members([ + "tag://ad4m", + "tag://tutorial", + "tag://blockchain", + ]); + expect(tutorialPost!.metadata).to.be.an("object"); + expect(tutorialPost!.metadata.author).to.equal("Alice"); + expect(tutorialPost!.metadata.views).to.equal(42); + expect(tutorialPost!.metadata.created).to.equal("2025-09-22T10:00:00Z"); + + // Test querying by title + const advancedPosts = await BlogPostClass.findAll(perspective!, { + where: { title: "Advanced AD4M Patterns" }, + }); + expect(advancedPosts).to.have.lengthOf(1); + expect(advancedPosts[0].metadata.author).to.equal("Bob"); + + // Verify SHACL structure for complex types + const { shape: blogShape } = BlogPostClass.generateSHACL(); + // collections: no maxCount constraint + expect(blogShape.properties.some((p: any) => !p.maxCount)).to.be.true; // tags and categories should be collections + // scalars: maxCount === 1 + expect(blogShape.properties.some((p: any) => p.maxCount === 1)).to.be + .true; // title and metadata should be scalar properties + expect( + blogShape.properties.some((p: any) => p.path === "blogpost://title"), + ).to.be.true; + expect( + blogShape.properties.some((p: any) => p.path === "blogpost://tags"), + ).to.be.true; + expect( + blogShape.properties.some((p: any) => p.path === "blogpost://metadata"), + ).to.be.true; + expect( + blogShape.properties.some( + (p: any) => p.path === "blogpost://categories", + ), + ).to.be.true; + }); + + it("should handle realistic Holon-like schema with nested objects", async () => { + const holonSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "PersonHolon", + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + profile: { + type: "object", + properties: { + bio: { type: "string" }, + location: { type: "string" }, + }, + }, + skills: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["name", "email"], + }; + + const PersonHolonClass = Ad4mModel.fromJSONSchema(holonSchema, { + name: "PersonHolon", + namespace: "holon://person/", + }) as any; + + await perspective!.ensureSDNASubjectClass(PersonHolonClass); + + // Test with realistic data + const person = new PersonHolonClass(perspective!); + person.name = "Alice Cooper"; + person.email = "alice@example.com"; + person.skills = [ + "skill://javascript", + "skill://typescript", + "skill://ad4m", + ]; + person.profile = { + bio: "Software developer passionate about decentralized systems", + location: "San Francisco", + }; + await person.save(); + + // Verify retrieval preserves nested structure + const retrieved = await PersonHolonClass.findAll(perspective!); + expect(retrieved).to.have.lengthOf(1); + + const alice = retrieved[0]; + expect(alice.profile).to.be.an("object"); + expect(alice.profile.bio).to.equal( + "Software developer passionate about decentralized systems", + ); + expect(alice.skills).to.include.members([ + "skill://javascript", + "skill://typescript", + "skill://ad4m", + ]); + }); + }); +}); diff --git a/tests/js/tests/model/model-getters.test.ts b/tests/js/tests/model/model-getters.test.ts new file mode 100644 index 000000000..36e0f71cf --- /dev/null +++ b/tests/js/tests/model/model-getters.test.ts @@ -0,0 +1,149 @@ +/** + * Ad4mModel — custom getter integration tests + * + * Covers: + * - @Property(getter:) — custom SurrealQL expression for computed properties + * - @HasMany(getter:) — custom SurrealQL expression for computed relations + * - None / empty-value filtering from getter results + * + * Run standalone: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --exit tests/model/model-getters.test.ts + */ + +import { expect } from "chai"; +import { + Ad4mClient, + Ad4mModel, + HasMany, + Link, + Literal, + Model, + PerspectiveProxy, + Property, +} from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; + +describe("Ad4mModel — Custom Getters", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + @Model({ name: "BlogPost" }) + class BlogPost extends Ad4mModel { + @Property({ through: "blog://title" }) + title: string = ""; + + @Property({ + through: "blog://parent", + getter: + "(->link[WHERE perspective = $perspective AND predicate = 'blog://reply_to'].out.uri)[0]", + }) + parentPost: string | undefined; + + @HasMany({ + through: "blog://tags", + getter: + "(->link[WHERE perspective = $perspective AND predicate = 'blog://tagged_with'].out.uri)", + }) + tags: string[] = []; + } + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-getters"); + ad4m = agent.client; + ownStop = agent.stop; + } + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + if (perspective) { + await ad4m.perspective.remove(perspective.uuid); + } + perspective = await ad4m.perspective.add("getter-test"); + await perspective.ensureSDNASubjectClass(BlogPost); + }); + + it("should evaluate getter for property", async () => { + const postRoot = Literal.from("Blog post for getter property test").toUrl(); + const parentRoot = Literal.from("Parent blog post").toUrl(); + + const post = new BlogPost(perspective, postRoot); + post.title = "Reply Post"; + await post.save(); + + const parent = new BlogPost(perspective, parentRoot); + parent.title = "Original Post"; + await parent.save(); + + // Create the link that getter should find + await perspective.add( + new Link({ + source: postRoot, + predicate: "blog://reply_to", + target: parentRoot, + }), + ); + + const retrievedPost = new BlogPost(perspective, postRoot); + await retrievedPost.get(); + + expect(retrievedPost.parentPost).to.equal(parentRoot); + }); + + it("should evaluate getter for relation", async () => { + const postRoot = Literal.from("Blog post for getter relation test").toUrl(); + const tag1 = Literal.from("tag:javascript").toUrl(); + const tag2 = Literal.from("tag:typescript").toUrl(); + + const post = new BlogPost(perspective, postRoot); + post.title = "Test Post"; + await post.save(); + + await perspective.add( + new Link({ + source: postRoot, + predicate: "blog://tagged_with", + target: tag1, + }), + ); + await perspective.add( + new Link({ + source: postRoot, + predicate: "blog://tagged_with", + target: tag2, + }), + ); + + const retrievedPost = new BlogPost(perspective, postRoot); + await retrievedPost.get(); + + expect(retrievedPost.tags).to.include(tag1); + expect(retrievedPost.tags).to.include(tag2); + expect(retrievedPost.tags.length).to.equal(2); + }); + + it("should filter out 'None' and empty values from getter results", async () => { + const postRoot = Literal.from("Blog post for None filtering test").toUrl(); + + const post = new BlogPost(perspective, postRoot); + post.title = "Post without parent"; + await post.save(); + + const retrievedPost = new BlogPost(perspective, postRoot); + await retrievedPost.get(); + + // Property should be undefined, not 'None' or empty string + expect(retrievedPost.parentPost).to.be.undefined; + }); +}); diff --git a/tests/js/tests/model/model-inheritance.test.ts b/tests/js/tests/model/model-inheritance.test.ts new file mode 100644 index 000000000..1e8591989 --- /dev/null +++ b/tests/js/tests/model/model-inheritance.test.ts @@ -0,0 +1,142 @@ +/** + * Ad4mModel — model inheritance integration tests + * + * Covers: WeakMap metadata registry, metadata isolation between base/derived, + * generateSHACL() sh:node inheritance, derived findAll() discrimination, + * instanceof check, and polymorphic base findAll(). + * + * Ported from playground scenario 09 (Model Inheritance). + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --serial --exit tests/model/model-inheritance.test.ts + */ + +import { expect } from "chai"; +import { Ad4mClient, PerspectiveProxy } from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { wipePerspective } from "../../utils/utils.js"; +import { TestPost, TestBaseModel, TestDerivedModel } from "./models.js"; + + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("Ad4mModel — Model Inheritance", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-inheritance"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-inheritance-test"); + await TestDerivedModel.register(perspective); + await TestPost.register(perspective); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + await wipePerspective(perspective); + await TestDerivedModel.register(perspective); + await TestPost.register(perspective); + }); + + // ── Metadata (no executor) ──────────────────────────────────────────────── + + it("getModelMetadata() on base returns only base fields", () => { + const meta = TestBaseModel.getModelMetadata(); + expect("content" in meta.properties).to.be.true; + expect("question" in meta.properties).to.be.false; + expect("pollType" in meta.properties).to.be.false; + }); + + it("getModelMetadata() on derived returns merged base+derived fields", () => { + const meta = TestDerivedModel.getModelMetadata(); + expect("content" in meta.properties).to.be.true; + expect("question" in meta.properties).to.be.true; + expect("pollType" in meta.properties).to.be.true; + }); + + it("derived class decorators do not corrupt base class metadata", () => { + // Read derived first, then verify base is still clean + TestDerivedModel.getModelMetadata(); + const baseMeta = TestBaseModel.getModelMetadata(); + const keys = Object.keys(baseMeta.properties); + expect(keys).to.have.length(1); + expect(keys[0]).to.equal("content"); + }); + + // ── SHACL generation ────────────────────────────────────────────────────── + + it("generateSHACL() for derived emits sh:node reference to base shape", () => { + const { shape } = TestDerivedModel.generateSHACL(); + expect(shape.parentShapes).to.be.an("array").with.length.greaterThan(0); + const parentShapeUri = shape.parentShapes![0]; + expect(parentShapeUri).to.include("TestBaseModel"); + }); + + it("generateSHACL() for derived does not duplicate base property shapes", () => { + const { shape } = TestDerivedModel.generateSHACL(); + const propPaths = (shape.properties ?? []).map((p: any) => p.path); + expect(propPaths).to.not.include("test://base_content"); + }); + + // ── Live / executor-facing ──────────────────────────────────────────────── + + it("TestDerivedModel.findAll() returns only derived instances (via @Flag)", async () => { + // Save a TestPost (different @Flag) as noise — it must not appear in derived results + await TestPost.create(perspective, { title: "noise post", body: "" }); + + const derived = await TestDerivedModel.create(perspective, { + content: "derived content", + question: "Favourite color?", + }); + + const results = await TestDerivedModel.findAll(perspective); + expect(results).to.have.length(1); + expect(results[0].id).to.equal(derived.id); + }); + + it("TestDerivedModel instance passes instanceof TestBaseModel check", () => { + const derived = new TestDerivedModel(perspective); + expect(derived instanceof TestBaseModel).to.be.true; + }); + + it("TestDerivedModel.findOne() returns instance with both content and question", async () => { + const derived = await TestDerivedModel.create(perspective, { + content: "shared content", + question: "Which option?", + }); + + const found = await TestDerivedModel.findOne(perspective, { + where: { id: derived.id }, + }); + expect(found).to.not.be.null; + expect(found!.content).to.equal("shared content"); + expect(found!.question).to.equal("Which option?"); + }); + + it("TestBaseModel.findAll() returns instances of both base and derived types (polymorphic)", async () => { + const derived = await TestDerivedModel.create(perspective, { + content: "polymorphic test", + question: "Any answer?", + }); + + // TestBaseModel.findAll() queries by test://base_content predicate — + // derived instances also carry this link (inherited @Property). + const allBase = await TestBaseModel.findAll(perspective); + expect(allBase.length).to.be.at.least(1); + expect(allBase.some((b) => b.id === derived.id)).to.be.true; + }); +}); diff --git a/tests/js/tests/model/model-prolog.test.ts b/tests/js/tests/model/model-prolog.test.ts new file mode 100644 index 000000000..e15c5437a --- /dev/null +++ b/tests/js/tests/model/model-prolog.test.ts @@ -0,0 +1,112 @@ +/** + * Ad4mModel — Prolog bridge tests + * + * Covers: generatePrologFacts() pure-function output (no executor needed for + * the generation itself) and perspective.infer() integration using + * model-generated Prolog facts. + * + * Ported from playground scenario 07 (Prolog Bridge). + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --exit tests/model/model-prolog.test.ts + */ + +import { expect } from "chai"; +import { + Ad4mClient, + PerspectiveProxy, + generatePrologFacts, +} from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { TestPost, TestTag } from "./models.js"; + + +// ── Pure function tests (no executor) ───────────────────────────────────────── +// +// generatePrologFacts() is a stateless compiler — it reads decorator metadata +// attached to the class and produces a Prolog string. No perspective or +// network connection is required. + +describe("Ad4mModel — generatePrologFacts() [pure]", function () { + // No executor — these are synchronous pure-function tests. + const postFacts = generatePrologFacts(TestPost); + const tagFacts = generatePrologFacts(TestTag); + + it("returns a non-empty string", () => { + expect(typeof postFacts).to.equal("string"); + expect(postFacts.length).to.be.greaterThan(0); + }); + + it("includes the @Flag predicate clause", () => { + // TestPost: @Flag({ through: 'test://post_type', value: 'test://post' }) + expect(postFacts).to.include( + "triple(X, 'test://post_type', 'test://post')", + ); + }); + + it("includes clauses for @Property predicates", () => { + // TestPost has title and body @Property decorators + expect(postFacts).to.include("'test://title'"); + expect(postFacts).to.include("'test://body'"); + }); + + it("includes clauses for @HasMany predicates", () => { + // TestPost has tags and comments @HasMany decorators + expect(postFacts).to.include("'test://has_tag'"); + expect(postFacts).to.include("'test://has_comment'"); + }); + + it("@BelongsToMany uses reverse clause form (V → X, not X → V)", () => { + // TestTag.posts is @BelongsToMany(() => TestPost, { through: 'test://has_tag' }). + // The link direction is Post→test://has_tag→Tag. To find all Posts for a Tag, + // Prolog must traverse in reverse: triple(V, 'test://has_tag', X). + expect(tagFacts).to.include("triple(V, 'test://has_tag', X)"); + // The forward form must NOT appear for a reverse relation. + expect(tagFacts).to.not.include("triple(X, 'test://has_tag', V)"); + }); +}); + +// ── perspective.infer() integration ─────────────────────────────────────────── +// +// Verifies the Prolog bridge end-to-end: facts generated from decorator +// metadata can be loaded into the Prolog engine and queried via infer(). + +describe("Ad4mModel — Prolog Bridge (executor)", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-prolog"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-prolog-test"); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + it("perspective.infer() succeeds using generated Prolog facts", async () => { + const facts = generatePrologFacts(TestPost); + // Goal: test_post(X). — returns [] (empty, no triples yet) but not null. + // A null return means a hard error (parse / compile failure). + const result = await perspective.infer(`${facts}\ntest_post(X).`); + expect(result).to.not.be.null; + }); + + it("infer() with TestTag reverse-relation facts returns a non-null result", async () => { + const facts = generatePrologFacts(TestTag); + // Goal: test_tag(X). — same reasoning as above. + const result = await perspective.infer(`${facts}\ntest_tag(X).`); + expect(result).to.not.be.null; + }); +}); diff --git a/tests/js/tests/model/model-query.test.ts b/tests/js/tests/model/model-query.test.ts new file mode 100644 index 000000000..87969458f --- /dev/null +++ b/tests/js/tests/model/model-query.test.ts @@ -0,0 +1,1283 @@ +/** + * Ad4mModel — query API integration tests + * + * Covers: findAll() with where / order / limit / offset, findOne(), count(), + * paginate(), findAllAndCount(), fluent ModelQueryBuilder, IncludeMap eager + * loading, properties field projection, Query composability, and + * instance.get(). + * + * Sections (in order): + * 1. where — exact match + * 2. where — operators (gt/gte/lt/lte/between/contains/not/IN) + * 3. where — combined conditions (AND semantics) + * 4. where — relation-based filtering (@BelongsToOne in where clause) + * 5. order + * 6. limit / offset + * 7. findOne + * 8. count + * 9. findAllAndCount + * 10. paginate + * 11. instance.get() + * 12. fluent QueryBuilder + * 13. IncludeMap — @HasMany / @HasOne / @BelongsToOne / @BelongsToMany + * 14. IncludeMap — sub-queries (where / order / limit / properties) + * 15. IncludeMap — nested (multi-level) + * 16. IncludeMap — edge cases (non-conforming nodes) + * 17. properties field projection + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --exit tests/model/model-query.test.ts + */ + +import { expect } from "chai"; +import { + Ad4mClient, + Ad4mModel, + Flag, + HasMany, + HasManyMethods, + Link, + Literal, + Model, + PerspectiveProxy, + Property, +} from "@coasys/ad4m"; +import { startAgent, waitUntil } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { wipePerspective, sleep } from "../../utils/utils.js"; +import { TestComment, TestPost, TestTag, TestReaction, TestChannel } from "./models.js"; + +describe("Ad4mModel — Query API", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + // Three seeded posts for ordering / filtering tests + let p1: TestPost, p2: TestPost, p3: TestPost; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-query"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-query-test"); + await TestPost.register(perspective); + await TestComment.register(perspective); + await TestTag.register(perspective); + await TestReaction.register(perspective); + await TestChannel.register(perspective); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + await wipePerspective(perspective); + await TestPost.register(perspective); + await TestComment.register(perspective); + await TestTag.register(perspective); + await TestReaction.register(perspective); + await TestChannel.register(perspective); + // Re-seed fresh posts for every test so tests are fully independent + p1 = await TestPost.create(perspective, { + title: "Alpha", + body: "first", + viewCount: 0, + }); + p2 = await TestPost.create(perspective, { + title: "Beta", + body: "second", + viewCount: 0, + }); + p3 = await TestPost.create(perspective, { + title: "Gamma", + body: "third", + viewCount: 0, + }); + }); + + // ── 1. where — exact match ───────────────────────────────────────────────── + + it("findAll() with where.id returns only the matching instance", async () => { + const results = await TestPost.findAll(perspective, { + where: { id: p1.id }, + }); + expect(results).to.have.length(1); + expect(results[0].title).to.equal("Alpha"); + }); + + it("findAll() with where.title returns only the matching title", async () => { + const results = await TestPost.findAll(perspective, { + where: { title: "Beta" }, + }); + expect(results).to.have.length(1); + expect(results[0].title).to.equal("Beta"); + }); + + it("where: { id: [p1.id, p2.id] } IN-style filter returns exactly those two instances", async () => { + const results = await TestPost.findAll(perspective, { + where: { id: [p1.id, p2.id] }, + }); + expect(results).to.have.length(2); + const ids = results.map((r) => r.id); + expect(ids).to.include(p1.id); + expect(ids).to.include(p2.id); + expect(ids).to.not.include(p3.id); + }); + + // ── 2. where — operators ─────────────────────────────────────────────────── + + it("where: { viewCount: { gt: 5 } } returns only posts with viewCount > 5", async () => { + await TestPost.create(perspective, { + title: "Low", + body: "", + viewCount: 3, + }); + const high = await TestPost.create(perspective, { + title: "High", + body: "", + viewCount: 10, + }); + const results = await TestPost.findAll(perspective, { + where: { viewCount: { gt: 5 } }, + }); + expect(results.every((r) => (r.viewCount as any as number) > 5)).to.be.true; + expect(results.some((r) => r.id === high.id)).to.be.true; + }); + + it("where: { viewCount: { gte: 10 } } includes the exact boundary", async () => { + const exact = await TestPost.create(perspective, { + title: "Exact", + body: "", + viewCount: 10, + }); + const below = await TestPost.create(perspective, { + title: "Below", + body: "", + viewCount: 9, + }); + const results = await TestPost.findAll(perspective, { + where: { viewCount: { gte: 10 } }, + }); + expect(results.some((r) => r.id === exact.id)).to.be.true; + expect(results.some((r) => r.id === below.id)).to.be.false; + }); + + it("where: { viewCount: { lt: 5 } } returns only posts with viewCount < 5", async () => { + const low = await TestPost.create(perspective, { + title: "VeryLow", + body: "", + viewCount: 2, + }); + const high = await TestPost.create(perspective, { + title: "VeryHigh", + body: "", + viewCount: 20, + }); + const results = await TestPost.findAll(perspective, { + where: { viewCount: { lt: 5 } }, + }); + expect(results.some((r) => r.id === low.id)).to.be.true; + expect(results.some((r) => r.id === high.id)).to.be.false; + }); + + it("where: { viewCount: { lte: 0 } } includes the zero boundary", async () => { + // p1/p2/p3 all have viewCount 0 + const results = await TestPost.findAll(perspective, { + where: { viewCount: { lte: 0 } }, + }); + expect(results.length).to.be.at.least(3); + expect(results.every((r) => (r.viewCount as any as number) <= 0)).to.be + .true; + }); + + it("where: { viewCount: { between: [5, 15] } } returns posts in the inclusive range", async () => { + const inRange = await TestPost.create(perspective, { + title: "InRange", + body: "", + viewCount: 10, + }); + const outRange = await TestPost.create(perspective, { + title: "OutRange", + body: "", + viewCount: 20, + }); + const results = await TestPost.findAll(perspective, { + where: { viewCount: { between: [5, 15] } }, + }); + expect(results.some((r) => r.id === inRange.id)).to.be.true; + expect(results.some((r) => r.id === outRange.id)).to.be.false; + }); + + it("where: { title: { contains: 'lph' } } matches by substring", async () => { + // p1.title = 'Alpha' contains 'lph' + const results = await TestPost.findAll(perspective, { + where: { title: { contains: "lph" } }, + }); + expect(results.some((r) => r.id === p1.id)).to.be.true; + expect(results.some((r) => r.id === p2.id)).to.be.false; + }); + + it("where: { title: { not: 'Alpha' } } excludes the single matching instance", async () => { + const results = await TestPost.findAll(perspective, { + where: { title: { not: "Alpha" } }, + }); + expect(results.some((r) => r.id === p1.id)).to.be.false; + expect(results.some((r) => r.id === p2.id)).to.be.true; + expect(results.some((r) => r.id === p3.id)).to.be.true; + }); + + it("where: { title: { not: ['Alpha', 'Beta'] } } excludes multiple values", async () => { + const results = await TestPost.findAll(perspective, { + where: { title: { not: ["Alpha", "Beta"] } }, + }); + expect(results.some((r) => r.id === p1.id)).to.be.false; + expect(results.some((r) => r.id === p2.id)).to.be.false; + expect(results.some((r) => r.id === p3.id)).to.be.true; + }); + + // ── 3. where — combined conditions (AND semantics) ───────────────────────── + + it("where with two fields applies AND semantics — only rows matching both are returned", async () => { + // p1: title='Alpha', viewCount=0 → matches { title: 'Alpha', viewCount: 0 } + // extra: title='Alpha', viewCount=99 → title matches but viewCount doesn't + const extra = await TestPost.create(perspective, { + title: "Alpha", + body: "dupe", + viewCount: 99, + }); + const results = await TestPost.findAll(perspective, { + where: { title: "Alpha", viewCount: 0 }, + }); + expect(results.some((r) => r.id === p1.id)).to.be.true; + expect(results.some((r) => r.id === extra.id)).to.be.false; + expect(results.some((r) => r.id === p2.id)).to.be.false; + }); + + // ── 4. where — relation-based filtering ─────────────────────────────────── + // + // `where` can reference relation fields (@BelongsToOne / @HasMany / etc.) + // in addition to @Property fields. This enables the pattern: + // Comment.findAll(perspective, { where: { post: postId } }) + // which replaces the deprecated `source` parameter. + + it("where on @BelongsToOne: { post: postId } returns only comments linked to that post", async () => { + const c1 = await TestComment.create(perspective, { body: "on alpha" }); + const c2 = await TestComment.create(perspective, { body: "on beta" }); + const c3 = await TestComment.create(perspective, { body: "orphan" }); + await p1.addComments(c1.id); + await p2.addComments(c2.id); + // c3 is not linked to any post + + const results = await TestComment.findAll(perspective, { + where: { post: p1.id }, + }); + expect(results).to.have.length(1); + expect(results[0].id).to.equal(c1.id); + expect(results[0].body).to.equal("on alpha"); + }); + + it("where on @BelongsToOne with findOne: { post: postId } returns the first matching comment", async () => { + const c1 = await TestComment.create(perspective, { body: "the one" }); + await p1.addComments(c1.id); + + const found = await TestComment.findOne(perspective, { + where: { post: p1.id }, + }); + expect(found).to.not.be.null; + expect(found!.id).to.equal(c1.id); + }); + + it("where on @BelongsToOne returns null/empty when no comments are linked to the given post", async () => { + // Create a comment linked to p2, but query for p1 + const c = await TestComment.create(perspective, { body: "on p2" }); + await p2.addComments(c.id); + + const results = await TestComment.findAll(perspective, { + where: { post: p1.id }, + }); + expect(results).to.have.length(0); + + const found = await TestComment.findOne(perspective, { + where: { post: p1.id }, + }); + expect(found).to.be.null; + }); + + it("where on @BelongsToOne combined with @Property: { post: postId, body: 'target' }", async () => { + const c1 = await TestComment.create(perspective, { body: "target" }); + const c2 = await TestComment.create(perspective, { body: "other" }); + await p1.addComments(c1.id); + await p1.addComments(c2.id); + + const results = await TestComment.findAll(perspective, { + where: { post: p1.id, body: "target" }, + }); + expect(results).to.have.length(1); + expect(results[0].body).to.equal("target"); + }); + + it("where on @BelongsToOne with IN-style array: { post: [p1.id, p2.id] }", async () => { + const c1 = await TestComment.create(perspective, { body: "on p1" }); + const c2 = await TestComment.create(perspective, { body: "on p2" }); + const c3 = await TestComment.create(perspective, { body: "on p3" }); + await p1.addComments(c1.id); + await p2.addComments(c2.id); + await p3.addComments(c3.id); + + const results = await TestComment.findAll(perspective, { + where: { post: [p1.id, p2.id] }, + }); + expect(results).to.have.length(2); + const ids = results.map((r) => r.id); + expect(ids).to.include(c1.id); + expect(ids).to.include(c2.id); + expect(ids).to.not.include(c3.id); + }); + + it("where on @BelongsToOne + include hydrates the relation", async () => { + const c1 = await TestComment.create(perspective, { body: "hydrate me" }); + await p1.addComments(c1.id); + + const found = await TestComment.findOne(perspective, { + where: { post: p1.id }, + include: { post: true }, + }); + expect(found).to.not.be.null; + expect(found!.post).to.be.instanceOf(TestPost); + expect((found!.post as TestPost).title).to.equal("Alpha"); + }); + + // ── 5. order ─────────────────────────────────────────────────────────────── + + it("findAll() with order: { title: 'ASC' } sorts alphabetically", async () => { + const results = await TestPost.findAll(perspective, { + order: { title: "ASC" }, + }); + const titles = results.map((r) => r.title); + expect(titles).to.deep.equal([...titles].sort()); + }); + + it("findAll() with order: { title: 'DESC' } reverse-sorts", async () => { + const results = await TestPost.findAll(perspective, { + order: { title: "DESC" }, + }); + const titles = results.map((r) => r.title); + expect(titles).to.deep.equal([...titles].sort().reverse()); + }); + + it("order: { viewCount: 'ASC' } sorts by a numeric property", async () => { + const low = await TestPost.create(perspective, { + title: "Low", + body: "", + viewCount: 1, + }); + const mid = await TestPost.create(perspective, { + title: "Mid", + body: "", + viewCount: 5, + }); + const high = await TestPost.create(perspective, { + title: "High", + body: "", + viewCount: 9, + }); + const results = await TestPost.findAll(perspective, { + where: { id: [low.id, mid.id, high.id] }, + order: { viewCount: "ASC" }, + }); + expect(results).to.have.length(3); + const counts = results.map((r) => r.viewCount as any as number); + expect(counts).to.deep.equal([...counts].sort((a, b) => a - b)); + }); + + it("order by multiple fields — primary sort then secondary sort", async () => { + const a1 = await TestPost.create(perspective, { + title: "A", + body: "", + viewCount: 2, + }); + const a2 = await TestPost.create(perspective, { + title: "A", + body: "", + viewCount: 1, + }); + const b1 = await TestPost.create(perspective, { + title: "B", + body: "", + viewCount: 5, + }); + const results = await TestPost.findAll(perspective, { + where: { id: [a1.id, a2.id, b1.id] }, + order: { title: "ASC", viewCount: "ASC" }, + }); + expect(results).to.have.length(3); + // All "A" entries must come before "B" + const titles = results.map((r) => r.title); + const bIndex = titles.indexOf("B"); + const lastAIndex = titles.lastIndexOf("A"); + expect(lastAIndex).to.be.lessThan(bIndex); + // Within "A", ascending viewCount: a2 (1) before a1 (2) + const aEntries = results.filter((r) => r.title === "A"); + expect(aEntries[0].viewCount as any as number).to.equal(1); + expect(aEntries[1].viewCount as any as number).to.equal(2); + }); + + // ── 6. limit / offset ────────────────────────────────────────────────────── + + it("findAll() with limit returns at most that many results", async () => { + const results = await TestPost.findAll(perspective, { limit: 2 }); + expect(results.length).to.be.at.most(2); + }); + + it("findAll() with offset skips the first N results", async () => { + const all = await TestPost.findAll(perspective); + const paged = await TestPost.findAll(perspective, { offset: 1 }); + expect(paged.length).to.equal(all.length - 1); + }); + + it("findAll() with limit + offset pages correctly without overlap", async () => { + const page1 = await TestPost.findAll(perspective, { + limit: 2, + offset: 0, + order: { title: "ASC" }, + }); + const page2 = await TestPost.findAll(perspective, { + limit: 2, + offset: 2, + order: { title: "ASC" }, + }); + expect(page1.length).to.be.at.most(2); + expect(page2.length).to.be.at.most(2); + const page1Ids = page1.map((p) => p.id); + const page2Ids = page2.map((p) => p.id); + expect(page1Ids.some((id) => page2Ids.includes(id))).to.be.false; + }); + + // ── 7. findOne ───────────────────────────────────────────────────────────── + + it("findOne() returns the matching instance", async () => { + const found = await TestPost.findOne(perspective, { where: { id: p2.id } }); + expect(found).to.not.be.null; + expect(found!.title).to.equal("Beta"); + }); + + it("findOne() returns null when no instance matches", async () => { + const missing = await TestPost.findOne(perspective, { + where: { id: "literal://string:no-such-id" }, + }); + expect(missing).to.be.null; + }); + + // ── 8. count ─────────────────────────────────────────────────────────────── + + it("count() returns the total number of instances", async () => { + const n = await TestPost.count(perspective, {}); + expect(n).to.equal(3); + }); + + it("count() with where clause counts only matching instances", async () => { + const n = await TestPost.count(perspective, { where: { title: "Alpha" } }); + expect(n).to.equal(1); + }); + + it("count() with comparison operator (gt) exercises the JS slow-path", async () => { + await TestPost.create(perspective, { + title: "HasCount", + body: "", + viewCount: 5, + }); + // p1/p2/p3 have viewCount 0 + const n = await TestPost.count(perspective, { + where: { viewCount: { gt: 0 } }, + }); + expect(n).to.equal(1); + }); + + // ── 9. findAllAndCount ───────────────────────────────────────────────────── + + it("findAllAndCount() returns both the instances and the total", async () => { + const { results, totalCount } = await TestPost.findAllAndCount( + perspective, + {}, + ); + expect(results).to.have.length(3); + expect(totalCount).to.equal(3); + }); + + it("findAllAndCount() with limit returns a page but totalCount reflects all rows", async () => { + const { results, totalCount } = await TestPost.findAllAndCount( + perspective, + { limit: 2 }, + ); + expect(results.length).to.be.at.most(2); + expect(totalCount).to.equal(3); + }); + + it("findAllAndCount() with where returns filtered results and the matching totalCount", async () => { + const { results, totalCount } = await TestPost.findAllAndCount( + perspective, + { + where: { title: "Alpha" }, + }, + ); + expect(results).to.have.length(1); + expect(results[0].id).to.equal(p1.id); + expect(totalCount).to.equal(1); + }); + + // ── 10. paginate ─────────────────────────────────────────────────────────── + + it("paginate() returns the correct page with metadata", async () => { + const page = await TestPost.paginate(perspective, 2, 1); + expect(page.results.length).to.be.at.most(2); + expect(page.totalCount).to.equal(3); + expect(page.pageNumber).to.equal(1); + expect(page.pageSize).to.equal(2); + }); + + it("paginate() with where filters before paginating and totalCount reflects matching rows only", async () => { + // Add a 4th 'Alpha' so we have 2 Alphas to paginate + await TestPost.create(perspective, { title: "Alpha", body: "duplicate" }); + const page = await TestPost.paginate(perspective, 1, 1, { + where: { title: "Alpha" }, + }); + expect(page.results).to.have.length(1); + expect(page.totalCount).to.equal(2); // not all 4 rows + expect(page.pageSize).to.equal(1); + expect(page.pageNumber).to.equal(1); + }); + + // ── 11. instance.get() ───────────────────────────────────────────────────── + + it("instance.get() hydrates a bare instance in-place and returns it", async () => { + // Construct a bare instance with only the id — no data yet + const bare = new TestPost(perspective, p1.id); + // Class field initialiser sets title = "" — the property is an empty + // string, not undefined, before hydration. + expect(bare.title).to.equal(""); + + const hydrated = await bare.get(); + expect(hydrated).to.equal(bare); // returns same instance + expect(bare.title).to.equal("Alpha"); + expect(bare.body).to.equal("first"); + }); + + it("instance.get() with include map eagerly hydrates relations", async () => { + const comment = await TestComment.create(perspective, { + body: "get-include", + }); + await p1.addComments(comment.id); + + const bare = new TestPost(perspective, p1.id); + await bare.get({ comments: true }); + + expect(bare.comments.length).to.be.at.least(1); + expect(bare.comments[0]).to.be.instanceOf(TestComment); + expect((bare.comments[0] as TestComment).body).to.equal("get-include"); + }); + + // ── 12. fluent QueryBuilder ──────────────────────────────────────────────── + + it("fluent .query().where().get() matches findAll()", async () => { + const json = await TestPost.findAll(perspective, { where: { id: p3.id } }); + const fluent = await TestPost.query(perspective).where({ id: p3.id }).get(); + expect(json.length).to.equal(fluent.length); + expect(json.every((j, i) => j.id === fluent[i].id)).to.be.true; + }); + + it("fluent .query().where().include().first() matches findOne() with include", async () => { + const comment = await TestComment.create(perspective, { body: "fluent" }); + await p1.addComments(comment.id); + + const json = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: true }, + }); + const fluent = await TestPost.query(perspective) + .where({ id: p1.id }) + .include({ comments: true }) + .first(); + + expect(json).to.not.be.null; + expect(fluent).to.not.be.null; + expect(json!.id).to.equal(fluent!.id); + expect(json!.comments.length).to.equal(fluent!.comments.length); + expect(json!.comments[0]).to.be.instanceOf(TestComment); + expect(fluent!.comments[0]).to.be.instanceOf(TestComment); + }); + + it("Query objects are composable with spread", async () => { + const base = { order: { title: "ASC" as const } }; + const withLimit = { ...base, limit: 2 }; + const results = await TestPost.findAll(perspective, withLimit); + expect(results.length).to.be.at.most(2); + const titles = results.map((r) => r.title); + expect(titles).to.deep.equal([...titles].sort()); + }); + + // ── 13. IncludeMap — relation types ─────────────────────────────────────── + + it("include: { comments: true } hydrates @HasMany to TestComment instances", async () => { + const comment = await TestComment.create(perspective, { body: "hydrated" }); + await p1.addComments(comment.id); + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: true }, + }); + expect(found).to.not.be.null; + expect(found!.comments.length).to.be.at.least(1); + expect(found!.comments[0]).to.be.instanceOf(TestComment); + expect(found!.comments[0].body).to.equal("hydrated"); + }); + + it("without include, @HasMany relations remain as string[]", async () => { + const comment = await TestComment.create(perspective, { + body: "stays string", + }); + await p1.addComments(comment.id); + const found = await TestPost.findOne(perspective, { where: { id: p1.id } }); + expect(found).to.not.be.null; + expect(found!.comments.length).to.be.at.least(1); + expect(typeof found!.comments[0]).to.equal("string"); + }); + + it("@HasOne — pinnedComment is a scalar ID without include", async () => { + const comment = await TestComment.create(perspective, { body: "pinned" }); + await p1.addPinnedComment(comment.id); + const found = await TestPost.findOne(perspective, { where: { id: p1.id } }); + expect(Array.isArray(found!.pinnedComment)).to.be.false; + expect(found!.pinnedComment as unknown as string).to.equal(comment.id); + }); + + it("@HasOne — include: { pinnedComment: true } hydrates to a TestComment instance", async () => { + const comment = await TestComment.create(perspective, { + body: "Pinned body", + }); + await p1.addPinnedComment(comment.id); + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { pinnedComment: true }, + }); + expect(found).to.not.be.null; + expect(found!.pinnedComment).to.be.instanceOf(TestComment); + expect((found!.pinnedComment as TestComment).body).to.equal("Pinned body"); + }); + + it("include: { post: true } hydrates @BelongsToOne to a TestPost instance", async () => { + const comment = await TestComment.create(perspective, { body: "reverse" }); + await p1.addComments(comment.id); + const found = await TestComment.findOne(perspective, { + where: { id: comment.id }, + include: { post: true }, + }); + expect(found).to.not.be.null; + expect(found!.post).to.be.instanceOf(TestPost); + expect((found!.post as TestPost).title).to.equal("Alpha"); + }); + + it("@BelongsToMany — tag.posts is string[] without include", async () => { + const tag = await TestTag.create(perspective, { label: "many" }); + await p1.addTags(tag.id); + await p2.addTags(tag.id); + let found: TestTag | null = null; + await waitUntil( + async () => { + found = await TestTag.findOne(perspective, { where: { id: tag.id } }); + if (!found) return false; + const ids = found.posts as unknown as string[]; + return ids.includes(p1.id) && ids.includes(p2.id); + }, + 5000, + "tag.posts to include both post IDs", + ); + expect(Array.isArray(found!.posts)).to.be.true; + const postIds = found!.posts as unknown as string[]; + expect(postIds).to.include(p1.id); + expect(postIds).to.include(p2.id); + }); + + it("@BelongsToMany — include: { posts: true } hydrates to TestPost instances", async () => { + const tag = await TestTag.create(perspective, { label: "hydrated-many" }); + const post1 = await TestPost.create(perspective, { + title: "Tagged Post 1", + body: "", + }); + const post2 = await TestPost.create(perspective, { + title: "Tagged Post 2", + body: "", + }); + await post1.addTags(tag.id); + await post2.addTags(tag.id); + const found = await TestTag.findOne(perspective, { + where: { id: tag.id }, + include: { posts: true }, + }); + expect(found).to.not.be.null; + expect(found!.posts.every((p) => p instanceof TestPost)).to.be.true; + expect(found!.posts.some((p) => (p as TestPost).id === post1.id)).to.be + .true; + expect(found!.posts.some((p) => (p as TestPost).id === post2.id)).to.be + .true; + }); + + it("findAll() with include hydrates relations across multiple instances in one pass", async () => { + const c1 = await TestComment.create(perspective, { body: "for alpha" }); + const c2 = await TestComment.create(perspective, { body: "for beta" }); + await p1.addComments(c1.id); + await p2.addComments(c2.id); + + const posts = await TestPost.findAll(perspective, { + where: { id: [p1.id, p2.id] }, + include: { comments: true }, + }); + expect(posts).to.have.length(2); + const alpha = posts.find((p) => p.id === p1.id)!; + const beta = posts.find((p) => p.id === p2.id)!; + expect(alpha.comments.some((c) => (c as TestComment).body === "for alpha")) + .to.be.true; + expect(beta.comments.some((c) => (c as TestComment).body === "for beta")).to + .be.true; + }); + + it("include on a post with no relations returns an empty array, not null", async () => { + // p3 has no comments attached + const found = await TestPost.findOne(perspective, { + where: { id: p3.id }, + include: { comments: true }, + }); + expect(found).to.not.be.null; + expect(Array.isArray(found!.comments)).to.be.true; + expect(found!.comments).to.have.length(0); + }); + + // ── 14. IncludeMap — sub-queries ─────────────────────────────────────────── + + it("include sub-query: { limit: 2 } caps the number of hydrated relations", async () => { + for (let i = 0; i < 3; i++) { + const c = await TestComment.create(perspective, { body: `c${i}` }); + await p1.addComments(c.id); + } + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: { limit: 2 } }, + }); + expect(found).to.not.be.null; + expect(found!.comments.length).to.be.at.most(2); + }); + + it("include sub-query: { where: { id } } narrows hydrated relations to matching ids", async () => { + const keep = await TestComment.create(perspective, { body: "keep" }); + const drop = await TestComment.create(perspective, { body: "drop" }); + await p1.addComments(keep.id); + await p1.addComments(drop.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: { where: { id: keep.id } } }, + }); + expect(found).to.not.be.null; + expect(found!.comments).to.have.length(1); + expect((found!.comments[0] as TestComment).id).to.equal(keep.id); + expect((found!.comments[0] as TestComment).body).to.equal("keep"); + }); + + it("include sub-query: { order: { body: 'ASC' } } sorts hydrated relations", async () => { + const cZ = await TestComment.create(perspective, { body: "zzz" }); + const cA = await TestComment.create(perspective, { body: "aaa" }); + const cM = await TestComment.create(perspective, { body: "mmm" }); + await p1.addComments(cZ.id); + await p1.addComments(cA.id); + await p1.addComments(cM.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: { order: { body: "ASC" } } }, + }); + expect(found).to.not.be.null; + const bodies = (found!.comments as TestComment[]).map((c) => c.body); + expect(bodies).to.deep.equal([...bodies].sort()); + }); + + it("@BelongsToMany — include: { posts: { order: { title: 'ASC' } } } orders hydrated posts", async () => { + const tag = await TestTag.create(perspective, { label: "ordered-many" }); + const postA = await TestPost.create(perspective, { + title: "Ant", + body: "", + }); + const postZ = await TestPost.create(perspective, { + title: "Zebra", + body: "", + }); + await postA.addTags(tag.id); + await postZ.addTags(tag.id); + + let found: TestTag | null = null; + await waitUntil( + async () => { + found = await TestTag.findOne(perspective, { + where: { id: tag.id }, + include: { posts: { order: { title: "ASC" } } }, + }); + return ( + found !== null && + found.posts.length === 2 && + found.posts[0] instanceof TestPost + ); + }, + 5000, + "tag.posts to be hydrated with 2 entries", + ); + const titles = (found!.posts as TestPost[]).map((p) => p.title); + expect(titles).to.deep.equal([...titles].sort()); + }); + + it("@BelongsToMany — include: { posts: { limit: 1 } } caps hydrated results", async () => { + const tag = await TestTag.create(perspective, { label: "capped-many" }); + await p1.addTags(tag.id); + await p2.addTags(tag.id); + await p3.addTags(tag.id); + + let found: TestTag | null = null; + await waitUntil( + async () => { + found = await TestTag.findOne(perspective, { + where: { id: tag.id }, + include: { posts: { limit: 1 } }, + }); + return found !== null && found.posts.length > 0; + }, + 5000, + "tag.posts to be non-empty", + ); + expect(found!.posts).to.have.length(1); + }); + + // ── 15. IncludeMap — nested (multi-level) ───────────────────────────────── + + it("nested include: post → comments → reactions (2 levels, findOne)", async () => { + const comment = await TestComment.create(perspective, { body: "nested" }); + const reaction = await TestReaction.create(perspective, { emoji: "👍" }); + await comment.addReactions(reaction.id); + await p1.addComments(comment.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: { include: { reactions: true } } }, + }); + + expect(found).to.not.be.null; + const hydratedComment = found!.comments[0] as TestComment; + expect(hydratedComment).to.be.instanceOf(TestComment); + expect(hydratedComment.reactions[0]).to.be.instanceOf(TestReaction); + expect((hydratedComment.reactions[0] as TestReaction).emoji).to.equal("👍"); + }); + + it("nested include: findAll() post → comments → reactions across multiple posts", async () => { + const c1 = await TestComment.create(perspective, { body: "on alpha" }); + const c2 = await TestComment.create(perspective, { body: "on beta" }); + const r1 = await TestReaction.create(perspective, { emoji: "❤️" }); + const r2 = await TestReaction.create(perspective, { emoji: "🚂" }); + await c1.addReactions(r1.id); + await c2.addReactions(r2.id); + await p1.addComments(c1.id); + await p2.addComments(c2.id); + + const posts = await TestPost.findAll(perspective, { + where: { id: [p1.id, p2.id] }, + include: { comments: { include: { reactions: true } } }, + }); + + expect(posts).to.have.length(2); + const alpha = posts.find((p) => p.title === "Alpha")!; + const beta = posts.find((p) => p.title === "Beta")!; + + expect( + ((alpha.comments[0] as TestComment).reactions[0] as TestReaction).emoji, + ).to.equal("❤️"); + expect( + ((beta.comments[0] as TestComment).reactions[0] as TestReaction).emoji, + ).to.equal("🚂"); + }); + + it("nested include with sub-query filter: post → comments (body filter) → reactions", async () => { + const keep = await TestComment.create(perspective, { body: "keep" }); + const drop = await TestComment.create(perspective, { body: "drop" }); + const reaction = await TestReaction.create(perspective, { emoji: "🔥" }); + await keep.addReactions(reaction.id); + await p1.addComments(keep.id); + await p1.addComments(drop.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { + comments: { + where: { body: "keep" }, + include: { reactions: true }, + }, + }, + }); + + expect(found).to.not.be.null; + expect(found!.comments).to.have.length(1); + expect((found!.comments[0] as TestComment).body).to.equal("keep"); + expect( + ((found!.comments[0] as TestComment).reactions[0] as TestReaction).emoji, + ).to.equal("🔥"); + }); + + it("nested include without the inner include leaves leaf relations as string[]", async () => { + const comment = await TestComment.create(perspective, { body: "no-nest" }); + const reaction = await TestReaction.create(perspective, { emoji: "🌟" }); + await comment.addReactions(reaction.id); + await p1.addComments(comment.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: true }, // reactions NOT included + }); + + expect(found).to.not.be.null; + const hydratedComment = found!.comments[0] as TestComment; + expect(hydratedComment).to.be.instanceOf(TestComment); + expect(typeof hydratedComment.reactions[0]).to.equal("string"); + }); + + // ── 16. IncludeMap — edge cases (non-conforming nodes) ──────────────────── + // + // Only nodes that conform to the related model's SDNA class are hydrated; + // bare URIs or nodes of a different type are silently dropped. + + describe("include: edge cases — non-conforming linked nodes", () => { + @Model({ name: "EdgeComment" }) + class EdgeComment extends Ad4mModel { + @Flag({ through: "ad4m://type", value: "ad4m://edge-comment" }) + type!: string; + + @Property({ through: "comment://text" }) + text: string = ""; + } + + @Model({ name: "EdgeArticle" }) + class EdgeArticle extends Ad4mModel { + @Property({ through: "article://title" }) + title: string = ""; + + @HasMany(() => EdgeComment, { through: "article://has_comment" }) + comments: EdgeComment[] = []; + } + interface EdgeArticle extends HasManyMethods<"comments"> {} + + let edgePerspective: PerspectiveProxy; + + beforeEach(async () => { + if (edgePerspective) { + await ad4m.perspective.remove(edgePerspective.uuid); + } + edgePerspective = await ad4m.perspective.add("include-edge-test"); + await edgePerspective.ensureSDNASubjectClass(EdgeComment); + await edgePerspective.ensureSDNASubjectClass(EdgeArticle); + await sleep(200); + }); + + afterEach(async () => { + if (edgePerspective) { + await ad4m.perspective.remove(edgePerspective.uuid); + (edgePerspective as any) = null; + } + }); + + it("include hydrates only conforming nodes — bare URIs are dropped", async () => { + const article = await EdgeArticle.create(edgePerspective, { + title: "Article with mixed links", + }); + const validComment = await EdgeComment.create(edgePerspective, { + text: "Valid", + }); + const invalidItem = Literal.from("not-a-comment").toUrl(); + + await article.addComments(validComment); + await edgePerspective.add( + new Link({ + source: article.id, + predicate: "article://has_comment", + target: invalidItem, + }), + ); + + const retrieved = await EdgeArticle.findOne(edgePerspective, { + where: { id: article.id }, + include: { comments: true }, + }); + + expect(retrieved).to.not.be.null; + expect(retrieved!.comments).to.have.lengthOf(1); + expect(retrieved!.comments[0].id).to.equal(validComment.id); + }); + + it("findAll() with include drops non-conforming nodes across multiple instances", async () => { + const article1 = await EdgeArticle.create(edgePerspective, { + title: "Article 1", + }); + const article2 = await EdgeArticle.create(edgePerspective, { + title: "Article 2", + }); + const c1 = await EdgeComment.create(edgePerspective, { + text: "Comment on 1", + }); + const c2 = await EdgeComment.create(edgePerspective, { + text: "Comment on 2", + }); + + await article1.addComments(c1); + await article2.addComments(c2); + await edgePerspective.add( + new Link({ + source: article1.id, + predicate: "article://has_comment", + target: Literal.from("not-a-comment-1").toUrl(), + }), + ); + await edgePerspective.add( + new Link({ + source: article2.id, + predicate: "article://has_comment", + target: Literal.from("not-a-comment-2").toUrl(), + }), + ); + + const articles = await EdgeArticle.findAll(edgePerspective, { + include: { comments: true }, + }); + + expect(articles).to.have.lengthOf(2); + const found1 = articles.find((a) => a.title === "Article 1")!; + const found2 = articles.find((a) => a.title === "Article 2")!; + expect(found1.comments).to.have.lengthOf(1); + expect(found1.comments[0].id).to.equal(c1.id); + expect(found2.comments).to.have.lengthOf(1); + expect(found2.comments[0].id).to.equal(c2.id); + }); + }); + + // ── 17. properties field projection ─────────────────────────────────────── + + it("properties: [] throws — empty array is disallowed", async () => { + let threw = false; + try { + await TestPost.findAll(perspective, { properties: [] }); + } catch (e: any) { + threw = true; + expect(e.message).to.include("properties[]"); + } + expect(threw).to.be.true; + }); + + it("properties: ['title'] returns only id + title — all other schema and metadata fields absent", async () => { + const results = await TestPost.findAll(perspective, { + properties: ["title"], + }); + expect(results.length).to.be.at.least(3); + for (const r of results) { + expect(r.id).to.be.a("string"); + expect(r.title).to.be.a("string"); + // The @Property decorator places shadow descriptors on the prototype, so + // `delete instance.body` removes the own property but `'body' in instance` + // still returns true. We assert on own properties to test projection. + expect(r).to.not.have.own.property("body"); + expect(r).to.not.have.own.property("viewCount"); + expect(r).to.not.have.own.property("comments"); + expect(r).to.not.have.own.property("tags"); + expect(r).to.not.have.own.property("author"); + expect(r).to.not.have.own.property("createdAt"); + expect(r).to.not.have.own.property("updatedAt"); + } + }); + + it("properties: ['body'] on findOne() strips all unrequested schema + metadata fields", async () => { + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + properties: ["body"], + }); + expect(found).to.not.be.null; + expect(found!.id).to.equal(p1.id); + expect(found!.body).to.equal("first"); + expect(found).to.not.have.own.property("title"); + expect(found).to.not.have.own.property("viewCount"); + expect(found).to.not.have.own.property("author"); + expect(found).to.not.have.own.property("createdAt"); + expect(found).to.not.have.own.property("updatedAt"); + }); + + it("properties: ['author', 'createdAt'] returns metadata fields when explicitly requested", async () => { + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + properties: ["author", "createdAt"], + }); + expect(found).to.not.be.null; + expect(found!.id).to.be.a("string"); + expect(found!.author).to.be.a("string"); + expect(found!.createdAt).to.exist; + expect(found).to.not.have.own.property("title"); + expect(found).to.not.have.own.property("body"); + expect(found).to.not.have.own.property("updatedAt"); + }); + + it("properties projection preserves internal machinery — addX methods and save() still work", async () => { + const results = await TestPost.findAll(perspective, { + properties: ["title"], + }); + expect(results.length).to.be.at.least(1); + const r = results[0]; + expect(r.id).to.be.a("string"); + expect(typeof (r as any).addComments).to.equal("function"); + // save() must not throw — dirty tracking skips everything (nothing changed) + await r.save(); + }); + + it("properties projection + dirty tracking: save() after projected fetch only writes the changed field", async () => { + // Fetch with only 'title' — snapshot records only title + const fetched = await TestPost.findOne(perspective, { + where: { id: p1.id }, + properties: ["title"], + }); + expect(fetched).to.not.be.null; + + // Mutate title and save — body/comments/etc. are absent so dirty tracking skips them + fetched!.title = "Updated"; + await fetched!.save(); + + const refetched = await TestPost.findOne(perspective, { + where: { id: p1.id }, + }); + expect(refetched!.title).to.equal("Updated"); + // body was never touched — must still be 'first' + expect(refetched!.body).to.equal("first"); + }); + + it("properties on nested include: { comments: { properties: ['body'] } } strips unrequested fields from comments", async () => { + const comment = await TestComment.create(perspective, { body: "partial" }); + await p1.addComments(comment.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + include: { comments: { properties: ["body"] } }, + }); + expect(found).to.not.be.null; + const c = found!.comments[0] as TestComment; + expect(c.id).to.be.a("string"); + expect(c.body).to.equal("partial"); + expect(c).to.not.have.own.property("reactions"); + }); + + it("top-level properties + include: post properties stripped, included relations still hydrated", async () => { + const comment = await TestComment.create(perspective, { body: "full" }); + await p1.addComments(comment.id); + + const found = await TestPost.findOne(perspective, { + where: { id: p1.id }, + properties: ["title"], + include: { comments: true }, + }); + expect(found).to.not.be.null; + expect(found!.title).to.equal("Alpha"); + expect(found).to.not.have.own.property("body"); + expect(found).to.not.have.own.property("author"); + expect(found).to.not.have.own.property("createdAt"); + // include is orthogonal to properties — relations are hydrated regardless + expect(found!.comments.length).to.be.at.least(1); + expect(found!.comments[0]).to.be.instanceOf(TestComment); + }); + + // ── 18. parent query ────────────────────────────────────────────────────── + + it("findAll() with parent (raw predicate form) returns only children of that node", async () => { + const channel = await TestChannel.create(perspective, { name: "chan" }); + await channel.addPosts(p1.id); + await channel.addPosts(p2.id); + + const results = await TestPost.findAll(perspective, { + parent: { id: channel.id, predicate: "test://channel_post" }, + }); + + const ids = results.map((r) => r.id); + expect(ids).to.include(p1.id); + expect(ids).to.include(p2.id); + expect(ids).to.not.include(p3.id); + }); + + it("findAll() with parent (model-backed, field inferred) returns only children of that node", async () => { + const channel = await TestChannel.create(perspective, { name: "chan" }); + await channel.addPosts(p1.id); + await channel.addPosts(p2.id); + + const results = await TestPost.findAll(perspective, { + parent: { model: TestChannel, id: channel.id }, + }); + + const ids = results.map((r) => r.id); + expect(ids).to.include(p1.id); + expect(ids).to.include(p2.id); + expect(ids).to.not.include(p3.id); + }); + + it("findAll() with parent (model-backed, explicit field) resolves the correct predicate", async () => { + const channel = await TestChannel.create(perspective, { name: "chan" }); + const comment = await TestComment.create(perspective, { body: "hello" }); + await channel.addPosts(p1.id); + await channel.addComments(comment.id); + + // Explicit field = 'posts' — must not return comments + const results = await TestPost.findAll(perspective, { + parent: { model: TestChannel, id: channel.id, field: "posts" }, + }); + + const ids = results.map((r) => r.id); + expect(ids).to.include(p1.id); + expect(ids).to.not.include(comment.id); + }); + + it("findOne() with parent returns a single child of that node", async () => { + const channel = await TestChannel.create(perspective, { name: "chan" }); + await channel.addPosts(p1.id); + + const found = await TestPost.findOne(perspective, { + parent: { model: TestChannel, id: channel.id }, + }); + + expect(found).to.not.be.null; + expect(found!.id).to.equal(p1.id); + }); + + it("findAll() with parent returns empty array when node has no children", async () => { + const channel = await TestChannel.create(perspective, { name: "empty" }); + // no posts linked + + const results = await TestPost.findAll(perspective, { + parent: { model: TestChannel, id: channel.id }, + }); + + expect(results).to.have.length(0); + }); + + it("parent query and where can be combined", async () => { + const channel = await TestChannel.create(perspective, { name: "chan" }); + await channel.addPosts(p1.id); // title = "Alpha" + await channel.addPosts(p2.id); // title = "Beta" + + const results = await TestPost.findAll(perspective, { + parent: { model: TestChannel, id: channel.id }, + where: { title: "Alpha" }, + }); + + expect(results).to.have.length(1); + expect(results[0].id).to.equal(p1.id); + }); +}); diff --git a/tests/js/tests/model/model-subscriptions.test.ts b/tests/js/tests/model/model-subscriptions.test.ts new file mode 100644 index 000000000..1231b1340 --- /dev/null +++ b/tests/js/tests/model/model-subscriptions.test.ts @@ -0,0 +1,397 @@ +/** + * Ad4mModel — subscription integration tests + * + * Covers: subscribe() initial callback, link-added re-fire, link-removed re-fire, + * unsubscribe() stopping callbacks, debounce batching, error handling, + * and ModelQueryBuilder.subscribe() / countSubscribe() / paginateSubscribe(). + * + * Ported from playground scenario 05 (Subscriptions) and the subscription + * sections of sdna.test.ts. + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --serial --exit tests/model/model-subscriptions.test.ts + */ + +import { expect } from "chai"; +import { Ad4mClient, PerspectiveProxy } from "@coasys/ad4m"; +import { startAgent, waitUntil } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { wipePerspective } from "../../utils/utils.js"; +import { TestComment, TestPost, TestTag, TestChannel } from "./models.js"; + + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function waitForCallbacks( + targetCount: number, + timeoutMs = 8000, +): { callback: (results: T[]) => void; done: Promise; all: T[][] } { + const all: T[][] = []; + let resolve: ((v: T[][]) => void) | null = null; + let timer: ReturnType | null = null; + + const done = new Promise((res, rej) => { + timer = setTimeout( + () => + rej( + new Error( + `Timeout: expected ${targetCount} callbacks, got ${all.length}`, + ), + ), + timeoutMs, + ); + resolve = res; + }); + + const callback = (results: T[]) => { + all.push(results); + if (all.length >= targetCount && resolve) { + if (timer) clearTimeout(timer); + resolve(all); + } + }; + + return { callback, done, all }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("Ad4mModel — Subscriptions", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-subscriptions"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-subscriptions-test"); + await TestPost.register(perspective); + await TestComment.register(perspective); + await TestTag.register(perspective); + await TestChannel.register(perspective); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + await wipePerspective(perspective); + await TestPost.register(perspective); + await TestComment.register(perspective); + await TestTag.register(perspective); + }); + + // ── 1. Immediate initial callback ───────────────────────────────────────── + + it("subscribe() calls callback immediately with initial results", async () => { + const { callback, done } = waitForCallbacks(1); + const sub = TestPost.subscribe(perspective, {}, callback); + const [results] = await done; + sub.unsubscribe(); + expect(Array.isArray(results)).to.be.true; + }); + + // ── 2. Re-fires on link-added ───────────────────────────────────────────── + + it("subscribe() calls callback again when a relevant link is added", async () => { + const all: TestPost[][] = []; + const sub = TestPost.subscribe(perspective, {}, (r) => all.push(r)); + + await waitUntil(() => all.length >= 1, 6000, "initial callback"); + + const post = new TestPost(perspective); + post.title = "New For Sub"; + post.body = ""; + await post.save(); + + await waitUntil( + () => all.some((batch) => batch.some((p) => p.id === post.id)), + 8000, + "new post appears in subscription results", + ); + sub.unsubscribe(); + + expect(all.length).to.be.at.least(2); + expect(all.some((batch) => batch.some((p) => p.id === post.id))).to.be.true; + }); + + // ── 3. Re-fires on link-removed ─────────────────────────────────────────── + + it("subscribe() calls callback again when a relevant link is removed", async () => { + const post = await TestPost.create(perspective, { + title: "Will Be Deleted", + body: "", + }); + const postId = post.id; + + const all: TestPost[][] = []; + const sub = TestPost.subscribe(perspective, {}, (r) => all.push(r)); + + await waitUntil( + () => all.some((batch) => batch.some((p) => p.id === postId)), + 8000, + "initial callback contains the post", + ); + + await post.delete(); + + await waitUntil( + () => { + const latest = all.at(-1); + return latest !== undefined && !latest.some((p) => p.id === postId); + }, + 8000, + "subscription fires without the deleted post", + ); + sub.unsubscribe(); + + expect(all.length).to.be.at.least(2); + expect(all.at(-1)!.some((p) => p.id === postId)).to.be.false; + }); + + // ── 4. unsubscribe() stops further callbacks ────────────────────────────── + + it("unsubscribe() stops further callback invocations", async () => { + let callCount = 0; + const sub = TestPost.subscribe(perspective, {}, () => { + callCount++; + }); + + // Wait for the immediate callback + await new Promise((r) => setTimeout(r, 400)); + const countAfterInitial = callCount; + sub.unsubscribe(); + + const post = new TestPost(perspective); + post.title = "Post After Unsub"; + post.body = ""; + await post.save(); + + // Give it a moment to fire — it should not + await new Promise((r) => setTimeout(r, 600)); + expect(callCount).to.equal(countAfterInitial); + }); + + // ── 5. debounce batches rapid changes ───────────────────────────────────── + + it("debounce option batches rapid successive link changes into fewer callbacks", async () => { + let callCount = 0; + const sub = TestPost.subscribe(perspective, { debounce: 400 }, () => { + callCount++; + }); + + // Wait for the initial callback + await new Promise((r) => setTimeout(r, 150)); + const countAfterInitial = callCount; + + // Fire 3 saves in rapid succession within the debounce window + await Promise.all( + Array.from({ length: 3 }, async (_, i) => { + const p = new TestPost(perspective); + p.title = `Rapid ${i}`; + p.body = ""; + await p.save(); + }), + ); + + // After debounce settles, there should be fewer than 3 extra callbacks + await new Promise((r) => setTimeout(r, 1000)); + sub.unsubscribe(); + + const extraCallbacks = callCount - countAfterInitial; + expect(extraCallbacks).to.be.lessThan(3); + }); + + // ── 6. ModelQueryBuilder.live() ────────────────────────────────────────── + + it("ModelQueryBuilder.live() delivers results and can be unsubscribed", async () => { + const all: TestPost[][] = []; + const sub = TestPost.query(perspective).live((r) => all.push(r)); + + await waitUntil( + () => all.length >= 1, + 6000, + "initial callback from query builder", + ); + expect(Array.isArray(all[0])).to.be.true; + + const post = new TestPost(perspective); + post.title = "QB Sub Post"; + post.body = ""; + await post.save(); + + await waitUntil( + () => all.some((batch) => batch.some((p) => p.id === post.id)), + 8000, + "new post appears via query builder live subscription", + ); + + sub.unsubscribe(); + + const countAfter = all.length; + await new Promise((r) => setTimeout(r, 400)); + expect(all.length).to.equal(countAfter); + }); + + // ── 7. ModelQueryBuilder.count() ───────────────────────────────────────── + + it("ModelQueryBuilder.count() returns correct count and changes after add", async () => { + const initialCount = await TestPost.query(perspective).count(); + + await TestPost.create(perspective, { title: "Count Post", body: "" }); + + const afterCount = await TestPost.query(perspective).count(); + expect(afterCount).to.equal(initialCount + 1); + }); + + // ── 9. @HasMany relation changes trigger re-fire ───────────────────────── + // + // Regression: instanceToSerializable previously omitted @HasMany relation + // fields from the fingerprint, so adding a tag/comment to an existing + // instance would not change the fingerprint and the subscription would + // never re-broadcast. + + it("subscribe() re-fires when a @HasMany relation is updated on an existing instance", async () => { + const post = await TestPost.create(perspective, { title: "Tagged", body: "" }); + + const all: TestPost[][] = []; + const sub = TestPost.subscribe(perspective, {}, (r) => all.push(r)); + + // Wait for the initial callback that contains the post + await waitUntil( + () => all.some((batch) => batch.some((p) => p.id === post.id)), + 8000, + "initial callback contains the post", + ); + const initialTagCount = (all.at(-1)!.find((p) => p.id === post.id)?.tags ?? []).length; + + // Add a tag — this is a @HasMany relation change, not a property change + const tag = await TestTag.create(perspective, { label: "rust" }); + await post.addTags(tag.id); + + // Subscription must re-fire with the updated tags array + await waitUntil( + () => { + const latest = all.at(-1); + const latestPost = latest?.find((p) => p.id === post.id); + return (latestPost?.tags ?? []).length > initialTagCount; + }, + 8000, + "subscription re-fires with updated tags after addTags()", + ); + sub.unsubscribe(); + + const finalPost = all.at(-1)!.find((p) => p.id === post.id)!; + expect(finalPost.tags.length).to.be.greaterThan(initialTagCount); + }); + + // ── 8. Error handling ───────────────────────────────────────────────────── + + it("subscribe() exposes lastError (null until failure)", async () => { + const sub = TestPost.subscribe(perspective, {}, () => {}); + await new Promise((r) => setTimeout(r, 300)); + expect(sub.lastError).to.be.null; + sub.unsubscribe(); + }); + + // ── 10. parent-scoped subscriptions ─────────────────────────────────────── + + it("parent-scoped subscribe() fires when a child is linked to the parent", async () => { + const channel = await TestChannel.create(perspective, { name: "sub-chan" }); + const post = await TestPost.create(perspective, { title: "Sub Post", body: "" }); + + const all: TestPost[][] = []; + const sub = TestPost.subscribe( + perspective, + { parent: { model: TestChannel, id: channel.id } }, + (r) => all.push(r), + ); + + // Wait for initial (empty) callback + await waitUntil(() => all.length >= 1, 6000, "initial callback"); + expect(all[0]).to.have.length(0); + + // Link the post to the channel + await channel.addPosts(post.id); + + // Subscription must re-fire with the post now included + await waitUntil( + () => all.some((batch) => batch.some((p) => p.id === post.id)), + 8000, + "re-fire after child linked to parent", + ); + sub.unsubscribe(); + + expect(all.some((batch) => batch.some((p) => p.id === post.id))).to.be.true; + }); + + it("parent-scoped subscribe() fires when a child is unlinked from the parent", async () => { + const channel = await TestChannel.create(perspective, { name: "sub-chan-2" }); + const post = await TestPost.create(perspective, { title: "Removable", body: "" }); + await channel.addPosts(post.id); + + const all: TestPost[][] = []; + const sub = TestPost.subscribe( + perspective, + { parent: { model: TestChannel, id: channel.id } }, + (r) => all.push(r), + ); + + // Wait until initial callback contains the post + await waitUntil( + () => all.some((batch) => batch.some((p) => p.id === post.id)), + 8000, + "initial callback contains the post", + ); + + // Remove the link + await channel.removePosts(post.id); + + // Subscription must re-fire with the post absent + await waitUntil( + () => { + const latest = all.at(-1); + return latest !== undefined && !latest.some((p) => p.id === post.id); + }, + 8000, + "re-fire after child unlinked from parent", + ); + sub.unsubscribe(); + + expect(all.at(-1)!.some((p) => p.id === post.id)).to.be.false; + }); + + it("parent-scoped subscribe() does NOT fire when a child is added to a different parent", async () => { + const channelA = await TestChannel.create(perspective, { name: "chan-a" }); + const channelB = await TestChannel.create(perspective, { name: "chan-b" }); + + const all: TestPost[][] = []; + const sub = TestPost.subscribe( + perspective, + { parent: { model: TestChannel, id: channelA.id } }, + (r) => all.push(r), + ); + + // Wait for initial (empty) callback + await waitUntil(() => all.length >= 1, 6000, "initial callback"); + const countAfterInitial = all.length; + + // Add a post to channel B — should not trigger channel A's subscription + const post = await TestPost.create(perspective, { title: "Wrong Chan", body: "" }); + await channelB.addPosts(post.id); + + await new Promise((r) => setTimeout(r, 1000)); + sub.unsubscribe(); + + expect(all.length).to.equal(countAfterInitial); + }); +}); diff --git a/tests/js/tests/model/model-transactions.test.ts b/tests/js/tests/model/model-transactions.test.ts new file mode 100644 index 000000000..3cfb6cf26 --- /dev/null +++ b/tests/js/tests/model/model-transactions.test.ts @@ -0,0 +1,330 @@ +/** + * Ad4mModel — transaction integration tests + * + * Covers: batching multiple saves in one transaction commit, save+update, + * save+delete, throw/rollback behaviour, and the Ad4mModel.transaction() API. + * + * Ported from playground scenario 06 (Transactions). + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --serial --exit tests/model/model-transactions.test.ts + */ + +import { expect } from "chai"; +import { Ad4mClient, PerspectiveProxy } from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { wipePerspective } from "../../utils/utils.js"; +import { TestPost, TestComment } from "./models.js"; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("Ad4mModel — Transactions", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-transactions"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-transactions-test"); + await TestPost.register(perspective); + await TestComment.register(perspective); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + await wipePerspective(perspective); + await TestPost.register(perspective); + await TestComment.register(perspective); + }); + + // ── 1. Batch creates in one transaction ─────────────────────────────────── + + it("commits multiple saves atomically and all objects are findable afterwards", async () => { + await TestPost.transaction(perspective, async (tx) => { + const p1 = new TestPost(perspective); + p1.title = "Tx Alpha"; + p1.body = "body a"; + await p1.save(tx.batchId); + + const p2 = new TestPost(perspective); + p2.title = "Tx Beta"; + p2.body = "body b"; + await p2.save(tx.batchId); + + const p3 = new TestPost(perspective); + p3.title = "Tx Gamma"; + p3.body = "body c"; + await p3.save(tx.batchId); + }); + + const all = await TestPost.findAll(perspective); + expect(all).to.have.length(3); + const titles = all.map((p) => p.title).sort(); + expect(titles).to.deep.equal(["Tx Alpha", "Tx Beta", "Tx Gamma"]); + }); + + // ── 2. Save then update in same transaction ─────────────────────────────── + + it("save + update inside transaction reflects final state after commit", async () => { + let savedId: string | undefined; + + await TestPost.transaction(perspective, async (tx) => { + const post = new TestPost(perspective); + post.title = "Before Update"; + post.body = ""; + await post.save(tx.batchId); + savedId = post.id; + + post.title = "After Update"; + await post.save(tx.batchId); + }); + + expect(savedId).to.be.a("string"); + const found = await TestPost.findOne(perspective, { + where: { id: savedId! }, + }); + expect(found).to.not.be.null; + expect(found!.title).to.equal("After Update"); + }); + + // ── 3. Save then delete in same transaction ─────────────────────────────── + + it("delete inside transaction removes a pre-existing record", async () => { + // Create the record BEFORE the transaction — then delete it inside one + const post = await TestPost.create(perspective, { + title: "Ephemeral", + body: "", + }); + const savedId = post.id; + + await TestPost.transaction(perspective, async (tx) => { + await post.delete(tx.batchId); + }); + + const found = await TestPost.findOne(perspective, { + where: { id: savedId }, + }); + expect(found).to.be.null; + }); + + // ── 4. Transaction does not affect pre-existing records ─────────────────── + + it("transaction only modifies the records it explicitly touches", async () => { + // Pre-existing post outside any transaction + const outside = await TestPost.create(perspective, { + title: "Outside Tx", + body: "", + }); + + await TestPost.transaction(perspective, async (tx) => { + const inside = new TestPost(perspective); + inside.title = "Inside Tx"; + inside.body = ""; + await inside.save(tx.batchId); + }); + + const all = await TestPost.findAll(perspective); + expect(all).to.have.length(2); + expect(all.some((p) => p.id === outside.id && p.title === "Outside Tx")).to + .be.true; + }); + + // ── 5. Reads within a transaction ───────────────────────────────────────── + + it("objects created inside a transaction are readable after commit", async () => { + const ids: string[] = []; + + await TestPost.transaction(perspective, async (tx) => { + for (let i = 0; i < 5; i++) { + const post = new TestPost(perspective); + post.title = `Bulk ${i}`; + post.body = ""; + await post.save(tx.batchId); + ids.push(post.id); + } + }); + + expect(ids).to.have.length(5); + for (const id of ids) { + const found = await TestPost.findOne(perspective, { where: { id: id } }); + expect(found, `Post ${id} should exist`).to.not.be.null; + } + }); + + // ── 6. Mixed transaction operations ────────────────────────────────────── + + it("handles mixed create/update and pre-tx delete inside one transaction", async () => { + // Create two records before the transaction + const keep = await TestPost.create(perspective, { + title: "Keep Me", + body: "", + }); + const remove = await TestPost.create(perspective, { + title: "Remove Me", + body: "", + }); + + await TestPost.transaction(perspective, async (tx) => { + // Create a new one + const newPost = new TestPost(perspective); + newPost.title = "New In Tx"; + newPost.body = ""; + await newPost.save(tx.batchId); + + // Update the keep record + keep.title = "Updated In Tx"; + await keep.save(tx.batchId); + + // Delete the remove record (pre-existing, not created in this batch) + await remove.delete(tx.batchId); + }); + + const all = await TestPost.findAll(perspective); + expect(all).to.have.length(2); // keep (updated) + newPost; remove was deleted + + const updated = await TestPost.findOne(perspective, { + where: { id: keep.id }, + }); + expect(updated!.title).to.equal("Updated In Tx"); + + const gone = await TestPost.findOne(perspective, { + where: { id: remove.id }, + }); + expect(gone).to.be.null; + }); + + // ── 7. Rollback on throw ────────────────────────────────────────────────── + + it("transaction that throws is not committed — data remains unpersisted", async () => { + let abortedId: string | undefined; + + let thrownErr: Error | undefined; + try { + await TestPost.transaction(perspective, async (tx) => { + const post = new TestPost(perspective); + post.title = "Should Not Persist"; + post.body = ""; + await post.save(tx.batchId); + abortedId = post.id; + throw new Error("intentional rollback"); + }); + } catch (err: any) { + thrownErr = err; + } + + // The transaction MUST re-throw the original error + expect(thrownErr?.message).to.equal("intentional rollback"); + + // commitBatch was never called — the node should not exist in the perspective + expect(abortedId).to.be.a("string"); + const found = await TestPost.findOne(perspective, { + where: { id: abortedId! }, + }); + expect(found).to.be.null; + }); + + // ── 8. Dirty tracking — relations are not duplicated on re-save ─────────── + + it("saving a hydrated instance only writes changed fields — relations are not duplicated", async () => { + // Create a post with two comments + const c1 = await TestComment.create(perspective, { body: "first" }); + const c2 = await TestComment.create(perspective, { body: "second" }); + const post = await TestPost.create(perspective, { + title: "Original", + body: "body", + }); + await post.addComments(c1.id); + await post.addComments(c2.id); + + // Fetch the post (triggers snapshot capture) + const fetched = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(fetched).to.not.be.null; + expect(fetched!.comments).to.have.length(2); + + // Mutate only the title, then save + fetched!.title = "Updated"; + await fetched!.save(); + + // Re-fetch and verify comments were NOT duplicated + const refetched = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(refetched).to.not.be.null; + expect(refetched!.title).to.equal("Updated"); + expect( + refetched!.comments, + "comments must not be duplicated after partial save", + ).to.have.length(2); + }); + + it("dirty tracking: a field not changed since hydration is not re-written", async () => { + const post = await TestPost.create(perspective, { + title: "Unchanged", + body: "body", + }); + + // Hydrate — establishes snapshot + const fetched = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(fetched).to.not.be.null; + + // Save without changing anything — must be idempotent + await fetched!.save(); + + const refetched = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(refetched!.title).to.equal("Unchanged"); + expect(refetched!.body).to.equal("body"); + }); + + it("dirty tracking: adding a relation via addX then saving does not duplicate pre-existing relations", async () => { + const c1 = await TestComment.create(perspective, { body: "original" }); + const c2 = await TestComment.create(perspective, { body: "new" }); + const post = await TestPost.create(perspective, { + title: "Post", + body: "", + }); + await post.addComments(c1.id); + + // Hydrate then add a second comment via the array directly (simulating + // a caller that appends to the relation array before saving) + const fetched = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect(fetched!.comments).to.have.length(1); + + // Push the new comment id into the array and save — the snapshot has [c1.id] + // so comments IS dirty (length differs) and setRelationSetter runs, but with + // the full [c1.id, c2.id] set — no duplication of c1. + (fetched!.comments as any[]).push(c2.id); + await fetched!.save(); + + const refetched = await TestPost.findOne(perspective, { + where: { id: post.id }, + }); + expect( + refetched!.comments, + "should have exactly 2 comments, not 3", + ).to.have.length(2); + const ids = refetched!.comments as unknown as string[]; + expect(ids).to.include(c1.id); + expect(ids).to.include(c2.id); + }); +}); diff --git a/tests/js/tests/model/model-where-operators.test.ts b/tests/js/tests/model/model-where-operators.test.ts new file mode 100644 index 000000000..e56b070ab --- /dev/null +++ b/tests/js/tests/model/model-where-operators.test.ts @@ -0,0 +1,169 @@ +/** + * Ad4mModel — WhereCondition operator integration tests + * + * Covers: array-IN, { not }, { not: array }, { contains }, + * { gt }, { gte }, { lt }, { lte }, { between }. + * + * String operators (not, not[], array IN) are pushed into SQL WHERE + * via fn::parse_literal comparisons. Numeric operators (gt/gte/lt/lte/between) + * and contains are post-filtered in JavaScript after a broad SQL fetch. + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 120000 --serial --exit tests/model/model-where-operators.test.ts + */ + +import { expect } from "chai"; +import { Ad4mClient, PerspectiveProxy } from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import { getSharedAgent } from "./hooks.js"; +import { wipePerspective } from "../../utils/utils.js"; +import { TestPost } from "./models.js"; + + +describe("Ad4mModel — WhereCondition operators", function () { + this.timeout(120_000); + + let ownStop: (() => Promise) | null = null; + let ad4m: Ad4mClient; + let perspective: PerspectiveProxy; + + // Three seeded posts with distinct titles and numeric viewCounts + let p1: TestPost; // title: "Alpha", viewCount: 1 + let p2: TestPost; // title: "Beta", viewCount: 3 + let p3: TestPost; // title: "Gamma", viewCount: 5 + + before(async () => { + const shared = getSharedAgent(); + if (shared) { + ad4m = shared.client; + } else { + const agent = await startAgent("model-where-operators"); + ad4m = agent.client; + ownStop = agent.stop; + } + perspective = await ad4m.perspective.add("model-where-operators-test"); + await TestPost.register(perspective); + }); + + after(async () => { + if (ownStop) await ownStop(); + }); + + beforeEach(async () => { + await wipePerspective(perspective); + await TestPost.register(perspective); + p1 = await TestPost.create(perspective, { + title: "Alpha", + body: "", + viewCount: 1, + }); + p2 = await TestPost.create(perspective, { + title: "Beta", + body: "", + viewCount: 3, + }); + p3 = await TestPost.create(perspective, { + title: "Gamma", + body: "", + viewCount: 5, + }); + }); + + // ── Array IN ────────────────────────────────────────────────────────────── + + it("where: { id: [id1, id2] } returns only those two instances", async () => { + const results = await TestPost.findAll(perspective, { + where: { id: [p1.id, p2.id] }, + }); + expect(results).to.have.length(2); + const ids = results.map((r) => r.id); + expect(ids).to.include(p1.id); + expect(ids).to.include(p2.id); + expect(ids).to.not.include(p3.id); + }); + + // ── not (single value) ──────────────────────────────────────────────────── + + it("where: { title: { not: 'Alpha' } } excludes the matching instance", async () => { + const results = await TestPost.findAll(perspective, { + where: { title: { not: "Alpha" } }, + }); + expect(results).to.have.length(2); + expect(results.every((r) => r.title !== "Alpha")).to.be.true; + }); + + // ── not (array) ─────────────────────────────────────────────────────────── + + it("where: { title: { not: ['Alpha', 'Beta'] } } excludes both from result set", async () => { + const results = await TestPost.findAll(perspective, { + where: { title: { not: ["Alpha", "Beta"] } }, + }); + expect(results).to.have.length(1); + expect(results[0].title).to.equal("Gamma"); + }); + + // ── contains ────────────────────────────────────────────────────────────── + + it("where: { title: { contains: 'eta' } } matches the substring in title", async () => { + // "Beta" contains "eta"; "Alpha" and "Gamma" do not + const results = await TestPost.findAll(perspective, { + where: { title: { contains: "eta" } }, + }); + expect(results).to.have.length(1); + expect(results[0].title).to.equal("Beta"); + }); + + // ── gt ──────────────────────────────────────────────────────────────────── + + it("where: { viewCount: { gt: 3 } } returns only instances strictly above 3", async () => { + const results = await TestPost.findAll(perspective, { + where: { viewCount: { gt: 3 } }, + }); + expect(results).to.have.length(1); + expect(results[0].id).to.equal(p3.id); // viewCount: 5 + }); + + // ── gte ─────────────────────────────────────────────────────────────────── + + it("where: { viewCount: { gte: 3 } } includes the boundary value", async () => { + const results = await TestPost.findAll(perspective, { + where: { viewCount: { gte: 3 } }, + }); + expect(results).to.have.length(2); + const ids = results.map((r) => r.id); + expect(ids).to.include(p2.id); // viewCount: 3 (at boundary) + expect(ids).to.include(p3.id); // viewCount: 5 + }); + + // ── lt ──────────────────────────────────────────────────────────────────── + + it("where: { viewCount: { lt: 3 } } returns only instances strictly below 3", async () => { + const results = await TestPost.findAll(perspective, { + where: { viewCount: { lt: 3 } }, + }); + expect(results).to.have.length(1); + expect(results[0].id).to.equal(p1.id); // viewCount: 1 + }); + + // ── lte ─────────────────────────────────────────────────────────────────── + + it("where: { viewCount: { lte: 3 } } includes the boundary value", async () => { + const results = await TestPost.findAll(perspective, { + where: { viewCount: { lte: 3 } }, + }); + expect(results).to.have.length(2); + const ids = results.map((r) => r.id); + expect(ids).to.include(p1.id); // viewCount: 1 + expect(ids).to.include(p2.id); // viewCount: 3 (at boundary) + }); + + // ── between ─────────────────────────────────────────────────────────────── + + it("where: { viewCount: { between: [2, 4] } } returns only instances inside the range", async () => { + const results = await TestPost.findAll(perspective, { + where: { viewCount: { between: [2, 4] } }, + }); + expect(results).to.have.length(1); + expect(results[0].id).to.equal(p2.id); // viewCount: 3 is in [2, 4] + }); +}); diff --git a/tests/js/tests/model/models.ts b/tests/js/tests/model/models.ts new file mode 100644 index 000000000..705571a72 --- /dev/null +++ b/tests/js/tests/model/models.ts @@ -0,0 +1,138 @@ +/** + * Shared test model definitions for the Ad4mModel integration tests. + * + * These are direct ports of the models in: + * we/apps/playgrounds/react/ad4m-model-testing/src/models/ + * + * Using the same predicates / decorators as the playground keeps the + * two test environments in sync — a test that passes in the playground + * should pass here too. + */ + +import { + Ad4mModel, + BelongsToMany, + BelongsToOne, + Flag, + HasMany, + HasManyMethods, + HasOne, + Model, + Property, +} from "@coasys/ad4m"; + +// ── TestReaction ───────────────────────────────────────────────────────────── + +@Model({ name: "TestReaction" }) +export class TestReaction extends Ad4mModel { + @Flag({ through: "test://reaction_type", value: "test://reaction" }) + type = "test://reaction"; + + @Property({ through: "test://emoji", required: true }) + emoji: string = ""; +} + +// ── TestComment (declared first to avoid circular-ref issues at class level) ── + +@Model({ name: "TestComment" }) +export class TestComment extends Ad4mModel { + @Flag({ through: "test://comment_type", value: "test://comment" }) + type = "test://comment"; + + @Property({ through: "test://body", required: true }) + body: string = ""; + + @HasMany(() => TestReaction, { through: "test://has_reaction" }) + reactions: TestReaction[] = []; + + /** Reverse traversal — find the TestPost that has a test://has_comment link pointing to this */ + @BelongsToOne(() => TestPost, { through: "test://has_comment" }) + post: TestPost | null = null; + + /** Reverse traversal — find the TestPost that has this comment as its pinnedComment */ + @BelongsToOne(() => TestPost, { through: "test://pinned_comment" }) + pinnedBy: TestPost | null = null; +} +export interface TestComment extends HasManyMethods<"reactions"> {} + +// ── TestTag ─────────────────────────────────────────────────────────────────── + +@Model({ name: "TestTag" }) +export class TestTag extends Ad4mModel { + @Flag({ through: "test://tag_type", value: "test://tag" }) + type = "test://tag"; + + @Property({ through: "test://label", required: true }) + label: string = ""; + + /** Reverse traversal — all TestPosts that have a test://has_tag link pointing to this tag */ + @BelongsToMany(() => TestPost, { through: "test://has_tag" }) + posts: TestPost[] = []; +} + +// ── TestPost ────────────────────────────────────────────────────────────────── + +@Model({ name: "TestPost" }) +export class TestPost extends Ad4mModel { + @Flag({ through: "test://post_type", value: "test://post" }) + type = "test://post"; + + @Property({ through: "test://title", required: true }) + title: string = ""; + + @Property({ through: "test://body" }) + body: string = ""; + + @Property({ through: "test://view_count" }) + viewCount: number = 0; + + @HasMany(() => TestTag, { through: "test://has_tag" }) + tags: TestTag[] = []; + + @HasMany(() => TestComment, { through: "test://has_comment" }) + comments: TestComment[] = []; + + @HasOne(() => TestComment, { through: "test://pinned_comment" }) + pinnedComment: TestComment | null = null; +} +export interface TestPost extends HasManyMethods< + "tags" | "comments" | "pinnedComment" +> {} + +// ── TestBaseModel ───────────────────────────────────────────────────────────── + +@Model({ name: "TestBaseModel" }) +export class TestBaseModel extends Ad4mModel { + /** No @Flag — all nodes with test://base_content qualify as base instances */ + @Property({ through: "test://base_content" }) + content: string = ""; +} + +// ── TestDerivedModel ────────────────────────────────────────────────────────── + +@Model({ name: "TestDerivedModel" }) +export class TestDerivedModel extends TestBaseModel { + @Flag({ through: "test://poll_type", value: "test://poll_block" }) + pollType = "test://poll_block"; + + @Property({ through: "test://poll_question", required: true }) + question: string = ""; +} + +// ── TestChannel ─────────────────────────────────────────────────────────────── + +@Model({ name: "TestChannel" }) +export class TestChannel extends Ad4mModel { + @Flag({ through: "test://channel_type", value: "test://channel" }) + type = "test://channel"; + + @Property({ through: "test://channel_name" }) + name: string = ""; + + @HasMany(() => TestPost, { through: "test://channel_post" }) + posts: TestPost[] = []; + + @HasMany(() => TestComment, { through: "test://channel_comment" }) + comments: TestComment[] = []; +} +export interface TestChannel extends HasManyMethods<"posts" | "comments"> {} diff --git a/tests/js/tests/multi-user-connect.test.ts b/tests/js/tests/multi-user-connect.test.ts deleted file mode 100644 index 72802bffc..000000000 --- a/tests/js/tests/multi-user-connect.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import path from "path"; -import { Ad4mClient } from "@coasys/ad4m"; -import Ad4mConnect from "../../../connect/src/core.ts"; -import fs from "fs-extra"; -import { fileURLToPath } from 'url'; -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { apolloClient, sleep, startExecutor } from "../utils/utils"; -import { ChildProcess } from 'node:child_process'; -import fetch from 'node-fetch' - -//@ts-ignore -global.fetch = fetch - -const expect = chai.expect; -chai.use(chaiAsPromised); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Multi-User Ad4m-Connect integration tests", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "multi-user-connect-agent"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15800 - const hcAdminPort = 15801 - const hcAppPort = 15802 - - let executorProcess: ChildProcess | null = null - let adminAd4mClient: Ad4mClient | null = null - - before(async () => { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }); - } - - // Start executor with multi-user mode enabled (no admin credential for this test) - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort, false); - - adminAd4mClient = new Ad4mClient(apolloClient(gqlPort), false) - - // Generate initial admin agent (needed for JWT signing) - await adminAd4mClient.agent.generate("passphrase") - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - }) - - describe("Multi-User Connect Flow", () => { - it("should create user and login via ad4m-connect", async () => { - // Create ad4m-connect instance with multi-user options - const ui = new Ad4mConnect({ - appName: "Multi-User Test App", - appDesc: "Testing multi-user functionality", - appDomain: "test.ad4m.org", - appIconPath: "https://example.com/icon.png", - capabilities: [{ - with: { domain: "*", pointers: ["*"] }, - can: ["*"] - }], - multiUser: true, - backendUrl: `ws://localhost:${gqlPort}/graphql`, - userEmail: "test@example.com", - userPassword: "password123" - }); - - // Connect should handle user creation and login automatically - const client = await ui.connect(); - expect(client).to.be.ok; - - // Verify we have an authenticated client - const status = await client.agent.status(); - expect(status.isUnlocked).to.be.true; - expect(status.did).to.be.ok; - expect(status.did).to.match(/^did:key:.+/); - - // Verify the agent.me returns the correct user DID - const agent = await client.agent.me(); - expect(agent.did).to.equal(status.did); - - console.log("Successfully connected as user:", agent.did); - }) - - it("should login existing user via ad4m-connect", async () => { - // First, create a user directly via admin client - const userResult = await adminAd4mClient!.agent.createUser("existing@example.com", "password456"); - expect(userResult.success).to.be.true; - - // Now try to connect via ad4m-connect with existing user credentials - const ui = new Ad4mConnect({ - appName: "Multi-User Test App", - appDesc: "Testing multi-user functionality", - appDomain: "test.ad4m.org", - capabilities: [{ - with: { domain: "*", pointers: ["*"] }, - can: ["*"] - }], - multiUser: true, - backendUrl: `ws://localhost:${gqlPort}/graphql`, - userEmail: "existing@example.com", - userPassword: "password456" - }); - - // Connect should login the existing user - const client = await ui.connect(); - expect(client).to.be.ok; - - // Verify we're logged in as the correct user - const agent = await client.agent.me(); - expect(agent.did).to.equal(userResult.did); - - console.log("Successfully logged in existing user:", agent.did); - }) - - it("should fail with wrong password", async () => { - // Try to connect with wrong password - const ui = new Ad4mConnect({ - appName: "Multi-User Test App", - appDesc: "Testing multi-user functionality", - appDomain: "test.ad4m.org", - capabilities: [{ - with: { domain: "*", pointers: ["*"] }, - can: ["*"] - }], - multiUser: true, - backendUrl: `ws://localhost:${gqlPort}/graphql`, - userEmail: "existing@example.com", - userPassword: "wrongpassword" - }); - - // Connect should fail - const call = async () => { - return await ui.connect(); - }; - - await expect(call()).to.be.rejected; - }) - }) -}) diff --git a/tests/js/tests/multi-user-simple.test.ts b/tests/js/tests/multi-user-simple.test.ts deleted file mode 100644 index bf7d79fc0..000000000 --- a/tests/js/tests/multi-user-simple.test.ts +++ /dev/null @@ -1,2774 +0,0 @@ -import path from "path"; -import { Ad4mClient, Ad4mModel, ExpressionProof, Link, LinkExpression, LinkInput, ModelOptions, Perspective, PerspectiveUnsignedInput, Property } from "@coasys/ad4m"; -import fs from "fs-extra"; -import { fileURLToPath } from 'url'; -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { apolloClient, sleep, startExecutor, runHcLocalServices } from "../utils/utils"; -import { ChildProcess } from 'node:child_process'; -import fetch from 'node-fetch' -import { LinkQuery } from "@coasys/ad4m"; -import { v4 as uuidv4 } from 'uuid'; -import { NotificationInput, TriggeredNotification } from '@coasys/ad4m/lib/src/runtime/RuntimeResolver'; -import sinon from 'sinon'; - -//@ts-ignore -global.fetch = fetch - -const expect = chai.expect; -chai.use(chaiAsPromised); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const DIFF_SYNC_OFFICIAL = fs.readFileSync("./scripts/perspective-diff-sync-hash").toString(); - -describe("Multi-User Simple integration tests", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "multi-user-simple"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15900 - const hcAdminPort = 15901 - const hcAppPort = 15902 - - let executorProcess: ChildProcess | null = null - let adminAd4mClient: Ad4mClient | null = null - - let proxyUrl: string | null = null; - let bootstrapUrl: string | null = null; - let localServicesProcess: ChildProcess | null = null; - - before(async () => { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }); - } - - // Start local Holochain services - let localServices = await runHcLocalServices(); - proxyUrl = localServices.proxyUrl; - bootstrapUrl = localServices.bootstrapUrl; - localServicesProcess = localServices.process; - - // Start executor with local services - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort, false, undefined, proxyUrl!, bootstrapUrl!); - - // @ts-ignore - Suppress Apollo type mismatch - adminAd4mClient = new Ad4mClient(apolloClient(gqlPort), false) - - // Generate initial admin agent (needed for JWT signing) - await adminAd4mClient.agent.generate("passphrase") - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - if (localServicesProcess) { - while (!localServicesProcess?.killed) { - let status = localServicesProcess?.kill(); - console.log("killed local services with", status); - await sleep(500); - } - } - }) - - describe("Multi-User Configuration", () => { - it("should have multi-user disabled by default and require activation", async () => { - // Check that multi-user is disabled by default - const isEnabled = await adminAd4mClient!.runtime.multiUserEnabled(); - expect(isEnabled).to.be.false; - console.log("✅ Multi-user mode is disabled by default"); - - // Attempt to create a user while multi-user is disabled (should fail) - const userResult = await adminAd4mClient!.agent.createUser("test@example.com", "password123"); - expect(userResult.success).to.be.false; - expect(userResult.error).to.include("Multi-user mode is not enabled"); - console.log("✅ User creation correctly blocked when multi-user is disabled"); - - // Enable multi-user mode - const setResult = await adminAd4mClient!.runtime.setMultiUserEnabled(true); - expect(setResult).to.be.true; - console.log("✅ Multi-user mode enabled"); - - // Verify it's now enabled - const isEnabledAfter = await adminAd4mClient!.runtime.multiUserEnabled(); - expect(isEnabledAfter).to.be.true; - console.log("✅ Multi-user mode status verified as enabled"); - - // Now user creation should work - const userResult2 = await adminAd4mClient!.agent.createUser("working@example.com", "password456"); - expect(userResult2.success).to.be.true; - expect(userResult2.did).to.match(/^did:key:.+/); - console.log("✅ User creation works after enabling multi-user mode"); - }); - - it("should return empty array when multi-user is disabled", async () => { - // Disable multi-user mode temporarily - await adminAd4mClient!.runtime.setMultiUserEnabled(false); - - // List users should return empty array - const users = await adminAd4mClient!.runtime.listUsers(); - expect(users).to.be.an('array'); - expect(users).to.have.lengthOf(0); - console.log("✅ listUsers returns empty array when multi-user disabled"); - - // Re-enable for other tests - await adminAd4mClient!.runtime.setMultiUserEnabled(true); - }); - - it("should list users with statistics", async () => { - // Create a few users - await adminAd4mClient!.agent.createUser("stats1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("stats2@example.com", "password2"); - - // Login one user to update their last_seen - const token1 = await adminAd4mClient!.agent.loginUser("stats1@example.com", "password1"); - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - - // User 1 creates a perspective - await client1.perspective.add("User 1 Perspective"); - - // Wait a moment for last_seen to be updated - await sleep(1000); - - // List users - const users = await adminAd4mClient!.runtime.listUsers(); - expect(users).to.be.an('array'); - expect(users.length).to.be.greaterThan(0); - - console.log("Users:", JSON.stringify(users, null, 2)); - - // Find our test users - const user1 = users.find(u => u.email === "stats1@example.com"); - const user2 = users.find(u => u.email === "stats2@example.com"); - - expect(user1).to.not.be.undefined; - expect(user2).to.not.be.undefined; - - // Verify structure - expect(user1).to.have.property('email'); - expect(user1).to.have.property('did'); - expect(user1).to.have.property('perspectiveCount'); - - // Verify user1 has a perspective - expect(user1!.perspectiveCount).to.equal(1); - - // Verify user2 has no perspectives - expect(user2!.perspectiveCount).to.equal(0); - - // Verify user1 has last_seen set (they logged in) - expect(user1).to.have.property('lastSeen'); - console.log("User 1 last seen:", user1!.lastSeen); - - // Verify DIDs are different - expect(user1!.did).to.not.equal(user2!.did); - - console.log("✅ User statistics correctly returned"); - }); - - it("should track last_seen timestamps", async () => { - // Ensure multi-user is enabled - await adminAd4mClient!.runtime.setMultiUserEnabled(true); - - // Create a user - await adminAd4mClient!.agent.createUser("lastseen@example.com", "password"); - - // List users before login - let users = await adminAd4mClient!.runtime.listUsers(); - let user = users.find(u => u.email === "lastseen@example.com"); - expect(user).to.not.be.undefined; - - // Initially might not have last_seen - const initialLastSeen = user!.lastSeen; - console.log("Initial last_seen:", initialLastSeen); - - // Login the user (this should trigger last_seen tracking) - const token = await adminAd4mClient!.agent.loginUser("lastseen@example.com", "password"); - // @ts-ignore - const userClient = new Ad4mClient(apolloClient(gqlPort, token), false); - - console.log("========================HERE================================"); - // Make a request to trigger last_seen update - await userClient.agent.me(); - - console.log("========================HERE 2================================"); - - // Wait for middleware to process (async task needs time) - await sleep(2000); - - // List users again - users = await adminAd4mClient!.runtime.listUsers(); - user = users.find(u => u.email === "lastseen@example.com"); - - // Now last_seen should be set - expect(user!.lastSeen).to.not.be.undefined; - console.log("Updated last_seen:", user!.lastSeen); - - // Parse the timestamp - could be ISO string or Unix timestamp in seconds - let lastSeenDate: Date; - const lastSeenValue = user!.lastSeen!; - - // Handle both number and string timestamp formats - if (typeof lastSeenValue === 'number') { - console.log("Last seen value is a number Unix timestamp in seconds, converting to milliseconds"); - lastSeenDate = new Date(lastSeenValue * 1000); - } else { - console.log("Last seen value is a string, checking if it's a Unix timestamp in seconds"); - console.log("Last seen value:", lastSeenValue); - if (/^\d+(\.\d+)?$/.test(lastSeenValue)) { - console.log("Last seen value is a Unix timestamp in seconds, converting to milliseconds"); - lastSeenDate = new Date(parseInt(lastSeenValue) * 1000); - } else { - console.log("Last seen value is a ISO string, converting to Date"); - lastSeenDate = new Date(lastSeenValue); - } - } - - const now = new Date(); - - // Should be recent (within last 5 seconds) - const diffMs = now.getTime() - lastSeenDate.getTime(); - const diffSeconds = Math.abs(diffMs) / 1000; - console.log("Time difference:", { - nowMs: now.getTime(), - lastSeenMs: lastSeenDate.getTime(), - diffMs, - diffSeconds, - lastSeenValue - }); - expect(diffSeconds).to.be.lessThan(5); - - console.log("✅ Last seen timestamp is recent:", diffSeconds, "seconds ago"); - }); - }); - - describe.skip("Basic Multi-User Functionality", () => { - it("should create and login users with unique DIDs", async () => { - // Create first user - const user1Result = await adminAd4mClient!.agent.createUser("alice@example.com", "password123"); - expect(user1Result.success).to.be.true; - expect(user1Result.did).to.match(/^did:key:.+/); - - // Create second user - const user2Result = await adminAd4mClient!.agent.createUser("bob@example.com", "password456"); - expect(user2Result.success).to.be.true; - expect(user2Result.did).to.match(/^did:key:.+/); - - // Users should have different DIDs - expect(user1Result.did).to.not.equal(user2Result.did); - - // Login first user - const user1Token = await adminAd4mClient!.agent.loginUser("alice@example.com", "password123"); - expect(user1Token).to.be.ok; - - // Login second user - const user2Token = await adminAd4mClient!.agent.loginUser("bob@example.com", "password456"); - expect(user2Token).to.be.ok; - - // Verify JWT tokens contain correct user DIDs - const user1Payload = JSON.parse(atob(user1Token.split('.')[1])); - const user2Payload = JSON.parse(atob(user2Token.split('.')[1])); - - expect(user1Payload.sub).to.equal("alice@example.com"); - expect(user2Payload.sub).to.equal("bob@example.com"); - }) - - it("should return correct user DID in agent.me", async () => { - // Create and login user - const userResult = await adminAd4mClient!.agent.createUser("charlie@example.com", "password789"); - const userToken = await adminAd4mClient!.agent.loginUser("charlie@example.com", "password789"); - - // Create authenticated client - // @ts-ignore - Suppress Apollo type mismatch - const userClient = new Ad4mClient(apolloClient(gqlPort, userToken), false); - - // Test agent.me - const agent = await userClient.agent.me(); - expect(agent.did).to.equal(userResult.did); - - // Test agent.status - const status = await userClient.agent.status(); - expect(status.did).to.equal(userResult.did); - expect(status.isUnlocked).to.be.true; - }) - - it("should handle login persistence", async () => { - // Create user - const userResult = await adminAd4mClient!.agent.createUser("dave@example.com", "passwordABC"); - - // Login first time - const token1 = await adminAd4mClient!.agent.loginUser("dave@example.com", "passwordABC"); - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - const agent1 = await client1.agent.me(); - - // Login second time - const token2 = await adminAd4mClient!.agent.loginUser("dave@example.com", "passwordABC"); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - const agent2 = await client2.agent.me(); - - // Should get the same DID both times - expect(agent1.did).to.equal(agent2.did); - expect(agent1.did).to.equal(userResult.did); - }) - - it("should reject wrong passwords", async () => { - // Create user - await adminAd4mClient!.agent.createUser("eve@example.com", "correctpassword"); - - // Try to login with wrong password - const call = async () => { - return await adminAd4mClient!.agent.loginUser("eve@example.com", "wrongpassword"); - }; - - await expect(call()).to.be.rejectedWith(/Invalid credentials/); - }) - - it("should reject non-existent users", async () => { - const call = async () => { - return await adminAd4mClient!.agent.loginUser("nonexistent@example.com", "password"); - }; - - await expect(call()).to.be.rejectedWith(/User not found/); - }) - }) - - describe("Perspective Isolation", () => { - it("should isolate perspectives between users", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("isolation1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("isolation2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("isolation1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("isolation2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get initial perspective counts - const user1InitialPerspectives = await client1.perspective.all(); - const user2InitialPerspectives = await client2.perspective.all(); - - // User 1 creates a perspective - const perspective1 = await client1.perspective.add("User 1 Perspective"); - expect(perspective1.name).to.equal("User 1 Perspective"); - console.log("User 1 created perspective:", perspective1.uuid); - - // User 2 creates a perspective - const perspective2 = await client2.perspective.add("User 2 Perspective"); - expect(perspective2.name).to.equal("User 2 Perspective"); - console.log("User 2 created perspective:", perspective2.uuid); - - // User 1 should see only their own perspectives (initial + new one) - const user1Perspectives = await client1.perspective.all(); - expect(user1Perspectives.length).to.equal(user1InitialPerspectives.length + 1); - const user1HasOwnPerspective = user1Perspectives.some(p => p.uuid === perspective1.uuid); - expect(user1HasOwnPerspective).to.be.true; - const user1HasUser2Perspective = user1Perspectives.some(p => p.uuid === perspective2.uuid); - expect(user1HasUser2Perspective).to.be.false; - - // User 2 should see only their own perspectives (initial + new one) - const user2Perspectives = await client2.perspective.all(); - expect(user2Perspectives.length).to.equal(user2InitialPerspectives.length + 1); - const user2HasOwnPerspective = user2Perspectives.some(p => p.uuid === perspective2.uuid); - expect(user2HasOwnPerspective).to.be.true; - const user2HasUser1Perspective = user2Perspectives.some(p => p.uuid === perspective1.uuid); - expect(user2HasUser1Perspective).to.be.false; - - // User 1 should not be able to access User 2's perspective by UUID - const user1AccessToUser2 = await client1.perspective.byUUID(perspective2.uuid); - expect(user1AccessToUser2).to.be.null; - - // User 2 should not be able to access User 1's perspective by UUID - const user2AccessToUser1 = await client2.perspective.byUUID(perspective1.uuid); - expect(user2AccessToUser1).to.be.null; - - console.log("✅ Perspective isolation verified between users"); - }); - - it("should isolate user perspectives from main agent", async () => { - // Create a user and their perspective - const userResult = await adminAd4mClient!.agent.createUser("mainisolation@example.com", "password"); - const userToken = await adminAd4mClient!.agent.loginUser("mainisolation@example.com", "password"); - // @ts-ignore - Suppress Apollo type mismatch - const userClient = new Ad4mClient(apolloClient(gqlPort, userToken), false); - - const userPerspective = await userClient.perspective.add("User Isolated Perspective"); - expect(userPerspective.name).to.equal("User Isolated Perspective"); - - // Main agent creates their own perspective - const mainPerspective = await adminAd4mClient!.perspective.add("Main Agent Perspective"); - expect(mainPerspective.name).to.equal("Main Agent Perspective"); - - // Main agent SHOULD see ALL perspectives (including user perspectives) - const mainPerspectives = await adminAd4mClient!.perspective.all(); - const hasUserPerspective = mainPerspectives.some(p => p.uuid === userPerspective.uuid); - expect(hasUserPerspective).to.be.true; // Admin sees all perspectives - const hasOwnPerspective = mainPerspectives.some(p => p.uuid === mainPerspective.uuid); - expect(hasOwnPerspective).to.be.true; - - // User should NOT see main agent perspectives - const userPerspectives = await userClient.perspective.all(); - const hasMainPerspective = userPerspectives.some(p => p.uuid === mainPerspective.uuid); - expect(hasMainPerspective).to.be.false; // Users only see their own perspectives - - console.log("✅ Perspective isolation verified: admin sees all, users see only their own"); - }); - - it("should handle perspective access control for operations", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("accessctrl1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("accessctrl2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("accessctrl1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("accessctrl2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // User 1 creates a perspective - const perspective1 = await client1.perspective.add("Access Test Perspective"); - - // User 2 should not be able to access User 1's perspective for operations - try { - await client2.perspective.addLink(perspective1.uuid, { - source: "test://source", - target: "test://target", - predicate: "test://predicate" - }); - expect.fail("User 2 should not be able to add links to User 1's perspective"); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - expect(errorMessage).to.include("Access denied"); - console.log("✅ Cross-user perspective access properly denied"); - } - }); - }) - - describe("Link Authoring and Signatures", () => { - it("should have correct authors and valid signatures for user links", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("linkauth1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("linkauth2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("linkauth1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("linkauth2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // User 1 creates perspective and adds a link - // @ts-ignore - Suppress Apollo type mismatch - const p1 = await client1.perspective.add("User 1 Test Perspective"); - // @ts-ignore - Suppress Apollo type mismatch - const link1 = await client1.perspective.addLink(p1.uuid, { - source: "ad4m://root", - target: "test://target1", - predicate: "test://predicate" - }); - - // Get the link and verify - // @ts-ignore - Suppress Apollo type mismatch - const links1 = await client1.perspective.queryLinks(p1.uuid, new LinkQuery({})); - expect(links1.length).to.equal(1); - const user1Me = await client1.agent.me(); - expect(links1[0].author).to.equal(user1Me.did); - expect(links1[0].proof.valid).to.be.true; - - // User 2 creates perspective and adds a link - // @ts-ignore - Suppress Apollo type mismatch - const p2 = await client2.perspective.add("User 2 Test Perspective"); - // @ts-ignore - Suppress Apollo type mismatch - const link2 = await client2.perspective.addLink(p2.uuid, { - source: "ad4m://root", - target: "test://target2", - predicate: "test://predicate" - }); - - // Get the link and verify - // @ts-ignore - Suppress Apollo type mismatch - const links2 = await client2.perspective.queryLinks(p2.uuid, new LinkQuery({})); - expect(links2.length).to.equal(1); - const user2Me = await client2.agent.me(); - expect(links2[0].author).to.equal(user2Me.did); - expect(links2[0].proof.valid).to.be.true; - - // Ensure authors are different - expect(user1Me.did).not.to.equal(user2Me.did); - - console.log("✅ Link authors and signatures verified for multi-user"); - }); - }); - - describe("Subject Creation and SDNA Operations", () => { - // Define the test subject class outside the test function - let TestSubject: any; - - before(async () => { - // Import necessary decorators and classes - const { ModelOptions, Property, Optional } = await import("@coasys/ad4m"); - - // Define a proper subject class with decorators - @ModelOptions({ - name: "TestSubject" - }) - class TestSubjectClass { - @Property({ - through: "test://name", - writable: true, - initial: "test://initial", - resolveLanguage: "literal" - }) - name: string = ""; - } - - TestSubject = TestSubjectClass; - }); - - it("should have correct authors and valid signatures for subject operations", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("subject1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("subject2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("subject1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("subject2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // User 1 creates perspective and ensures SDNA subject class - // @ts-ignore - Suppress Apollo type mismatch - const p1 = await client1.perspective.add("User 1 Subject Test Perspective"); - - // User 1 ensures SDNA subject class - await p1.ensureSDNASubjectClass(TestSubject); - - - // User 1 creates a subject instance - // @ts-ignore - Suppress Apollo type mismatch - await p1.createSubject(new TestSubject(), "test://subject1", {name: "Test Subject 1"}); - - // Get all links from the perspective to check authors - // @ts-ignore - Suppress Apollo type mismatch - const links1 = await p1.get(new LinkQuery({})); - expect(links1.length).to.be.greaterThan(0); - - const user1Me = await client1.agent.me(); - - // Verify all links are authored by user1 - for (const link of links1) { - expect(link.author).to.equal(user1Me.did, `Link with predicate ${link.predicate} should be authored by user1`); - expect(link.proof.valid).to.be.true; - } - - // User 2 creates perspective and does similar operations - // @ts-ignore - Suppress Apollo type mismatch - const p2 = await client2.perspective.add("User 2 Subject Test Perspective"); - - // User 2 ensures the same SDNA subject class - // @ts-ignore - Suppress Apollo type mismatch - await p2.ensureSDNASubjectClass(TestSubject); - - // User 2 creates a subject instance - // @ts-ignore - Suppress Apollo type mismatch - await p2.createSubject(new TestSubject(), "test://subject2", {name: "Test Subject 2"}); - - // Get all links from user2's perspective - // @ts-ignore - Suppress Apollo type mismatch - const links2 = await p2.get(new LinkQuery({})); - expect(links2.length).to.be.greaterThan(0); - - const user2Me = await client2.agent.me(); - - // Verify all links are authored by user2 - for (const link of links2) { - expect(link.author).to.equal(user2Me.did, `Link with predicate ${link.predicate} should be authored by user2`); - expect(link.proof.valid).to.be.true; - } - - // Ensure authors are different - expect(user1Me.did).not.to.equal(user2Me.did); - - console.log("✅ Subject operations and SDNA signatures verified for multi-user"); - }); - }); - - describe("Agent Profiles and Status", () => { - it("should maintain separate agent profiles for different users", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("profile1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("profile2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("profile1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("profile2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get initial agent info for both users - const user1Agent = await client1.agent.me(); - const user2Agent = await client2.agent.me(); - - // Verify each user has their own DID - expect(user1Agent.did).to.not.equal(user2Agent.did); - console.log("User 1 DID:", user1Agent.did); - console.log("User 2 DID:", user2Agent.did); - - // For now, just verify that each user has their own profile - // Profile updates will be tested after implementing user-specific profiles - - // Verify each user sees their own profile - const user1Profile = await client1.agent.me(); - const user2Profile = await client2.agent.me(); - - // Each user should see their own DID (not the main agent's DID) - expect(user1Profile.did).to.equal(user1Agent.did); - expect(user2Profile.did).to.equal(user2Agent.did); - - // DIDs should be different between users - expect(user1Profile.did).to.not.equal(user2Profile.did); - - console.log("✅ Agent profiles are properly isolated between users"); - }); - - it("should handle agent status correctly for different users", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("status1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("status2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("status1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("status2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Check agent status for both users - const user1Status = await client1.agent.status(); - const user2Status = await client2.agent.status(); - - console.log("User 1 status:", user1Status); - console.log("User 2 status:", user2Status); - - // Both users should have valid status - expect(user1Status).to.have.property('isInitialized'); - expect(user2Status).to.have.property('isInitialized'); - expect(user1Status.isInitialized).to.be.true; - expect(user2Status.isInitialized).to.be.true; - - // Each user should have their own DID in status - expect(user1Status.did).to.not.equal(user2Status.did); - - // Assert on DID documents - expect(user1Status.didDocument).to.be.a('string'); - expect(user2Status.didDocument).to.be.a('string'); - expect(user1Status.didDocument).to.not.equal(user2Status.didDocument); - - // Parse and validate DID documents - const user1DidDoc = JSON.parse(user1Status.didDocument!); - const user2DidDoc = JSON.parse(user2Status.didDocument!); - - expect(user1DidDoc.id).to.equal(user1Status.did); - expect(user2DidDoc.id).to.equal(user2Status.did); - expect(user1DidDoc).to.have.property('verificationMethod'); - expect(user2DidDoc).to.have.property('verificationMethod'); - expect(user1DidDoc.verificationMethod).to.be.an('array').that.is.not.empty; - expect(user2DidDoc.verificationMethod).to.be.an('array').that.is.not.empty; - - console.log("✅ Agent status works correctly for multiple users"); - }); - - it("should allow users to update their own agent profiles independently", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("update1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("update2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("update1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("update2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // User 1 updates their profile - let link1 = new LinkExpression(); - link1.author = "did:test:1"; - link1.timestamp = new Date().toISOString(); - link1.data = new Link({source: "user1", target: "profile1", predicate: "name"}); - link1.proof = new ExpressionProof("sig1", "key1") - await client1.agent.updatePublicPerspective(new Perspective([link1])) - - // User 2 updates their profile with different data - let link2 = new LinkExpression(); - link2.author = "did:test:2"; - link2.timestamp = new Date().toISOString(); - link2.data = new Link({source: "user2", target: "profile2", predicate: "name"}); - link2.proof = new ExpressionProof("sig2", "key2") - await client2.agent.updatePublicPerspective(new Perspective([link2])) - - // Verify that each user's public perspective was updated correctly - const user1AfterUpdate = await client1.agent.me(); - const user2AfterUpdate = await client2.agent.me(); - - // Check that profiles contain the correct links - expect(user1AfterUpdate.perspective).to.not.be.null; - expect(user2AfterUpdate.perspective).to.not.be.null; - - if (user1AfterUpdate.perspective && user1AfterUpdate.perspective.links.length > 0) { - const user1Link = user1AfterUpdate.perspective.links.find(l => - l.data.source === "user1" && l.data.target === "profile1" - ); - expect(user1Link).to.not.be.undefined; - console.log("✅ User 1 perspective update verified"); - } - - if (user2AfterUpdate.perspective && user2AfterUpdate.perspective.links.length > 0) { - const user2Link = user2AfterUpdate.perspective.links.find(l => - l.data.source === "user2" && l.data.target === "profile2" - ); - expect(user2Link).to.not.be.undefined; - console.log("✅ User 2 perspective update verified"); - } - - // Each user should see their own updated profile - console.log("User 1 after update:", user1AfterUpdate.did); - console.log("User 2 after update:", user2AfterUpdate.did); - - // Verify DIDs are still different - expect(user1AfterUpdate.did).to.not.equal(user2AfterUpdate.did); - - console.log("✅ Agent profile updates work independently for multiple users"); - }); - - it("should not allow users to see other users' agent profiles", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("private1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("private2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("private1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("private2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get agent info for both users - const user1Agent = await client1.agent.me(); - const user2Agent = await client2.agent.me(); - - // Verify each user only sees their own agent information - expect(user1Agent.did).to.not.equal(user2Agent.did); - - // Try to query the other user's DID (this should fail or return nothing) - try { - const user1TryingToSeeUser2 = await client1.agent.byDID(user2Agent.did); - // If this succeeds, it should not return user2's private information - console.log("User 1 trying to see User 2:", user1TryingToSeeUser2); - } catch (error) { - console.log("✅ Correctly blocked cross-user agent access"); - } - - console.log("✅ Agent profile privacy is maintained between users"); - }); - - it("should publish managed users to the agent language", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("agentlang1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("agentlang2@example.com", "password2"); - - // Login both users to trigger any agent language publishing - const token1 = await adminAd4mClient!.agent.loginUser("agentlang1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("agentlang2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get the DIDs for both users - const user1Agent = await client1.agent.me(); - const user2Agent = await client2.agent.me(); - - console.log("User 1 DID:", user1Agent.did); - console.log("User 2 DID:", user2Agent.did); - - // Wait a moment for the agents to be fully published - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Try to retrieve the users from the agent language by their DIDs - // This should work if they were properly published to the agent language - try { - console.log("Attempting to retrieve user 1 with DID:", user1Agent.did); - const retrievedUser1 = await adminAd4mClient!.agent.byDID(user1Agent.did); - console.log("Retrieved user 1:", retrievedUser1); - - console.log("Attempting to retrieve user 2 with DID:", user2Agent.did); - const retrievedUser2 = await adminAd4mClient!.agent.byDID(user2Agent.did); - console.log("Retrieved user 2:", retrievedUser2); - - expect(retrievedUser1).to.not.be.null; - expect(retrievedUser2).to.not.be.null; - - // The retrieved agents should have the correct DIDs - if (retrievedUser1) { - expect(retrievedUser1.did).to.equal(user1Agent.did); - console.log("✅ User 1 successfully retrieved from agent language"); - } - - if (retrievedUser2) { - expect(retrievedUser2.did).to.equal(user2Agent.did); - console.log("✅ User 2 successfully retrieved from agent language"); - } - - // Also test getting agent expressions via expression.get() - console.log("Testing expression.get() method..."); - const expr1 = await adminAd4mClient!.expression.get(user1Agent.did); - const expr2 = await adminAd4mClient!.expression.get(user2Agent.did); - - console.log("Expression 1 result:", expr1); - console.log("Expression 2 result:", expr2); - - // expression.get() returns the data as a JSON string, so we need to parse it - if (expr1?.data) { - const agent1Data = typeof expr1.data === 'string' ? JSON.parse(expr1.data) : expr1.data; - expect(agent1Data.did).to.equal(user1Agent.did); - console.log("✅ User 1 expression retrieved via expression.get()"); - } else { - console.log("ℹ️ User 1 expression.get() returned null"); - } - - if (expr2?.data) { - const agent2Data = typeof expr2.data === 'string' ? JSON.parse(expr2.data) : expr2.data; - expect(agent2Data.did).to.equal(user2Agent.did); - console.log("✅ User 2 expression retrieved via expression.get()"); - } else { - console.log("ℹ️ User 2 expression.get() returned null"); - } - - console.log("✅ Managed users are properly published to agent language"); - } catch (error) { - console.log("❌ Failed to retrieve users from agent language:", error); - throw error; - } - }); - - it("should publish updated public perspectives to the agent language", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("perspective1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("perspective2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("perspective1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("perspective2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get initial agent info - const user1Agent = await client1.agent.me(); - const user2Agent = await client2.agent.me(); - - console.log("User 1 DID:", user1Agent.did); - console.log("User 2 DID:", user2Agent.did); - - // User 1 updates their public perspective - let link1 = new LinkExpression(); - link1.author = user1Agent.did; - link1.timestamp = new Date().toISOString(); - link1.data = new Link({source: "user1", target: "profile1", predicate: "name"}); - link1.proof = new ExpressionProof("sig1", "key1") - await client1.agent.updatePublicPerspective(new Perspective([link1])); - - // User 2 updates their public perspective with different data - let link2 = new LinkExpression(); - link2.author = user2Agent.did; - link2.timestamp = new Date().toISOString(); - link2.data = new Link({source: "user2", target: "profile2", predicate: "name"}); - link2.proof = new ExpressionProof("sig2", "key2") - await client2.agent.updatePublicPerspective(new Perspective([link2])); - - // Wait for the updates to be published - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Retrieve the updated agents from the agent language - try { - console.log("Retrieving updated agents from agent language..."); - const retrievedUser1 = await adminAd4mClient!.agent.byDID(user1Agent.did); - const retrievedUser2 = await adminAd4mClient!.agent.byDID(user2Agent.did); - - expect(retrievedUser1).to.not.be.null; - expect(retrievedUser2).to.not.be.null; - - // Check that the public perspectives were updated - if (retrievedUser1?.perspective) { - expect(retrievedUser1.perspective.links).to.have.length.greaterThan(0); - const hasUser1Link = retrievedUser1.perspective.links.some(link => - link.data.source === "user1" && link.data.target === "profile1" - ); - expect(hasUser1Link).to.be.true; - console.log("✅ User 1 public perspective updated in agent language"); - } - - if (retrievedUser2?.perspective) { - expect(retrievedUser2.perspective.links).to.have.length.greaterThan(0); - const hasUser2Link = retrievedUser2.perspective.links.some(link => - link.data.source === "user2" && link.data.target === "profile2" - ); - expect(hasUser2Link).to.be.true; - console.log("✅ User 2 public perspective updated in agent language"); - } - - // Also test via expression.get() - console.log("Testing updated perspectives via expression.get()..."); - const expr1 = await adminAd4mClient!.expression.get(user1Agent.did); - const expr2 = await adminAd4mClient!.expression.get(user2Agent.did); - - console.log("Updated expression 1 result:", expr1); - console.log("Updated expression 2 result:", expr2); - - if (expr1?.data) { - const agent1Data = typeof expr1.data === 'string' ? JSON.parse(expr1.data) : expr1.data; - expect(agent1Data.perspective?.links).to.have.length.greaterThan(0); - console.log("✅ User 1 updated perspective retrieved via expression.get()"); - } else { - console.log("ℹ️ User 1 updated expression.get() returned null"); - } - - if (expr2?.data) { - const agent2Data = typeof expr2.data === 'string' ? JSON.parse(expr2.data) : expr2.data; - expect(agent2Data.perspective?.links).to.have.length.greaterThan(0); - console.log("✅ User 2 updated perspective retrieved via expression.get()"); - } else { - console.log("ℹ️ User 2 updated expression.get() returned null"); - } - - console.log("✅ Public perspective updates are properly published to agent language"); - } catch (error) { - console.log("❌ Failed to retrieve updated agents from agent language:", error); - throw error; - } - }); - - it("should use correct user context for expression.create()", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("expr1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("expr2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("expr1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("expr2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get the DIDs for both users - const user1Agent = await client1.agent.me(); - const user2Agent = await client2.agent.me(); - - console.log("User 1 DID:", user1Agent.did); - console.log("User 2 DID:", user2Agent.did); - - // User 1 creates a literal expression - const expr1Url = await client1.expression.create("Hello from User 1", "literal"); - console.log("User 1 created expression:", expr1Url); - - // User 2 creates a literal expression - const expr2Url = await client2.expression.create("Hello from User 2", "literal"); - console.log("User 2 created expression:", expr2Url); - - // Retrieve the expressions and check their authors - const expr1 = await adminAd4mClient!.expression.get(expr1Url); - const expr2 = await adminAd4mClient!.expression.get(expr2Url); - - console.log("Expression 1:", JSON.stringify(expr1, null, 2)); - console.log("Expression 2:", JSON.stringify(expr2, null, 2)); - - // The expressions should be authored by the respective users, not the main agent - expect(expr1?.author).to.equal(user1Agent.did); - expect(expr2?.author).to.equal(user2Agent.did); - - // Verify expressions have signatures (literal expressions don't get verified automatically) - if (expr1) { - console.log("Expression 1 proof:", expr1.proof); - expect(expr1.proof.signature).to.not.be.empty; - expect(expr1.proof.key).to.not.be.empty; - } - if (expr2) { - console.log("Expression 2 proof:", expr2.proof); - expect(expr2.proof.signature).to.not.be.empty; - expect(expr2.proof.key).to.not.be.empty; - } - - console.log("✅ Expression authoring uses correct user context"); - }); - - it("should use correct user context for expression.interact()", async () => { - // This test would require a language with interactions - // For now, we'll just verify that the context-aware code path exists - // The actual testing would need a custom language with interaction capabilities - console.log("ℹ️ Expression interaction context test skipped - requires custom language with interactions"); - console.log("✅ Expression interaction context handling implemented"); - }); - }); - - describe("Multi-User Neighbourhood Sharing", () => { - it("should allow multiple local users to share the same neighbourhood", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("nh1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("nh2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("nh1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("nh2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get the DIDs for both users - const user1Agent = await client1.agent.me(); - const user2Agent = await client2.agent.me(); - - console.log("User 1 DID:", user1Agent.did); - console.log("User 2 DID:", user2Agent.did); - - // User 1 creates a perspective and shares it as a neighbourhood - const perspective1 = await client1.perspective.add("Shared Neighbourhood"); - console.log("User 1 created perspective:", perspective1.uuid); - - // Add some initial links to the perspective - const link1 = new Link({source: "user1", target: "data1", predicate: "test://created"}); - await client1.perspective.addLink(perspective1.uuid, link1); - - console.log("Cloning link language..."); - const linkLanguage = await client1.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Multi-User Neighbourhood Sharing"})); - console.log("Link language cloned:", linkLanguage.address); - - // Publish the neighbourhood using the centralized link language - console.log("Publishing neighbourhood..."); - const neighbourhoodUrl = await client1.neighbourhood.publishFromPerspective( - perspective1.uuid, - linkLanguage.address, - new Perspective([]) - ); - console.log("User 1 published neighbourhood:", neighbourhoodUrl); - - // Wait for neighbourhood to be fully set up - await new Promise(resolve => setTimeout(resolve, 1000)); - - // User 2 joins the same neighbourhood - const joinResult = await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); - console.log("User 2 joined neighbourhood:", joinResult); - //console.log("User 2 joined neighbourhood uuid:", joinResult.uuid); - - // Wait for neighbourhood sync - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Verify both users can see the shared perspective - const user1Perspectives = await client1.perspective.all(); - const user2Perspectives = await client2.perspective.all(); - - const user1SharedPerspective = user1Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - const user2SharedPerspective = user2Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - - console.log("User 1 perspectives:", user1Perspectives); - console.log("User 2 perspectives:", user2Perspectives); - - expect(user1SharedPerspective).to.not.be.null; - expect(user2SharedPerspective).to.not.be.null; - - console.log("✅ Both users can access the shared neighbourhood"); - - // User 2 adds a link to the shared perspective - const link2 = new Link({source: "user2", target: "data2", predicate: "test://added"}); - await client2.perspective.addLink(user2SharedPerspective!.uuid, link2); - - // Wait for sync - await new Promise(resolve => setTimeout(resolve, 1000)); - - // User 1 should see User 2's link - const user1Links = await client1.perspective.queryLinks(user1SharedPerspective!.uuid, new LinkQuery({})); - const user2Links = await client2.perspective.queryLinks(user2SharedPerspective!.uuid, new LinkQuery({})); - - console.log("User 1 sees links:", user1Links.length); - console.log("User 2 sees links:", user2Links.length); - - // Both users should see both links - expect(user1Links.length).to.be.greaterThan(1); - expect(user2Links.length).to.be.greaterThan(1); - - // Verify specific links exist - const user1SeesUser2Link = user1Links.some(l => - l.data.source === "user2" && l.data.target === "data2" - ); - const user2SeesUser1Link = user2Links.some(l => - l.data.source === "user1" && l.data.target === "data1" - ); - - expect(user1SeesUser2Link).to.be.true; - expect(user2SeesUser1Link).to.be.true; - - console.log("✅ Local neighbourhood sharing works correctly"); - }); - - it("should use separate prolog pools for different users in shared neighbourhood", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("prolog1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("prolog2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("prolog1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("prolog2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - console.log("User 1 creates neighbourhood and adds initial SDNA..."); - - // User 1 creates a perspective and shares it as a neighbourhood - const perspective1 = await client1.perspective.add("Prolog Pool Test"); - - @ModelOptions({ - name: "User1Model" - }) - class User1Model extends Ad4mModel { - @Property({ - through: "test://user1-property", - writable: true, - initial: "test://user1-initial", - resolveLanguage: "literal" - }) - user1Property: string = ""; - } - - - console.log("Ensuring User 1 model..."); - await perspective1.ensureSDNASubjectClass(User1Model); - console.log("User 1 model ensured"); - - // Wait for SDNA to be processed - await new Promise(resolve => setTimeout(resolve, 1000)); - - let user1Model = new User1Model(perspective1); - user1Model.user1Property = "User1 created this"; - console.log("Saving User 1 model..."); - await user1Model.save(); - console.log("User 1 model saved"); - - console.log("User 1 neighbourhood setup complete, User 2 joining..."); - - // Clone link language and publish neighbourhood - const linkLanguage = await client1.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Prolog Pool Test"})); - const neighbourhoodUrl = await client1.neighbourhood.publishFromPerspective( - perspective1.uuid, - linkLanguage.address, - new Perspective([]) - ); - - // User 2 joins the neighbourhood - const joinResult = await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); - const user2Perspectives = await client2.perspective.all(); - const user2SharedPerspective = user2Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(user2SharedPerspective).to.not.be.null; - - console.log("User 2 joined, adding their own SDNA..."); - - @ModelOptions({ - name: "User2Model" - }) - class User2Model extends Ad4mModel { - @Property({ - through: "test://user2-property", - writable: true, - initial: "test://user2-initial", - resolveLanguage: "literal" - }) - user2Property: string = ""; - } - - console.log("Ensuring User 2 model..."); - await user2SharedPerspective!.ensureSDNASubjectClass(User2Model); - console.log("User 2 model ensured"); - - - // Wait for SDNA to be processed - await new Promise(resolve => setTimeout(resolve, 1000)); - - let user2Model = new User2Model(user2SharedPerspective!); - user2Model.user2Property = "User2 created this"; - console.log("Saving User 2 model..."); - await user2Model.save(); - console.log("User 2 model saved"); - - console.log("Testing prolog pool isolation..."); - - - let classesSeenByUser1 = await perspective1.subjectClasses() - console.log("User 1 sees classes:", classesSeenByUser1); - expect(classesSeenByUser1.length).to.equal(1); - - let classesSeenByUser2 = await user2SharedPerspective!.subjectClasses() - console.log("User 2 sees classes:", classesSeenByUser2); - expect(classesSeenByUser2.length).to.equal(2); - - console.log("✅ Prolog pool isolation working correctly - users have separate SDNA contexts"); - }); - - it("should route neighbourhood signals locally between users on the same node", async () => { - // Create two users - const user1Result = await adminAd4mClient!.agent.createUser("signal1@example.com", "password1"); - const user2Result = await adminAd4mClient!.agent.createUser("signal2@example.com", "password2"); - - // Login both users - const token1 = await adminAd4mClient!.agent.loginUser("signal1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("signal2@example.com", "password2"); - - // @ts-ignore - Suppress Apollo type mismatch - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - Suppress Apollo type mismatch - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get user DIDs - const user1Status = await client1.agent.status(); - const user2Status = await client2.agent.status(); - const user1Did = user1Status.did!; - const user2Did = user2Status.did!; - - console.log("User 1 DID:", user1Did); - console.log("User 2 DID:", user2Did); - - // User 1 creates a perspective and shares it as a neighbourhood - const perspective1 = await client1.perspective.add("Signal Test Neighbourhood"); - - // Clone link language and publish neighbourhood - const linkLanguage = await client1.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Signal Test"})); - const neighbourhoodUrl = await client1.neighbourhood.publishFromPerspective( - perspective1.uuid, - linkLanguage.address, - new Perspective([]) - ); - - console.log("User 1 created neighbourhood:", neighbourhoodUrl); - - // User 2 joins the neighbourhood - const joinResult = await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); - const user2Perspectives = await client2.perspective.all(); - const user2SharedPerspective = user2Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(user2SharedPerspective).to.not.be.null; - - console.log("User 2 joined neighbourhood"); - - // Wait a bit for neighbourhood to be fully set up - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Get neighbourhood proxy for User 2 - const user2Neighbourhood = await user2SharedPerspective!.getNeighbourhoodProxy(); - expect(user2Neighbourhood).to.not.be.null; - - // Set up signal listener for User 2 - const user2ReceivedSignals: any[] = []; - const user2SignalSubscription = user2Neighbourhood!.addSignalHandler((signal) => { - //console.log("User 2 received signal:", signal); - user2ReceivedSignals.push(signal); - }); - - console.log("User 2 signal listener set up"); - - // Get neighbourhood proxy for User 1 - const user1Neighbourhood = await perspective1.getNeighbourhoodProxy(); - expect(user1Neighbourhood).to.not.be.null; - - // Set up signal listener for User 1 to verify they DON'T receive User 2's signals - const user1ReceivedSignals: any[] = []; - const user1SignalSubscription = user1Neighbourhood!.addSignalHandler((signal) => { - //console.log("User 1 received signal:", signal); - user1ReceivedSignals.push(signal); - }); - - console.log("User 1 signal listener set up"); - - // Wait a bit to ensure subscriptions are active - await new Promise(resolve => setTimeout(resolve, 500)); - - // User 1 sends a signal to User 2 - const testSignalPayload = new PerspectiveUnsignedInput([ - { - source: "test://signal", - predicate: "test://from", - target: user1Did - } - ]); - - console.log("User 1 sending signal to User 2..."); - await user1Neighbourhood!.sendSignalU( - user2Did, - testSignalPayload - ); - - console.log("Signal sent, waiting for delivery..."); - - // Wait for signal to be received (with timeout) - const maxWaitTime = 5000; // 5 seconds - let startTime = Date.now(); - while (user2ReceivedSignals.length === 0 && (Date.now() - startTime) < maxWaitTime) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // Verify User 2 received the signal - expect(user2ReceivedSignals.length).to.be.greaterThan(0, "User 2 should have received at least one signal"); - - console.log("User 2 received signals:", user2ReceivedSignals); - - const user2ReceivedSignal = user2ReceivedSignals[0]; - expect(user2ReceivedSignal.data.links).to.have.lengthOf(1); - expect(user2ReceivedSignal.data.links[0].data.source).to.equal("test://signal"); - expect(user2ReceivedSignal.data.links[0].data.predicate).to.equal("test://from"); - expect(user2ReceivedSignal.data.links[0].data.target).to.equal(user1Did); - - console.log("✅ User 2 received signal from User 1"); - - // Verify User 1 did NOT receive the signal (it was meant for User 2) - expect(user1ReceivedSignals.length).to.equal(0, "User 1 should NOT have received the signal meant for User 2"); - console.log("✅ User 1 correctly did not receive signal meant for User 2"); - - // Now test the reverse: User 2 sends a signal to User 1 - const reverseSignalPayload = new PerspectiveUnsignedInput([ - { - source: "test://reverse-signal", - predicate: "test://from", - target: user2Did - } - ]); - - console.log("User 2 sending signal to User 1..."); - await user2Neighbourhood!.sendSignalU( - user1Did, - reverseSignalPayload - ); - - // Wait for signal to be received - startTime = Date.now(); - while (user1ReceivedSignals.length === 0 && (Date.now() - startTime) < maxWaitTime) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // Verify User 1 received the signal - expect(user1ReceivedSignals.length).to.be.greaterThan(0, "User 1 should have received at least one signal"); - - const user1ReceivedSignal = user1ReceivedSignals[0]; - expect(user1ReceivedSignal.data.links).to.have.lengthOf(1); - expect(user1ReceivedSignal.data.links[0].data.source).to.equal("test://reverse-signal"); - expect(user1ReceivedSignal.data.links[0].data.predicate).to.equal("test://from"); - expect(user1ReceivedSignal.data.links[0].data.target).to.equal(user2Did); - - console.log("✅ User 1 received signal from User 2"); - - // Verify User 2 did NOT receive their own signal back (User 1 should have only 1 signal from first send) - expect(user2ReceivedSignals.length).to.equal(1, "User 2 should still only have 1 signal (not their own reverse signal)"); - console.log("✅ User 2 correctly did not receive signal meant for User 1"); - - console.log("✅ Local signal routing working correctly - signals properly isolated between users"); - }); - - it("should receive neighbourhood signals between two managed users (Flux scenario)", async () => { - console.log("\n=== Replicating Flux Scenario: Fresh Agent with Managed Users ==="); - - // Create two managed users (simulating Flux signup flow) - console.log("Creating first managed user..."); - await adminAd4mClient!.agent.createUser("flux1@example.com", "password1"); - const token1 = await adminAd4mClient!.agent.loginUser("flux1@example.com", "password1"); - - console.log("Creating second managed user..."); - await adminAd4mClient!.agent.createUser("flux2@example.com", "password2"); - const token2 = await adminAd4mClient!.agent.loginUser("flux2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get user DIDs - const user1Status = await client1.agent.me(); - const user2Status = await client2.agent.me(); - const user1Did = user1Status.did!; - const user2Did = user2Status.did!; - - console.log("User 1 DID:", user1Did); - console.log("User 2 DID:", user2Did); - - // FIRST managed user creates a perspective and neighbourhood - console.log("\nUser 1 (first managed user) creating neighbourhood..."); - const perspective1 = await client1.perspective.add("Flux Test Neighbourhood"); - - // Add a test link - await client1.perspective.addLink(perspective1.uuid, new Link({ - source: "test://initial", - target: "test://data", - predicate: "test://created_by_user1" - })); - - // Clone link language and publish neighbourhood (using Holochain p-diff-sync) - const linkLanguage = await client1.languages.applyTemplateAndPublish( - DIFF_SYNC_OFFICIAL, - JSON.stringify({uid: uuidv4(), name: "Flux Test Neighbourhood"}) - ); - - console.log("Link language cloned:", linkLanguage.address); - - const neighbourhoodUrl = await client1.neighbourhood.publishFromPerspective( - perspective1.uuid, - linkLanguage.address, - new Perspective([]) - ); - - console.log("User 1 published neighbourhood:", neighbourhoodUrl); - - // Wait for neighbourhood to be published - await new Promise(resolve => setTimeout(resolve, 2000)); - - // SECOND managed user joins the neighbourhood - console.log("\nUser 2 (second managed user) joining neighbourhood..."); - const joinResult = await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); - console.log("User 2 join result:", joinResult.uuid); - - const user2Perspectives = await client2.perspective.all(); - const user2SharedPerspective = user2Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(user2SharedPerspective).to.not.be.null; - - console.log("User 2 joined neighbourhood"); - - // Wait for neighbourhood to sync - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Get neighbourhood proxies - const user1Neighbourhood = await perspective1.getNeighbourhoodProxy(); - const user2Neighbourhood = await user2SharedPerspective!.getNeighbourhoodProxy(); - - expect(user1Neighbourhood).to.not.be.null; - expect(user2Neighbourhood).to.not.be.null; - - console.log("\n=== Testing Signal Delivery ==="); - - // Set up signal listeners - const user1ReceivedSignals: any[] = []; - const user2ReceivedSignals: any[] = []; - - const user1SignalHandler = user1Neighbourhood!.addSignalHandler((signal) => { - console.log("✉️ User 1 received signal:", JSON.stringify(signal, null, 2)); - user1ReceivedSignals.push(signal); - }); - - const user2SignalHandler = user2Neighbourhood!.addSignalHandler((signal) => { - console.log("✉️ User 2 received signal:", JSON.stringify(signal, null, 2)); - user2ReceivedSignals.push(signal); - }); - - console.log("Signal handlers set up for both users"); - - // Wait for subscriptions to be active - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Check if users can see each other in otherAgents - console.log("\n=== Checking otherAgents() ==="); - const user1Others = await user1Neighbourhood!.otherAgents(); - const user2Others = await user2Neighbourhood!.otherAgents(); - - console.log("User 1 sees others:", user1Others); - console.log("User 2 sees others:", user2Others); - - // User 1 sends a signal to User 2 - console.log("\n=== User 1 sending signal to User 2 ==="); - const signal1to2 = new PerspectiveUnsignedInput([ - new Link({ - source: "test://signal", - predicate: "test://user1_to_user2", - target: user1Did - }) - ]); - - await user1Neighbourhood!.sendSignalU(user2Did, signal1to2); - console.log("Signal sent from User 1 to User 2"); - - // Wait for signal delivery - await new Promise(resolve => setTimeout(resolve, 2000)); - - // User 2 sends a signal to User 1 - console.log("\n=== User 2 sending signal to User 1 ==="); - const signal2to1 = new PerspectiveUnsignedInput([ - new Link({ - source: "test://signal", - predicate: "test://user2_to_user1", - target: user2Did - }) - ]); - - await user2Neighbourhood!.sendSignalU(user1Did, signal2to1); - console.log("Signal sent from User 2 to User 1"); - - // Wait for signal delivery - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Verify signals were received - console.log("\n=== Verification ==="); - console.log("User 1 received signals count:", user1ReceivedSignals.length); - console.log("User 2 received signals count:", user2ReceivedSignals.length); - - if (user2ReceivedSignals.length > 0) { - console.log("User 2 received signals:", JSON.stringify(user2ReceivedSignals, null, 2)); - } - if (user1ReceivedSignals.length > 0) { - console.log("User 1 received signals:", JSON.stringify(user1ReceivedSignals, null, 2)); - } - - // Assertions - expect(user2ReceivedSignals.length).to.be.greaterThan(0, "User 2 should receive signal from User 1"); - expect(user1ReceivedSignals.length).to.be.greaterThan(0, "User 1 should receive signal from User 2"); - - console.log("User 2 received signals:", JSON.stringify(user2ReceivedSignals, null, 2)); - console.log("User 1 received signals:", JSON.stringify(user1ReceivedSignals, null, 2)); - // Verify signal content - const user2Signal = user2ReceivedSignals[0]; - expect(user2Signal.data.links[0].data.predicate).to.equal("test://user1_to_user2"); - - const user1Signal = user1ReceivedSignals[0]; - expect(user1Signal.data.links[0].data.predicate).to.equal("test://user2_to_user1"); - - console.log("✅ Neighbourhood signals working between managed users (Flux scenario)"); - }); - - it("should exchange neighbourhood signals between main agent and managed user", async () => { - console.log("\n=== Testing signals between main agent and managed user ==="); - - // The admin client (empty/admin-credential token) IS the main agent. - // A managed user joins the same neighbourhood. Signals must work both ways. - const mainAgentStatus = await adminAd4mClient!.agent.status(); - const mainAgentDid = mainAgentStatus.did!; - console.log("Main agent DID:", mainAgentDid); - - // Create and login a managed user - await adminAd4mClient!.agent.createUser("main_agent_signal@example.com", "password"); - const userToken = await adminAd4mClient!.agent.loginUser("main_agent_signal@example.com", "password"); - // @ts-ignore - const userClient = new Ad4mClient(apolloClient(gqlPort, userToken), false); - - const userStatus = await userClient.agent.me(); - const userDid = userStatus.did!; - console.log("Managed user DID:", userDid); - - // Main agent creates the neighbourhood - const mainPerspective = await adminAd4mClient!.perspective.add("Main-Agent Neighbourhood"); - const linkLanguage = await adminAd4mClient!.languages.applyTemplateAndPublish( - DIFF_SYNC_OFFICIAL, - JSON.stringify({ uid: uuidv4(), name: "Main-Agent Signal Test" }) - ); - const neighbourhoodUrl = await adminAd4mClient!.neighbourhood.publishFromPerspective( - mainPerspective.uuid, - linkLanguage.address, - new Perspective([]) - ); - console.log("Main agent created neighbourhood:", neighbourhoodUrl); - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Managed user joins the neighbourhood - await userClient.neighbourhood.joinFromUrl(neighbourhoodUrl); - const userPerspectives = await userClient.perspective.all(); - const userSharedPerspective = userPerspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(userSharedPerspective).to.not.be.null; - console.log("Managed user joined neighbourhood"); - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Get neighbourhood proxies for both sides - const mainAgentNH = await mainPerspective.getNeighbourhoodProxy(); - const userNH = await userSharedPerspective!.getNeighbourhoodProxy(); - expect(mainAgentNH).to.not.be.null; - expect(userNH).to.not.be.null; - - // Register signal listeners on both sides - const mainAgentReceivedSignals: any[] = []; - const userReceivedSignals: any[] = []; - - mainAgentNH!.addSignalHandler((signal) => { - console.log("✉️ Main agent received signal:", JSON.stringify(signal)); - mainAgentReceivedSignals.push(signal); - }); - userNH!.addSignalHandler((signal) => { - console.log("✉️ Managed user received signal:", JSON.stringify(signal)); - userReceivedSignals.push(signal); - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - // --- Test 1: main agent sends signal to managed user --- - console.log("\n--- Main agent sending signal to managed user ---"); - await mainAgentNH!.sendSignalU(userDid, new PerspectiveUnsignedInput([ - new Link({ source: "test://signal", predicate: "test://main_to_user", target: mainAgentDid }) - ])); - - const maxWait = 5000; - let start = Date.now(); - while (userReceivedSignals.length === 0 && Date.now() - start < maxWait) { - await new Promise(r => setTimeout(r, 100)); - } - expect(userReceivedSignals.length).to.be.greaterThan(0, "Managed user should receive signal from main agent"); - expect(userReceivedSignals[0].data.links[0].data.predicate).to.equal("test://main_to_user"); - console.log("✅ Managed user received signal from main agent"); - - // --- Test 2: managed user sends signal to main agent --- - console.log("\n--- Managed user sending signal to main agent ---"); - await userNH!.sendSignalU(mainAgentDid, new PerspectiveUnsignedInput([ - new Link({ source: "test://signal", predicate: "test://user_to_main", target: userDid }) - ])); - - start = Date.now(); - while (mainAgentReceivedSignals.length === 0 && Date.now() - start < maxWait) { - await new Promise(r => setTimeout(r, 100)); - } - expect(mainAgentReceivedSignals.length).to.be.greaterThan(0, "Main agent should receive signal from managed user"); - expect(mainAgentReceivedSignals[0].data.links[0].data.predicate).to.equal("test://user_to_main"); - console.log("✅ Main agent received signal from managed user"); - - // --- Test 3: managed user broadcasts, main agent receives --- - console.log("\n--- Managed user broadcasting, main agent should receive ---"); - const mainAgentCountBefore = mainAgentReceivedSignals.length; - await userNH!.sendBroadcastU(new PerspectiveUnsignedInput([ - new Link({ source: "test://broadcast", predicate: "test://user_broadcast", target: userDid }) - ])); - - start = Date.now(); - while (mainAgentReceivedSignals.length === mainAgentCountBefore && Date.now() - start < maxWait) { - await new Promise(r => setTimeout(r, 100)); - } - expect(mainAgentReceivedSignals.length).to.be.greaterThan(mainAgentCountBefore, "Main agent should receive broadcast from managed user"); - const broadcastSignal = mainAgentReceivedSignals[mainAgentReceivedSignals.length - 1]; - expect(broadcastSignal.data.links[0].data.predicate).to.equal("test://user_broadcast"); - console.log("✅ Main agent received broadcast from managed user"); - - console.log("✅ Signal exchange between main agent and managed user works correctly"); - }); - }); - - describe("Multi-Node Multi-User Integration", () => { - // Test with 2 nodes, each with 2 users (4 users total) - const node2AppDataPath = path.join(TEST_DIR, "agents", "multi-user-node2"); - const node2GqlPort = 16000; - const node2HcAdminPort = 16001; - const node2HcAppPort = 16002; - - let node2ExecutorProcess: ChildProcess | null = null; - let node2AdminClient: Ad4mClient | null = null; - - // User clients for node 1 - let node1User1Client: Ad4mClient | null = null; - let node1User2Client: Ad4mClient | null = null; - let node1User1Did: string = ""; - let node1User2Did: string = ""; - - // User clients for node 2 - let node2User1Client: Ad4mClient | null = null; - let node2User2Client: Ad4mClient | null = null; - let node2User1Did: string = ""; - let node2User2Did: string = ""; - - before(async function() { - this.timeout(300000); // Increase timeout for setup with Holochain 0.7.0 - - console.log("\n=== Setting up Node 2 ==="); - if (!fs.existsSync(node2AppDataPath)) { - fs.mkdirSync(node2AppDataPath, { recursive: true }); - } - - // Start node 2 executor with local services - node2ExecutorProcess = await startExecutor( - node2AppDataPath, - bootstrapSeedPath, - node2GqlPort, - node2HcAdminPort, - node2HcAppPort, - false, - undefined, - proxyUrl!, - bootstrapUrl! - ); - - // @ts-ignore - node2AdminClient = new Ad4mClient(apolloClient(node2GqlPort), false); - await node2AdminClient.agent.generate("passphrase"); - await node2AdminClient.runtime.setMultiUserEnabled(true); - - console.log("\n=== Creating users on Node 1 ==="); - // Create and login 2 users on node 1 - await adminAd4mClient!.agent.createUser("node1user1@example.com", "password1"); - const node1User1Token = await adminAd4mClient!.agent.loginUser("node1user1@example.com", "password1"); - // @ts-ignore - node1User1Client = new Ad4mClient(apolloClient(gqlPort, node1User1Token), false); - const node1User1Agent = await node1User1Client.agent.me(); - node1User1Did = node1User1Agent.did; - console.log("Node 1 User 1 DID:", node1User1Did); - - await adminAd4mClient!.agent.createUser("node1user2@example.com", "password2"); - const node1User2Token = await adminAd4mClient!.agent.loginUser("node1user2@example.com", "password2"); - // @ts-ignore - node1User2Client = new Ad4mClient(apolloClient(gqlPort, node1User2Token), false); - const node1User2Agent = await node1User2Client.agent.me(); - node1User2Did = node1User2Agent.did; - console.log("Node 1 User 2 DID:", node1User2Did); - - console.log("\n=== Creating users on Node 2 ==="); - // Create and login 2 users on node 2 - await node2AdminClient.agent.createUser("node2user1@example.com", "password3"); - const node2User1Token = await node2AdminClient.agent.loginUser("node2user1@example.com", "password3"); - // @ts-ignore - node2User1Client = new Ad4mClient(apolloClient(node2GqlPort, node2User1Token), false); - const node2User1Agent = await node2User1Client.agent.me(); - node2User1Did = node2User1Agent.did; - console.log("Node 2 User 1 DID:", node2User1Did); - - await node2AdminClient.agent.createUser("node2user2@example.com", "password4"); - const node2User2Token = await node2AdminClient.agent.loginUser("node2user2@example.com", "password4"); - // @ts-ignore - node2User2Client = new Ad4mClient(apolloClient(node2GqlPort, node2User2Token), false); - const node2User2Agent = await node2User2Client.agent.me(); - node2User2Did = node2User2Agent.did; - console.log("Node 2 User 2 DID:", node2User2Did); - - // Make nodes known to each other (for Holochain peer discovery) - console.log("\n=== Making nodes known to each other ==="); - const node1AgentInfos = await adminAd4mClient!.runtime.hcAgentInfos(); - const node2AgentInfos = await node2AdminClient.runtime.hcAgentInfos(); - await adminAd4mClient!.runtime.hcAddAgentInfos(node2AgentInfos); - await node2AdminClient.runtime.hcAddAgentInfos(node1AgentInfos); - - console.log("\n=== Setup complete ===\n"); - }); - - after(async function() { - this.timeout(20000); - if (node2ExecutorProcess) { - while (!node2ExecutorProcess?.killed) { - let status = node2ExecutorProcess?.kill(); - console.log("killed node 2 executor with", status); - await sleep(500); - } - } - }); - - it("should return all DIDs in 'others()' for each user", async function() { - this.timeout(240000); // Increased for Holochain 0.7.0 - allow initial wait + polling time - - console.log("\n=== Testing 'others()' functionality ==="); - - // Node 1 User 1 creates and publishes a neighbourhood - const perspective = await node1User1Client!.perspective.add("Multi-Node Test Neighbourhood"); - - // Add initial link - await node1User1Client!.perspective.addLink(perspective.uuid, { - source: "test://root", - target: "test://data", - predicate: "test://contains" - }); - - // Clone and publish neighbourhood - const linkLanguage = await node1User1Client!.languages.applyTemplateAndPublish( - DIFF_SYNC_OFFICIAL, - JSON.stringify({uid: uuidv4(), name: "Multi-Node Test"}) - ); - - const neighbourhoodUrl = await node1User1Client!.neighbourhood.publishFromPerspective( - perspective.uuid, - linkLanguage.address, - new Perspective([]) - ); - - console.log("Published neighbourhood:", neighbourhoodUrl); - - // All other users join the neighbourhood - console.log("Node 1 User 2 joining..."); - await node1User2Client!.neighbourhood.joinFromUrl(neighbourhoodUrl); - await sleep(2000); // Wait for join to complete - - console.log("Node 2 User 1 joining..."); - await node2User1Client!.neighbourhood.joinFromUrl(neighbourhoodUrl); - await sleep(2000); // Wait for join to complete - - console.log("Node 2 User 2 joining..."); - await node2User2Client!.neighbourhood.joinFromUrl(neighbourhoodUrl); - await sleep(2000); // Wait for join to complete - - // Re-exchange agent infos with retry to handle K2 space initialization delays (Holochain 0.7.0) - // After users join and link language is installed, new DNA/K2 spaces are created - // We need to retry agent info exchange to allow K2 spaces to become ready - console.log("Re-exchanging agent infos with retry for K2 space readiness..."); - for (let attempt = 1; attempt <= 5; attempt++) { - try { - console.log(`Agent info exchange attempt ${attempt}/5`); - const node1AgentInfos = await adminAd4mClient!.runtime.hcAgentInfos(); - const node2AgentInfos = await node2AdminClient!.runtime.hcAgentInfos(); - await adminAd4mClient!.runtime.hcAddAgentInfos(node2AgentInfos); - await node2AdminClient!.runtime.hcAddAgentInfos(node1AgentInfos); - console.log(`✅ Agent info exchange attempt ${attempt} successful`); - } catch (error) { - console.log(`⚠️ Agent info exchange attempt ${attempt} failed:`, error); - } - if (attempt < 5) { - await sleep(3000); // Wait before retry - } - } - - // Wait for neighbourhood to sync and owners lists to be updated - console.log("Waiting for neighbourhood sync and owners list updates..."); - await sleep(15000); - - // Get neighbourhood proxies for each user - const node1User1Perspectives = await node1User1Client!.perspective.all(); - const node1User1Neighbourhood = node1User1Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(node1User1Neighbourhood).to.not.be.undefined; - const node1User1Proxy = await node1User1Neighbourhood!.getNeighbourhoodProxy(); - - const node1User2Perspectives = await node1User2Client!.perspective.all(); - const node1User2Neighbourhood = node1User2Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(node1User2Neighbourhood).to.not.be.undefined; - const node1User2Proxy = await node1User2Neighbourhood!.getNeighbourhoodProxy(); - - const node2User1Perspectives = await node2User1Client!.perspective.all(); - const node2User1Neighbourhood = node2User1Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(node2User1Neighbourhood).to.not.be.undefined; - const node2User1Proxy = await node2User1Neighbourhood!.getNeighbourhoodProxy(); - - const node2User2Perspectives = await node2User2Client!.perspective.all(); - const node2User2Neighbourhood = node2User2Perspectives.find(p => p.sharedUrl === neighbourhoodUrl); - expect(node2User2Neighbourhood).to.not.be.undefined; - const node2User2Proxy = await node2User2Neighbourhood!.getNeighbourhoodProxy(); - - // Check 'others()' for each user - should see all other users' DIDs - // Poll with retries to account for DHT gossip delays - console.log("\nChecking 'others()' for each user:"); - - // Helper function to poll until all expected DIDs are seen - const pollUntilAllSeen = async (proxy: any, expectedDids: string[], userLabel: string, maxAttempts = 50) => { - console.log(`Polling ${userLabel} for others...`); - console.log(`Expected DIDs:`, expectedDids); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const others = await proxy.otherAgents(); - console.log(`${userLabel} sees others (attempt ${attempt}):`, others); - - const allFound = expectedDids.every(did => { - console.log(`Checking if ${did} is in ${others}`); - let result = others.includes(did); - console.log(`Result: ${result}`); - return result; - }); - if (allFound) { - console.log(`✅ ${userLabel} sees all expected users!`); - return others; - } - - if (attempt < maxAttempts) { - console.log(`${userLabel} waiting for DHT gossip... (${attempt}/${maxAttempts})`); - await sleep(2000); - } - } - - // Return the last result even if not complete - const finalOthers = await proxy.otherAgents(); - console.log(`${userLabel} final result after ${maxAttempts} attempts:`, finalOthers); - return finalOthers; - }; - - const node1User1Others = await pollUntilAllSeen( - node1User1Proxy!, - [node1User2Did, node2User1Did, node2User2Did], - "Node 1 User 1" - ); - expect(node1User1Others).to.include(node1User2Did, "Node 1 User 1 should see Node 1 User 2"); - expect(node1User1Others).to.include(node2User1Did, "Node 1 User 1 should see Node 2 User 1"); - expect(node1User1Others).to.include(node2User2Did, "Node 1 User 1 should see Node 2 User 2"); - expect(node1User1Others).to.have.lengthOf(3, "Node 1 User 1 should see exactly 3 other users"); - - const node1User2Others = await pollUntilAllSeen( - node1User2Proxy!, - [node1User1Did, node2User1Did, node2User2Did], - "Node 1 User 2" - ); - console.log("Node 1 User 2 sees others:", node1User2Others); - expect(node1User2Others).to.include(node1User1Did, "Node 1 User 2 should see Node 1 User 1"); - expect(node1User2Others).to.include(node2User1Did, "Node 1 User 2 should see Node 2 User 1"); - expect(node1User2Others).to.include(node2User2Did, "Node 1 User 2 should see Node 2 User 2"); - expect(node1User2Others).to.have.lengthOf(3, "Node 1 User 2 should see exactly 3 other users"); - - const node2User1Others = await pollUntilAllSeen( - node2User1Proxy!, - [node1User1Did, node1User2Did, node2User2Did], - "Node 2 User 1" - ); - console.log("Node 2 User 1 sees others:", node2User1Others); - expect(node2User1Others).to.include(node1User1Did, "Node 2 User 1 should see Node 1 User 1"); - expect(node2User1Others).to.include(node1User2Did, "Node 2 User 1 should see Node 1 User 2"); - expect(node2User1Others).to.include(node2User2Did, "Node 2 User 1 should see Node 2 User 2"); - expect(node2User1Others).to.have.lengthOf(3, "Node 2 User 1 should see exactly 3 other users"); - - const node2User2Others = await pollUntilAllSeen( - node2User2Proxy!, - [node1User1Did, node1User2Did, node2User1Did], - "Node 2 User 2" - ); - console.log("Node 2 User 2 sees others:", node2User2Others); - expect(node2User2Others).to.include(node1User1Did, "Node 2 User 2 should see Node 1 User 1"); - expect(node2User2Others).to.include(node1User2Did, "Node 2 User 2 should see Node 1 User 2"); - expect(node2User2Others).to.include(node2User1Did, "Node 2 User 2 should see Node 2 User 1"); - expect(node2User2Others).to.have.lengthOf(3, "Node 2 User 2 should see exactly 3 other users"); - - console.log("\n✅ All users correctly see all other users via others()"); - }); - - it("should route p2p signals correctly between users across nodes", async function() { - this.timeout(120000); // Increased for Holochain 0.7.0 - - console.log("\n=== Testing cross-node p2p signal routing ==="); - - // Get neighbourhood URL from previous test - const node1User1Perspectives = await node1User1Client!.perspective.all(); - const node1User1Neighbourhood = node1User1Perspectives.find(p => p.sharedUrl); - expect(node1User1Neighbourhood).to.not.be.undefined; - const node1User1Proxy = await node1User1Neighbourhood!.getNeighbourhoodProxy(); - - const node2User1Perspectives = await node2User1Client!.perspective.all(); - const node2User1Neighbourhood = node2User1Perspectives.find(p => p.sharedUrl); - expect(node2User1Neighbourhood).to.not.be.undefined; - const node2User1Proxy = await node2User1Neighbourhood!.getNeighbourhoodProxy(); - - const node2User2Perspectives = await node2User2Client!.perspective.all(); - const node2User2Neighbourhood = node2User2Perspectives.find(p => p.sharedUrl); - expect(node2User2Neighbourhood).to.not.be.undefined; - const node2User2Proxy = await node2User2Neighbourhood!.getNeighbourhoodProxy(); - - // Set up signal handlers - const node2User1ReceivedSignals: any[] = []; - node2User1Proxy!.addSignalHandler((signal) => { - console.log("Node 2 User 1 received signal from:", signal.author); - node2User1ReceivedSignals.push(signal); - }); - - const node2User2ReceivedSignals: any[] = []; - node2User2Proxy!.addSignalHandler((signal) => { - console.log("Node 2 User 2 received signal from:", signal.author); - node2User2ReceivedSignals.push(signal); - }); - - await sleep(3000); // Let handlers initialize and subscriptions become active - - - // Node 2 User 1 sends a signal to Node 2 User 2 (both on same node - local routing) - console.log(`\nNode 2 User 1 (${node2User1Did.substring(0, 20)}...) sending signal to Node 2 User 2 (${node2User2Did.substring(0, 20)}...)`); - await node2User1Proxy!.sendSignalU(node2User2Did, new PerspectiveUnsignedInput([ - { - source: "test://signal0", - predicate: "test://from", - target: node2User1Did - } - ])); - - // Wait for signal delivery - console.log("Waiting for signal delivery..."); - const maxWaitTime = 20000; - let startTime = Date.now(); - while (node2User2ReceivedSignals.length === 0 && (Date.now() - startTime) < maxWaitTime) { - await sleep(100); - console.log("."); - } - - console.log("Signal delivery complete"); - - // Verify Node 2 User 1 received the signal - console.log("Node 2 User 2 received signals:", node2User2ReceivedSignals); - expect(node2User2ReceivedSignals.length).to.be.greaterThan(0, "Node 2 User 2 should have received signal"); - expect(node2User2ReceivedSignals[0].author).to.equal(node2User1Did); - console.log("✅ Node 2 User 2 received signal from Node 2 User 1"); - - let node2user2signalCount = node2User2ReceivedSignals.length; - - // Node 1 User 1 sends a signal to Node 2 User 1 - console.log(`\nNode 1 User 1 (${node1User1Did.substring(0, 20)}...) sending signal to Node 2 User 1 (${node2User1Did.substring(0, 20)}...)`); - await node1User1Proxy!.sendSignalU(node2User1Did, new PerspectiveUnsignedInput([ - { - source: "test://signal1", - predicate: "test://from", - target: node1User1Did - } - ])); - - // Wait for signal delivery - startTime = Date.now(); - console.log("Waiting for signal delivery..."); - while (node2User1ReceivedSignals.length === 0 && (Date.now() - startTime) < maxWaitTime) { - await sleep(100); - console.log("."); - } - console.log("Signal delivery complete"); - - // Verify Node 2 User 1 received the signal - console.log("Node 2 User 1 received signals:", node2User1ReceivedSignals); - expect(node2User1ReceivedSignals.length).to.be.greaterThan(0, "Node 2 User 1 should have received signal"); - expect(node2User1ReceivedSignals[0].author).to.equal(node1User1Did); - console.log("✅ Node 2 User 1 received signal from Node 1 User 1"); - - // Verify Node 2 User 2 did NOT receive the signal (it was meant for Node 2 User 1) - expect(node2User2ReceivedSignals.length).to.equal(node2user2signalCount, "Node 2 User 2 should NOT have received signal meant for Node 2 User 1"); - console.log("✅ Node 2 User 2 correctly did not receive signal"); - - // Now test the reverse: Node 2 User 1 sends to Node 1 User 1 - const node1User1ReceivedSignals: any[] = []; - node1User1Proxy!.addSignalHandler((signal) => { - console.log("Node 1 User 1 received signal from:", signal.author); - node1User1ReceivedSignals.push(signal); - }); - - await sleep(1500); - - console.log(`\nNode 2 User 1 (${node2User1Did.substring(0, 20)}...) sending signal to Node 1 User 1 (${node1User1Did.substring(0, 20)}...)`); - await node2User1Proxy!.sendSignalU(node1User1Did, new PerspectiveUnsignedInput([ - { - source: "test://signal2", - predicate: "test://from", - target: node2User1Did - } - ])); - - startTime = Date.now(); - while (node1User1ReceivedSignals.length === 0 && (Date.now() - startTime) < maxWaitTime) { - await sleep(100); - } - - expect(node1User1ReceivedSignals.length).to.be.greaterThan(0, "Node 1 User 1 should have received signal"); - expect(node1User1ReceivedSignals[0].author).to.equal(node2User1Did); - console.log("✅ Node 1 User 1 received signal from Node 2 User 1"); - - console.log("\n✅ Cross-node p2p signal routing works correctly"); - }); - - it("should sync links correctly between all users across nodes", async function() { - this.timeout(180000); // Increased for Holochain 0.7.0 - link sync takes longer - - console.log("\n=== Testing cross-node link synchronization ==="); - - // Get neighbourhood perspectives for all users - const node1User1Perspectives = await node1User1Client!.perspective.all(); - const node1User1Neighbourhood = node1User1Perspectives.find(p => p.sharedUrl); - expect(node1User1Neighbourhood).to.not.be.undefined; - - const node1User2Perspectives = await node1User2Client!.perspective.all(); - const node1User2Neighbourhood = node1User2Perspectives.find(p => p.sharedUrl); - expect(node1User2Neighbourhood).to.not.be.undefined; - - const node2User1Perspectives = await node2User1Client!.perspective.all(); - const node2User1Neighbourhood = node2User1Perspectives.find(p => p.sharedUrl); - expect(node2User1Neighbourhood).to.not.be.undefined; - - const node2User2Perspectives = await node2User2Client!.perspective.all(); - const node2User2Neighbourhood = node2User2Perspectives.find(p => p.sharedUrl); - expect(node2User2Neighbourhood).to.not.be.undefined; - - // Each user adds a unique link - console.log("\nEach user adding their own unique link..."); - - await node1User1Client!.perspective.addLink(node1User1Neighbourhood!.uuid, { - source: "test://node1user1", - target: "test://link1", - predicate: "test://created" - }); - console.log("Node 1 User 1 added link"); - - await node1User2Client!.perspective.addLink(node1User2Neighbourhood!.uuid, { - source: "test://node1user2", - target: "test://link2", - predicate: "test://created" - }); - console.log("Node 1 User 2 added link"); - - await node2User1Client!.perspective.addLink(node2User1Neighbourhood!.uuid, { - source: "test://node2user1", - target: "test://link3", - predicate: "test://created" - }); - console.log("Node 2 User 1 added link"); - - await node2User2Client!.perspective.addLink(node2User2Neighbourhood!.uuid, { - source: "test://node2user2", - target: "test://link4", - predicate: "test://created" - }); - console.log("Node 2 User 2 added link"); - - // Wait for synchronization - console.log("\nWaiting for sync..."); - await sleep(10000); - - // Query links from each user's perspective - console.log("\nQuerying links from each user's perspective..."); - - const node1User1Links = await node1User1Client!.perspective.queryLinks( - node1User1Neighbourhood!.uuid, - new LinkQuery({}) - ); - console.log(`Node 1 User 1 sees ${node1User1Links.length} links`); - - const node1User2Links = await node1User2Client!.perspective.queryLinks( - node1User2Neighbourhood!.uuid, - new LinkQuery({}) - ); - console.log(`Node 1 User 2 sees ${node1User2Links.length} links`); - - const node2User1Links = await node2User1Client!.perspective.queryLinks( - node2User1Neighbourhood!.uuid, - new LinkQuery({}) - ); - console.log(`Node 2 User 1 sees ${node2User1Links.length} links`); - - const node2User2Links = await node2User2Client!.perspective.queryLinks( - node2User2Neighbourhood!.uuid, - new LinkQuery({}) - ); - console.log(`Node 2 User 2 sees ${node2User2Links.length} links`); - - // All users should see at least 5 links (1 from setup + 4 from each user) - expect(node1User1Links.length).to.be.greaterThanOrEqual(5, "Node 1 User 1 should see all links"); - expect(node1User2Links.length).to.be.greaterThanOrEqual(5, "Node 1 User 2 should see all links"); - expect(node2User1Links.length).to.be.greaterThanOrEqual(5, "Node 2 User 1 should see all links"); - expect(node2User2Links.length).to.be.greaterThanOrEqual(5, "Node 2 User 2 should see all links"); - - // Verify each user sees all the specific links - const checkLinkExists = (links: any[], source: string, target: string) => { - return links.some(l => l.data.source === source && l.data.target === target); - }; - - // Check Node 1 User 1 sees all links - expect(checkLinkExists(node1User1Links, "test://node1user1", "test://link1")).to.be.true; - expect(checkLinkExists(node1User1Links, "test://node1user2", "test://link2")).to.be.true; - expect(checkLinkExists(node1User1Links, "test://node2user1", "test://link3")).to.be.true; - expect(checkLinkExists(node1User1Links, "test://node2user2", "test://link4")).to.be.true; - console.log("✅ Node 1 User 1 sees all links"); - - // Check Node 1 User 2 sees all links - expect(checkLinkExists(node1User2Links, "test://node1user1", "test://link1")).to.be.true; - expect(checkLinkExists(node1User2Links, "test://node1user2", "test://link2")).to.be.true; - expect(checkLinkExists(node1User2Links, "test://node2user1", "test://link3")).to.be.true; - expect(checkLinkExists(node1User2Links, "test://node2user2", "test://link4")).to.be.true; - console.log("✅ Node 1 User 2 sees all links"); - - // Check Node 2 User 1 sees all links - expect(checkLinkExists(node2User1Links, "test://node1user1", "test://link1")).to.be.true; - expect(checkLinkExists(node2User1Links, "test://node1user2", "test://link2")).to.be.true; - expect(checkLinkExists(node2User1Links, "test://node2user1", "test://link3")).to.be.true; - expect(checkLinkExists(node2User1Links, "test://node2user2", "test://link4")).to.be.true; - console.log("✅ Node 2 User 1 sees all links"); - - // Check Node 2 User 2 sees all links - expect(checkLinkExists(node2User2Links, "test://node1user1", "test://link1")).to.be.true; - expect(checkLinkExists(node2User2Links, "test://node1user2", "test://link2")).to.be.true; - expect(checkLinkExists(node2User2Links, "test://node2user1", "test://link3")).to.be.true; - expect(checkLinkExists(node2User2Links, "test://node2user2", "test://link4")).to.be.true; - console.log("✅ Node 2 User 2 sees all links"); - - // Verify link authors are correct - const node1User1Link = node1User1Links.find(l => l.data.source === "test://node1user1"); - expect(node1User1Link?.author).to.equal(node1User1Did); - - const node1User2Link = node1User1Links.find(l => l.data.source === "test://node1user2"); - expect(node1User2Link?.author).to.equal(node1User2Did); - - const node2User1Link = node1User1Links.find(l => l.data.source === "test://node2user1"); - expect(node2User1Link?.author).to.equal(node2User1Did); - - const node2User2Link = node1User1Links.find(l => l.data.source === "test://node2user2"); - expect(node2User2Link?.author).to.equal(node2User2Did); - - console.log("✅ All links have correct authors"); - - console.log("\n✅ Cross-node link synchronization works correctly"); - }); - }); - - describe("Perspective Subscriptions", () => { - it("should only notify users about their own perspectives in perspectiveAdded", async () => { - console.log("\n=== Testing perspective subscription filtering ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("sub1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("sub2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("sub1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("sub2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Track perspective added events for both users - const user1Events: any[] = []; - const user2Events: any[] = []; - - // Subscribe both users to perspectiveAdded - console.log("Subscribing users to perspectiveAdded..."); - - // @ts-ignore - client1.perspective.addPerspectiveAddedListener((perspective) => { - console.log(`User 1 received perspectiveAdded event: ${perspective.name} (UUID: ${perspective.uuid})`); - user1Events.push(perspective); - }); - client1.perspective.subscribePerspectiveAdded(); - - // @ts-ignore - client2.perspective.addPerspectiveAddedListener((perspective) => { - console.log(`User 2 received perspectiveAdded event: ${perspective.name} (UUID: ${perspective.uuid})`); - user2Events.push(perspective); - }); - client2.perspective.subscribePerspectiveAdded(); - - // Wait for subscriptions to be active - await sleep(1000); - console.log("✅ Subscriptions active"); - - // User 1 creates a perspective - console.log("\nUser 1 creating perspective..."); - const user1Perspective = await client1.perspective.add("User 1 Only Perspective"); - console.log(`User 1 created perspective: ${user1Perspective.uuid}`); - - // Wait for events to propagate - await sleep(2000); - - // User 2 creates a perspective - console.log("\nUser 2 creating perspective..."); - const user2Perspective = await client2.perspective.add("User 2 Only Perspective"); - console.log(`User 2 created perspective: ${user2Perspective.uuid}`); - - // Wait for events to propagate - await sleep(2000); - - console.log(`\nUser 1 received ${user1Events.length} events`); - console.log(`User 2 received ${user2Events.length} events`); - - // Each user should only see their own perspective creation event - expect(user1Events.length).to.equal(1, "User 1 should only receive 1 event (their own perspective)"); - expect(user2Events.length).to.equal(1, "User 2 should only receive 1 event (their own perspective)"); - - expect(user1Events[0].uuid).to.equal(user1Perspective.uuid, "User 1 should only see their own perspective"); - expect(user2Events[0].uuid).to.equal(user2Perspective.uuid, "User 2 should only see their own perspective"); - - console.log("✅ Perspective subscription filtering works correctly"); - }); - - it("should only notify users about their own perspectives in perspectiveUpdated", async () => { - console.log("\n=== Testing perspectiveUpdated subscription filtering ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("update1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("update2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("update1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("update2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Create perspectives for both users - const user1Perspective = await client1.perspective.add("User 1 Update Test"); - const user2Perspective = await client2.perspective.add("User 2 Update Test"); - - // Track events - const user1UpdateEvents: any[] = []; - const user2UpdateEvents: any[] = []; - - // Subscribe to perspectiveUpdated - console.log("Subscribing users to perspectiveUpdated..."); - // @ts-ignore - client1.perspective.addPerspectiveUpdatedListener((perspective) => { - console.log(`User 1 received perspectiveUpdated event: ${perspective.name} (UUID: ${perspective.uuid})`); - user1UpdateEvents.push(perspective); - }); - client1.perspective.subscribePerspectiveUpdated(); - - // @ts-ignore - client2.perspective.addPerspectiveUpdatedListener((perspective) => { - console.log(`User 2 received perspectiveUpdated event: ${perspective.name} (UUID: ${perspective.uuid})`); - user2UpdateEvents.push(perspective); - }); - client2.perspective.subscribePerspectiveUpdated(); - - await sleep(1000); - console.log("✅ Subscriptions active"); - - // User 1 updates their perspective metadata (name) - console.log("\nUser 1 updating their perspective name..."); - await client1.perspective.update(user1Perspective.uuid, "User 1 Updated Name"); - - await sleep(2000); - - // User 2 updates their perspective metadata (name) - console.log("\nUser 2 updating their perspective name..."); - await client2.perspective.update(user2Perspective.uuid, "User 2 Updated Name"); - - await sleep(2000); - - console.log(`\nUser 1 received ${user1UpdateEvents.length} update events`); - console.log(`User 2 received ${user2UpdateEvents.length} update events`); - - // Each user should only see updates to their own perspectives - expect(user1UpdateEvents.length).to.equal(1, "User 1 should only receive updates for their own perspective"); - expect(user2UpdateEvents.length).to.equal(1, "User 2 should only receive updates for their own perspective"); - - expect(user1UpdateEvents[0].uuid).to.equal(user1Perspective.uuid); - expect(user2UpdateEvents[0].uuid).to.equal(user2Perspective.uuid); - - expect(user1UpdateEvents[0].name).to.equal("User 1 Updated Name"); - expect(user2UpdateEvents[0].name).to.equal("User 2 Updated Name"); - - console.log("✅ perspectiveUpdated subscription filtering works correctly"); - }); - - it("should only notify users about links in their own perspectives", async () => { - console.log("\n=== Testing perspective_link_added subscription filtering ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("linkuser1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("linkuser2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("linkuser1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("linkuser2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Create perspectives for both users - const user1Perspective = await client1.perspective.add("User 1 Link Test"); - const user2Perspective = await client2.perspective.add("User 2 Link Test"); - - // Track events - const user1LinkEvents: any[] = []; - const user2LinkEvents: any[] = []; - - // Subscribe to perspective_link_added for each user's perspective - console.log("Subscribing users to perspective_link_added..."); - // @ts-ignore - client1.perspective.addPerspectiveLinkAddedListener(user1Perspective.uuid, [(link) => { - console.log(`User 1 received link added event in perspective ${user1Perspective.uuid}`); - user1LinkEvents.push(link); - }]); - - // @ts-ignore - client2.perspective.addPerspectiveLinkAddedListener(user2Perspective.uuid, [(link) => { - console.log(`User 2 received link added event in perspective ${user2Perspective.uuid}`); - user2LinkEvents.push(link); - }]); - - await sleep(1000); - console.log("✅ Subscriptions active"); - - // User 1 adds a link to their perspective - console.log("\nUser 1 adding link to their perspective..."); - await client1.perspective.addLink(user1Perspective.uuid, { - source: "test://user1", - target: "test://data1", - predicate: "test://has" - }); - - await sleep(2000); - - // User 2 adds a link to their perspective - console.log("\nUser 2 adding link to their perspective..."); - await client2.perspective.addLink(user2Perspective.uuid, { - source: "test://user2", - target: "test://data2", - predicate: "test://has" - }); - - await sleep(2000); - - console.log(`\nUser 1 received ${user1LinkEvents.length} link events`); - console.log(`User 2 received ${user2LinkEvents.length} link events`); - - console.log(user1LinkEvents); - console.log(user2LinkEvents); - - // Each user should ONLY see link events for links they added; they should NOT see the other user's - const user1HasCorrectLink = user1LinkEvents.some( - (event) => event.data.source === "test://user1" && event.data.target === "test://data1" - ); - const user1HasOtherUserLink = user1LinkEvents.some( - (event) => event.data.source === "test://user2" && event.data.target === "test://data2" - ); - const user2HasCorrectLink = user2LinkEvents.some( - (event) => event.data.source === "test://user2" && event.data.target === "test://data2" - ); - const user2HasOtherUserLink = user2LinkEvents.some( - (event) => event.data.source === "test://user1" && event.data.target === "test://data1" - ); - - expect(user1HasCorrectLink, "User 1 should receive events for their own link").to.be.true; - expect(user1HasOtherUserLink, "User 1 should NOT receive events for User 2's link").to.be.false; - expect(user2HasCorrectLink, "User 2 should receive events for their own link").to.be.true; - expect(user2HasOtherUserLink, "User 2 should NOT receive events for User 1's link").to.be.false; - - expect(user1LinkEvents[0].data.source).to.equal("test://user1"); - expect(user2LinkEvents[0].data.source).to.equal("test://user2"); - - console.log("✅ perspective_link_added subscription filtering works correctly"); - }); - - it("should only notify users about removal of their own perspectives", async () => { - console.log("\n=== Testing perspectiveRemoved subscription filtering ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("remove1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("remove2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("remove1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("remove2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Create perspectives for both users - const user1Perspective = await client1.perspective.add("User 1 Remove Test"); - const user2Perspective = await client2.perspective.add("User 2 Remove Test"); - - // Track events - const user1RemoveEvents: string[] = []; - const user2RemoveEvents: string[] = []; - - // Subscribe to perspectiveRemoved - console.log("Subscribing users to perspectiveRemoved..."); - // @ts-ignore - client1.perspective.addPerspectiveRemovedListener((uuid) => { - console.log(`User 1 received perspectiveRemoved event: ${uuid}`); - user1RemoveEvents.push(uuid); - }); - client1.perspective.subscribePerspectiveRemoved(); - - // @ts-ignore - client2.perspective.addPerspectiveRemovedListener((uuid) => { - console.log(`User 2 received perspectiveRemoved event: ${uuid}`); - user2RemoveEvents.push(uuid); - }); - client2.perspective.subscribePerspectiveRemoved(); - - await sleep(1000); - console.log("✅ Subscriptions active"); - - // User 1 removes their perspective - console.log("\nUser 1 removing their perspective..."); - await client1.perspective.remove(user1Perspective.uuid); - - await sleep(2000); - - // User 2 removes their perspective - console.log("\nUser 2 removing their perspective..."); - await client2.perspective.remove(user2Perspective.uuid); - - await sleep(2000); - - console.log(`\nUser 1 received ${user1RemoveEvents.length} removal events`); - console.log(`User 2 received ${user2RemoveEvents.length} removal events`); - - // Each user should only see removal of their own perspectives - expect(user1RemoveEvents.length).to.equal(1, "User 1 should only be notified about their own perspective removal"); - expect(user2RemoveEvents.length).to.equal(1, "User 2 should only be notified about their own perspective removal"); - - expect(user1RemoveEvents[0]).to.equal(user1Perspective.uuid); - expect(user2RemoveEvents[0]).to.equal(user2Perspective.uuid); - - console.log("✅ perspectiveRemoved subscription filtering works correctly"); - }); - }); - - describe("Multi-User Notifications", () => { - it("should isolate notifications between users", async () => { - console.log("\n=== Testing notification isolation between users ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("notify1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("notify2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("notify1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("notify2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // User 1 creates a perspective and notification - const user1Perspective = await client1.perspective.add("User 1 Notification Test"); - const user1Notification: NotificationInput = { - description: "User 1's notification", - appName: "User 1 App", - appUrl: "https://user1.app", - appIconPath: "/user1.png", - trigger: `SELECT source, target FROM link WHERE predicate = 'user1://test'`, - perspectiveIds: [user1Perspective.uuid], - webhookUrl: "https://user1.webhook", - webhookAuth: "user1-auth" - }; - - // User 2 creates a perspective and notification - const user2Perspective = await client2.perspective.add("User 2 Notification Test"); - const user2Notification: NotificationInput = { - description: "User 2's notification", - appName: "User 2 App", - appUrl: "https://user2.app", - appIconPath: "/user2.png", - trigger: `SELECT source, target FROM link WHERE predicate = 'user2://test'`, - perspectiveIds: [user2Perspective.uuid], - webhookUrl: "https://user2.webhook", - webhookAuth: "user2-auth" - }; - - // Install notifications - managed users get auto-granted - const notif1Id = await client1.runtime.requestInstallNotification(user1Notification); - const notif2Id = await client2.runtime.requestInstallNotification(user2Notification); - - await sleep(500); - - // User 1 retrieves notifications - should only see their own and it should be auto-granted - const user1Notifications = await client1.runtime.notifications(); - console.log(`User 1 sees ${user1Notifications.length} notification(s)`); - expect(user1Notifications.length).to.equal(1); - expect(user1Notifications[0].description).to.equal("User 1's notification"); - expect(user1Notifications[0].id).to.equal(notif1Id); - expect(user1Notifications[0].granted).to.be.true; - - // User 2 retrieves notifications - should only see their own and it should be auto-granted - const user2Notifications = await client2.runtime.notifications(); - console.log(`User 2 sees ${user2Notifications.length} notification(s)`); - expect(user2Notifications.length).to.equal(1); - expect(user2Notifications[0].description).to.equal("User 2's notification"); - expect(user2Notifications[0].id).to.equal(notif2Id); - expect(user2Notifications[0].granted).to.be.true; - - console.log("✅ Notification isolation verified - each user sees only their own notifications"); - console.log("✅ Managed user notifications are auto-granted"); - }); - - it("should use correct agent DID for each user's notification queries", async () => { - console.log("\n=== Testing per-user agent DID in notification queries ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("did1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("did2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("did1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("did2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - // Get each user's agent DID - const user1Status = await client1.agent.status(); - const user2Status = await client2.agent.status(); - const user1Did = user1Status.did; - const user2Did = user2Status.did; - - console.log(`User 1 DID: ${user1Did}`); - console.log(`User 2 DID: ${user2Did}`); - - expect(user1Did).to.not.equal(user2Did, "Users should have different DIDs"); - - // Create perspectives for both users - const user1Perspective = await client1.perspective.add("User 1 Mention Test"); - const user2Perspective = await client2.perspective.add("User 2 Mention Test"); - - // User 1 creates a mention notification (using $agentDid variable) - const user1Notification: NotificationInput = { - description: "User 1 was mentioned", - appName: "Mentions for User 1", - appUrl: "https://mentions.app", - appIconPath: "/mentions.png", - trigger: `SELECT - source as message_id, - fn::parse_literal(target) as content, - $agentDid as mentioned_user - FROM link - WHERE predicate = 'rdf://content' - AND fn::contains(fn::parse_literal(target), $agentDid)`, - perspectiveIds: [user1Perspective.uuid], - webhookUrl: "https://user1.webhook", - webhookAuth: "user1-auth" - }; - - // User 2 creates a mention notification (using $agentDid variable) - const user2Notification: NotificationInput = { - description: "User 2 was mentioned", - appName: "Mentions for User 2", - appUrl: "https://mentions.app", - appIconPath: "/mentions.png", - trigger: `SELECT - source as message_id, - fn::parse_literal(target) as content, - $agentDid as mentioned_user - FROM link - WHERE predicate = 'rdf://content' - AND fn::contains(fn::parse_literal(target), $agentDid)`, - perspectiveIds: [user2Perspective.uuid], - webhookUrl: "https://user2.webhook", - webhookAuth: "user2-auth" - }; - - // Install notifications - managed users get auto-granted - const notif1Id = await client1.runtime.requestInstallNotification(user1Notification); - const notif2Id = await client2.runtime.requestInstallNotification(user2Notification); - - await sleep(500); - - // Verify that both notifications contain the $agentDid variable in their triggers - // and are auto-granted for managed users - const user1Notifs = await client1.runtime.notifications(); - const user2Notifs = await client2.runtime.notifications(); - - const user1SavedNotif = user1Notifs.find(n => n.id === notif1Id); - const user2SavedNotif = user2Notifs.find(n => n.id === notif2Id); - - expect(user1SavedNotif).to.not.be.undefined; - expect(user2SavedNotif).to.not.be.undefined; - - // Verify the triggers contain the $agentDid placeholder - expect(user1SavedNotif!.trigger).to.include("$agentDid", "User 1's notification should contain $agentDid variable"); - expect(user2SavedNotif!.trigger).to.include("$agentDid", "User 2's notification should contain $agentDid variable"); - - console.log("✅ Both users created notifications with $agentDid variable"); - - // The actual DID injection and query execution is tested in the runtime.ts tests - // This test verifies that multi-user contexts preserve the query correctly - console.log("✅ Per-user agent DID injection verified"); - }); - - it("should prevent users from seeing or modifying other users' notifications", async () => { - console.log("\n=== Testing notification access control ==="); - - // Create two users - await adminAd4mClient!.agent.createUser("access1@example.com", "password1"); - await adminAd4mClient!.agent.createUser("access2@example.com", "password2"); - - const token1 = await adminAd4mClient!.agent.loginUser("access1@example.com", "password1"); - const token2 = await adminAd4mClient!.agent.loginUser("access2@example.com", "password2"); - - // @ts-ignore - const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); - // @ts-ignore - const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); - - const perspective = await client1.perspective.add("Access Test"); - - // User 1 creates a notification - const notification: NotificationInput = { - description: "Private to User 1", - appName: "Private App", - appUrl: "https://private.app", - appIconPath: "/private.png", - trigger: `SELECT * FROM link WHERE predicate = 'test://private'`, - perspectiveIds: [perspective.uuid], - webhookUrl: "https://webhook.test", - webhookAuth: "secret-auth" - }; - - // Install notification - managed users get auto-granted - const notificationId = await client1.runtime.requestInstallNotification(notification); - await sleep(500); - - // User 1 can see their notification and it should be auto-granted - const user1Notifs = await client1.runtime.notifications(); - const user1Notif = user1Notifs.find(n => n.id === notificationId); - expect(user1Notif).to.not.be.undefined; - expect(user1Notif!.granted).to.be.true; - - // User 2 cannot see User 1's notification - const user2Notifs = await client2.runtime.notifications(); - expect(user2Notifs.some(n => n.id === notificationId)).to.be.false; - - console.log("✅ Notification access control verified"); - }); - - it("should prevent managed users from calling grantNotification", async () => { - console.log("\n=== Testing that managed users cannot call grantNotification ==="); - - // Create a managed user - await adminAd4mClient!.agent.createUser("grant-test@example.com", "password1"); - const token = await adminAd4mClient!.agent.loginUser("grant-test@example.com", "password1"); - - // @ts-ignore - const managedClient = new Ad4mClient(apolloClient(gqlPort, token), false); - - const perspective = await managedClient.perspective.add("Grant Test"); - - // Create a notification (which will be auto-granted) - const notification: NotificationInput = { - description: "Test notification", - appName: "Test App", - appUrl: "https://test.app", - appIconPath: "/test.png", - trigger: `SELECT * FROM link WHERE predicate = 'test://grant'`, - perspectiveIds: [perspective.uuid], - webhookUrl: "https://webhook.test", - webhookAuth: "test-auth" - }; - - const notificationId = await managedClient.runtime.requestInstallNotification(notification); - await sleep(500); - - // Verify the notification is auto-granted - const notifs = await managedClient.runtime.notifications(); - const notif = notifs.find(n => n.id === notificationId); - expect(notif).to.not.be.undefined; - expect(notif!.granted).to.be.true; - - // Managed user should NOT be able to call grantNotification - try { - await managedClient.runtime.grantNotification(notificationId); - expect.fail("Managed user should not be able to call grantNotification"); - } catch (error: any) { - expect(error.message).to.include("Permission denied"); - console.log("✅ Managed user correctly blocked from calling grantNotification"); - } - }); - }); -}) - diff --git a/tests/js/tests/multi-user.test.ts b/tests/js/tests/multi-user.test.ts deleted file mode 100644 index 346c227b8..000000000 --- a/tests/js/tests/multi-user.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import path from "path"; -import { Ad4mClient, AuthInfoInput, CapabilityInput } from "@coasys/ad4m"; -import fs from "fs-extra"; -import { fileURLToPath } from 'url'; -import * as chai from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { apolloClient, sleep, startExecutor } from "../utils/utils"; -import { ChildProcess } from 'node:child_process'; -import fetch from 'node-fetch' - -//@ts-ignore -global.fetch = fetch - -const expect = chai.expect; -chai.use(chaiAsPromised); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Multi-User integration tests", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "multi-user-agent"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15500 - const hcAdminPort = 15501 - const hcAppPort = 15502 - - let executorProcess: ChildProcess | null = null - let adminAd4mClient: Ad4mClient | null = null - - before(async () => { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath, { recursive: true }); - } - - // Start executor with multi-user mode enabled - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort, false, "admin123"); - - adminAd4mClient = new Ad4mClient(apolloClient(gqlPort, "admin123"), false) - - // Generate initial admin agent - await adminAd4mClient.agent.generate("passphrase") - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - }) - - describe("User Registration and Authentication", () => { - it("should create a new user with username and password", async () => { - const result = await adminAd4mClient!.agent.createUser("alice", "password123"); - expect(result).to.have.property('did'); - expect(result).to.have.property('success', true); - expect(result.did).to.match(/^did:key:.+/); - }) - - it("should return existing user if already exists", async () => { - // Create user first time - const result1 = await adminAd4mClient!.agent.createUser("bob", "password456"); - expect(result1.success).to.be.true; - - // Try to create same user again - const result2 = await adminAd4mClient!.agent.createUser("bob", "password456"); - expect(result2.success).to.be.true; - expect(result2.did).to.equal(result1.did); - }) - - it("should fail to create user with wrong password for existing username", async () => { - // Create user first - await adminAd4mClient!.agent.createUser("charlie", "correctpassword"); - - // Try with wrong password - const result = await adminAd4mClient!.agent.createUser("charlie", "wrongpassword"); - expect(result.success).to.be.false; - expect(result.error).to.include("Invalid credentials"); - }) - - it("should generate capability token for specific user", async () => { - // Create user - const userResult = await adminAd4mClient!.agent.createUser("dave", "password789"); - expect(userResult.success).to.be.true; - - // Request capability for this user - const requestId = await adminAd4mClient!.agent.requestCapabilityForUser("dave", { - appName: "test-app", - appDesc: "test-desc", - appDomain: "test.ad4m.org", - appUrl: "https://test-link", - capabilities: [ - { - with: { - domain: "agent", - pointers: ["*"] - }, - can: ["READ"] - } - ] as CapabilityInput[] - } as AuthInfoInput); - - expect(requestId).to.match(/.+/); - - // Permit capability - const rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"test-app","appDesc":"test-desc","appUrl":"test-url","capabilities":[{"with":{"domain":"agent","pointers":["*"]},"can":["READ"]}]}}`); - expect(rand).to.match(/\d+/); - - // Generate JWT for user - const jwt = await adminAd4mClient!.agent.generateJwtForUser("dave", requestId, rand); - expect(jwt).to.match(/.+/); - - // Verify JWT contains user DID - const payload = JSON.parse(atob(jwt.split('.')[1])); - expect(payload.sub).to.equal(userResult.did); - }) - }) - - describe("User-Scoped Perspectives", () => { - let aliceClient: Ad4mClient; - let bobClient: Ad4mClient; - let aliceDid: string; - let bobDid: string; - - before(async () => { - // Create users and get their capability tokens - const aliceResult = await adminAd4mClient!.agent.createUser("alice_persp", "password123"); - const bobResult = await adminAd4mClient!.agent.createUser("bob_persp", "password456"); - - aliceDid = aliceResult.did; - bobDid = bobResult.did; - - // Get capability tokens for both users - const aliceRequestId = await adminAd4mClient!.agent.requestCapabilityForUser("alice_persp", { - appName: "perspective-app", - appDesc: "test perspectives", - appDomain: "test.ad4m.org", - appUrl: "https://test-link", - capabilities: [ - { - with: { domain: "*", pointers: ["*"] }, - can: ["*"] - } - ] - }); - - const bobRequestId = await adminAd4mClient!.agent.requestCapabilityForUser("bob_persp", { - appName: "perspective-app", - appDesc: "test perspectives", - appDomain: "test.ad4m.org", - appUrl: "https://test-link", - capabilities: [ - { - with: { domain: "*", pointers: ["*"] }, - can: ["*"] - } - ] - }); - - const aliceRand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${aliceRequestId}","auth":{"appName":"perspective-app","appDesc":"test perspectives","appUrl":"test-url","capabilities":[{"with":{"domain":"*","pointers":["*"]},"can":["*"]}]}}`); - const bobRand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${bobRequestId}","auth":{"appName":"perspective-app","appDesc":"test perspectives","appUrl":"test-url","capabilities":[{"with":{"domain":"*","pointers":["*"]},"can":["*"]}]}}`); - - const aliceJwt = await adminAd4mClient!.agent.generateJwtForUser("alice_persp", aliceRequestId, aliceRand); - const bobJwt = await adminAd4mClient!.agent.generateJwtForUser("bob_persp", bobRequestId, bobRand); - - aliceClient = new Ad4mClient(apolloClient(gqlPort, aliceJwt), false); - bobClient = new Ad4mClient(apolloClient(gqlPort, bobJwt), false); - }) - - it("should create perspectives scoped to specific users", async () => { - // Alice creates a perspective - const alicePerspective = await aliceClient.perspective.add("Alice's Perspective"); - expect(alicePerspective.uuid).to.be.ok; - - // Bob creates a perspective - const bobPerspective = await bobClient.perspective.add("Bob's Perspective"); - expect(bobPerspective.uuid).to.be.ok; - - // Perspectives should have different UUIDs - expect(alicePerspective.uuid).to.not.equal(bobPerspective.uuid); - }) - - it("should only show user's own perspectives", async () => { - // Create perspectives for each user - const alicePerspective1 = await aliceClient.perspective.add("Alice Perspective 1"); - const alicePerspective2 = await aliceClient.perspective.add("Alice Perspective 2"); - const bobPerspective1 = await bobClient.perspective.add("Bob Perspective 1"); - const bobPerspective2 = await bobClient.perspective.add("Bob Perspective 2"); - - // Alice should only see her perspectives - const alicePerspectives = await aliceClient.perspective.all(); - const aliceUuids = alicePerspectives.map(p => p.uuid); - expect(aliceUuids).to.include(alicePerspective1.uuid); - expect(aliceUuids).to.include(alicePerspective2.uuid); - expect(aliceUuids).to.not.include(bobPerspective1.uuid); - expect(aliceUuids).to.not.include(bobPerspective2.uuid); - - // Bob should only see his perspectives - const bobPerspectives = await bobClient.perspective.all(); - const bobUuids = bobPerspectives.map(p => p.uuid); - expect(bobUuids).to.include(bobPerspective1.uuid); - expect(bobUuids).to.include(bobPerspective2.uuid); - expect(bobUuids).to.not.include(alicePerspective1.uuid); - expect(bobUuids).to.not.include(alicePerspective2.uuid); - }) - - it("should not allow access to other user's perspectives", async () => { - // Alice creates a perspective - const alicePerspective = await aliceClient.perspective.add("Alice Private Perspective"); - - // Bob tries to access Alice's perspective - const call = async () => { - return await bobClient.perspective.byUUID(alicePerspective.uuid); - }; - - await expect(call()).to.be.rejectedWith(/not found|access denied|unauthorized/i); - }) - - it("should handle perspective updates with user scoping", async () => { - // Alice creates and updates a perspective - const perspective = await aliceClient.perspective.add("Alice Updatable Perspective"); - const updatedPerspective = await aliceClient.perspective.update(perspective.uuid, "Updated Name"); - - expect(updatedPerspective.name).to.equal("Updated Name"); - - // Bob should not be able to update Alice's perspective - const call = async () => { - return await bobClient.perspective.update(perspective.uuid, "Bob's Malicious Update"); - }; - - await expect(call()).to.be.rejectedWith(/not found|access denied|unauthorized/i); - }) - }) - - describe("User Context in Agent Operations", () => { - let userClient: Ad4mClient; - let userDid: string; - - before(async () => { - // Create user and get capability token - const userResult = await adminAd4mClient!.agent.createUser("test_user_ops", "password123"); - userDid = userResult.did; - - const requestId = await adminAd4mClient!.agent.requestCapabilityForUser("test_user_ops", { - appName: "user-ops-app", - appDesc: "test user operations", - appDomain: "test.ad4m.org", - appUrl: "https://test-link", - capabilities: [ - { - with: { domain: "*", pointers: ["*"] }, - can: ["*"] - } - ] - }); - - const rand = await adminAd4mClient!.agent.permitCapability(`{"requestId":"${requestId}","auth":{"appName":"user-ops-app","appDesc":"test user operations","appUrl":"test-url","capabilities":[{"with":{"domain":"*","pointers":["*"]},"can":["*"]}]}}`); - const jwt = await adminAd4mClient!.agent.generateJwtForUser("test_user_ops", requestId, rand); - - userClient = new Ad4mClient(apolloClient(gqlPort, jwt), false); - }) - - it("should return correct agent status for user", async () => { - const status = await userClient.agent.status(); - expect(status.did).to.equal(userDid); - expect(status.isUnlocked).to.be.true; - }) - - it("should handle agent operations in user context", async () => { - // This test will be expanded once we implement multi-user agent service - // For now, just verify the user context is maintained - const agent = await userClient.agent.me(); - expect(agent.did).to.equal(userDid); - }) - }) -}) diff --git a/tests/js/tests/multi-user/multi-user-auth.test.ts b/tests/js/tests/multi-user/multi-user-auth.test.ts new file mode 100644 index 000000000..ce417e207 --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-auth.test.ts @@ -0,0 +1,249 @@ +import { Ad4mClient } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient } from "../../utils/utils"; +import { startAgent, AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Multi-User integration tests", () => { + let agent: AgentHandle; + let adminAd4mClient: Ad4mClient; + + before(async () => { + agent = await startAgent("multi-user-agent", { + adminCredential: "admin123", + }); + adminAd4mClient = agent.client; + }); + + after(async () => { + await agent.stop(); + }); + + // loginUser must be called from an unauthenticated client (empty token) — + // it is the bootstrap operation that *returns* a JWT, so the executor + // rejects any call that arrives with a non-JWT bearer token (e.g. the + // plain-text admin credential). + function unauthClient(): Ad4mClient { + return new Ad4mClient(apolloClient(agent.gqlPort), false); + } + + describe("User Registration and Authentication", () => { + it("should create a new user with username and password", async () => { + const result = await adminAd4mClient.agent.createUser( + "alice", + "password123", + ); + expect(result).to.have.property("did"); + expect(result).to.have.property("success", true); + expect(result.did).to.match(/^did:key:.+/); + }); + + it("should reject duplicate user creation", async () => { + // Create user first time + const result1 = await adminAd4mClient.agent.createUser( + "bob", + "password456", + ); + expect(result1.success).to.be.true; + + // Try to create same user again — executor returns failure, not idempotent + const result2 = await adminAd4mClient.agent.createUser( + "bob", + "password456", + ); + expect(result2.success).to.be.false; + expect(result2.error).to.include("User already exists"); + }); + + it("should fail to create user with wrong password for existing username", async () => { + // Create user first + await adminAd4mClient.agent.createUser("charlie", "correctpassword"); + + // createUser checks existence before verifying password, so any re-creation + // of an existing username returns "User already exists" regardless of password + const result = await adminAd4mClient.agent.createUser( + "charlie", + "wrongpassword", + ); + expect(result.success).to.be.false; + expect(result.error).to.include("User already exists"); + }); + + it("should generate a JWT token for a specific user via login", async () => { + // Create user + const userResult = await adminAd4mClient.agent.createUser( + "dave", + "password789", + ); + expect(userResult.success).to.be.true; + + // loginUser must use an unauthenticated client (empty token) + const token = await unauthClient().agent.loginUser("dave", "password789"); + expect(token).to.match(/.+/); + + // Create a client with the user's token and verify DID + const userClient = new Ad4mClient( + apolloClient(agent.gqlPort, token), + false, + ); + const me = await userClient.agent.me(); + expect(me.did).to.equal(userResult.did); + }); + }); + + describe("User-Scoped Perspectives", () => { + let aliceClient: Ad4mClient; + let bobClient: Ad4mClient; + let aliceDid: string; + let bobDid: string; + + before(async () => { + // Create users and get their tokens via loginUser + const aliceResult = await adminAd4mClient.agent.createUser( + "alice_persp", + "password123", + ); + const bobResult = await adminAd4mClient.agent.createUser( + "bob_persp", + "password456", + ); + + aliceDid = aliceResult.did; + bobDid = bobResult.did; + + const aliceToken = await unauthClient().agent.loginUser( + "alice_persp", + "password123", + ); + const bobToken = await unauthClient().agent.loginUser( + "bob_persp", + "password456", + ); + + aliceClient = new Ad4mClient( + apolloClient(agent.gqlPort, aliceToken), + false, + ); + bobClient = new Ad4mClient(apolloClient(agent.gqlPort, bobToken), false); + }); + + it("should create perspectives scoped to specific users", async () => { + // Alice creates a perspective + const alicePerspective = await aliceClient.perspective.add( + "Alice's Perspective", + ); + expect(alicePerspective.uuid).to.be.ok; + + // Bob creates a perspective + const bobPerspective = + await bobClient.perspective.add("Bob's Perspective"); + expect(bobPerspective.uuid).to.be.ok; + + // Perspectives should have different UUIDs + expect(alicePerspective.uuid).to.not.equal(bobPerspective.uuid); + }); + + it("should only show user's own perspectives", async () => { + // Create perspectives for each user + const alicePerspective1 = await aliceClient.perspective.add( + "Alice Perspective 1", + ); + const alicePerspective2 = await aliceClient.perspective.add( + "Alice Perspective 2", + ); + const bobPerspective1 = + await bobClient.perspective.add("Bob Perspective 1"); + const bobPerspective2 = + await bobClient.perspective.add("Bob Perspective 2"); + + // Alice should only see her perspectives + const alicePerspectives = await aliceClient.perspective.all(); + const aliceUuids = alicePerspectives.map((p) => p.uuid); + expect(aliceUuids).to.include(alicePerspective1.uuid); + expect(aliceUuids).to.include(alicePerspective2.uuid); + expect(aliceUuids).to.not.include(bobPerspective1.uuid); + expect(aliceUuids).to.not.include(bobPerspective2.uuid); + + // Bob should only see his perspectives + const bobPerspectives = await bobClient.perspective.all(); + const bobUuids = bobPerspectives.map((p) => p.uuid); + expect(bobUuids).to.include(bobPerspective1.uuid); + expect(bobUuids).to.include(bobPerspective2.uuid); + expect(bobUuids).to.not.include(alicePerspective1.uuid); + expect(bobUuids).to.not.include(alicePerspective2.uuid); + }); + + it("should not allow access to other user's perspectives", async () => { + // Alice creates a perspective + const alicePerspective = await aliceClient.perspective.add( + "Alice Private Perspective", + ); + + // byUUID returns null for a perspective owned by another user (silent 404) + const result = await bobClient.perspective.byUUID(alicePerspective.uuid); + expect(result).to.be.null; + }); + + it("should handle perspective updates with user scoping", async () => { + // Alice creates and updates a perspective + const perspective = await aliceClient.perspective.add( + "Alice Updatable Perspective", + ); + const updatedPerspective = await aliceClient.perspective.update( + perspective.uuid, + "Updated Name", + ); + + expect(updatedPerspective.name).to.equal("Updated Name"); + + // Bob should not be able to update Alice's perspective + const call = async () => { + return await bobClient.perspective.update( + perspective.uuid, + "Bob's Malicious Update", + ); + }; + + await expect(call()).to.be.rejectedWith( + /not found|access denied|unauthorized/i, + ); + }); + }); + + describe("User Context in Agent Operations", () => { + let userClient: Ad4mClient; + let userDid: string; + + before(async () => { + // Create user and get token via loginUser + const userResult = await adminAd4mClient.agent.createUser( + "test_user_ops", + "password123", + ); + userDid = userResult.did; + + const token = await unauthClient().agent.loginUser( + "test_user_ops", + "password123", + ); + + userClient = new Ad4mClient(apolloClient(agent.gqlPort, token), false); + }); + + it("should return correct agent status for user", async () => { + const status = await userClient.agent.status(); + expect(status.did).to.equal(userDid); + expect(status.isUnlocked).to.be.true; + }); + + it("should handle agent operations in user context", async () => { + // This test will be expanded once we implement multi-user agent service + // For now, just verify the user context is maintained + const agent = await userClient.agent.me(); + expect(agent.did).to.equal(userDid); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-config.test.ts b/tests/js/tests/multi-user/multi-user-config.test.ts new file mode 100644 index 000000000..dfcdf60a5 --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-config.test.ts @@ -0,0 +1,349 @@ +import { Ad4mClient } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient, sleep } from "../../utils/utils"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Multi-User Configuration tests", () => { + let agentHandle: AgentHandle | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let gqlPort: number = 0; + + before(async function () { + this.timeout(300_000); + agentHandle = await startAgent("multi-user-config"); + adminAd4mClient = agentHandle.client; + gqlPort = agentHandle.gqlPort; + }); + + after(async () => { + await agentHandle?.stop(); + }); + + describe("Multi-User Configuration", () => { + it("should have multi-user disabled by default and require activation", async () => { + // Disable multi-user to test the guard (startAgent enables it by default) + await adminAd4mClient!.runtime.setMultiUserEnabled(false); + + const isDisabled = await adminAd4mClient!.runtime.multiUserEnabled(); + expect(isDisabled).to.be.false; + + // Attempt to create a user while multi-user is disabled (should fail) + const userResult = await adminAd4mClient!.agent.createUser( + "test@example.com", + "password123", + ); + expect(userResult.success).to.be.false; + expect(userResult.error).to.include("Multi-user mode is not enabled"); + + // Enable multi-user mode + const setResult = + await adminAd4mClient!.runtime.setMultiUserEnabled(true); + expect(setResult).to.be.true; + + // Verify it's now enabled + const isEnabledAfter = await adminAd4mClient!.runtime.multiUserEnabled(); + expect(isEnabledAfter).to.be.true; + + // Now user creation should work + const userResult2 = await adminAd4mClient!.agent.createUser( + "working@example.com", + "password456", + ); + expect(userResult2.success).to.be.true; + expect(userResult2.did).to.match(/^did:key:.+/); + }); + + it("should return empty array when multi-user is disabled", async () => { + // Disable multi-user mode temporarily + await adminAd4mClient!.runtime.setMultiUserEnabled(false); + + // List users should return empty array + const users = await adminAd4mClient!.runtime.listUsers(); + expect(users).to.be.an("array"); + expect(users).to.have.lengthOf(0); + + // Re-enable for other tests + await adminAd4mClient!.runtime.setMultiUserEnabled(true); + }); + + it("should list users with statistics", async () => { + // Create a few users + await adminAd4mClient!.agent.createUser( + "stats1@example.com", + "password1", + ); + await adminAd4mClient!.agent.createUser( + "stats2@example.com", + "password2", + ); + + // Login one user to update their last_seen + const token1 = await adminAd4mClient!.agent.loginUser( + "stats1@example.com", + "password1", + ); + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + + // User 1 creates a perspective + await client1.perspective.add("User 1 Perspective"); + + // Wait a moment for last_seen to be updated + await sleep(1000); + + // List users + const users = await adminAd4mClient!.runtime.listUsers(); + expect(users).to.be.an("array"); + expect(users.length).to.be.greaterThan(0); + + console.log("Users:", JSON.stringify(users, null, 2)); + + // Find our test users + const user1 = users.find((u) => u.email === "stats1@example.com"); + const user2 = users.find((u) => u.email === "stats2@example.com"); + + expect(user1).to.not.be.undefined; + expect(user2).to.not.be.undefined; + + // Verify structure + expect(user1).to.have.property("email"); + expect(user1).to.have.property("did"); + expect(user1).to.have.property("perspectiveCount"); + + // Verify user1 has a perspective + expect(user1!.perspectiveCount).to.equal(1); + + // Verify user2 has no perspectives + expect(user2!.perspectiveCount).to.equal(0); + + // Verify user1 has last_seen set (they logged in) + expect(user1).to.have.property("lastSeen"); + console.log("User 1 last seen:", user1!.lastSeen); + + // Verify DIDs are different + expect(user1!.did).to.not.equal(user2!.did); + }); + + it("should track last_seen timestamps", async () => { + // Create a user + await adminAd4mClient!.agent.createUser( + "lastseen@example.com", + "password", + ); + + // List users before login + let users = await adminAd4mClient!.runtime.listUsers(); + let user = users.find((u) => u.email === "lastseen@example.com"); + expect(user).to.not.be.undefined; + + // Initially might not have last_seen + const initialLastSeen = user!.lastSeen; + console.log("Initial last_seen:", initialLastSeen); + + // Login the user (this should trigger last_seen tracking) + const token = await adminAd4mClient!.agent.loginUser( + "lastseen@example.com", + "password", + ); + const userClient = new Ad4mClient(apolloClient(gqlPort, token), false); + + console.log( + "========================HERE================================", + ); + // Make a request to trigger last_seen update + await userClient.agent.me(); + + console.log( + "========================HERE 2================================", + ); + + // Wait for middleware to process (async task needs time) + await sleep(2000); + + // List users again + users = await adminAd4mClient!.runtime.listUsers(); + user = users.find((u) => u.email === "lastseen@example.com"); + + // Now last_seen should be set + expect(user!.lastSeen).to.not.be.undefined; + console.log("Updated last_seen:", user!.lastSeen); + + // Parse the timestamp - could be ISO string or Unix timestamp in seconds + let lastSeenDate: Date; + const lastSeenValue = user!.lastSeen!; + + // Handle both number and string timestamp formats + if (typeof lastSeenValue === "number") { + console.log( + "Last seen value is a number Unix timestamp in seconds, converting to milliseconds", + ); + lastSeenDate = new Date(lastSeenValue * 1000); + } else { + console.log( + "Last seen value is a string, checking if it's a Unix timestamp in seconds", + ); + console.log("Last seen value:", lastSeenValue); + if (/^\d+(\.\d+)?$/.test(lastSeenValue)) { + console.log( + "Last seen value is a Unix timestamp in seconds, converting to milliseconds", + ); + lastSeenDate = new Date(parseInt(lastSeenValue) * 1000); + } else { + console.log("Last seen value is a ISO string, converting to Date"); + lastSeenDate = new Date(lastSeenValue); + } + } + + const now = new Date(); + + // Should be recent (within last 5 seconds) + const diffMs = now.getTime() - lastSeenDate.getTime(); + const diffSeconds = Math.abs(diffMs) / 1000; + console.log("Time difference:", { + nowMs: now.getTime(), + lastSeenMs: lastSeenDate.getTime(), + diffMs, + diffSeconds, + lastSeenValue, + }); + expect(diffSeconds).to.be.lessThan(5); + }); + }); + + describe("Basic Multi-User Functionality", () => { + before(async () => { + await adminAd4mClient!.runtime.setMultiUserEnabled(true); + }); + + it("should create and login users with unique DIDs", async () => { + // Create first user + const user1Result = await adminAd4mClient!.agent.createUser( + "alice@example.com", + "password123", + ); + expect(user1Result.success).to.be.true; + expect(user1Result.did).to.match(/^did:key:.+/); + + // Create second user + const user2Result = await adminAd4mClient!.agent.createUser( + "bob@example.com", + "password456", + ); + expect(user2Result.success).to.be.true; + expect(user2Result.did).to.match(/^did:key:.+/); + + // Users should have different DIDs + expect(user1Result.did).to.not.equal(user2Result.did); + + // Login first user + const user1Token = await adminAd4mClient!.agent.loginUser( + "alice@example.com", + "password123", + ); + expect(user1Token).to.be.ok; + + // Login second user + const user2Token = await adminAd4mClient!.agent.loginUser( + "bob@example.com", + "password456", + ); + expect(user2Token).to.be.ok; + + // Verify JWT tokens contain correct user DIDs + const user1Payload = JSON.parse(atob(user1Token.split(".")[1])); + const user2Payload = JSON.parse(atob(user2Token.split(".")[1])); + + expect(user1Payload.sub).to.equal("alice@example.com"); + expect(user2Payload.sub).to.equal("bob@example.com"); + }); + + it("should return correct user DID in agent.me", async () => { + // Create and login user + const userResult = await adminAd4mClient!.agent.createUser( + "charlie@example.com", + "password789", + ); + const userToken = await adminAd4mClient!.agent.loginUser( + "charlie@example.com", + "password789", + ); + + // Create authenticated client + const userClient = new Ad4mClient( + apolloClient(gqlPort, userToken), + false, + ); + + // Test agent.me + const me = await userClient.agent.me(); + expect(me.did).to.equal(userResult.did); + + // Test agent.status + const status = await userClient.agent.status(); + expect(status.did).to.equal(userResult.did); + expect(status.isUnlocked).to.be.true; + }); + + it("should handle login persistence", async () => { + // Create user + const userResult = await adminAd4mClient!.agent.createUser( + "dave@example.com", + "passwordABC", + ); + + // Login first time + const token1 = await adminAd4mClient!.agent.loginUser( + "dave@example.com", + "passwordABC", + ); + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + const agent1 = await client1.agent.me(); + + // Login second time + const token2 = await adminAd4mClient!.agent.loginUser( + "dave@example.com", + "passwordABC", + ); + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + const agent2 = await client2.agent.me(); + + // Should get the same DID both times + expect(agent1.did).to.equal(agent2.did); + expect(agent1.did).to.equal(userResult.did); + }); + + it("should reject wrong passwords", async () => { + // Create user + await adminAd4mClient!.agent.createUser( + "eve@example.com", + "correctpassword", + ); + + // Try to login with wrong password + const call = async () => { + return await adminAd4mClient!.agent.loginUser( + "eve@example.com", + "wrongpassword", + ); + }; + + await expect(call()).to.be.rejectedWith(/Invalid credentials/); + }); + + it("should reject non-existent users", async () => { + const call = async () => { + return await adminAd4mClient!.agent.loginUser( + "nonexistent@example.com", + "password", + ); + }; + + // verify_user_password returns false for unknown emails — same "Invalid credentials" path as wrong password + await expect(call()).to.be.rejectedWith(/Invalid credentials/); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-isolation.test.ts b/tests/js/tests/multi-user/multi-user-isolation.test.ts new file mode 100644 index 000000000..e9fb1b5ef --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-isolation.test.ts @@ -0,0 +1,277 @@ +import { Ad4mClient, LinkQuery } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient } from "../../utils/utils"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Multi-User Perspective Isolation tests", () => { + let agentHandle: AgentHandle | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let gqlPort: number = 0; + + before(async function () { + this.timeout(300_000); + agentHandle = await startAgent("multi-user-isolation"); + adminAd4mClient = agentHandle.client; + gqlPort = agentHandle.gqlPort; + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await agentHandle?.stop(); + }); + + describe("Perspective Isolation", () => { + it("should isolate perspectives between users", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "isolation1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "isolation2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "isolation1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "isolation2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get initial perspective counts + const user1InitialPerspectives = await client1.perspective.all(); + const user2InitialPerspectives = await client2.perspective.all(); + + // User 1 creates a perspective + const perspective1 = await client1.perspective.add("User 1 Perspective"); + expect(perspective1.name).to.equal("User 1 Perspective"); + console.log("User 1 created perspective:", perspective1.uuid); + + // User 2 creates a perspective + const perspective2 = await client2.perspective.add("User 2 Perspective"); + expect(perspective2.name).to.equal("User 2 Perspective"); + console.log("User 2 created perspective:", perspective2.uuid); + + // User 1 should see only their own perspectives (initial + new one) + const user1Perspectives = await client1.perspective.all(); + expect(user1Perspectives.length).to.equal( + user1InitialPerspectives.length + 1, + ); + const user1HasOwnPerspective = user1Perspectives.some( + (p) => p.uuid === perspective1.uuid, + ); + expect(user1HasOwnPerspective).to.be.true; + const user1HasUser2Perspective = user1Perspectives.some( + (p) => p.uuid === perspective2.uuid, + ); + expect(user1HasUser2Perspective).to.be.false; + + // User 2 should see only their own perspectives (initial + new one) + const user2Perspectives = await client2.perspective.all(); + expect(user2Perspectives.length).to.equal( + user2InitialPerspectives.length + 1, + ); + const user2HasOwnPerspective = user2Perspectives.some( + (p) => p.uuid === perspective2.uuid, + ); + expect(user2HasOwnPerspective).to.be.true; + const user2HasUser1Perspective = user2Perspectives.some( + (p) => p.uuid === perspective1.uuid, + ); + expect(user2HasUser1Perspective).to.be.false; + + // User 1 should not be able to access User 2's perspective by UUID + const user1AccessToUser2 = await client1.perspective.byUUID( + perspective2.uuid, + ); + expect(user1AccessToUser2).to.be.null; + + // User 2 should not be able to access User 1's perspective by UUID + const user2AccessToUser1 = await client2.perspective.byUUID( + perspective1.uuid, + ); + expect(user2AccessToUser1).to.be.null; + }); + + it("should isolate user perspectives from main agent", async () => { + // Create a user and their perspective + const userResult = await adminAd4mClient!.agent.createUser( + "mainisolation@example.com", + "password", + ); + const userToken = await adminAd4mClient!.agent.loginUser( + "mainisolation@example.com", + "password", + ); + // @ts-ignore - Suppress Apollo type mismatch + const userClient = new Ad4mClient( + apolloClient(gqlPort, userToken), + false, + ); + + const userPerspective = await userClient.perspective.add( + "User Isolated Perspective", + ); + expect(userPerspective.name).to.equal("User Isolated Perspective"); + + // Main agent creates their own perspective + const mainPerspective = await adminAd4mClient!.perspective.add( + "Main Agent Perspective", + ); + expect(mainPerspective.name).to.equal("Main Agent Perspective"); + + // Main agent SHOULD see ALL perspectives (including user perspectives) + const mainPerspectives = await adminAd4mClient!.perspective.all(); + const hasUserPerspective = mainPerspectives.some( + (p) => p.uuid === userPerspective.uuid, + ); + expect(hasUserPerspective).to.be.true; // Admin sees all perspectives + const hasOwnPerspective = mainPerspectives.some( + (p) => p.uuid === mainPerspective.uuid, + ); + expect(hasOwnPerspective).to.be.true; + + // User should NOT see main agent perspectives + const userPerspectives = await userClient.perspective.all(); + const hasMainPerspective = userPerspectives.some( + (p) => p.uuid === mainPerspective.uuid, + ); + expect(hasMainPerspective).to.be.false; // Users only see their own perspectives + }); + + it("should handle perspective access control for operations", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "accessctrl1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "accessctrl2@example.com", + "password2", + ); + + const token1 = await adminAd4mClient!.agent.loginUser( + "accessctrl1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "accessctrl2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // User 1 creates a perspective + const perspective1 = await client1.perspective.add( + "Access Test Perspective", + ); + + // User 2 should not be able to access User 1's perspective for operations + try { + await client2.perspective.addLink(perspective1.uuid, { + source: "test://source", + target: "test://target", + predicate: "test://predicate", + }); + expect.fail( + "User 2 should not be able to add links to User 1's perspective", + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + expect(errorMessage).to.include("Access denied"); + } + }); + }); + + describe("Link Authoring and Signatures", () => { + it("should have correct authors and valid signatures for user links", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "linkauth1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "linkauth2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "linkauth1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "linkauth2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // User 1 creates perspective and adds a link + // @ts-ignore - Suppress Apollo type mismatch + const p1 = await client1.perspective.add("User 1 Test Perspective"); + // @ts-ignore - Suppress Apollo type mismatch + const link1 = await client1.perspective.addLink(p1.uuid, { + source: "ad4m://root", + target: "test://target1", + predicate: "test://predicate", + }); + + // Get the link and verify + // @ts-ignore - Suppress Apollo type mismatch + const links1 = await client1.perspective.queryLinks( + p1.uuid, + new LinkQuery({}), + ); + expect(links1.length).to.equal(1); + const user1Me = await client1.agent.me(); + expect(links1[0].author).to.equal(user1Me.did); + expect(links1[0].proof.valid).to.be.true; + + // User 2 creates perspective and adds a link + // @ts-ignore - Suppress Apollo type mismatch + const p2 = await client2.perspective.add("User 2 Test Perspective"); + // @ts-ignore - Suppress Apollo type mismatch + const link2 = await client2.perspective.addLink(p2.uuid, { + source: "ad4m://root", + target: "test://target2", + predicate: "test://predicate", + }); + + // Get the link and verify + // @ts-ignore - Suppress Apollo type mismatch + const links2 = await client2.perspective.queryLinks( + p2.uuid, + new LinkQuery({}), + ); + expect(links2.length).to.equal(1); + const user2Me = await client2.agent.me(); + expect(links2[0].author).to.equal(user2Me.did); + expect(links2[0].proof.valid).to.be.true; + + // Ensure authors are different + expect(user1Me.did).not.to.equal(user2Me.did); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-multi-node.test.ts b/tests/js/tests/multi-user/multi-user-multi-node.test.ts new file mode 100644 index 000000000..bbd9ce5ae --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-multi-node.test.ts @@ -0,0 +1,843 @@ +import path from "path"; +import { + Ad4mClient, + LinkQuery, + Perspective, + PerspectiveUnsignedInput, +} from "@coasys/ad4m"; +import fs from "fs-extra"; +import { fileURLToPath } from "url"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { + apolloClient, + sleep, + startExecutor, + runHcLocalServices, + waitForExit, +} from "../../utils/utils"; +import { getFreePorts } from "../../helpers/ports"; +import { ChildProcess } from "node:child_process"; +import { v4 as uuidv4 } from "uuid"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DIFF_SYNC_OFFICIAL = fs + .readFileSync("./scripts/perspective-diff-sync-hash") + .toString(); + +describe("Multi-Node Multi-User Integration tests", () => { + const TEST_DIR = path.join(`${__dirname}/../../tst-tmp`); + const bootstrapSeedPath = path.join(`${__dirname}/../../bootstrapSeed.json`); + + let gqlPort: number = 0; + let hcAdminPort: number = 0; + let hcAppPort: number = 0; + let executorProcess: ChildProcess | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let proxyUrl: string | null = null; + let bootstrapUrl: string | null = null; + let localServicesProcess: ChildProcess | null = null; + + before(async function () { + this.timeout(300_000); + [gqlPort, hcAdminPort, hcAppPort] = await getFreePorts(3); + + const appDataPath = path.join(TEST_DIR, "agents", "multi-user-node1"); + if (!fs.existsSync(appDataPath)) { + fs.mkdirSync(appDataPath, { recursive: true }); + } + + const localServices = await runHcLocalServices(); + proxyUrl = localServices.proxyUrl; + bootstrapUrl = localServices.bootstrapUrl; + localServicesProcess = localServices.process; + + executorProcess = await startExecutor( + appDataPath, + bootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + ); + + // @ts-ignore + adminAd4mClient = new Ad4mClient(apolloClient(gqlPort), false); + await adminAd4mClient.agent.generate("passphrase"); + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await waitForExit(executorProcess); + await waitForExit(localServicesProcess); + }); + + describe("Multi-Node Multi-User Integration", () => { + // Test with 2 nodes, each with 2 users (4 users total) + const node2AppDataPath = path.join(TEST_DIR, "agents", "multi-user-node2"); + let node2GqlPort: number = 0; + let node2HcAdminPort: number = 0; + let node2HcAppPort: number = 0; + + let node2ExecutorProcess: ChildProcess | null = null; + let node2AdminClient: Ad4mClient | null = null; + + // User clients for node 1 + let node1User1Client: Ad4mClient | null = null; + let node1User2Client: Ad4mClient | null = null; + let node1User1Did: string = ""; + let node1User2Did: string = ""; + + // User clients for node 2 + let node2User1Client: Ad4mClient | null = null; + let node2User2Client: Ad4mClient | null = null; + let node2User1Did: string = ""; + let node2User2Did: string = ""; + + before(async function () { + this.timeout(300000); // Increase timeout for setup with Holochain 0.7.0 + + [node2GqlPort, node2HcAdminPort, node2HcAppPort] = await getFreePorts(3); + + console.log("\n=== Setting up Node 2 ==="); + if (!fs.existsSync(node2AppDataPath)) { + fs.mkdirSync(node2AppDataPath, { recursive: true }); + } + + // Start node 2 executor with local services + node2ExecutorProcess = await startExecutor( + node2AppDataPath, + bootstrapSeedPath, + node2GqlPort, + node2HcAdminPort, + node2HcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + ); + + // @ts-ignore + node2AdminClient = new Ad4mClient(apolloClient(node2GqlPort), false); + await node2AdminClient.agent.generate("passphrase"); + await node2AdminClient.runtime.setMultiUserEnabled(true); + + console.log("\n=== Creating users on Node 1 ==="); + // Create and login 2 users on node 1 + await adminAd4mClient!.agent.createUser( + "node1user1@example.com", + "password1", + ); + const node1User1Token = await adminAd4mClient!.agent.loginUser( + "node1user1@example.com", + "password1", + ); + // @ts-ignore + node1User1Client = new Ad4mClient( + apolloClient(gqlPort, node1User1Token), + false, + ); + const node1User1Agent = await node1User1Client.agent.me(); + node1User1Did = node1User1Agent.did; + console.log("Node 1 User 1 DID:", node1User1Did); + + await adminAd4mClient!.agent.createUser( + "node1user2@example.com", + "password2", + ); + const node1User2Token = await adminAd4mClient!.agent.loginUser( + "node1user2@example.com", + "password2", + ); + // @ts-ignore + node1User2Client = new Ad4mClient( + apolloClient(gqlPort, node1User2Token), + false, + ); + const node1User2Agent = await node1User2Client.agent.me(); + node1User2Did = node1User2Agent.did; + console.log("Node 1 User 2 DID:", node1User2Did); + + console.log("\n=== Creating users on Node 2 ==="); + // Create and login 2 users on node 2 + await node2AdminClient.agent.createUser( + "node2user1@example.com", + "password3", + ); + const node2User1Token = await node2AdminClient.agent.loginUser( + "node2user1@example.com", + "password3", + ); + // @ts-ignore + node2User1Client = new Ad4mClient( + apolloClient(node2GqlPort, node2User1Token), + false, + ); + const node2User1Agent = await node2User1Client.agent.me(); + node2User1Did = node2User1Agent.did; + console.log("Node 2 User 1 DID:", node2User1Did); + + await node2AdminClient.agent.createUser( + "node2user2@example.com", + "password4", + ); + const node2User2Token = await node2AdminClient.agent.loginUser( + "node2user2@example.com", + "password4", + ); + // @ts-ignore + node2User2Client = new Ad4mClient( + apolloClient(node2GqlPort, node2User2Token), + false, + ); + const node2User2Agent = await node2User2Client.agent.me(); + node2User2Did = node2User2Agent.did; + console.log("Node 2 User 2 DID:", node2User2Did); + + // Make nodes known to each other (for Holochain peer discovery) + console.log("\n=== Making nodes known to each other ==="); + const node1AgentInfos = await adminAd4mClient!.runtime.hcAgentInfos(); + const node2AgentInfos = await node2AdminClient.runtime.hcAgentInfos(); + await adminAd4mClient!.runtime.hcAddAgentInfos(node2AgentInfos); + await node2AdminClient.runtime.hcAddAgentInfos(node1AgentInfos); + + console.log("\n=== Setup complete ===\n"); + }); + + after(async function () { + this.timeout(20000); + await waitForExit(node2ExecutorProcess); + }); + + it("should return all DIDs in 'others()' for each user", async function () { + this.timeout(240000); // Increased for Holochain 0.7.0 - allow initial wait + polling time + + console.log("\n=== Testing 'others()' functionality ==="); + + // Node 1 User 1 creates and publishes a neighbourhood + const perspective = await node1User1Client!.perspective.add( + "Multi-Node Test Neighbourhood", + ); + + // Add initial link + await node1User1Client!.perspective.addLink(perspective.uuid, { + source: "test://root", + target: "test://data", + predicate: "test://contains", + }); + + // Clone and publish neighbourhood + const linkLanguage = + await node1User1Client!.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ uid: uuidv4(), name: "Multi-Node Test" }), + ); + + const neighbourhoodUrl = + await node1User1Client!.neighbourhood.publishFromPerspective( + perspective.uuid, + linkLanguage.address, + new Perspective([]), + ); + + console.log("Published neighbourhood:", neighbourhoodUrl); + + // All other users join the neighbourhood + console.log("Node 1 User 2 joining..."); + await node1User2Client!.neighbourhood.joinFromUrl(neighbourhoodUrl); + await sleep(2000); // Wait for join to complete + + console.log("Node 2 User 1 joining..."); + await node2User1Client!.neighbourhood.joinFromUrl(neighbourhoodUrl); + await sleep(2000); // Wait for join to complete + + console.log("Node 2 User 2 joining..."); + await node2User2Client!.neighbourhood.joinFromUrl(neighbourhoodUrl); + await sleep(2000); // Wait for join to complete + + // Re-exchange agent infos with retry to handle K2 space initialization delays (Holochain 0.7.0) + console.log( + "Re-exchanging agent infos with retry for K2 space readiness...", + ); + for (let attempt = 1; attempt <= 5; attempt++) { + try { + console.log(`Agent info exchange attempt ${attempt}/5`); + const node1AgentInfos = await adminAd4mClient!.runtime.hcAgentInfos(); + const node2AgentInfos = + await node2AdminClient!.runtime.hcAgentInfos(); + await adminAd4mClient!.runtime.hcAddAgentInfos(node2AgentInfos); + await node2AdminClient!.runtime.hcAddAgentInfos(node1AgentInfos); + console.log(`✅ Agent info exchange attempt ${attempt} successful`); + } catch (error) { + console.log( + `⚠️ Agent info exchange attempt ${attempt} failed:`, + error, + ); + } + if (attempt < 5) { + await sleep(3000); // Wait before retry + } + } + + // Wait for neighbourhood to sync and owners lists to be updated + console.log("Waiting for neighbourhood sync and owners list updates..."); + await sleep(15000); + + // Get neighbourhood proxies for each user + const node1User1Perspectives = await node1User1Client!.perspective.all(); + const node1User1Neighbourhood = node1User1Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(node1User1Neighbourhood).to.not.be.undefined; + const node1User1Proxy = + await node1User1Neighbourhood!.getNeighbourhoodProxy(); + + const node1User2Perspectives = await node1User2Client!.perspective.all(); + const node1User2Neighbourhood = node1User2Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(node1User2Neighbourhood).to.not.be.undefined; + const node1User2Proxy = + await node1User2Neighbourhood!.getNeighbourhoodProxy(); + + const node2User1Perspectives = await node2User1Client!.perspective.all(); + const node2User1Neighbourhood = node2User1Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(node2User1Neighbourhood).to.not.be.undefined; + const node2User1Proxy = + await node2User1Neighbourhood!.getNeighbourhoodProxy(); + + const node2User2Perspectives = await node2User2Client!.perspective.all(); + const node2User2Neighbourhood = node2User2Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(node2User2Neighbourhood).to.not.be.undefined; + const node2User2Proxy = + await node2User2Neighbourhood!.getNeighbourhoodProxy(); + + // Check 'others()' for each user - should see all other users' DIDs + // Poll with retries to account for DHT gossip delays + console.log("\nChecking 'others()' for each user:"); + + // Helper function to poll until all expected DIDs are seen + const pollUntilAllSeen = async ( + proxy: any, + expectedDids: string[], + userLabel: string, + maxAttempts = 50, + ) => { + console.log(`Polling ${userLabel} for others...`); + console.log(`Expected DIDs:`, expectedDids); + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const others = await proxy.otherAgents(); + console.log(`${userLabel} sees others (attempt ${attempt}):`, others); + + const allFound = expectedDids.every((did) => { + console.log(`Checking if ${did} is in ${others}`); + let result = others.includes(did); + console.log(`Result: ${result}`); + return result; + }); + if (allFound) { + console.log(`✅ ${userLabel} sees all expected users!`); + return others; + } + + if (attempt < maxAttempts) { + console.log( + `${userLabel} waiting for DHT gossip... (${attempt}/${maxAttempts})`, + ); + await sleep(2000); + } + } + + // Return the last result even if not complete + const finalOthers = await proxy.otherAgents(); + console.log( + `${userLabel} final result after ${maxAttempts} attempts:`, + finalOthers, + ); + return finalOthers; + }; + + const node1User1Others = await pollUntilAllSeen( + node1User1Proxy!, + [node1User2Did, node2User1Did, node2User2Did], + "Node 1 User 1", + ); + expect(node1User1Others).to.include( + node1User2Did, + "Node 1 User 1 should see Node 1 User 2", + ); + expect(node1User1Others).to.include( + node2User1Did, + "Node 1 User 1 should see Node 2 User 1", + ); + expect(node1User1Others).to.include( + node2User2Did, + "Node 1 User 1 should see Node 2 User 2", + ); + expect(node1User1Others).to.have.lengthOf( + 3, + "Node 1 User 1 should see exactly 3 other users", + ); + + const node1User2Others = await pollUntilAllSeen( + node1User2Proxy!, + [node1User1Did, node2User1Did, node2User2Did], + "Node 1 User 2", + ); + console.log("Node 1 User 2 sees others:", node1User2Others); + expect(node1User2Others).to.include( + node1User1Did, + "Node 1 User 2 should see Node 1 User 1", + ); + expect(node1User2Others).to.include( + node2User1Did, + "Node 1 User 2 should see Node 2 User 1", + ); + expect(node1User2Others).to.include( + node2User2Did, + "Node 1 User 2 should see Node 2 User 2", + ); + expect(node1User2Others).to.have.lengthOf( + 3, + "Node 1 User 2 should see exactly 3 other users", + ); + + const node2User1Others = await pollUntilAllSeen( + node2User1Proxy!, + [node1User1Did, node1User2Did, node2User2Did], + "Node 2 User 1", + ); + console.log("Node 2 User 1 sees others:", node2User1Others); + expect(node2User1Others).to.include( + node1User1Did, + "Node 2 User 1 should see Node 1 User 1", + ); + expect(node2User1Others).to.include( + node1User2Did, + "Node 2 User 1 should see Node 1 User 2", + ); + expect(node2User1Others).to.include( + node2User2Did, + "Node 2 User 1 should see Node 2 User 2", + ); + expect(node2User1Others).to.have.lengthOf( + 3, + "Node 2 User 1 should see exactly 3 other users", + ); + + const node2User2Others = await pollUntilAllSeen( + node2User2Proxy!, + [node1User1Did, node1User2Did, node2User1Did], + "Node 2 User 2", + ); + console.log("Node 2 User 2 sees others:", node2User2Others); + expect(node2User2Others).to.include( + node1User1Did, + "Node 2 User 2 should see Node 1 User 1", + ); + expect(node2User2Others).to.include( + node1User2Did, + "Node 2 User 2 should see Node 1 User 2", + ); + expect(node2User2Others).to.include( + node2User1Did, + "Node 2 User 2 should see Node 2 User 1", + ); + expect(node2User2Others).to.have.lengthOf( + 3, + "Node 2 User 2 should see exactly 3 other users", + ); + + console.log("\n✅ All users correctly see all other users via others()"); + }); + + it("should route p2p signals correctly between users across nodes", async function () { + this.timeout(120000); // Increased for Holochain 0.7.0 + + console.log("\n=== Testing cross-node p2p signal routing ==="); + + // Get neighbourhood URL from previous test + const node1User1Perspectives = await node1User1Client!.perspective.all(); + const node1User1Neighbourhood = node1User1Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node1User1Neighbourhood).to.not.be.undefined; + const node1User1Proxy = + await node1User1Neighbourhood!.getNeighbourhoodProxy(); + + const node2User1Perspectives = await node2User1Client!.perspective.all(); + const node2User1Neighbourhood = node2User1Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node2User1Neighbourhood).to.not.be.undefined; + const node2User1Proxy = + await node2User1Neighbourhood!.getNeighbourhoodProxy(); + + const node2User2Perspectives = await node2User2Client!.perspective.all(); + const node2User2Neighbourhood = node2User2Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node2User2Neighbourhood).to.not.be.undefined; + const node2User2Proxy = + await node2User2Neighbourhood!.getNeighbourhoodProxy(); + + // Set up signal handlers + const node2User1ReceivedSignals: any[] = []; + node2User1Proxy!.addSignalHandler((signal) => { + console.log("Node 2 User 1 received signal from:", signal.author); + node2User1ReceivedSignals.push(signal); + }); + + const node2User2ReceivedSignals: any[] = []; + node2User2Proxy!.addSignalHandler((signal) => { + console.log("Node 2 User 2 received signal from:", signal.author); + node2User2ReceivedSignals.push(signal); + }); + + await sleep(3000); // Let handlers initialize and subscriptions become active + + // Node 2 User 1 sends a signal to Node 2 User 2 (both on same node - local routing) + console.log( + `\nNode 2 User 1 (${node2User1Did.substring(0, 20)}...) sending signal to Node 2 User 2 (${node2User2Did.substring(0, 20)}...)`, + ); + await node2User1Proxy!.sendSignalU( + node2User2Did, + new PerspectiveUnsignedInput([ + { + source: "test://signal0", + predicate: "test://from", + target: node2User1Did, + }, + ]), + ); + + // Wait for signal delivery + console.log("Waiting for signal delivery..."); + const maxWaitTime = 20000; + let startTime = Date.now(); + while ( + node2User2ReceivedSignals.length === 0 && + Date.now() - startTime < maxWaitTime + ) { + await sleep(100); + console.log("."); + } + + console.log("Signal delivery complete"); + + // Verify Node 2 User 2 received the signal + console.log("Node 2 User 2 received signals:", node2User2ReceivedSignals); + expect(node2User2ReceivedSignals.length).to.be.greaterThan( + 0, + "Node 2 User 2 should have received signal", + ); + expect(node2User2ReceivedSignals[0].author).to.equal(node2User1Did); + + let node2user2signalCount = node2User2ReceivedSignals.length; + + // Node 1 User 1 sends a signal to Node 2 User 1 + console.log( + `\nNode 1 User 1 (${node1User1Did.substring(0, 20)}...) sending signal to Node 2 User 1 (${node2User1Did.substring(0, 20)}...)`, + ); + await node1User1Proxy!.sendSignalU( + node2User1Did, + new PerspectiveUnsignedInput([ + { + source: "test://signal1", + predicate: "test://from", + target: node1User1Did, + }, + ]), + ); + + // Wait for signal delivery + startTime = Date.now(); + console.log("Waiting for signal delivery..."); + while ( + node2User1ReceivedSignals.length === 0 && + Date.now() - startTime < maxWaitTime + ) { + await sleep(100); + console.log("."); + } + console.log("Signal delivery complete"); + + // Verify Node 2 User 1 received the signal + console.log("Node 2 User 1 received signals:", node2User1ReceivedSignals); + expect(node2User1ReceivedSignals.length).to.be.greaterThan( + 0, + "Node 2 User 1 should have received signal", + ); + expect(node2User1ReceivedSignals[0].author).to.equal(node1User1Did); + + // Verify Node 2 User 2 did NOT receive the signal (it was meant for Node 2 User 1) + expect(node2User2ReceivedSignals.length).to.equal( + node2user2signalCount, + "Node 2 User 2 should NOT have received signal meant for Node 2 User 1", + ); + + // Now test the reverse: Node 2 User 1 sends to Node 1 User 1 + const node1User1ReceivedSignals: any[] = []; + node1User1Proxy!.addSignalHandler((signal) => { + console.log("Node 1 User 1 received signal from:", signal.author); + node1User1ReceivedSignals.push(signal); + }); + + await sleep(1500); + + console.log( + `\nNode 2 User 1 (${node2User1Did.substring(0, 20)}...) sending signal to Node 1 User 1 (${node1User1Did.substring(0, 20)}...)`, + ); + await node2User1Proxy!.sendSignalU( + node1User1Did, + new PerspectiveUnsignedInput([ + { + source: "test://signal2", + predicate: "test://from", + target: node2User1Did, + }, + ]), + ); + + startTime = Date.now(); + while ( + node1User1ReceivedSignals.length === 0 && + Date.now() - startTime < maxWaitTime + ) { + await sleep(100); + } + + expect(node1User1ReceivedSignals.length).to.be.greaterThan( + 0, + "Node 1 User 1 should have received signal", + ); + expect(node1User1ReceivedSignals[0].author).to.equal(node2User1Did); + + console.log("\n✅ Cross-node p2p signal routing works correctly"); + }); + + it("should sync links correctly between all users across nodes", async function () { + this.timeout(180000); // Increased for Holochain 0.7.0 - link sync takes longer + + console.log("\n=== Testing cross-node link synchronization ==="); + + // Get neighbourhood perspectives for all users + const node1User1Perspectives = await node1User1Client!.perspective.all(); + const node1User1Neighbourhood = node1User1Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node1User1Neighbourhood).to.not.be.undefined; + + const node1User2Perspectives = await node1User2Client!.perspective.all(); + const node1User2Neighbourhood = node1User2Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node1User2Neighbourhood).to.not.be.undefined; + + const node2User1Perspectives = await node2User1Client!.perspective.all(); + const node2User1Neighbourhood = node2User1Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node2User1Neighbourhood).to.not.be.undefined; + + const node2User2Perspectives = await node2User2Client!.perspective.all(); + const node2User2Neighbourhood = node2User2Perspectives.find( + (p) => p.sharedUrl, + ); + expect(node2User2Neighbourhood).to.not.be.undefined; + + // Each user adds a unique link + console.log("\nEach user adding their own unique link..."); + + await node1User1Client!.perspective.addLink( + node1User1Neighbourhood!.uuid, + { + source: "test://node1user1", + target: "test://link1", + predicate: "test://created", + }, + ); + console.log("Node 1 User 1 added link"); + + await node1User2Client!.perspective.addLink( + node1User2Neighbourhood!.uuid, + { + source: "test://node1user2", + target: "test://link2", + predicate: "test://created", + }, + ); + console.log("Node 1 User 2 added link"); + + await node2User1Client!.perspective.addLink( + node2User1Neighbourhood!.uuid, + { + source: "test://node2user1", + target: "test://link3", + predicate: "test://created", + }, + ); + console.log("Node 2 User 1 added link"); + + await node2User2Client!.perspective.addLink( + node2User2Neighbourhood!.uuid, + { + source: "test://node2user2", + target: "test://link4", + predicate: "test://created", + }, + ); + console.log("Node 2 User 2 added link"); + + // Wait for synchronization + console.log("\nWaiting for sync..."); + await sleep(10000); + + // Query links from each user's perspective + console.log("\nQuerying links from each user's perspective..."); + + const node1User1Links = await node1User1Client!.perspective.queryLinks( + node1User1Neighbourhood!.uuid, + new LinkQuery({}), + ); + console.log(`Node 1 User 1 sees ${node1User1Links.length} links`); + + const node1User2Links = await node1User2Client!.perspective.queryLinks( + node1User2Neighbourhood!.uuid, + new LinkQuery({}), + ); + console.log(`Node 1 User 2 sees ${node1User2Links.length} links`); + + const node2User1Links = await node2User1Client!.perspective.queryLinks( + node2User1Neighbourhood!.uuid, + new LinkQuery({}), + ); + console.log(`Node 2 User 1 sees ${node2User1Links.length} links`); + + const node2User2Links = await node2User2Client!.perspective.queryLinks( + node2User2Neighbourhood!.uuid, + new LinkQuery({}), + ); + console.log(`Node 2 User 2 sees ${node2User2Links.length} links`); + + // All users should see at least 5 links (1 from setup + 4 from each user) + expect(node1User1Links.length).to.be.greaterThanOrEqual( + 5, + "Node 1 User 1 should see all links", + ); + expect(node1User2Links.length).to.be.greaterThanOrEqual( + 5, + "Node 1 User 2 should see all links", + ); + expect(node2User1Links.length).to.be.greaterThanOrEqual( + 5, + "Node 2 User 1 should see all links", + ); + expect(node2User2Links.length).to.be.greaterThanOrEqual( + 5, + "Node 2 User 2 should see all links", + ); + + // Verify each user sees all the specific links + const checkLinkExists = ( + links: any[], + source: string, + target: string, + ) => { + return links.some( + (l) => l.data.source === source && l.data.target === target, + ); + }; + + // Check Node 1 User 1 sees all links + expect( + checkLinkExists(node1User1Links, "test://node1user1", "test://link1"), + ).to.be.true; + expect( + checkLinkExists(node1User1Links, "test://node1user2", "test://link2"), + ).to.be.true; + expect( + checkLinkExists(node1User1Links, "test://node2user1", "test://link3"), + ).to.be.true; + expect( + checkLinkExists(node1User1Links, "test://node2user2", "test://link4"), + ).to.be.true; + + // Check Node 1 User 2 sees all links + expect( + checkLinkExists(node1User2Links, "test://node1user1", "test://link1"), + ).to.be.true; + expect( + checkLinkExists(node1User2Links, "test://node1user2", "test://link2"), + ).to.be.true; + expect( + checkLinkExists(node1User2Links, "test://node2user1", "test://link3"), + ).to.be.true; + expect( + checkLinkExists(node1User2Links, "test://node2user2", "test://link4"), + ).to.be.true; + + // Check Node 2 User 1 sees all links + expect( + checkLinkExists(node2User1Links, "test://node1user1", "test://link1"), + ).to.be.true; + expect( + checkLinkExists(node2User1Links, "test://node1user2", "test://link2"), + ).to.be.true; + expect( + checkLinkExists(node2User1Links, "test://node2user1", "test://link3"), + ).to.be.true; + expect( + checkLinkExists(node2User1Links, "test://node2user2", "test://link4"), + ).to.be.true; + + // Check Node 2 User 2 sees all links + expect( + checkLinkExists(node2User2Links, "test://node1user1", "test://link1"), + ).to.be.true; + expect( + checkLinkExists(node2User2Links, "test://node1user2", "test://link2"), + ).to.be.true; + expect( + checkLinkExists(node2User2Links, "test://node2user1", "test://link3"), + ).to.be.true; + expect( + checkLinkExists(node2User2Links, "test://node2user2", "test://link4"), + ).to.be.true; + + // Verify link authors are correct + const node1User1Link = node1User1Links.find( + (l) => l.data.source === "test://node1user1", + ); + expect(node1User1Link?.author).to.equal(node1User1Did); + + const node1User2Link = node1User1Links.find( + (l) => l.data.source === "test://node1user2", + ); + expect(node1User2Link?.author).to.equal(node1User2Did); + + const node2User1Link = node1User1Links.find( + (l) => l.data.source === "test://node2user1", + ); + expect(node2User1Link?.author).to.equal(node2User1Did); + + const node2User2Link = node1User1Links.find( + (l) => l.data.source === "test://node2user2", + ); + expect(node2User2Link?.author).to.equal(node2User2Did); + + console.log("\n✅ Cross-node link synchronization works correctly"); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-neighbourhood.test.ts b/tests/js/tests/multi-user/multi-user-neighbourhood.test.ts new file mode 100644 index 000000000..18f689167 --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-neighbourhood.test.ts @@ -0,0 +1,922 @@ +import path from "path"; +import { + Ad4mClient, + Ad4mModel, + Link, + LinkQuery, + Model, + Perspective, + PerspectiveUnsignedInput, + Property, +} from "@coasys/ad4m"; +import fs from "fs-extra"; +import { fileURLToPath } from "url"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { + apolloClient, + startExecutor, + runHcLocalServices, + waitForExit, +} from "../../utils/utils"; +import { getFreePorts } from "../../helpers/ports"; +import { ChildProcess } from "node:child_process"; +import { v4 as uuidv4 } from "uuid"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DIFF_SYNC_OFFICIAL = fs + .readFileSync("./scripts/perspective-diff-sync-hash") + .toString(); + +describe("Multi-User Neighbourhood Sharing tests", () => { + const TEST_DIR = path.join(`${__dirname}/../../tst-tmp`); + const bootstrapSeedPath = path.join(`${__dirname}/../../bootstrapSeed.json`); + + let gqlPort: number = 0; + let hcAdminPort: number = 0; + let hcAppPort: number = 0; + let executorProcess: ChildProcess | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let proxyUrl: string | null = null; + let bootstrapUrl: string | null = null; + let localServicesProcess: ChildProcess | null = null; + + before(async function () { + this.timeout(300_000); + [gqlPort, hcAdminPort, hcAppPort] = await getFreePorts(3); + + const appDataPath = path.join( + TEST_DIR, + "agents", + "multi-user-neighbourhood", + ); + if (!fs.existsSync(appDataPath)) { + fs.mkdirSync(appDataPath, { recursive: true }); + } + + const localServices = await runHcLocalServices(); + proxyUrl = localServices.proxyUrl; + bootstrapUrl = localServices.bootstrapUrl; + localServicesProcess = localServices.process; + + executorProcess = await startExecutor( + appDataPath, + bootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + false, + undefined, + proxyUrl!, + bootstrapUrl!, + ); + + // @ts-ignore + adminAd4mClient = new Ad4mClient(apolloClient(gqlPort), false); + await adminAd4mClient.agent.generate("passphrase"); + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await waitForExit(executorProcess); + await waitForExit(localServicesProcess); + }); + + describe("Multi-User Neighbourhood Sharing", () => { + it("should allow multiple local users to share the same neighbourhood", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "nh1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "nh2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "nh1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "nh2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get the DIDs for both users + const user1Agent = await client1.agent.me(); + const user2Agent = await client2.agent.me(); + + console.log("User 1 DID:", user1Agent.did); + console.log("User 2 DID:", user2Agent.did); + + // User 1 creates a perspective and shares it as a neighbourhood + const perspective1 = await client1.perspective.add( + "Shared Neighbourhood", + ); + console.log("User 1 created perspective:", perspective1.uuid); + + // Add some initial links to the perspective + const link1 = new Link({ + source: "test://user1", + target: "test://data1", + predicate: "test://created", + }); + await client1.perspective.addLink(perspective1.uuid, link1); + + console.log("Cloning link language..."); + const linkLanguage = await client1.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ + uid: uuidv4(), + name: "Multi-User Neighbourhood Sharing", + }), + ); + console.log("Link language cloned:", linkLanguage.address); + + // Publish the neighbourhood using the centralized link language + console.log("Publishing neighbourhood..."); + const neighbourhoodUrl = + await client1.neighbourhood.publishFromPerspective( + perspective1.uuid, + linkLanguage.address, + new Perspective([]), + ); + console.log("User 1 published neighbourhood:", neighbourhoodUrl); + + // Wait for neighbourhood to be fully set up + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // User 2 joins the same neighbourhood + const joinResult = + await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); + console.log("User 2 joined neighbourhood:", joinResult); + + // Wait for neighbourhood sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Verify both users can see the shared perspective + const user1Perspectives = await client1.perspective.all(); + const user2Perspectives = await client2.perspective.all(); + + const user1SharedPerspective = user1Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + const user2SharedPerspective = user2Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + + console.log("User 1 perspectives:", user1Perspectives); + console.log("User 2 perspectives:", user2Perspectives); + + expect(user1SharedPerspective).to.not.be.null; + expect(user2SharedPerspective).to.not.be.null; + + // User 2 adds a link to the shared perspective + const link2 = new Link({ + source: "test://user2", + target: "test://data2", + predicate: "test://added", + }); + await client2.perspective.addLink(user2SharedPerspective!.uuid, link2); + + // Wait for sync + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // User 1 should see User 2's link + const user1Links = await client1.perspective.queryLinks( + user1SharedPerspective!.uuid, + new LinkQuery({}), + ); + const user2Links = await client2.perspective.queryLinks( + user2SharedPerspective!.uuid, + new LinkQuery({}), + ); + + console.log("User 1 sees links:", user1Links.length); + console.log("User 2 sees links:", user2Links.length); + + // Both users should see both links + expect(user1Links.length).to.be.greaterThan(1); + expect(user2Links.length).to.be.greaterThan(1); + + // Verify specific links exist + const user1SeesUser2Link = user1Links.some( + (l) => + l.data.source === "test://user2" && l.data.target === "test://data2", + ); + const user2SeesUser1Link = user2Links.some( + (l) => + l.data.source === "test://user1" && l.data.target === "test://data1", + ); + + expect(user1SeesUser2Link).to.be.true; + expect(user2SeesUser1Link).to.be.true; + }); + + it("should isolate SDNA/SHACL links per user in a shared neighbourhood", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "prolog1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "prolog2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "prolog1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "prolog2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + console.log("User 1 creates neighbourhood and adds initial SDNA..."); + + // User 1 creates a perspective and shares it as a neighbourhood + const perspective1 = await client1.perspective.add("Prolog Pool Test"); + + @Model({ + name: "User1Model", + }) + class User1Model extends Ad4mModel { + @Property({ + through: "test://user1-property", + }) + user1Property: string = ""; + } + + console.log("Ensuring User 1 model..."); + await perspective1.ensureSDNASubjectClass(User1Model); + console.log("User 1 model ensured"); + + // Wait for SDNA to be processed + await new Promise((resolve) => setTimeout(resolve, 1000)); + + let user1Model = new User1Model(perspective1); + user1Model.user1Property = "User1 created this"; + console.log("Saving User 1 model..."); + await user1Model.save(); + console.log("User 1 model saved"); + + console.log("User 1 neighbourhood setup complete, User 2 joining..."); + + // Clone link language and publish neighbourhood + const linkLanguage = await client1.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ uid: uuidv4(), name: "Prolog Pool Test" }), + ); + const neighbourhoodUrl = + await client1.neighbourhood.publishFromPerspective( + perspective1.uuid, + linkLanguage.address, + new Perspective([]), + ); + + // User 2 joins the neighbourhood + const joinResult = + await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); + const user2Perspectives = await client2.perspective.all(); + const user2SharedPerspective = user2Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(user2SharedPerspective).to.not.be.null; + + console.log("User 2 joined, adding their own SDNA..."); + + @Model({ + name: "User2Model", + }) + class User2Model extends Ad4mModel { + @Property({ + through: "test://user2-property", + }) + user2Property: string = ""; + } + + console.log("Ensuring User 2 model..."); + await user2SharedPerspective!.ensureSDNASubjectClass(User2Model); + console.log("User 2 model ensured"); + + // Wait for SDNA to be processed + await new Promise((resolve) => setTimeout(resolve, 1000)); + + let user2Model = new User2Model(user2SharedPerspective!); + user2Model.user2Property = "User2 created this"; + console.log("Saving User 2 model..."); + await user2Model.save(); + console.log("User 2 model saved"); + + console.log("Testing per-user SHACL link isolation..."); + + // User 2's SDNA syncs back to User 1 through the shared neighbourhood. + // Give it a moment to propagate before asserting. + await new Promise((resolve) => setTimeout(resolve, 2000)); + + let classesSeenByUser1 = await perspective1.subjectClasses(); + console.log("User 1 sees classes:", classesSeenByUser1); + expect(classesSeenByUser1.length).to.equal(2); + + let classesSeenByUser2 = await user2SharedPerspective!.subjectClasses(); + console.log("User 2 sees classes:", classesSeenByUser2); + expect(classesSeenByUser2.length).to.equal(2); + }); + + it("should route neighbourhood signals locally between users on the same node", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "signal1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "signal2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "signal1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "signal2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get user DIDs + const user1Status = await client1.agent.status(); + const user2Status = await client2.agent.status(); + const user1Did = user1Status.did!; + const user2Did = user2Status.did!; + + console.log("User 1 DID:", user1Did); + console.log("User 2 DID:", user2Did); + + // User 1 creates a perspective and shares it as a neighbourhood + const perspective1 = await client1.perspective.add( + "Signal Test Neighbourhood", + ); + + // Clone link language and publish neighbourhood + const linkLanguage = await client1.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ uid: uuidv4(), name: "Signal Test" }), + ); + const neighbourhoodUrl = + await client1.neighbourhood.publishFromPerspective( + perspective1.uuid, + linkLanguage.address, + new Perspective([]), + ); + + console.log("User 1 created neighbourhood:", neighbourhoodUrl); + + // User 2 joins the neighbourhood + const joinResult = + await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); + const user2Perspectives = await client2.perspective.all(); + const user2SharedPerspective = user2Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(user2SharedPerspective).to.not.be.null; + + console.log("User 2 joined neighbourhood"); + + // Wait a bit for neighbourhood to be fully set up + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Get neighbourhood proxy for User 2 + const user2Neighbourhood = + await user2SharedPerspective!.getNeighbourhoodProxy(); + expect(user2Neighbourhood).to.not.be.null; + + // Set up signal listener for User 2 + const user2ReceivedSignals: any[] = []; + const user2SignalSubscription = user2Neighbourhood!.addSignalHandler( + (signal) => { + user2ReceivedSignals.push(signal); + }, + ); + + console.log("User 2 signal listener set up"); + + // Get neighbourhood proxy for User 1 + const user1Neighbourhood = await perspective1.getNeighbourhoodProxy(); + expect(user1Neighbourhood).to.not.be.null; + + // Set up signal listener for User 1 to verify they DON'T receive User 2's signals + const user1ReceivedSignals: any[] = []; + const user1SignalSubscription = user1Neighbourhood!.addSignalHandler( + (signal) => { + user1ReceivedSignals.push(signal); + }, + ); + + console.log("User 1 signal listener set up"); + + // Wait a bit to ensure subscriptions are active + await new Promise((resolve) => setTimeout(resolve, 500)); + + // User 1 sends a signal to User 2 + const testSignalPayload = new PerspectiveUnsignedInput([ + { + source: "test://signal", + predicate: "test://from", + target: user1Did, + }, + ]); + + console.log("User 1 sending signal to User 2..."); + await user1Neighbourhood!.sendSignalU(user2Did, testSignalPayload); + + console.log("Signal sent, waiting for delivery..."); + + // Wait for signal to be received (with timeout) + const maxWaitTime = 5000; // 5 seconds + let startTime = Date.now(); + while ( + user2ReceivedSignals.length === 0 && + Date.now() - startTime < maxWaitTime + ) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // Verify User 2 received the signal + expect(user2ReceivedSignals.length).to.be.greaterThan( + 0, + "User 2 should have received at least one signal", + ); + + console.log("User 2 received signals:", user2ReceivedSignals); + + const user2ReceivedSignal = user2ReceivedSignals[0]; + expect(user2ReceivedSignal.data.links).to.have.lengthOf(1); + expect(user2ReceivedSignal.data.links[0].data.source).to.equal( + "test://signal", + ); + expect(user2ReceivedSignal.data.links[0].data.predicate).to.equal( + "test://from", + ); + expect(user2ReceivedSignal.data.links[0].data.target).to.equal(user1Did); + + // Verify User 1 did NOT receive the signal (it was meant for User 2) + expect(user1ReceivedSignals.length).to.equal( + 0, + "User 1 should NOT have received the signal meant for User 2", + ); + + // Now test the reverse: User 2 sends a signal to User 1 + const reverseSignalPayload = new PerspectiveUnsignedInput([ + { + source: "test://reverse-signal", + predicate: "test://from", + target: user2Did, + }, + ]); + + console.log("User 2 sending signal to User 1..."); + await user2Neighbourhood!.sendSignalU(user1Did, reverseSignalPayload); + + // Wait for signal to be received + startTime = Date.now(); + while ( + user1ReceivedSignals.length === 0 && + Date.now() - startTime < maxWaitTime + ) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // Verify User 1 received the signal + expect(user1ReceivedSignals.length).to.be.greaterThan( + 0, + "User 1 should have received at least one signal", + ); + + const user1ReceivedSignal = user1ReceivedSignals[0]; + expect(user1ReceivedSignal.data.links).to.have.lengthOf(1); + expect(user1ReceivedSignal.data.links[0].data.source).to.equal( + "test://reverse-signal", + ); + expect(user1ReceivedSignal.data.links[0].data.predicate).to.equal( + "test://from", + ); + expect(user1ReceivedSignal.data.links[0].data.target).to.equal(user2Did); + + // Verify User 2 did NOT receive their own signal back (User 1 should have only 1 signal from first send) + expect(user2ReceivedSignals.length).to.equal( + 1, + "User 2 should still only have 1 signal (not their own reverse signal)", + ); + }); + + it("should receive neighbourhood signals between two managed users (Flux scenario)", async () => { + console.log( + "\n=== Replicating Flux Scenario: Fresh Agent with Managed Users ===", + ); + + // Create two managed users (simulating Flux signup flow) + console.log("Creating first managed user..."); + await adminAd4mClient!.agent.createUser("flux1@example.com", "password1"); + const token1 = await adminAd4mClient!.agent.loginUser( + "flux1@example.com", + "password1", + ); + + console.log("Creating second managed user..."); + await adminAd4mClient!.agent.createUser("flux2@example.com", "password2"); + const token2 = await adminAd4mClient!.agent.loginUser( + "flux2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get user DIDs + const user1Status = await client1.agent.me(); + const user2Status = await client2.agent.me(); + const user1Did = user1Status.did!; + const user2Did = user2Status.did!; + + console.log("User 1 DID:", user1Did); + console.log("User 2 DID:", user2Did); + + // FIRST managed user creates a perspective and neighbourhood + console.log("\nUser 1 (first managed user) creating neighbourhood..."); + const perspective1 = await client1.perspective.add( + "Flux Test Neighbourhood", + ); + + // Add a test link + await client1.perspective.addLink( + perspective1.uuid, + new Link({ + source: "test://initial", + target: "test://data", + predicate: "test://created_by_user1", + }), + ); + + // Clone link language and publish neighbourhood (using Holochain p-diff-sync) + const linkLanguage = await client1.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ uid: uuidv4(), name: "Flux Test Neighbourhood" }), + ); + + console.log("Link language cloned:", linkLanguage.address); + + const neighbourhoodUrl = + await client1.neighbourhood.publishFromPerspective( + perspective1.uuid, + linkLanguage.address, + new Perspective([]), + ); + + console.log("User 1 published neighbourhood:", neighbourhoodUrl); + + // Wait for neighbourhood to be published + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // SECOND managed user joins the neighbourhood + console.log("\nUser 2 (second managed user) joining neighbourhood..."); + const joinResult = + await client2.neighbourhood.joinFromUrl(neighbourhoodUrl); + console.log("User 2 join result:", joinResult.uuid); + + const user2Perspectives = await client2.perspective.all(); + const user2SharedPerspective = user2Perspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(user2SharedPerspective).to.not.be.null; + + console.log("User 2 joined neighbourhood"); + + // Wait for neighbourhood to sync + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Get neighbourhood proxies + const user1Neighbourhood = await perspective1.getNeighbourhoodProxy(); + const user2Neighbourhood = + await user2SharedPerspective!.getNeighbourhoodProxy(); + + expect(user1Neighbourhood).to.not.be.null; + expect(user2Neighbourhood).to.not.be.null; + + console.log("\n=== Testing Signal Delivery ==="); + + // Set up signal listeners + const user1ReceivedSignals: any[] = []; + const user2ReceivedSignals: any[] = []; + + const user1SignalHandler = user1Neighbourhood!.addSignalHandler( + (signal) => { + console.log( + "✉️ User 1 received signal:", + JSON.stringify(signal, null, 2), + ); + user1ReceivedSignals.push(signal); + }, + ); + + const user2SignalHandler = user2Neighbourhood!.addSignalHandler( + (signal) => { + console.log( + "✉️ User 2 received signal:", + JSON.stringify(signal, null, 2), + ); + user2ReceivedSignals.push(signal); + }, + ); + + console.log("Signal handlers set up for both users"); + + // Wait for subscriptions to be active + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Check if users can see each other in otherAgents + console.log("\n=== Checking otherAgents() ==="); + const user1Others = await user1Neighbourhood!.otherAgents(); + const user2Others = await user2Neighbourhood!.otherAgents(); + + console.log("User 1 sees others:", user1Others); + console.log("User 2 sees others:", user2Others); + + // User 1 sends a signal to User 2 + console.log("\n=== User 1 sending signal to User 2 ==="); + const signal1to2 = new PerspectiveUnsignedInput([ + new Link({ + source: "test://signal", + predicate: "test://user1_to_user2", + target: user1Did, + }), + ]); + + await user1Neighbourhood!.sendSignalU(user2Did, signal1to2); + console.log("Signal sent from User 1 to User 2"); + + // Wait for signal delivery + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // User 2 sends a signal to User 1 + console.log("\n=== User 2 sending signal to User 1 ==="); + const signal2to1 = new PerspectiveUnsignedInput([ + new Link({ + source: "test://signal", + predicate: "test://user2_to_user1", + target: user2Did, + }), + ]); + + await user2Neighbourhood!.sendSignalU(user1Did, signal2to1); + console.log("Signal sent from User 2 to User 1"); + + // Wait for signal delivery + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Verify signals were received + console.log("\n=== Verification ==="); + console.log( + "User 1 received signals count:", + user1ReceivedSignals.length, + ); + console.log( + "User 2 received signals count:", + user2ReceivedSignals.length, + ); + + if (user2ReceivedSignals.length > 0) { + console.log( + "User 2 received signals:", + JSON.stringify(user2ReceivedSignals, null, 2), + ); + } + if (user1ReceivedSignals.length > 0) { + console.log( + "User 1 received signals:", + JSON.stringify(user1ReceivedSignals, null, 2), + ); + } + + // Assertions + expect(user2ReceivedSignals.length).to.be.greaterThan( + 0, + "User 2 should receive signal from User 1", + ); + expect(user1ReceivedSignals.length).to.be.greaterThan( + 0, + "User 1 should receive signal from User 2", + ); + + console.log( + "User 2 received signals:", + JSON.stringify(user2ReceivedSignals, null, 2), + ); + console.log( + "User 1 received signals:", + JSON.stringify(user1ReceivedSignals, null, 2), + ); + // Verify signal content + const user2Signal = user2ReceivedSignals[0]; + expect(user2Signal.data.links[0].data.predicate).to.equal( + "test://user1_to_user2", + ); + + const user1Signal = user1ReceivedSignals[0]; + expect(user1Signal.data.links[0].data.predicate).to.equal( + "test://user2_to_user1", + ); + }); + + it("should exchange neighbourhood signals between main agent and managed user", async () => { + console.log( + "\n=== Testing signals between main agent and managed user ===", + ); + + // The admin client (empty/admin-credential token) IS the main agent. + // A managed user joins the same neighbourhood. Signals must work both ways. + const mainAgentStatus = await adminAd4mClient!.agent.status(); + const mainAgentDid = mainAgentStatus.did!; + console.log("Main agent DID:", mainAgentDid); + + // Create and login a managed user + await adminAd4mClient!.agent.createUser( + "main_agent_signal@example.com", + "password", + ); + const userToken = await adminAd4mClient!.agent.loginUser( + "main_agent_signal@example.com", + "password", + ); + // @ts-ignore + const userClient = new Ad4mClient( + apolloClient(gqlPort, userToken), + false, + ); + + const userStatus = await userClient.agent.me(); + const userDid = userStatus.did!; + console.log("Managed user DID:", userDid); + + // Main agent creates the neighbourhood + const mainPerspective = await adminAd4mClient!.perspective.add( + "Main-Agent Neighbourhood", + ); + const linkLanguage = + await adminAd4mClient!.languages.applyTemplateAndPublish( + DIFF_SYNC_OFFICIAL, + JSON.stringify({ uid: uuidv4(), name: "Main-Agent Signal Test" }), + ); + const neighbourhoodUrl = + await adminAd4mClient!.neighbourhood.publishFromPerspective( + mainPerspective.uuid, + linkLanguage.address, + new Perspective([]), + ); + console.log("Main agent created neighbourhood:", neighbourhoodUrl); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Managed user joins the neighbourhood + await userClient.neighbourhood.joinFromUrl(neighbourhoodUrl); + const userPerspectives = await userClient.perspective.all(); + const userSharedPerspective = userPerspectives.find( + (p) => p.sharedUrl === neighbourhoodUrl, + ); + expect(userSharedPerspective).to.not.be.null; + console.log("Managed user joined neighbourhood"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get neighbourhood proxies for both sides + const mainAgentNH = await mainPerspective.getNeighbourhoodProxy(); + const userNH = await userSharedPerspective!.getNeighbourhoodProxy(); + expect(mainAgentNH).to.not.be.null; + expect(userNH).to.not.be.null; + + // Register signal listeners on both sides + const mainAgentReceivedSignals: any[] = []; + const userReceivedSignals: any[] = []; + + mainAgentNH!.addSignalHandler((signal) => { + console.log("✉️ Main agent received signal:", JSON.stringify(signal)); + mainAgentReceivedSignals.push(signal); + }); + userNH!.addSignalHandler((signal) => { + console.log("✉️ Managed user received signal:", JSON.stringify(signal)); + userReceivedSignals.push(signal); + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // --- Test 1: main agent sends signal to managed user --- + console.log("\n--- Main agent sending signal to managed user ---"); + await mainAgentNH!.sendSignalU( + userDid, + new PerspectiveUnsignedInput([ + new Link({ + source: "test://signal", + predicate: "test://main_to_user", + target: mainAgentDid, + }), + ]), + ); + + const maxWait = 5000; + let start = Date.now(); + while (userReceivedSignals.length === 0 && Date.now() - start < maxWait) { + await new Promise((r) => setTimeout(r, 100)); + } + expect(userReceivedSignals.length).to.be.greaterThan( + 0, + "Managed user should receive signal from main agent", + ); + expect(userReceivedSignals[0].data.links[0].data.predicate).to.equal( + "test://main_to_user", + ); + + // --- Test 2: managed user sends signal to main agent --- + console.log("\n--- Managed user sending signal to main agent ---"); + await userNH!.sendSignalU( + mainAgentDid, + new PerspectiveUnsignedInput([ + new Link({ + source: "test://signal", + predicate: "test://user_to_main", + target: userDid, + }), + ]), + ); + + start = Date.now(); + while ( + mainAgentReceivedSignals.length === 0 && + Date.now() - start < maxWait + ) { + await new Promise((r) => setTimeout(r, 100)); + } + expect(mainAgentReceivedSignals.length).to.be.greaterThan( + 0, + "Main agent should receive signal from managed user", + ); + expect(mainAgentReceivedSignals[0].data.links[0].data.predicate).to.equal( + "test://user_to_main", + ); + + // --- Test 3: managed user broadcasts, main agent receives --- + console.log( + "\n--- Managed user broadcasting, main agent should receive ---", + ); + const mainAgentCountBefore = mainAgentReceivedSignals.length; + await userNH!.sendBroadcastU( + new PerspectiveUnsignedInput([ + new Link({ + source: "test://broadcast", + predicate: "test://user_broadcast", + target: userDid, + }), + ]), + ); + + start = Date.now(); + while ( + mainAgentReceivedSignals.length === mainAgentCountBefore && + Date.now() - start < maxWait + ) { + await new Promise((r) => setTimeout(r, 100)); + } + expect(mainAgentReceivedSignals.length).to.be.greaterThan( + mainAgentCountBefore, + "Main agent should receive broadcast from managed user", + ); + const broadcastSignal = + mainAgentReceivedSignals[mainAgentReceivedSignals.length - 1]; + expect(broadcastSignal.data.links[0].data.predicate).to.equal( + "test://user_broadcast", + ); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-notifications.test.ts b/tests/js/tests/multi-user/multi-user-notifications.test.ts new file mode 100644 index 000000000..b4040245a --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-notifications.test.ts @@ -0,0 +1,340 @@ +import { Ad4mClient } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient, sleep } from "../../utils/utils"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; +import { NotificationInput } from "@coasys/ad4m/lib/src/runtime/RuntimeResolver"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Multi-User Notifications tests", () => { + let agentHandle: AgentHandle | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let gqlPort: number = 0; + + before(async function () { + this.timeout(300_000); + agentHandle = await startAgent("multi-user-notifications"); + adminAd4mClient = agentHandle.client; + gqlPort = agentHandle.gqlPort; + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await agentHandle?.stop(); + }); + + describe("Multi-User Notifications", () => { + it("should isolate notifications between users", async () => { + console.log("\n=== Testing notification isolation between users ==="); + + // Create two users + await adminAd4mClient!.agent.createUser( + "notify1@example.com", + "password1", + ); + await adminAd4mClient!.agent.createUser( + "notify2@example.com", + "password2", + ); + + const token1 = await adminAd4mClient!.agent.loginUser( + "notify1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "notify2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // User 1 creates a perspective and notification + const user1Perspective = await client1.perspective.add( + "User 1 Notification Test", + ); + const user1Notification: NotificationInput = { + description: "User 1's notification", + appName: "User 1 App", + appUrl: "https://user1.app", + appIconPath: "/user1.png", + trigger: `SELECT source, target FROM link WHERE predicate = 'user1://test'`, + perspectiveIds: [user1Perspective.uuid], + webhookUrl: "https://user1.webhook", + webhookAuth: "user1-auth", + }; + + // User 2 creates a perspective and notification + const user2Perspective = await client2.perspective.add( + "User 2 Notification Test", + ); + const user2Notification: NotificationInput = { + description: "User 2's notification", + appName: "User 2 App", + appUrl: "https://user2.app", + appIconPath: "/user2.png", + trigger: `SELECT source, target FROM link WHERE predicate = 'user2://test'`, + perspectiveIds: [user2Perspective.uuid], + webhookUrl: "https://user2.webhook", + webhookAuth: "user2-auth", + }; + + // Install notifications - managed users get auto-granted + const notif1Id = + await client1.runtime.requestInstallNotification(user1Notification); + const notif2Id = + await client2.runtime.requestInstallNotification(user2Notification); + + await sleep(500); + + // User 1 retrieves notifications - should only see their own and it should be auto-granted + const user1Notifications = await client1.runtime.notifications(); + console.log(`User 1 sees ${user1Notifications.length} notification(s)`); + expect(user1Notifications.length).to.equal(1); + expect(user1Notifications[0].description).to.equal( + "User 1's notification", + ); + expect(user1Notifications[0].id).to.equal(notif1Id); + expect(user1Notifications[0].granted).to.be.true; + + // User 2 retrieves notifications - should only see their own and it should be auto-granted + const user2Notifications = await client2.runtime.notifications(); + console.log(`User 2 sees ${user2Notifications.length} notification(s)`); + expect(user2Notifications.length).to.equal(1); + expect(user2Notifications[0].description).to.equal( + "User 2's notification", + ); + expect(user2Notifications[0].id).to.equal(notif2Id); + expect(user2Notifications[0].granted).to.be.true; + }); + + it("should use correct agent DID for each user's notification queries", async () => { + console.log( + "\n=== Testing per-user agent DID in notification queries ===", + ); + + // Create two users + await adminAd4mClient!.agent.createUser("did1@example.com", "password1"); + await adminAd4mClient!.agent.createUser("did2@example.com", "password2"); + + const token1 = await adminAd4mClient!.agent.loginUser( + "did1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "did2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get each user's agent DID + const user1Status = await client1.agent.status(); + const user2Status = await client2.agent.status(); + const user1Did = user1Status.did; + const user2Did = user2Status.did; + + console.log(`User 1 DID: ${user1Did}`); + console.log(`User 2 DID: ${user2Did}`); + + expect(user1Did).to.not.equal( + user2Did, + "Users should have different DIDs", + ); + + // Create perspectives for both users + const user1Perspective = await client1.perspective.add( + "User 1 Mention Test", + ); + const user2Perspective = await client2.perspective.add( + "User 2 Mention Test", + ); + + // User 1 creates a mention notification (using $agentDid variable) + const user1Notification: NotificationInput = { + description: "User 1 was mentioned", + appName: "Mentions for User 1", + appUrl: "https://mentions.app", + appIconPath: "/mentions.png", + trigger: `SELECT + source as message_id, + fn::parse_literal(target) as content, + $agentDid as mentioned_user + FROM link + WHERE predicate = 'rdf://content' + AND fn::contains(fn::parse_literal(target), $agentDid)`, + perspectiveIds: [user1Perspective.uuid], + webhookUrl: "https://user1.webhook", + webhookAuth: "user1-auth", + }; + + // User 2 creates a mention notification (using $agentDid variable) + const user2Notification: NotificationInput = { + description: "User 2 was mentioned", + appName: "Mentions for User 2", + appUrl: "https://mentions.app", + appIconPath: "/mentions.png", + trigger: `SELECT + source as message_id, + fn::parse_literal(target) as content, + $agentDid as mentioned_user + FROM link + WHERE predicate = 'rdf://content' + AND fn::contains(fn::parse_literal(target), $agentDid)`, + perspectiveIds: [user2Perspective.uuid], + webhookUrl: "https://user2.webhook", + webhookAuth: "user2-auth", + }; + + // Install notifications - managed users get auto-granted + const notif1Id = + await client1.runtime.requestInstallNotification(user1Notification); + const notif2Id = + await client2.runtime.requestInstallNotification(user2Notification); + + await sleep(500); + + // Verify that both notifications contain the $agentDid variable in their triggers + // and are auto-granted for managed users + const user1Notifs = await client1.runtime.notifications(); + const user2Notifs = await client2.runtime.notifications(); + + const user1SavedNotif = user1Notifs.find((n) => n.id === notif1Id); + const user2SavedNotif = user2Notifs.find((n) => n.id === notif2Id); + + expect(user1SavedNotif).to.not.be.undefined; + expect(user2SavedNotif).to.not.be.undefined; + + // Verify the triggers contain the $agentDid placeholder + expect(user1SavedNotif!.trigger).to.include( + "$agentDid", + "User 1's notification should contain $agentDid variable", + ); + expect(user2SavedNotif!.trigger).to.include( + "$agentDid", + "User 2's notification should contain $agentDid variable", + ); + + // The actual DID injection and query execution is tested in the runtime.ts tests + // This test verifies that multi-user contexts preserve the query correctly + }); + + it("should prevent users from seeing or modifying other users' notifications", async () => { + console.log("\n=== Testing notification access control ==="); + + // Create two users + await adminAd4mClient!.agent.createUser( + "access1@example.com", + "password1", + ); + await adminAd4mClient!.agent.createUser( + "access2@example.com", + "password2", + ); + + const token1 = await adminAd4mClient!.agent.loginUser( + "access1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "access2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + const perspective = await client1.perspective.add("Access Test"); + + // User 1 creates a notification + const notification: NotificationInput = { + description: "Private to User 1", + appName: "Private App", + appUrl: "https://private.app", + appIconPath: "/private.png", + trigger: `SELECT * FROM link WHERE predicate = 'test://private'`, + perspectiveIds: [perspective.uuid], + webhookUrl: "https://webhook.test", + webhookAuth: "secret-auth", + }; + + // Install notification - managed users get auto-granted + const notificationId = + await client1.runtime.requestInstallNotification(notification); + await sleep(500); + + // User 1 can see their notification and it should be auto-granted + const user1Notifs = await client1.runtime.notifications(); + const user1Notif = user1Notifs.find((n) => n.id === notificationId); + expect(user1Notif).to.not.be.undefined; + expect(user1Notif!.granted).to.be.true; + + // User 2 cannot see User 1's notification + const user2Notifs = await client2.runtime.notifications(); + expect(user2Notifs.some((n) => n.id === notificationId)).to.be.false; + }); + + it("should prevent managed users from calling grantNotification", async () => { + console.log( + "\n=== Testing that managed users cannot call grantNotification ===", + ); + + // Create a managed user + await adminAd4mClient!.agent.createUser( + "grant-test@example.com", + "password1", + ); + const token = await adminAd4mClient!.agent.loginUser( + "grant-test@example.com", + "password1", + ); + + // @ts-ignore + const managedClient = new Ad4mClient(apolloClient(gqlPort, token), false); + + const perspective = await managedClient.perspective.add("Grant Test"); + + // Create a notification (which will be auto-granted) + const notification: NotificationInput = { + description: "Test notification", + appName: "Test App", + appUrl: "https://test.app", + appIconPath: "/test.png", + trigger: `SELECT * FROM link WHERE predicate = 'test://grant'`, + perspectiveIds: [perspective.uuid], + webhookUrl: "https://webhook.test", + webhookAuth: "test-auth", + }; + + const notificationId = + await managedClient.runtime.requestInstallNotification(notification); + await sleep(500); + + // Verify the notification is auto-granted + const notifs = await managedClient.runtime.notifications(); + const notif = notifs.find((n) => n.id === notificationId); + expect(notif).to.not.be.undefined; + expect(notif!.granted).to.be.true; + + // Managed user should NOT be able to call grantNotification + try { + await managedClient.runtime.grantNotification(notificationId); + expect.fail( + "Managed user should not be able to call grantNotification", + ); + } catch (error: any) { + expect(error.message).to.include("Permission denied"); + } + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-profiles.test.ts b/tests/js/tests/multi-user/multi-user-profiles.test.ts new file mode 100644 index 000000000..7c494fde6 --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-profiles.test.ts @@ -0,0 +1,568 @@ +import { + Ad4mClient, + ExpressionProof, + Link, + LinkExpression, + Perspective, +} from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient } from "../../utils/utils"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Multi-User Agent Profiles tests", () => { + let agentHandle: AgentHandle | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let gqlPort: number = 0; + + before(async function () { + this.timeout(300_000); + agentHandle = await startAgent("multi-user-profiles"); + adminAd4mClient = agentHandle.client; + gqlPort = agentHandle.gqlPort; + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await agentHandle?.stop(); + }); + + describe("Agent Profiles and Status", () => { + it("should maintain separate agent profiles for different users", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "profile1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "profile2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "profile1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "profile2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get initial agent info for both users + const user1Agent = await client1.agent.me(); + const user2Agent = await client2.agent.me(); + + // Verify each user has their own DID + expect(user1Agent.did).to.not.equal(user2Agent.did); + console.log("User 1 DID:", user1Agent.did); + console.log("User 2 DID:", user2Agent.did); + + // Verify each user sees their own profile + const user1Profile = await client1.agent.me(); + const user2Profile = await client2.agent.me(); + + // Each user should see their own DID (not the main agent's DID) + expect(user1Profile.did).to.equal(user1Agent.did); + expect(user2Profile.did).to.equal(user2Agent.did); + + // DIDs should be different between users + expect(user1Profile.did).to.not.equal(user2Profile.did); + }); + + it("should handle agent status correctly for different users", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "status1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "status2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "status1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "status2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Check agent status for both users + const user1Status = await client1.agent.status(); + const user2Status = await client2.agent.status(); + + console.log("User 1 status:", user1Status); + console.log("User 2 status:", user2Status); + + // Both users should have valid status + expect(user1Status).to.have.property("isInitialized"); + expect(user2Status).to.have.property("isInitialized"); + expect(user1Status.isInitialized).to.be.true; + expect(user2Status.isInitialized).to.be.true; + + // Each user should have their own DID in status + expect(user1Status.did).to.not.equal(user2Status.did); + + // Assert on DID documents + expect(user1Status.didDocument).to.be.a("string"); + expect(user2Status.didDocument).to.be.a("string"); + expect(user1Status.didDocument).to.not.equal(user2Status.didDocument); + + // Parse and validate DID documents + const user1DidDoc = JSON.parse(user1Status.didDocument!); + const user2DidDoc = JSON.parse(user2Status.didDocument!); + + expect(user1DidDoc.id).to.equal(user1Status.did); + expect(user2DidDoc.id).to.equal(user2Status.did); + expect(user1DidDoc).to.have.property("verificationMethod"); + expect(user2DidDoc).to.have.property("verificationMethod"); + expect(user1DidDoc.verificationMethod).to.be.an("array").that.is.not + .empty; + expect(user2DidDoc.verificationMethod).to.be.an("array").that.is.not + .empty; + }); + + it("should allow users to update their own agent profiles independently", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "update1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "update2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "update1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "update2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // User 1 updates their profile + let link1 = new LinkExpression(); + link1.author = "did:test:1"; + link1.timestamp = new Date().toISOString(); + link1.data = new Link({ + source: "user1", + target: "profile1", + predicate: "name", + }); + link1.proof = new ExpressionProof("sig1", "key1"); + await client1.agent.updatePublicPerspective(new Perspective([link1])); + + // User 2 updates their profile with different data + let link2 = new LinkExpression(); + link2.author = "did:test:2"; + link2.timestamp = new Date().toISOString(); + link2.data = new Link({ + source: "user2", + target: "profile2", + predicate: "name", + }); + link2.proof = new ExpressionProof("sig2", "key2"); + await client2.agent.updatePublicPerspective(new Perspective([link2])); + + // Verify that each user's public perspective was updated correctly + const user1AfterUpdate = await client1.agent.me(); + const user2AfterUpdate = await client2.agent.me(); + + // Check that profiles contain the correct links + expect(user1AfterUpdate.perspective).to.not.be.null; + expect(user2AfterUpdate.perspective).to.not.be.null; + + if ( + user1AfterUpdate.perspective && + user1AfterUpdate.perspective.links.length > 0 + ) { + const user1Link = user1AfterUpdate.perspective.links.find( + (l) => l.data.source === "user1" && l.data.target === "profile1", + ); + expect(user1Link).to.not.be.undefined; + } + + if ( + user2AfterUpdate.perspective && + user2AfterUpdate.perspective.links.length > 0 + ) { + const user2Link = user2AfterUpdate.perspective.links.find( + (l) => l.data.source === "user2" && l.data.target === "profile2", + ); + expect(user2Link).to.not.be.undefined; + } + + console.log("User 1 after update:", user1AfterUpdate.did); + console.log("User 2 after update:", user2AfterUpdate.did); + + // Verify DIDs are still different + expect(user1AfterUpdate.did).to.not.equal(user2AfterUpdate.did); + }); + + it("should not allow users to see other users' agent profiles", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "private1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "private2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "private1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "private2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get agent info for both users + const user1Agent = await client1.agent.me(); + const user2Agent = await client2.agent.me(); + + // Verify each user only sees their own agent information + expect(user1Agent.did).to.not.equal(user2Agent.did); + + // Try to query the other user's DID (this should fail or return nothing) + try { + const user1TryingToSeeUser2 = await client1.agent.byDID(user2Agent.did); + // If this succeeds, it should not return user2's private information + console.log("User 1 trying to see User 2:", user1TryingToSeeUser2); + } catch (error) {} + }); + + it("should publish managed users to the agent language", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "agentlang1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "agentlang2@example.com", + "password2", + ); + + // Login both users to trigger any agent language publishing + const token1 = await adminAd4mClient!.agent.loginUser( + "agentlang1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "agentlang2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get the DIDs for both users + const user1Agent = await client1.agent.me(); + const user2Agent = await client2.agent.me(); + + console.log("User 1 DID:", user1Agent.did); + console.log("User 2 DID:", user2Agent.did); + + // Wait a moment for the agents to be fully published + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Try to retrieve the users from the agent language by their DIDs + try { + console.log("Attempting to retrieve user 1 with DID:", user1Agent.did); + const retrievedUser1 = await adminAd4mClient!.agent.byDID( + user1Agent.did, + ); + console.log("Retrieved user 1:", retrievedUser1); + + console.log("Attempting to retrieve user 2 with DID:", user2Agent.did); + const retrievedUser2 = await adminAd4mClient!.agent.byDID( + user2Agent.did, + ); + console.log("Retrieved user 2:", retrievedUser2); + + expect(retrievedUser1).to.not.be.null; + expect(retrievedUser2).to.not.be.null; + + if (retrievedUser1) { + expect(retrievedUser1.did).to.equal(user1Agent.did); + } + + if (retrievedUser2) { + expect(retrievedUser2.did).to.equal(user2Agent.did); + } + + // Also test getting agent expressions via expression.get() + console.log("Testing expression.get() method..."); + const expr1 = await adminAd4mClient!.expression.get(user1Agent.did); + const expr2 = await adminAd4mClient!.expression.get(user2Agent.did); + + console.log("Expression 1 result:", expr1); + console.log("Expression 2 result:", expr2); + + if (expr1?.data) { + const agent1Data = + typeof expr1.data === "string" + ? JSON.parse(expr1.data) + : expr1.data; + expect(agent1Data.did).to.equal(user1Agent.did); + } else { + console.log("ℹ️ User 1 expression.get() returned null"); + } + + if (expr2?.data) { + const agent2Data = + typeof expr2.data === "string" + ? JSON.parse(expr2.data) + : expr2.data; + expect(agent2Data.did).to.equal(user2Agent.did); + } else { + console.log("ℹ️ User 2 expression.get() returned null"); + } + } catch (error) { + console.log("❌ Failed to retrieve users from agent language:", error); + throw error; + } + }); + + it("should publish updated public perspectives to the agent language", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "perspective1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "perspective2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "perspective1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "perspective2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Get initial agent info + const user1Agent = await client1.agent.me(); + const user2Agent = await client2.agent.me(); + + console.log("User 1 DID:", user1Agent.did); + console.log("User 2 DID:", user2Agent.did); + + // User 1 updates their public perspective + let link1 = new LinkExpression(); + link1.author = user1Agent.did; + link1.timestamp = new Date().toISOString(); + link1.data = new Link({ + source: "user1", + target: "profile1", + predicate: "name", + }); + link1.proof = new ExpressionProof("sig1", "key1"); + await client1.agent.updatePublicPerspective(new Perspective([link1])); + + // User 2 updates their public perspective with different data + let link2 = new LinkExpression(); + link2.author = user2Agent.did; + link2.timestamp = new Date().toISOString(); + link2.data = new Link({ + source: "user2", + target: "profile2", + predicate: "name", + }); + link2.proof = new ExpressionProof("sig2", "key2"); + await client2.agent.updatePublicPerspective(new Perspective([link2])); + + // Wait for the updates to be published + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Retrieve the updated agents from the agent language + try { + console.log("Retrieving updated agents from agent language..."); + const retrievedUser1 = await adminAd4mClient!.agent.byDID( + user1Agent.did, + ); + const retrievedUser2 = await adminAd4mClient!.agent.byDID( + user2Agent.did, + ); + + expect(retrievedUser1).to.not.be.null; + expect(retrievedUser2).to.not.be.null; + + if (retrievedUser1?.perspective) { + expect(retrievedUser1.perspective.links).to.have.length.greaterThan( + 0, + ); + const hasUser1Link = retrievedUser1.perspective.links.some( + (link) => + link.data.source === "user1" && link.data.target === "profile1", + ); + expect(hasUser1Link).to.be.true; + } + + if (retrievedUser2?.perspective) { + expect(retrievedUser2.perspective.links).to.have.length.greaterThan( + 0, + ); + const hasUser2Link = retrievedUser2.perspective.links.some( + (link) => + link.data.source === "user2" && link.data.target === "profile2", + ); + expect(hasUser2Link).to.be.true; + } + + // Also test via expression.get() + console.log("Testing updated perspectives via expression.get()..."); + const expr1 = await adminAd4mClient!.expression.get(user1Agent.did); + const expr2 = await adminAd4mClient!.expression.get(user2Agent.did); + + if (expr1?.data) { + const agent1Data = + typeof expr1.data === "string" + ? JSON.parse(expr1.data) + : expr1.data; + expect(agent1Data.perspective?.links).to.have.length.greaterThan(0); + } else { + console.log("ℹ️ User 1 updated expression.get() returned null"); + } + + if (expr2?.data) { + const agent2Data = + typeof expr2.data === "string" + ? JSON.parse(expr2.data) + : expr2.data; + expect(agent2Data.perspective?.links).to.have.length.greaterThan(0); + } else { + console.log("ℹ️ User 2 updated expression.get() returned null"); + } + } catch (error) { + console.log( + "❌ Failed to retrieve updated agents from agent language:", + error, + ); + throw error; + } + }); + + it("should use correct user context for expression.create()", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "expr1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "expr2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "expr1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "expr2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + const user1Agent = await client1.agent.me(); + const user2Agent = await client2.agent.me(); + + console.log("User 1 DID:", user1Agent.did); + console.log("User 2 DID:", user2Agent.did); + + // User 1 creates a literal expression + const expr1Url = await client1.expression.create( + "Hello from User 1", + "literal", + ); + console.log("User 1 created expression:", expr1Url); + + // User 2 creates a literal expression + const expr2Url = await client2.expression.create( + "Hello from User 2", + "literal", + ); + console.log("User 2 created expression:", expr2Url); + + // Retrieve the expressions and check their authors + const expr1 = await adminAd4mClient!.expression.get(expr1Url); + const expr2 = await adminAd4mClient!.expression.get(expr2Url); + + console.log("Expression 1:", JSON.stringify(expr1, null, 2)); + console.log("Expression 2:", JSON.stringify(expr2, null, 2)); + + // The expressions should be authored by the respective users, not the main agent + expect(expr1?.author).to.equal(user1Agent.did); + expect(expr2?.author).to.equal(user2Agent.did); + + if (expr1) { + console.log("Expression 1 proof:", expr1.proof); + expect(expr1.proof.signature).to.not.be.empty; + expect(expr1.proof.key).to.not.be.empty; + } + if (expr2) { + console.log("Expression 2 proof:", expr2.proof); + expect(expr2.proof.signature).to.not.be.empty; + expect(expr2.proof.key).to.not.be.empty; + } + }); + + it("should use correct user context for expression.interact()", async () => { + // This test would require a language with interactions + // For now, we'll just verify that the context-aware code path exists + console.log( + "ℹ️ Expression interaction context test skipped - requires custom language with interactions", + ); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-sdna.test.ts b/tests/js/tests/multi-user/multi-user-sdna.test.ts new file mode 100644 index 000000000..8cdec9a6c --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-sdna.test.ts @@ -0,0 +1,130 @@ +import { Ad4mClient, LinkQuery, Model, Property } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient } from "../../utils/utils"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +@Model({ name: "TestSubject" }) +class TestSubject { + @Property({ + through: "test://name", + }) + name: string = ""; +} + +describe("Multi-User SDNA Operations tests", () => { + let agentHandle: AgentHandle | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let gqlPort: number = 0; + + before(async function () { + this.timeout(300_000); + agentHandle = await startAgent("multi-user-sdna"); + adminAd4mClient = agentHandle.client; + gqlPort = agentHandle.gqlPort; + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await agentHandle?.stop(); + }); + + describe("Subject Creation and SDNA Operations", () => { + it("should have correct authors and valid signatures for subject operations", async () => { + // Create two users + const user1Result = await adminAd4mClient!.agent.createUser( + "subject1@example.com", + "password1", + ); + const user2Result = await adminAd4mClient!.agent.createUser( + "subject2@example.com", + "password2", + ); + + // Login both users + const token1 = await adminAd4mClient!.agent.loginUser( + "subject1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "subject2@example.com", + "password2", + ); + + // @ts-ignore - Suppress Apollo type mismatch + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore - Suppress Apollo type mismatch + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // User 1 creates perspective and ensures SDNA subject class + // @ts-ignore - Suppress Apollo type mismatch + const p1 = await client1.perspective.add( + "User 1 Subject Test Perspective", + ); + + // User 1 ensures SDNA subject class + await p1.ensureSDNASubjectClass(TestSubject); + + // User 1 creates a subject instance + // @ts-ignore - Suppress Apollo type mismatch + await p1.createSubject(new TestSubject(), "test://subject1", { + name: "Test Subject 1", + }); + + // Get all links from the perspective to check authors + // @ts-ignore - Suppress Apollo type mismatch + const links1 = await p1.get(new LinkQuery({})); + expect(links1.length).to.be.greaterThan(0); + + const user1Me = await client1.agent.me(); + + // Verify all links are authored by user1 + for (const link of links1) { + expect(link.author).to.equal( + user1Me.did, + `Link with predicate ${link.predicate} should be authored by user1`, + ); + expect(link.proof.valid).to.be.true; + } + + // User 2 creates perspective and does similar operations + // @ts-ignore - Suppress Apollo type mismatch + const p2 = await client2.perspective.add( + "User 2 Subject Test Perspective", + ); + + // User 2 ensures the same SDNA subject class + // @ts-ignore - Suppress Apollo type mismatch + await p2.ensureSDNASubjectClass(TestSubject); + + // User 2 creates a subject instance + // @ts-ignore - Suppress Apollo type mismatch + await p2.createSubject(new TestSubject(), "test://subject2", { + name: "Test Subject 2", + }); + + // Get all links from user2's perspective + // @ts-ignore - Suppress Apollo type mismatch + const links2 = await p2.get(new LinkQuery({})); + expect(links2.length).to.be.greaterThan(0); + + const user2Me = await client2.agent.me(); + + // Verify all links are authored by user2 + for (const link of links2) { + expect(link.author).to.equal( + user2Me.did, + `Link with predicate ${link.predicate} should be authored by user2`, + ); + expect(link.proof.valid).to.be.true; + } + + // Ensure authors are different + expect(user1Me.did).not.to.equal(user2Me.did); + }); + }); +}); diff --git a/tests/js/tests/multi-user/multi-user-subscriptions.test.ts b/tests/js/tests/multi-user/multi-user-subscriptions.test.ts new file mode 100644 index 000000000..cfa2ce11f --- /dev/null +++ b/tests/js/tests/multi-user/multi-user-subscriptions.test.ts @@ -0,0 +1,450 @@ +import { Ad4mClient } from "@coasys/ad4m"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { apolloClient, sleep } from "../../utils/utils"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; + +const expect = chai.expect; +chai.use(chaiAsPromised); + +describe("Multi-User Perspective Subscriptions tests", () => { + let agentHandle: AgentHandle | null = null; + let adminAd4mClient: Ad4mClient | null = null; + let gqlPort: number = 0; + + before(async function () { + this.timeout(300_000); + agentHandle = await startAgent("multi-user-subscriptions"); + adminAd4mClient = agentHandle.client; + gqlPort = agentHandle.gqlPort; + await adminAd4mClient.runtime.setMultiUserEnabled(true); + }); + + after(async () => { + await agentHandle?.stop(); + }); + + describe("Perspective Subscriptions", () => { + it("should only notify users about their own perspectives in perspectiveAdded", async () => { + console.log("\n=== Testing perspective subscription filtering ==="); + + // Create two users + await adminAd4mClient!.agent.createUser("sub1@example.com", "password1"); + await adminAd4mClient!.agent.createUser("sub2@example.com", "password2"); + + const token1 = await adminAd4mClient!.agent.loginUser( + "sub1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "sub2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Track perspective added events for both users + const user1Events: any[] = []; + const user2Events: any[] = []; + + // Subscribe both users to perspectiveAdded + console.log("Subscribing users to perspectiveAdded..."); + + // @ts-ignore + client1.perspective.addPerspectiveAddedListener((perspective) => { + console.log( + `User 1 received perspectiveAdded event: ${perspective.name} (UUID: ${perspective.uuid})`, + ); + user1Events.push(perspective); + }); + client1.perspective.subscribePerspectiveAdded(); + + // @ts-ignore + client2.perspective.addPerspectiveAddedListener((perspective) => { + console.log( + `User 2 received perspectiveAdded event: ${perspective.name} (UUID: ${perspective.uuid})`, + ); + user2Events.push(perspective); + }); + client2.perspective.subscribePerspectiveAdded(); + + // Wait for subscriptions to be active + await sleep(1000); + + // User 1 creates a perspective + console.log("\nUser 1 creating perspective..."); + const user1Perspective = await client1.perspective.add( + "User 1 Only Perspective", + ); + console.log(`User 1 created perspective: ${user1Perspective.uuid}`); + + // Wait for events to propagate + await sleep(2000); + + // User 2 creates a perspective + console.log("\nUser 2 creating perspective..."); + const user2Perspective = await client2.perspective.add( + "User 2 Only Perspective", + ); + console.log(`User 2 created perspective: ${user2Perspective.uuid}`); + + // Wait for events to propagate + await sleep(2000); + + console.log(`\nUser 1 received ${user1Events.length} events`); + console.log(`User 2 received ${user2Events.length} events`); + + // Each user should only see their own perspective creation event + expect(user1Events.length).to.equal( + 1, + "User 1 should only receive 1 event (their own perspective)", + ); + expect(user2Events.length).to.equal( + 1, + "User 2 should only receive 1 event (their own perspective)", + ); + + expect(user1Events[0].uuid).to.equal( + user1Perspective.uuid, + "User 1 should only see their own perspective", + ); + expect(user2Events[0].uuid).to.equal( + user2Perspective.uuid, + "User 2 should only see their own perspective", + ); + }); + + it("should only notify users about their own perspectives in perspectiveUpdated", async () => { + console.log( + "\n=== Testing perspectiveUpdated subscription filtering ===", + ); + + // Create two users + await adminAd4mClient!.agent.createUser( + "update1@example.com", + "password1", + ); + await adminAd4mClient!.agent.createUser( + "update2@example.com", + "password2", + ); + + const token1 = await adminAd4mClient!.agent.loginUser( + "update1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "update2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Create perspectives for both users + const user1Perspective = + await client1.perspective.add("User 1 Update Test"); + const user2Perspective = + await client2.perspective.add("User 2 Update Test"); + + // Track events + const user1UpdateEvents: any[] = []; + const user2UpdateEvents: any[] = []; + + // Subscribe to perspectiveUpdated + console.log("Subscribing users to perspectiveUpdated..."); + // @ts-ignore + client1.perspective.addPerspectiveUpdatedListener((perspective) => { + console.log( + `User 1 received perspectiveUpdated event: ${perspective.name} (UUID: ${perspective.uuid})`, + ); + user1UpdateEvents.push(perspective); + }); + client1.perspective.subscribePerspectiveUpdated(); + + // @ts-ignore + client2.perspective.addPerspectiveUpdatedListener((perspective) => { + console.log( + `User 2 received perspectiveUpdated event: ${perspective.name} (UUID: ${perspective.uuid})`, + ); + user2UpdateEvents.push(perspective); + }); + client2.perspective.subscribePerspectiveUpdated(); + + await sleep(1000); + + // User 1 updates their perspective metadata (name) + console.log("\nUser 1 updating their perspective name..."); + await client1.perspective.update( + user1Perspective.uuid, + "User 1 Updated Name", + ); + + await sleep(2000); + + // User 2 updates their perspective metadata (name) + console.log("\nUser 2 updating their perspective name..."); + await client2.perspective.update( + user2Perspective.uuid, + "User 2 Updated Name", + ); + + await sleep(2000); + + console.log( + `\nUser 1 received ${user1UpdateEvents.length} update events`, + ); + console.log(`User 2 received ${user2UpdateEvents.length} update events`); + + // Each user should only see updates to their own perspectives + expect(user1UpdateEvents.length).to.equal( + 1, + "User 1 should only receive updates for their own perspective", + ); + expect(user2UpdateEvents.length).to.equal( + 1, + "User 2 should only receive updates for their own perspective", + ); + + expect(user1UpdateEvents[0].uuid).to.equal(user1Perspective.uuid); + expect(user2UpdateEvents[0].uuid).to.equal(user2Perspective.uuid); + + expect(user1UpdateEvents[0].name).to.equal("User 1 Updated Name"); + expect(user2UpdateEvents[0].name).to.equal("User 2 Updated Name"); + }); + + it("should only notify users about links in their own perspectives", async () => { + console.log( + "\n=== Testing perspective_link_added subscription filtering ===", + ); + + // Create two users + await adminAd4mClient!.agent.createUser( + "linkuser1@example.com", + "password1", + ); + await adminAd4mClient!.agent.createUser( + "linkuser2@example.com", + "password2", + ); + + const token1 = await adminAd4mClient!.agent.loginUser( + "linkuser1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "linkuser2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Create perspectives for both users + const user1Perspective = + await client1.perspective.add("User 1 Link Test"); + const user2Perspective = + await client2.perspective.add("User 2 Link Test"); + + // Track events + const user1LinkEvents: any[] = []; + const user2LinkEvents: any[] = []; + + // Subscribe to perspective_link_added for each user's perspective + console.log("Subscribing users to perspective_link_added..."); + // @ts-ignore + client1.perspective.addPerspectiveLinkAddedListener( + user1Perspective.uuid, + [ + // @ts-ignore + (link) => { + console.log( + `User 1 received link added event in perspective ${user1Perspective.uuid}`, + ); + user1LinkEvents.push(link); + }, + ], + ); + + // @ts-ignore + client2.perspective.addPerspectiveLinkAddedListener( + user2Perspective.uuid, + [ + // @ts-ignore + (link) => { + console.log( + `User 2 received link added event in perspective ${user2Perspective.uuid}`, + ); + user2LinkEvents.push(link); + }, + ], + ); + + await sleep(1000); + + // User 1 adds a link to their perspective + console.log("\nUser 1 adding link to their perspective..."); + await client1.perspective.addLink(user1Perspective.uuid, { + source: "test://user1", + target: "test://data1", + predicate: "test://has", + }); + + await sleep(2000); + + // User 2 adds a link to their perspective + console.log("\nUser 2 adding link to their perspective..."); + await client2.perspective.addLink(user2Perspective.uuid, { + source: "test://user2", + target: "test://data2", + predicate: "test://has", + }); + + await sleep(2000); + + console.log(`\nUser 1 received ${user1LinkEvents.length} link events`); + console.log(`User 2 received ${user2LinkEvents.length} link events`); + + console.log(user1LinkEvents); + console.log(user2LinkEvents); + + // Each user should ONLY see link events for links they added; they should NOT see the other user's + const user1HasCorrectLink = user1LinkEvents.some( + (event) => + event.data.source === "test://user1" && + event.data.target === "test://data1", + ); + const user1HasOtherUserLink = user1LinkEvents.some( + (event) => + event.data.source === "test://user2" && + event.data.target === "test://data2", + ); + const user2HasCorrectLink = user2LinkEvents.some( + (event) => + event.data.source === "test://user2" && + event.data.target === "test://data2", + ); + const user2HasOtherUserLink = user2LinkEvents.some( + (event) => + event.data.source === "test://user1" && + event.data.target === "test://data1", + ); + + expect( + user1HasCorrectLink, + "User 1 should receive events for their own link", + ).to.be.true; + expect( + user1HasOtherUserLink, + "User 1 should NOT receive events for User 2's link", + ).to.be.false; + expect( + user2HasCorrectLink, + "User 2 should receive events for their own link", + ).to.be.true; + expect( + user2HasOtherUserLink, + "User 2 should NOT receive events for User 1's link", + ).to.be.false; + + expect(user1LinkEvents[0].data.source).to.equal("test://user1"); + expect(user2LinkEvents[0].data.source).to.equal("test://user2"); + }); + + it("should only notify users about removal of their own perspectives", async () => { + console.log( + "\n=== Testing perspectiveRemoved subscription filtering ===", + ); + + // Create two users + await adminAd4mClient!.agent.createUser( + "remove1@example.com", + "password1", + ); + await adminAd4mClient!.agent.createUser( + "remove2@example.com", + "password2", + ); + + const token1 = await adminAd4mClient!.agent.loginUser( + "remove1@example.com", + "password1", + ); + const token2 = await adminAd4mClient!.agent.loginUser( + "remove2@example.com", + "password2", + ); + + // @ts-ignore + const client1 = new Ad4mClient(apolloClient(gqlPort, token1), false); + // @ts-ignore + const client2 = new Ad4mClient(apolloClient(gqlPort, token2), false); + + // Create perspectives for both users + const user1Perspective = + await client1.perspective.add("User 1 Remove Test"); + const user2Perspective = + await client2.perspective.add("User 2 Remove Test"); + + // Track events + const user1RemoveEvents: string[] = []; + const user2RemoveEvents: string[] = []; + + // Subscribe to perspectiveRemoved + console.log("Subscribing users to perspectiveRemoved..."); + // @ts-ignore + client1.perspective.addPerspectiveRemovedListener((uuid) => { + console.log(`User 1 received perspectiveRemoved event: ${uuid}`); + user1RemoveEvents.push(uuid); + }); + client1.perspective.subscribePerspectiveRemoved(); + + // @ts-ignore + client2.perspective.addPerspectiveRemovedListener((uuid) => { + console.log(`User 2 received perspectiveRemoved event: ${uuid}`); + user2RemoveEvents.push(uuid); + }); + client2.perspective.subscribePerspectiveRemoved(); + + await sleep(1000); + + // User 1 removes their perspective + console.log("\nUser 1 removing their perspective..."); + await client1.perspective.remove(user1Perspective.uuid); + + await sleep(2000); + + // User 2 removes their perspective + console.log("\nUser 2 removing their perspective..."); + await client2.perspective.remove(user2Perspective.uuid); + + await sleep(2000); + + console.log( + `\nUser 1 received ${user1RemoveEvents.length} removal events`, + ); + console.log(`User 2 received ${user2RemoveEvents.length} removal events`); + + // Each user should only see removal of their own perspectives + expect(user1RemoveEvents.length).to.equal( + 1, + "User 1 should only be notified about their own perspective removal", + ); + expect(user2RemoveEvents.length).to.equal( + 1, + "User 2 should only be notified about their own perspective removal", + ); + + expect(user1RemoveEvents[0]).to.equal(user1Perspective.uuid); + expect(user2RemoveEvents[0]).to.equal(user2Perspective.uuid); + }); + }); +}); diff --git a/tests/js/tests/neighbourhood.ts b/tests/js/tests/neighbourhood.ts deleted file mode 100644 index 37de2c8dc..000000000 --- a/tests/js/tests/neighbourhood.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { Link, Perspective, LinkExpression, ExpressionProof, LinkQuery, PerspectiveState, NeighbourhoodProxy, PerspectiveUnsignedInput, PerspectiveProxy, PerspectiveHandle } from "@coasys/ad4m"; -import { TestContext } from './integration.test' -import { sleep } from "../utils/utils"; -import fs from "fs"; -import { v4 as uuidv4 } from 'uuid'; -import { expect } from "chai"; - -const DIFF_SYNC_OFFICIAL = fs.readFileSync("./scripts/perspective-diff-sync-hash").toString(); -let aliceP1: null | PerspectiveProxy = null; -let bobP1: null | PerspectiveHandle = null; - -export default function neighbourhoodTests(testContext: TestContext) { - return () => { - describe('Neighbourhood', () => { - it('can publish and join locally @alice', async () => { - const ad4mClient = testContext.alice!; - - const create = await ad4mClient!.perspective.add("publish-test"); - expect(create.name).to.be.equal("publish-test"); - expect(create.neighbourhood).to.be.null; - expect(create.state).to.be.equal(PerspectiveState.Private); - - //Create unique perspective-diff-sync to simulate real scenario - const socialContext = await ad4mClient.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's perspective-diff-sync"})); - expect(socialContext.name).to.be.equal("Alice's perspective-diff-sync"); - - let link = new LinkExpression() - link.author = "did:test"; - link.timestamp = new Date().toISOString(); - link.data = new Link({source: "ad4m://src", target: "test://target", predicate: "ad4m://pred"}); - link.proof = new ExpressionProof("sig", "key"); - const publishPerspective = await ad4mClient.neighbourhood.publishFromPerspective(create.uuid, socialContext.address, - new Perspective( - [link] - ) - ); - - //Check that we got an ad4m url back - expect(publishPerspective.split("://").length).to.be.equal(2); - - const perspective = await ad4mClient.perspective.byUUID(create.uuid); - expect(perspective?.neighbourhood).not.to.be.undefined; - expect(perspective?.neighbourhood!.data.linkLanguage).to.be.equal(socialContext.address); - expect(perspective?.neighbourhood!.data.meta.links.length).to.be.equal(1); - - // The perspective should start in NeighbourhoodCreationInitiated state - expect(perspective?.state).to.be.equal(PerspectiveState.NeighboudhoodCreationInitiated); - - // Wait for the perspective to transition to Synced state - let tries = 0; - const maxTries = 10; - let currentPerspective = perspective; - while (currentPerspective?.state !== PerspectiveState.Synced && tries < maxTries) { - await sleep(1000); - currentPerspective = await ad4mClient.perspective.byUUID(create.uuid); - tries++; - } - expect(currentPerspective?.state).to.be.equal(PerspectiveState.Synced); - }) - - it('can be created by Alice and joined by Bob', async () => { - const alice = testContext.alice - const bob = testContext.bob - - const aliceP1 = await alice.perspective.add("friends") - const socialContext = await alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob"})); - expect(socialContext.name).to.be.equal("Alice's neighbourhood with Bob"); - const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) - - let bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - - await testContext.makeAllNodesKnown() - - expect(bobP1!.name).not.to.be.undefined; - expect(bobP1!.sharedUrl).to.be.equal(neighbourhoodUrl) - expect(bobP1!.neighbourhood).not.to.be.undefined;; - expect(bobP1!.neighbourhood!.data.linkLanguage).to.be.equal(socialContext.address); - expect(bobP1!.neighbourhood!.data.meta.links.length).to.be.equal(0); - }) - - it('shared link created by Alice received by Bob', async () => { - const alice = testContext.alice - const bob = testContext.bob - - const aliceP1 = await alice.perspective.add("friends") - const socialContext = await alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob test shared links"})); - const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) - - let bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - - await testContext.makeAllNodesKnown() - expect(bobP1!.state).to.be.oneOf([PerspectiveState.LinkLanguageInstalledButNotSynced, PerspectiveState.Synced]); - - await sleep(1000) - - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - - await sleep(1000) - - let bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - let tries = 1 - - while(bobLinks.length < 1 && tries < 60) { - console.log("Bob retrying getting links..."); - await sleep(1000) - bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(bobLinks.length).to.be.equal(1) - expect(bobLinks[0].data.target).to.be.equal('test://test') - expect(bobLinks[0].proof.valid).to.be.true; - }) - - - it('local link created by Alice NOT received by Bob', async () => { - const alice = testContext.alice - const bob = testContext.bob - - aliceP1 = await alice.perspective.add("friends") - const socialContext = await alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob test local links"})); - const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) - console.log("neighbourhoodUrl", neighbourhoodUrl); - bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - - await testContext.makeAllNodesKnown() - - await sleep(1000) - - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}, 'local') - - await sleep(1000) - - let bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - let tries = 1 - - while(bobLinks.length < 1 && tries < 5) { - console.log("Bob retrying getting NOT received links..."); - await sleep(1000) - bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(bobLinks.length).to.be.equal(0) - }) - - it('stress test - Bob receives 1500 links created rapidly by Alice', async () => { - const alice = testContext.alice - const bob = testContext.bob - - aliceP1 = await alice.perspective.add("friends") - const socialContext = await alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob stress test"})); - const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) - console.log("neighbourhoodUrl", neighbourhoodUrl); - bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - - await testContext.makeAllNodesKnown() - - await sleep(1000) - - // Create 1500 links as fast as possible - //const linkPromises = [] - for(let i = 0; i < 1500; i++) { - console.log("Alice adding link ", i) - const link = await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: `test://test/${i}`}) - console.log("Link expression:", link) - } - //await Promise.all(linkPromises) - - console.log("wait 15s for initial sync") - await sleep(15000) - - let bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - let tries = 1 - const maxTries = 180 // 3 minutes with 1 second sleep (increased for fallback sync) - - while(bobLinks.length < 1500 && tries < maxTries) { - console.log(`Bob retrying getting links... Got ${bobLinks.length}/1500`); - await sleep(1000) - bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(bobLinks.length).to.be.equal(1500) - // Verify a few random links to ensure data integrity - expect(bobLinks.some(link => link.data.target === 'test://test/0')).to.be.true - expect(bobLinks.some(link => link.data.target === 'test://test/749')).to.be.true - expect(bobLinks.some(link => link.data.target === 'test://test/1499')).to.be.true - bobLinks.forEach(link => { - expect(link.proof.valid).to.be.true - }) - - // make sure we're getting out of burst mode again - await sleep(11000) - - // Alice creates some links - console.log("Alice creating links...") - await testContext.alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://alice', target: 'test://alice/1'}) - await testContext.alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://alice', target: 'test://alice/2'}) - await testContext.alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://alice', target: 'test://alice/3'}) - - // Wait for sync with retry loop - bobLinks = await testContext.bob.perspective.queryLinks(bobP1.uuid, new LinkQuery({source: 'ad4m://alice'})) - let bobTries = 1 - const maxTriesBob = 20 // 20 tries with 2 second sleep = 40 seconds max - - while(bobLinks.length < 3 && bobTries < maxTriesBob) { - console.log(`Bob retrying getting Alice's links... Got ${bobLinks.length}/3`); - await sleep(2000) - bobLinks = await testContext.bob.perspective.queryLinks(bobP1.uuid, new LinkQuery({source: 'ad4m://alice'})) - bobTries++ - } - - // Verify Bob received Alice's links - expect(bobLinks.length).to.equal(3) - expect(bobLinks.some(link => link.data.target === 'test://alice/1')).to.be.true - expect(bobLinks.some(link => link.data.target === 'test://alice/2')).to.be.true - expect(bobLinks.some(link => link.data.target === 'test://alice/3')).to.be.true - - // Bob creates some links - console.log("Bob creating links...") - await testContext.bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://bob', target: 'test://bob/1'}) - await testContext.bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://bob', target: 'test://bob/2'}) - await testContext.bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://bob', target: 'test://bob/3'}) - - // Wait for sync with retry loop - let aliceLinks = await testContext.alice.perspective.queryLinks(aliceP1.uuid, new LinkQuery({source: 'ad4m://bob'})) - tries = 1 - const maxTriesAlice = 20 // 2 minutes with 1 second sleep - - while(aliceLinks.length < 3 && tries < maxTriesAlice) { - console.log(`Alice retrying getting links... Got ${aliceLinks.length}/3`); - await sleep(2000) - aliceLinks = await testContext.alice.perspective.queryLinks(aliceP1.uuid, new LinkQuery({source: 'ad4m://bob'})) - tries++ - } - - // Verify Alice received Bob's links - //let aliceLinks = await testContext.alice.perspective.queryLinks(aliceP1.uuid, new LinkQuery({source: 'bob'})) - expect(aliceLinks.length).to.equal(3) - expect(aliceLinks.some(link => link.data.target === 'test://bob/1')).to.be.true - expect(aliceLinks.some(link => link.data.target === 'test://bob/2')).to.be.true - expect(aliceLinks.some(link => link.data.target === 'test://bob/3')).to.be.true - }) - - it('can delete neighbourhood', async () => { - const alice = testContext.alice; - const bob = testContext.bob; - - const deleteNeighbourhood = await alice.perspective.remove(aliceP1!.uuid); - expect(deleteNeighbourhood.perspectiveRemove).to.be.true; - - const bobDeleteNeighbourhood = await bob.perspective.remove(bobP1!.uuid); - expect(bobDeleteNeighbourhood.perspectiveRemove).to.be.true; - - const perspectives = await alice.perspective.all(); - }) - - // it('can get the correct state change signals', async () => { - // const aliceP1 = await testContext.alice.perspective.add("state-changes") - // expect(aliceP1.state).to.be.equal(PerspectiveState.Private); - - // const socialContext = await testContext.alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob"})); - // expect(socialContext.name).to.be.equal("Alice's neighbourhood with Bob"); - // const neighbourhoodUrl = await testContext.alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) - - // let aliceSyncChangeCalls = 0; - // let aliceSyncChangeData = null; - // const aliceSyncChangeHandler = (payload: PerspectiveState) => { - // aliceSyncChangeCalls += 1; - // //@ts-ignore - // aliceSyncChangeData = payload; - // return null; - // }; - - // aliceP1.addSyncStateChangeListener(aliceSyncChangeHandler); - - // await testContext.alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - - // let bobSyncChangeCalls = 0; - // let bobSyncChangeData = null; - // const bobSyncChangeHandler = (payload: PerspectiveState) => { - // console.log("bob got new state", payload); - // bobSyncChangeCalls += 1; - // //@ts-ignore - // bobSyncChangeData = payload; - // return null; - // }; - - // let bobHandler = await testContext.bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - // let bobP1 = await testContext.bob.perspective.byUUID(bobHandler.uuid); - // expect(bobP1?.state).to.be.equal(PerspectiveState.LinkLanguageInstalledButNotSynced); - - // await bobP1!.addSyncStateChangeListener(bobSyncChangeHandler); - - // //These next assertions are flaky since they depend on holochain not syncing right away, which most of the time is the case - - // let bobLinks = await testContext.bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - // let tries = 1 - - // while(bobLinks.length < 1 && tries < 300) { - // await sleep(1000) - // bobLinks = await testContext.bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - // tries++ - // } - - // expect(bobLinks.length).to.be.equal(1) - - // await sleep(5000); - - // // expect(aliceSyncChangeCalls).to.be.equal(2); - // // expect(aliceSyncChangeData).to.be.equal(PerspectiveState.Synced); - - // expect(bobSyncChangeCalls).to.be.equal(1); - // expect(bobSyncChangeData).to.be.equal(PerspectiveState.Synced); - // }) - - - describe('with set up and joined NH for Telepresence', async () => { - let aliceNH: NeighbourhoodProxy|undefined - let bobNH: NeighbourhoodProxy|undefined - let aliceDID: string|undefined - let bobDID: string|undefined - - before(async () => { - const alice = testContext.alice - const bob = testContext.bob - - const aliceP1 = await alice.perspective.add("telepresence") - const linkLang = await alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood for Telepresence"})); - const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective(aliceP1.uuid, linkLang.address, new Perspective()) - await sleep(5000) - const bobP1Handle = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - const bobP1 = await bob.perspective.byUUID(bobP1Handle.uuid) - await testContext.makeAllNodesKnown() - - aliceNH = aliceP1.getNeighbourhoodProxy() - bobNH = bobP1!.getNeighbourhoodProxy() - aliceDID = (await alice.agent.me()).did - bobDID = (await bob.agent.me()).did - await sleep(5000) - }) - - it('they see each other in `otherAgents`', async () => { - // Wait for agents to discover each other with retry loop - let aliceAgents = await aliceNH!.otherAgents() - let bobAgents = await bobNH!.otherAgents() - let tries = 1 - const maxTries = 60 // 60 tries with 1 second sleep = 1 minute max - - while ((aliceAgents.length < 1 || bobAgents.length < 1) && tries < maxTries) { - console.log(`Waiting for agents to discover each other... Alice: ${aliceAgents.length}, Bob: ${bobAgents.length}`); - await sleep(1000) - aliceAgents = await aliceNH!.otherAgents() - bobAgents = await bobNH!.otherAgents() - tries++ - } - - console.log("alice agents", aliceAgents); - console.log("bob agents", bobAgents); - expect(aliceAgents.length).to.be.equal(1) - expect(aliceAgents[0]).to.be.equal(bobDID) - expect(bobAgents.length).to.be.equal(1) - expect(bobAgents[0]).to.be.equal(aliceDID) - }) - - it('they can set their online status and see each others online status in `onlineAgents`', async () => { - let link = new LinkExpression() - link.author = "did:test"; - link.timestamp = new Date().toISOString(); - link.data = new Link({source: "ad4m://src", target: "test://target", predicate: "ad4m://pred"}); - link.proof = new ExpressionProof("sig", "key"); - link.proof.invalid = true; - link.proof.valid = false; - const testPerspective = new Perspective([link]) - await aliceNH!.setOnlineStatus(testPerspective) - await bobNH!.setOnlineStatus(testPerspective) - - const aliceOnline = await aliceNH!.onlineAgents() - const bobOnline = await bobNH!.onlineAgents() - expect(aliceOnline.length).to.be.equal(1) - expect(aliceOnline[0].did).to.be.equal(bobDID) - console.log(aliceOnline[0].status); - expect(aliceOnline[0].status.data.links).to.deep.equal(testPerspective.links) - - expect(bobOnline.length).to.be.equal(1) - expect(bobOnline[0].did).to.be.equal(aliceDID) - expect(bobOnline[0].status.data.links).to.deep.equal(testPerspective.links) - - - await aliceNH!.setOnlineStatusU(PerspectiveUnsignedInput.fromLink(new Link({ - source: "test://source", - target: "test://target" - }))) - - const bobOnline2 = await bobNH!.onlineAgents() - - expect(bobOnline2.length).to.be.equal(1) - expect(bobOnline2[0].did).to.be.equal(aliceDID) - expect(bobOnline2[0].status.data.links[0].data.source).to.equal("test://source") - expect(bobOnline2[0].status.data.links[0].data.target).to.equal("test://target") - expect(bobOnline2[0].status.data.links[0].proof.valid).to.be.true - // TODO: Signature check for the whole perspective is broken - // Got to fix that and add back this assertion - //expect(bobOnline2[0].status.proof.valid).to.be.true - - }) - - it('they can send signals via `sendSignal` and receive callbacks via `addSignalHandler`', async () => { - let aliceCalls = 0; - let aliceData = null; - const aliceHandler = async (payload: Perspective) => { - aliceCalls += 1; - //@ts-ignore - aliceData = payload; - }; - aliceNH!.addSignalHandler(aliceHandler) - - let bobCalls = 0; - let bobData = null; - const bobHandler = async (payload: Perspective) => { - bobCalls += 1; - //@ts-ignore - bobData = payload; - }; - bobNH!.addSignalHandler(bobHandler) - - let link = new LinkExpression() - link.author = aliceDID; - link.timestamp = new Date().toISOString(); - link.data = new Link({source: "alice", target: "bob", predicate: "signal"}); - link.proof = new ExpressionProof("sig", "key"); - const aliceSignal = new Perspective([link]) - - await aliceNH!.sendSignal(bobDID!, aliceSignal) - - await sleep(1000) - - expect(bobCalls).to.be.equal(1) - expect(aliceCalls).to.be.equal(0) - - link.proof.invalid = true; - link.proof.valid = false; - //@ts-ignore - expect(bobData.data.links).to.deep.equal(aliceSignal.links) - - - let link2 = new Link({source: "bob", target: "alice", predicate: "signal"}); - const bobSignal = new PerspectiveUnsignedInput([link2]) - - await bobNH!.sendBroadcastU(bobSignal) - - await sleep(1000) - - expect(aliceCalls).to.be.equal(1) - - //@ts-ignore - expect(aliceData.data.links[0].data).to.deep.equal(link2) - }) - - it('supports loopback functionality for broadcasts', async () => { - let aliceCalls = 0; - let aliceData = null; - const aliceHandler = async (payload: Perspective) => { - aliceCalls += 1; - //@ts-ignore - aliceData = payload; - }; - aliceNH!.addSignalHandler(aliceHandler) - - let bobCalls = 0; - let bobData = null; - const bobHandler = async (payload: Perspective) => { - bobCalls += 1; - //@ts-ignore - bobData = payload; - }; - bobNH!.addSignalHandler(bobHandler) - - // Test broadcast without loopback - let link = new Link({source: "alice", target: "broadcast", predicate: "test"}); - const aliceSignal = new PerspectiveUnsignedInput([link]) - await aliceNH!.sendBroadcastU(aliceSignal) - - await sleep(1000) - - expect(bobCalls).to.be.equal(1) - expect(aliceCalls).to.be.equal(0) // Alice shouldn't receive her own broadcast - //@ts-ignore - expect(bobData.data.links[0].data).to.deep.equal(link) - - // Reset counters - bobCalls = 0 - aliceCalls = 0 - bobData = null - aliceData = null - - // Test broadcast with loopback enabled - let link2 = new Link({source: "alice", target: "broadcast-loopback", predicate: "test"}); - const aliceSignal2 = new PerspectiveUnsignedInput([link2]) - // @ts-ignore - Ignoring the type error since we know the implementation supports loopback - await aliceNH!.sendBroadcastU(aliceSignal2, true) - - await sleep(1000) - - expect(bobCalls).to.be.equal(1) - expect(aliceCalls).to.be.equal(1) // Alice should receive her own broadcast - //@ts-ignore - expect(bobData.data.links[0].data).to.deep.equal(link2) - //@ts-ignore - expect(aliceData.data.links[0].data).to.deep.equal(link2) - - // Test Bob's broadcast with loopback - let link3 = new Link({source: "bob", target: "broadcast-loopback", predicate: "test"}); - const bobSignal = new PerspectiveUnsignedInput([link3]) - // @ts-ignore - Ignoring the type error since we know the implementation supports loopback - await bobNH!.sendBroadcastU(bobSignal, true) - - await sleep(1000) - - expect(bobCalls).to.be.equal(2) // Bob should receive his own broadcast - expect(aliceCalls).to.be.equal(2) // Alice should receive Bob's broadcast - //@ts-ignore - expect(bobData.data.links[0].data).to.deep.equal(link3) - //@ts-ignore - expect(aliceData.data.links[0].data).to.deep.equal(link3) - }) - }) - }) - } -} diff --git a/tests/js/tests/perspective.ts b/tests/js/tests/perspective.ts deleted file mode 100644 index 47c5b8cb9..000000000 --- a/tests/js/tests/perspective.ts +++ /dev/null @@ -1,1031 +0,0 @@ -import { Ad4mClient, Link, LinkQuery, PerspectiveProxy, PerspectiveState } from "@coasys/ad4m"; -import { TestContext } from './integration.test' -import { expect } from "chai"; -import * as sinon from "sinon"; -import { sleep } from "../utils/utils"; - -export default function perspectiveTests(testContext: TestContext) { - return () => { - describe('Perspectives', () => { - it('can create, get & delete perspective', async () => { - const ad4mClient = testContext.ad4mClient! - - let perspectiveCount = (await ad4mClient.perspective.all()).length - - const create = await ad4mClient.perspective.add("test"); - expect(create.name).to.equal("test"); - - const get = await ad4mClient!.perspective.byUUID(create.uuid); - expect(get!.name).to.equal("test"); - - const update = await ad4mClient!.perspective.update(create.uuid, "updated-test"); - expect(update.name).to.equal("updated-test"); - - const getUpdated = await ad4mClient!.perspective.byUUID(update.uuid ); - expect(getUpdated!.name).to.equal("updated-test"); - - const perspectives = await ad4mClient.perspective.all(); - expect(perspectives.length).to.equal(perspectiveCount + 1); - - const perspectiveSnaphot = await ad4mClient.perspective.snapshotByUUID(update.uuid ); - expect(perspectiveSnaphot!.links.length).to.equal(0); - - const deletePerspective = await ad4mClient!.perspective.remove(update.uuid ); - expect(deletePerspective.perspectiveRemove).to.be.true; - - const getDeleted = await ad4mClient!.perspective.byUUID(update.uuid ); - expect(getDeleted).to.be.null; - }) - - it('can CRUD local perspective links', async () => { - const ad4mClient = testContext.ad4mClient!; - - const create = await ad4mClient.perspective.add("test-crud"); - expect(create.name).to.equal("test-crud"); - - const linkAdd = await create.add(new Link({ - source: "test://test-source", - predicate: "test://test-predicate", - target: "test://test-target" - })); - - const links = await create.get({} as LinkQuery); - expect(links.length).to.equal(1); - - await create.remove(linkAdd); - - const linksPostDelete = await create.get({} as LinkQuery); - expect(linksPostDelete.length).to.equal(0); - - const snapshot = await create.snapshot(); - expect(snapshot.links.length).to.equal(0); - }) - - it('can CRUD local perspective links with local link method', async () => { - const ad4mClient = testContext.ad4mClient!; - - const create = await ad4mClient.perspective.add("test-crud"); - expect(create.name).to.equal("test-crud"); - - const linkAdd = await create.add(new Link({ - source: "test://test-source", - predicate: "test://test-predicate", - target: "test://test-target" - }), 'local'); - - const links = await create.get({} as LinkQuery); - expect(links.length).to.equal(1); - expect(links[0].status).to.equal('LOCAL') - - await create.remove(linkAdd); - - const linksPostDelete = await create.get({} as LinkQuery); - expect(linksPostDelete.length).to.equal(0); - - const snapshot = await create.snapshot(); - expect(snapshot.links.length).to.equal(0); - }) - - it('can make mutations using perspective addLinks(), removeLinks() & linkMutations()', async () => { - const ad4mClient = testContext.ad4mClient!; - - const create = await ad4mClient.perspective.add("test-mutations"); - expect(create.name).to.equal("test-mutations"); - - const links = [ - new Link({ - source: "test://test-source", - predicate: "test://test-predicate", - target: "test://test-target" - }), - new Link({ - source: "test://test-source2", - predicate: "test://test-predicate2", - target: "test://test-target2" - }) - ]; - const linkAdds = await create.addLinks(links); - expect(linkAdds.length).to.equal(2); - - const linksPostAdd = await create.get({} as LinkQuery); - expect(linksPostAdd.length).to.equal(2); - - const linkRemoves = await create.removeLinks(linkAdds); - expect(linkRemoves.length).to.equal(2); - - const linksPostRemove = await create.get({} as LinkQuery); - expect(linksPostRemove.length).to.equal(0); - - const addTwoMore = await create.addLinks(links); - - const linkMutation = { - additions: links, - removals: addTwoMore - }; - const linkMutations = await create.linkMutations(linkMutation); - expect(linkMutations.additions.length).to.equal(2); - expect(linkMutations.removals.length).to.equal(2); - - const linksPostMutation = await create.get({} as LinkQuery); - expect(linksPostMutation.length).to.equal(2); - }) - - it(`doesn't error when duplicate entries passed to removeLinks`, async () => { - const ad4mClient = testContext.ad4mClient!; - const perspective = await ad4mClient.perspective.add('test-duplicate-link-removal'); - expect(perspective.name).to.equal('test-duplicate-link-removal'); - - // create link - const link = { source: 'ad4m://root', predicate: 'ad4m://p', target: 'test://abc' }; - const addLink = await perspective.add(link); - expect(addLink.data.target).to.equal("test://abc"); - - // get link expression - const linkExpression = (await perspective.get(new LinkQuery(link)))[0]; - expect(linkExpression.data.target).to.equal("test://abc"); - - // attempt to remove link twice (currently errors and prevents further execution of code) - await perspective.removeLinks([linkExpression, linkExpression]) - - // check link is removed - const links = await perspective.get(new LinkQuery(link)); - expect(links.length).to.equal(0); - }) - - it('test local perspective links - time query', async () => { - const ad4mClient = testContext.ad4mClient! - - const create = await ad4mClient!.perspective.add("test-links-time"); - expect(create.name).to.equal("test-links-time"); - - let addLink = await ad4mClient!.perspective.addLink(create.uuid, new Link({source: "lang://test", target: "lang://test-target", predicate: "lang://predicate"})); - await sleep(10); - let addLink2 = await ad4mClient!.perspective.addLink(create.uuid, new Link({source: "lang://test", target: "lang://test-target2", predicate: "lang://predicate"})); - await sleep(10); - let addLink3 = await ad4mClient!.perspective.addLink(create.uuid, new Link({source: "lang://test", target: "lang://test-target3", predicate: "lang://predicate"})); - await sleep(10); - let addLink4 = await ad4mClient!.perspective.addLink(create.uuid, new Link({source: "lang://test", target: "lang://test-target4", predicate: "lang://predicate"})); - await sleep(10); - let addLink5 = await ad4mClient!.perspective.addLink(create.uuid, new Link({source: "lang://test", target: "lang://test-target5", predicate: "lang://predicate"})); - - // Get all the links - let queryLinksAll = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test", fromDate: new Date(new Date(addLink.timestamp).getTime()), untilDate: new Date()})); - expect(queryLinksAll.length).to.equal(5); - - - // Get 3 of the links in descending order - let queryLinksAsc = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test", fromDate: new Date(), untilDate: new Date("August 19, 1975 23:15:30"), limit: 3})); - expect(queryLinksAsc.length).to.equal(3); - expect(queryLinksAsc[0].data.target).to.equal(addLink5.data.target) - expect(queryLinksAsc[1].data.target).to.equal(addLink4.data.target) - expect(queryLinksAsc[2].data.target).to.equal(addLink3.data.target) - - // Get 3 of the links in descending order - let queryLinksDesc = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test", fromDate: new Date("August 19, 1975 23:15:30"), untilDate: new Date(), limit: 3})); - expect(queryLinksDesc.length).to.equal(3); - expect(queryLinksDesc[0].data.target).to.equal(addLink.data.target) - expect(queryLinksDesc[1].data.target).to.equal(addLink2.data.target) - expect(queryLinksDesc[2].data.target).to.equal(addLink3.data.target) - - - //Test can get all links but first by querying from second timestamp - let queryLinks = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test", fromDate: new Date(new Date(addLink2.timestamp).getTime() - 1), untilDate: new Date()})); - expect(queryLinks.length).to.equal(4); - - //Test can get links limited - let queryLinksLimited = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test", fromDate: new Date(new Date(addLink2.timestamp).getTime() - 1), untilDate: new Date(), limit: 3})); - expect(queryLinksLimited.length).to.equal(3); - - //Test can get only the first link - let queryLinksFirst = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({ - source: "lang://test", fromDate: new Date(addLink.timestamp), - untilDate: new Date(new Date(addLink2.timestamp).getTime() - 1) - })); - expect(queryLinksFirst.length).to.equal(1); - expect(queryLinksFirst[0].data.target).to.equal("lang://test-target"); - }) - - it('test local perspective links', async () => { - const ad4mClient = testContext.ad4mClient! - - const create = await ad4mClient!.perspective.add("test-links"); - expect(create.name).to.equal("test-links"); - - let addLink = await ad4mClient!.perspective.addLink(create.uuid, new Link({source: "lang://test", target: "lang://test-target", predicate: "lang://predicate"})); - expect(addLink.data.target).to.equal("lang://test-target"); - expect(addLink.data.source).to.equal("lang://test"); - - //Test can get by source, target, predicate - let queryLinks = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test"})); - expect(queryLinks.length).to.equal(1); - expect(queryLinks[0].data.target).to.equal("lang://test-target"); - expect(queryLinks[0].data.source).to.equal("lang://test"); - - let queryLinksTarget = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({target: "lang://test-target"})); - expect(queryLinksTarget.length).to.equal(1); - expect(queryLinksTarget[0].data.target).to.equal("lang://test-target"); - expect(queryLinksTarget[0].data.source).to.equal("lang://test"); - - let queryLinksPredicate = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({predicate: "lang://predicate"})); - expect(queryLinksPredicate.length).to.equal(1); - expect(queryLinksPredicate[0].data.target).to.equal("lang://test-target"); - expect(queryLinksPredicate[0].data.source).to.equal("lang://test"); - - const perspectiveSnaphot = await ad4mClient.perspective.snapshotByUUID(create.uuid ); - expect(perspectiveSnaphot!.links.length).to.equal(1); - - //Update the link to new link - const updateLink = await ad4mClient.perspective.updateLink(create.uuid, addLink, - new Link({source: "lang://test2", target: "lang://test-target2", predicate: "lang://predicate2"})); - expect(updateLink.data.target).to.equal("lang://test-target2"); - expect(updateLink.data.source).to.equal("lang://test2"); - - const perspectiveSnaphotLinkUpdate = await ad4mClient.perspective.snapshotByUUID(create.uuid ); - expect(perspectiveSnaphotLinkUpdate!.links.length).to.equal(1); - - //Test cannot get old link - let queryLinksOld = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test"})); - expect(queryLinksOld.length).to.equal(0); - - //Test can get new link - let queryLinksUpdated = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test2"})); - expect(queryLinksUpdated.length).to.equal(1); - - const deleteLink = await ad4mClient!.perspective.removeLink(create.uuid, updateLink); - expect(deleteLink).to.equal(true); - - let queryLinksDeleted = await ad4mClient!.perspective.queryLinks(create.uuid, new LinkQuery({source: "lang://test2"})); - expect(queryLinksDeleted.length).to.equal(0); - }) - - it('subscriptions', async () => { - const ad4mClient: Ad4mClient = testContext.ad4mClient! - - const perspectiveAdded = sinon.fake() - ad4mClient.perspective.addPerspectiveAddedListener(perspectiveAdded) - const perspectiveUpdated = sinon.fake() - ad4mClient.perspective.addPerspectiveUpdatedListener(perspectiveUpdated) - const perspectiveRemoved = sinon.fake() - ad4mClient.perspective.addPerspectiveRemovedListener(perspectiveRemoved) - - const name = "Subscription Test Perspective" - const p = await ad4mClient.perspective.add(name) - await sleep(1000) - expect(perspectiveAdded.calledOnce).to.be.true; - const pSeenInAddCB = perspectiveAdded.getCall(0).args[0]; - expect(pSeenInAddCB.uuid).to.equal(p.uuid) - expect(pSeenInAddCB.name).to.equal(p.name) - - const p1 = await ad4mClient.perspective.update(p.uuid , "New Name") - await sleep(1000) - expect(perspectiveUpdated.calledOnce).to.be.true; - const pSeenInUpdateCB = perspectiveUpdated.getCall(0).args[0]; - expect(pSeenInUpdateCB.uuid).to.equal(p1.uuid) - expect(pSeenInUpdateCB.name).to.equal(p1.name) - expect(pSeenInUpdateCB.state).to.equal(PerspectiveState.Private) - - const linkAdded = sinon.fake() - await ad4mClient.perspective.addPerspectiveLinkAddedListener(p1.uuid, [linkAdded]) - const linkRemoved = sinon.fake() - await ad4mClient.perspective.addPerspectiveLinkRemovedListener(p1.uuid, [linkRemoved]) - const linkUpdated = sinon.fake() - await ad4mClient.perspective.addPerspectiveLinkUpdatedListener(p1.uuid, [linkUpdated]) - - const linkExpression = await ad4mClient.perspective.addLink(p1.uuid , {source: 'ad4m://root', target: 'lang://123'}) - await sleep(1000) - expect(linkAdded.called).to.be.true; - expect(linkAdded.getCall(0).args[0]).to.eql(linkExpression) - - const updatedLinkExpression = await ad4mClient.perspective.updateLink(p1.uuid , linkExpression, {source: 'ad4m://root', target: 'lang://456'}) - await sleep(1000) - expect(linkUpdated.called).to.be.true; - expect(linkUpdated.getCall(0).args[0].newLink).to.eql(updatedLinkExpression) - - const copiedUpdatedLinkExpression = {...updatedLinkExpression} - - await ad4mClient.perspective.removeLink(p1.uuid , updatedLinkExpression) - await sleep(1000) - expect(linkRemoved.called).to.be.true; - //expect(linkRemoved.getCall(0).args[0]).to.eql(copiedUpdatedLinkExpression) - }) - - // SdnaOnly doesn't load links into prolog engine - it.skip('shares subscription between identical prolog queries', async () => { - const ad4mClient: Ad4mClient = testContext.ad4mClient! - const p = await ad4mClient.perspective.add("Subscription test") - - const query = 'triple(X, _, "test://target").' - - // Create first subscription - const sub1 = await p.subscribeInfer(query) - const sub1Id = sub1.id - const callback1 = sinon.fake() - sub1.onResult(callback1) - - // Create second subscription with same query - const sub2 = await p.subscribeInfer(query) - const sub2Id = sub2.id - const callback2 = sinon.fake() - sub2.onResult(callback2) - - // Assert they got same subscription ID - expect(sub1Id).to.equal(sub2Id) - - // Wait for the subscriptions to be established - // it's sending the initial result a couple of times - // to allow clients to wait and ensure for the subscription to be established - await sleep(1000) - - // Add a link that matches the query - await p.add(new Link({ - source: "test://source", - target: "test://target" - })) - - await sleep(1000) - - // Verify both callbacks were called - expect(callback1.called).to.be.true - expect(callback2.called).to.be.true - - // Verify both got same result - const result1 = callback1.getCall(callback1.callCount - 1).args[0] - const result2 = callback2.getCall(callback2.callCount - 1).args[0] - console.log("result1", result1) - expect(result1).to.deep.equal(result2) - expect(result1[0].X).to.equal("test://source") - }) - - // SdnaOnly doesn't load links into prolog engine - it.skip('can run Prolog queries', async () => { - const ad4mClient: Ad4mClient = testContext.ad4mClient! - const p = await ad4mClient.perspective.add("Prolog test") - await p.add(new Link({ - source: "ad4m://root", - target: "note-ipfs://Qm123" - })) - await p.add(new Link({ - source: "note-ipfs://Qm123", - target: "todo-ontology://is-todo" - })) - - const result = await p.infer('triple(X, _, "todo-ontology://is-todo").') - expect(result).not.to.be.false; - expect(result.length).to.equal(1) - expect(result[0].X).to.equal('note-ipfs://Qm123') - - expect(await p.infer('reachable("ad4m://root", "todo-ontology://is-todo")')).to.be.true; - - const linkResult = await p.infer('link(X, _, "todo-ontology://is-todo", Timestamp, Author).') - expect(linkResult).not.to.be.false; - expect(linkResult.length).to.equal(1) - expect(linkResult[0].X).to.equal('note-ipfs://Qm123'); - expect(linkResult[0].Timestamp).not.to.be.null; - expect(linkResult[0].Author).not.to.be.null; - }) - }) - - describe.skip('Batch Operations', () => { - let proxy: PerspectiveProxy - let ad4mClient: Ad4mClient - - beforeEach(async () => { - ad4mClient = testContext.ad4mClient! - proxy = await ad4mClient.perspective.add("batch test"); - }) - - it('can create and commit empty batch', async () => { - const batchId = await proxy.createBatch() - expect(batchId).to.be.a('string') - - const result = await proxy.commitBatch(batchId) - expect(result.additions).to.be.an('array') - expect(result.additions.length).to.equal(0) - expect(result.removals).to.be.an('array') - expect(result.removals.length).to.equal(0) - }) - - it('can add links in batch', async () => { - const batchId = await proxy.createBatch() - - const link1 = new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://target1' - }) - const link2 = new Link({ - source: 'test://source2', - predicate: 'test://predicate2', - target: 'test://target2' - }) - - // Add links to batch - await proxy.add(link1, 'shared', batchId) - await proxy.add(link2, 'shared', batchId) - - // Links should not be visible before commit - let links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(0) - - // Commit batch - const result = await proxy.commitBatch(batchId) - expect(result.additions.length).to.equal(2) - expect(result.removals.length).to.equal(0) - - // Verify links are now visible - links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(2) - expect(links.map(l => l.data.target)).to.include('test://target1') - expect(links.map(l => l.data.target)).to.include('test://target2') - }) - - it('can remove links in batch', async () => { - // Add some initial links - const link1 = new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://target1' - }) - const link2 = new Link({ - source: 'test://source2', - predicate: 'test://predicate2', - target: 'test://target2' - }) - - const expr1 = await proxy.add(link1) - const expr2 = await proxy.add(link2) - - // Create batch for removals - const batchId = await proxy.createBatch() - - // Remove links in batch - await proxy.remove(expr1, batchId) - await proxy.remove(expr2, batchId) - - // Links should still be visible before commit - let links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(2) - - // Commit batch - const result = await proxy.commitBatch(batchId) - expect(result.additions.length).to.equal(0) - expect(result.removals.length).to.equal(2) - - // Verify links are now removed - links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(0) - }) - - it('can mix additions and removals in batch', async () => { - // Add an initial link - const link1 = new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://target1' - }) - const expr1 = await proxy.add(link1) - - // Create batch - const batchId = await proxy.createBatch() - - // Remove existing link and add new one in batch - await proxy.remove(expr1, batchId) - const link2 = new Link({ - source: 'test://source2', - predicate: 'test://predicate2', - target: 'test://target2' - }) - await proxy.add(link2, 'shared', batchId) - - // Original state should be unchanged before commit - let links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(1) - expect(links[0].data.target).to.equal('test://target1') - - // Commit batch - const result = await proxy.commitBatch(batchId) - expect(result.additions.length).to.equal(1) - expect(result.removals.length).to.equal(1) - - // Verify final state - links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(1) - expect(links[0].data.target).to.equal('test://target2') - }) - - it('can update links in batch', async () => { - // Add an initial link - const link1 = new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://target1' - }) - const expr1 = await proxy.add(link1) - - // Create batch - const batchId = await proxy.createBatch() - - // Update link in batch - const newLink = new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://updated-target' - }) - await proxy.update(expr1, newLink, batchId) - - // Original state should be unchanged before commit - let links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(1) - expect(links[0].data.target).to.equal('test://target1') - - // Commit batch - const result = await proxy.commitBatch(batchId) - expect(result.additions.length).to.equal(1) - expect(result.removals.length).to.equal(1) - - // Verify final state - links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(1) - expect(links[0].data.target).to.equal('test://updated-target') - }) - - it('can handle multiple batches concurrently', async () => { - // Create two batches - const batchId1 = await proxy.createBatch() - const batchId2 = await proxy.createBatch() - - // Add different links to each batch - const link1 = new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://target1' - }) - const link2 = new Link({ - source: 'test://source2', - predicate: 'test://predicate2', - target: 'test://target2' - }) - - await proxy.add(link1, 'shared', batchId1) - await proxy.add(link2, 'shared', batchId2) - - // Verify no links are visible yet - let links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(0) - - // Commit first batch - const result1 = await proxy.commitBatch(batchId1) - expect(result1.additions.length).to.equal(1) - expect(result1.removals.length).to.equal(0) - - // Verify only first link is visible - links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(1) - expect(links[0].data.target).to.equal('test://target1') - - // Commit second batch - const result2 = await proxy.commitBatch(batchId2) - expect(result2.additions.length).to.equal(1) - expect(result2.removals.length).to.equal(0) - - // Verify both links are now visible - links = await proxy.get({} as LinkQuery) - expect(links.length).to.equal(2) - expect(links.map(l => l.data.target)).to.include('test://target1') - expect(links.map(l => l.data.target)).to.include('test://target2') - }) - - it('handles batch operations with addLinks and removeLinks', async () => { - const batchId = await proxy.createBatch() - - // Add multiple links in one call - const links = [ - new Link({ - source: 'test://source1', - predicate: 'test://predicate1', - target: 'test://target1' - }), - new Link({ - source: 'test://source2', - predicate: 'test://predicate2', - target: 'test://target2' - }) - ] - - await proxy.addLinks(links, 'shared', batchId) - - // Verify links are not visible yet - let currentLinks = await proxy.get({} as LinkQuery) - expect(currentLinks.length).to.equal(0) - - // Commit batch - const result = await proxy.commitBatch(batchId) - expect(result.additions.length).to.equal(2) - expect(result.removals.length).to.equal(0) - - // Verify links are now visible - currentLinks = await proxy.get({} as LinkQuery) - expect(currentLinks.length).to.equal(2) - - // Create new batch for removal - const removeBatchId = await proxy.createBatch() - - // Remove multiple links in one call - await proxy.removeLinks(currentLinks, removeBatchId) - - // Verify links are still visible before commit - currentLinks = await proxy.get({} as LinkQuery) - expect(currentLinks.length).to.equal(2) - - // Commit removal batch - const removeResult = await proxy.commitBatch(removeBatchId) - expect(removeResult.additions.length).to.equal(0) - expect(removeResult.removals.length).to.equal(2) - - // Verify all links are removed - currentLinks = await proxy.get({} as LinkQuery) - expect(currentLinks.length).to.equal(0) - }) - - it("should support batch operations with executeCommands", async () => { - const perspective = await ad4mClient.perspective.add("test-batch-execute-commands"); - const batchId = await perspective.createBatch(); - - // Execute commands in batch - await perspective.executeAction( - [ - { - action: "addLink", - source: "test://source1", - predicate: "test://predicate1", - target: "test://target1" - }, - { - action: "addLink", - source: "test://source2", - predicate: "test://predicate2", - target: "test://target2" - } - ], - "test://expression", - [], - batchId - ); - - // Verify links are not visible before commit - let links = await perspective.get(new LinkQuery({})); - expect(links.length).to.equal(0); - - // Commit batch and verify links are now visible - const diff = await perspective.commitBatch(batchId); - expect(diff.additions.length).to.equal(2); - expect(diff.removals.length).to.equal(0); - - links = await perspective.get({ isMatch: () => true }); - expect(links.length).to.equal(2); - - // Verify link contents - const link1 = links.find(l => l.data.source === "test://source1"); - if (!link1) throw new Error("Expected to find link1"); - expect(link1.data.predicate).to.equal("test://predicate1"); - expect(link1.data.target).to.equal("test://target1"); - - const link2 = links.find(l => l.data.source === "test://source2"); - if (!link2) throw new Error("Expected to find link2"); - expect(link2.data.predicate).to.equal("test://predicate2"); - expect(link2.data.target).to.equal("test://target2"); - }); - - it("should support batch operations with multiple commands", async () => { - const perspective = await ad4mClient.perspective.add("test-batch-multiple-commands"); - - // Add a link outside the batch first - await perspective.executeAction( - [{ - action: "addLink", - source: "test://source0", - predicate: "test://predicate0", - target: "test://target0" - }], - "test://expression", - [] - ); - - const batchId = await perspective.createBatch(); - - // Execute multiple commands in batch including a remove - await perspective.executeAction( - [ - { - action: "removeLink", - source: "test://source0", - predicate: "test://predicate0", - target: "test://target0" - }, - { - action: "addLink", - source: "test://source1", - predicate: "test://predicate1", - target: "test://target1" - }, - { - action: "setSingleTarget", - source: "test://source2", - predicate: "test://predicate2", - target: "test://target2" - } - ], - "test://expression", - [], - batchId - ); - - // Verify state before commit - let links = await perspective.get(new LinkQuery({})); - expect(links.length).to.equal(1); // Only the initial link - expect(links[0].data.source).to.equal("test://source0"); - - // Commit batch and verify final state - const diff = await perspective.commitBatch(batchId); - expect(diff.additions.length).to.equal(2); // New links - expect(diff.removals.length).to.equal(1); // Removed initial link - - links = await perspective.get(new LinkQuery({})); - expect(links.length).to.equal(2); // Two new links - - // Verify final link contents - const link1 = links.find(l => l.data.source === "test://source1"); - if (!link1) throw new Error("Expected to find link1"); - expect(link1.data.predicate).to.equal("test://predicate1"); - expect(link1.data.target).to.equal("test://target1"); - - const link2 = links.find(l => l.data.source === "test://source2"); - if (!link2) throw new Error("Expected to find link2"); - expect(link2.data.predicate).to.equal("test://predicate2"); - expect(link2.data.target).to.equal("test://target2"); - - // Verify removed link is gone - const removedLink = links.find(l => l.data.source === "test://source0"); - expect(removedLink).to.be.undefined; - }); - }) - - describe('PerspectiveProxy', () => { - let proxy: PerspectiveProxy - let ad4mClient: Ad4mClient - before(async () => { - ad4mClient = testContext.ad4mClient! - proxy = await ad4mClient.perspective.add("proxy test"); - }) - - it('can do link CRUD', async () => { - const all = new LinkQuery({}) - const testLink = new Link({ - source: 'test://source', - predicate: 'test://predicate', - target: 'test://target' - }) - - expect(await proxy.get(all)).to.eql([]) - - await proxy.add(testLink) - let links = await proxy.get(all) - expect(links.length).to.equal(1) - - let link = new Link(links[0].data) - expect(link).to.eql(testLink) - - const updatedLink = new Link({ - source: link.source, - predicate: link.predicate, - target: 'test://new_target' - }) - await proxy.update(links[0], updatedLink) - - links = await proxy.get(all) - expect(links.length).to.equal(1) - link = new Link(links[0].data) - expect(link).to.eql(updatedLink) - - await proxy.remove(links[0]) - expect(await proxy.get(all)).to.eql([]) - }) - - it('can do singleTarget operations', async () => { - const all = new LinkQuery({}) - - expect(await proxy.get(all)).to.eql([]) - const link1 = new Link({ - source: 'test://source', - predicate: 'test://predicate', - target: 'test://target1' - }) - - await proxy.setSingleTarget(link1) - const result1 = (await proxy.get(all))[0].data - expect(result1.source).to.equal(link1.source) - expect(result1.predicate).to.equal(link1.predicate) - expect(result1.target).to.equal(link1.target) - expect(await proxy.getSingleTarget(new LinkQuery(link1))).to.equal('test://target1') - - const link2 = new Link({ - source: 'test://source', - predicate: 'test://predicate', - target: 'test://target2' - }) - - await proxy.setSingleTarget(link2) - - const result2 = (await proxy.get(all))[0].data - expect(result2.source).to.equal(link2.source) - expect(result2.predicate).to.equal(link2.predicate) - expect(result2.target).to.equal(link2.target) - expect(await proxy.getSingleTarget(new LinkQuery(link1))).to.equal('test://target2') - }) - - // SdnaOnly doesn't load links into prolog engine - it.skip('can subscribe to Prolog query results', async () => { - // Add some test data - await proxy.add(new Link({ - source: "ad4m://root", - target: "note-ipfs://Qm123" - })) - await proxy.add(new Link({ - source: "note-ipfs://Qm123", - target: "todo-ontology://is-todo" - })) - - // Create subscription - const subscription = await (proxy as any).subscribeInfer('triple(X, _, "todo-ontology://is-todo").') - - // Check initial result - const initialResult = subscription.result - expect(initialResult).to.be.an('array') - expect(initialResult.length).to.equal(1) - expect(initialResult[0].X).to.equal('note-ipfs://Qm123') - - // Set up callback for updates - const updates: any[] = [] - const unsubscribe = subscription.onResult((result: any) => { - updates.push(result) - }) - - // Add another link that should trigger an update - await proxy.add(new Link({ - source: "note-ipfs://Qm456", - target: "todo-ontology://is-todo" - })) - - // Wait for subscription update - await sleep(1000) - - // Verify we got an update - expect(updates.length).to.be.greaterThan(0) - const latestResult = updates[updates.length - 1] - expect(latestResult).to.be.an('array') - expect(latestResult.length).to.equal(2) - expect(latestResult.map((r: any) => r.X)).to.include('note-ipfs://Qm123') - expect(latestResult.map((r: any) => r.X)).to.include('note-ipfs://Qm456') - - // Clean up subscription - unsubscribe() - subscription.dispose() - }) - - }) - - describe('SurrealDB Queries', () => { - it('should execute basic SurrealQL SELECT query', async () => { - const ad4mClient = testContext.ad4mClient! - const perspective = await ad4mClient.perspective.add("test-surrealdb-basic") - - // Add sample links - const link1 = new Link({ - source: "test://source1", - predicate: "test://follows", - target: "test://target1" - }) - const link2 = new Link({ - source: "test://source2", - predicate: "test://likes", - target: "test://target2" - }) - - await perspective.addLinks([link1, link2]) - - // Execute SurrealQL query - const result = await perspective.querySurrealDB('SELECT * FROM link') - - // Verify results - expect(result).to.be.an('array') - expect(result.length).to.be.greaterThanOrEqual(2) - - // Check that our links are present - const sources = result.map((r: any) => r.source) - expect(sources).to.include('test://source1') - expect(sources).to.include('test://source2') - - // Clean up - await ad4mClient.perspective.remove(perspective.uuid) - }) - - it('should handle SurrealQL query with WHERE clause', async () => { - const ad4mClient = testContext.ad4mClient! - const perspective = await ad4mClient.perspective.add("test-surrealdb-where") - - // Add links with different predicates - const followsLink = new Link({ - source: "test://alice", - predicate: "test://follows", - target: "test://bob" - }) - const likesLink = new Link({ - source: "test://alice", - predicate: "test://likes", - target: "test://post123" - }) - - await perspective.addLinks([followsLink, likesLink]) - - // Query with WHERE clause - const result = await perspective.querySurrealDB( - "SELECT * FROM link WHERE predicate = 'test://follows'" - ) - - // Verify filtered results - expect(result).to.be.an('array') - expect(result.length).to.be.greaterThanOrEqual(1) - - // All results should have the 'follows' predicate - result.forEach((link: any) => { - if (link.source === 'test://alice') { - expect(link.predicate).to.equal('test://follows') - } - }) - - // Clean up - await ad4mClient.perspective.remove(perspective.uuid) - }) - - it('should return empty array for query with no matches', async () => { - const ad4mClient = testContext.ad4mClient! - const perspective = await ad4mClient.perspective.add("test-surrealdb-empty") - - // Add a link - await perspective.add(new Link({ - source: "test://source", - predicate: "test://predicate", - target: "test://target" - })) - - // Query for something that doesn't exist - const result = await perspective.querySurrealDB( - "SELECT * FROM link WHERE predicate = 'test://nonexistent'" - ) - - // Should return empty array - expect(result).to.be.an('array') - expect(result.length).to.equal(0) - - // Clean up - await ad4mClient.perspective.remove(perspective.uuid) - }) - - it('should integrate with link mutations', async () => { - const ad4mClient = testContext.ad4mClient! - const perspective = await ad4mClient.perspective.add("test-surrealdb-mutations") - - // Add a link - const link = new Link({ - source: "test://mutation-source", - predicate: "test://mutation-predicate", - target: "test://mutation-target" - }) - - const addedLink = await perspective.add(link) - - // Query to verify it's there - let result = await perspective.querySurrealDB( - "SELECT * FROM link WHERE source = 'test://mutation-source'" - ) - - expect(result).to.be.an('array') - expect(result.length).to.be.greaterThanOrEqual(1) - - // Remove the link - await perspective.removeLinks([addedLink]) - - // Query again and verify it's gone - result = await perspective.querySurrealDB( - "SELECT * FROM link WHERE source = 'test://mutation-source'" - ) - - expect(result).to.be.an('array') - expect(result.length).to.equal(0) - - // Clean up - await ad4mClient.perspective.remove(perspective.uuid) - }) - }) - } -} \ No newline at end of file diff --git a/tests/js/tests/prolog-and-literals.test.ts b/tests/js/tests/prolog-and-literals.test.ts deleted file mode 100644 index 9c0c0255b..000000000 --- a/tests/js/tests/prolog-and-literals.test.ts +++ /dev/null @@ -1,3710 +0,0 @@ -import { expect } from "chai"; -import { ChildProcess } from 'node:child_process'; -import { Ad4mClient, Link, LinkQuery, Literal, PerspectiveProxy, - SmartLiteral, SMART_LITERAL_CONTENT_PREDICATE, - InstanceQuery, Subject, - Ad4mModel, - Flag, - Property, - ReadOnly, - Collection, - ModelOptions, - Optional, - PropertyOptions, -} from "@coasys/ad4m"; -import { readFileSync } from "node:fs"; -import { startExecutor, apolloClient } from "../utils/utils"; -import path from "path"; -import { fileURLToPath } from 'url'; -import fetch from 'node-fetch' -import sinon from 'sinon'; - -//@ts-ignore -global.fetch = fetch - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Prolog + Literals", () => { - let ad4m: Ad4mClient | null = null - let executorProcess: ChildProcess | null = null - - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "prolog-agent"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 16600 - const hcAdminPort = 16601 - const hcAppPort = 16602 - - before(async () => { - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort); - - console.log("Creating ad4m client") - // @ts-ignore - Apollo Client version mismatch between dependencies - ad4m = new Ad4mClient(apolloClient(gqlPort)) - console.log("Generating agent") - await ad4m.agent.generate("secret") - console.log("Done") - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - }) - - it("should get agent status", async () => { - let result = await ad4m!.agent.status() - expect(result).to.not.be.null - expect(result!.isInitialized).to.be.true - }) - - describe("Subjects (SHACL-based API)", () => { - let perspective: PerspectiveProxy | null = null - - before(async () => { - perspective = await ad4m!.perspective.add("test") - // for test debugging: - //console.log("UUID: " + perspective.uuid) - }) - - // REMOVED: Legacy Prolog SDNA test - Prolog SDNA is superseded by SHACL - // The addSdna API now accepts optional sdnaCode with shaclJson being the primary input. - // See "SDNA creation decorators" tests below for the modern SHACL-based API. - - // NOTE: Legacy Subject proxy tests removed in SHACL migration PR. - // The Subject proxy API (Subject.init(), getSubjectProxy()) requires Prolog queries - // and has been superseded by the Ad4mModel API which is Prolog-free and SHACL-native. - // Production code (Flux) uses Ad4mModel exclusively. - // See "SDNA creation decorators" tests below for the modern API. - - describe("SDNA creation decorators", () => { - @ModelOptions({ - name: "Message" - }) - class Message extends Ad4mModel { - @Flag({ - through: "ad4m://type", - value: "ad4m://message" - }) - type: string = "" - - @InstanceQuery() - static async all(perspective: PerspectiveProxy): Promise { return [] } - - @Optional({ - through: "todo://state", - }) - body?: string - } - - // This class matches the SDNA in ./sdna/subject.pl - // and this test proves the decorators create the exact same SDNA code - @ModelOptions({ - name: "Todo" - }) - class Todo extends Ad4mModel { - // Setting this member "subjectConstructer" allows for adding custom - // actions that will be run when a subject is constructed. - // - // In this test, we don't need to use it, because the used "initial" - // parameter on "state" below will have the same effect as the following: - // subjectConstructor = [addLink("this", "todo://state", "todo://ready")] - - // Setting this member "isSubjectInstance" allows for adding custom clauses - // to the instance check. - // - // In this test, we don't need to use it, because the used "required" - // parameter on "state" below will have the same effect as the following: - // isSubjectInstance = [hasLink("todo://state")] - - //@ts-ignore - @InstanceQuery() - static async all(perspective: PerspectiveProxy): Promise { return [] } - - @InstanceQuery({where: {state: "todo://ready"}}) - static async allReady(perspective: PerspectiveProxy): Promise { return [] } - - @InstanceQuery({where: { state: "todo://done" }}) - static async allDone(perspective: PerspectiveProxy): Promise { return [] } - - //@ts-ignore - @Property({ - through: "todo://state", - initial: "todo://ready" - }) - state!: string - - @Optional({ - through: "todo://has_title", - writable: true, - resolveLanguage: "literal" - }) - title?: string - - @Collection({ through: "todo://comment" }) - comments: string[] = [] - - @Collection({ through: "flux://entry_type" }) - entries: string[] = [] - - @Collection({ - through: "flux://entry_type", - where: { isInstance: Message } - }) - messages: string[] = [] - } - - before(async () => { - // Register SHACL SDNA once for all tests in this block - await perspective!.ensureSDNASubjectClass(Todo) - }) - - it("should find the TODO subject class from the test SDNA", async () => { - let classes = await perspective!.subjectClasses(); - - expect(classes.length).to.equal(1) - expect(classes[0]).to.equal("Todo") - }) - - it("should generate correct SDNA from a JS class", async () => { - // @ts-ignore - const { name, sdna } = Todo.generateSDNA(); - - const regExp = /\("Todo", ([^)]+)\)/; - const matches = regExp.exec(sdna); - const value = matches![1]; - - const equal = readFileSync("./sdna/subject.pl").toString().replace(/c\)/g, `${value})`).replace(/\(c/g, `(${value}`); - - expect(sdna.normalize('NFC')).to.equal(equal.normalize('NFC')) - }) - - it("should be possible to use that class for type-safe interaction with subject instances", async () => { - // Create additional todos for the following tests - // Todo 1: stays at initial "ready" state - let root1 = Literal.from("Ready todo").toUrl() - let todo1 = new Todo(perspective!, root1) - await todo1.save() - - // Todo 2 & 3: set to "done" state - let root2 = Literal.from("Done todo 1").toUrl() - let todo2 = new Todo(perspective!, root2) - await todo2.save() - todo2.state = "todo://done" - await todo2.update() - - let root3 = Literal.from("Done todo 2").toUrl() - let todo3 = new Todo(perspective!, root3) - await todo3.save() - todo3.state = "todo://done" - await todo3.update() - - // construct new subject intance using Ad4mModel API - let root = Literal.from("Decorated class construction test").toUrl() - - let todo = new Todo(perspective!, root) - await todo.save() - - // Verify the instance was created with required links - const stateLinks = await perspective!.get(new LinkQuery({source: root, predicate: "todo://state"})) - expect(stateLinks.length).to.equal(1) - expect(stateLinks[0].data.target).to.equal("todo://ready") - - // Check name mapping - const nameMappingUrl = Literal.fromUrl(`literal://string:shacl://Todo`).toUrl() - const nameMappingLinks = await perspective!.get(new LinkQuery({source: nameMappingUrl})) - nameMappingLinks.forEach(link => console.log(" ", link.data.predicate, "->", link.data.target)) - - const isInstance = await perspective!.isSubjectInstance(root, Todo) - expect(isInstance).to.not.be.false - - // Ad4mModel API - use the todo instance directly (no need for getSubjectProxy) - expect(todo).to.have.property("state") - expect(todo).to.have.property("title") - expect(todo).to.have.property("comments") - - todo.state = "todo://review" - await todo.update() - const stateAfter = await todo.state - - expect(stateAfter).to.equal("todo://review") - expect(await todo.comments).to.be.empty - - let comment = Literal.from("new comment").toUrl() - todo.comments = [comment] - await todo.update() - expect(await todo.comments).to.deep.equal([comment]) - }) - - it("can retrieve all instances through instaceQuery decoratored all()", async () => { - let todos = await Todo.all(perspective!) - expect(todos.length).to.equal(4) - }) - - it("can retrieve all mathching instance through InstanceQuery(where: ..)", async () => { - let todos = await Todo.allReady(perspective!) - expect(todos.length).to.equal(1) - expect(await todos[0].state).to.equal("todo://ready") - - todos = await Todo.allDone(perspective!) - expect(todos.length).to.equal(2) - expect(await todos[0].state).to.equal("todo://done") - }) - - // REMOVED: InstanceQuery(condition: ..) test - required Prolog-only allSelf method - // The InstanceQuery with condition parameter required Prolog inference. - // Future: Could be reimplemented with SHACL-based query conditions via SurrealDB. - - it("can deal with properties that resolve the URI and create Expressions", async () => { - let todos = await Todo.all(perspective!) - - // Guard: If no todos exist, create one for this test - if (todos.length === 0) { - throw new Error("Test prerequisite failed: No todos available. Please ensure todos are created in the setup or earlier tests.") - } - - // Find a todo without a title (to avoid data contamination from other tests) - let todo = null; - for (const t of todos) { - const title = await t.title - if (title === undefined || title === null || title === "") { - todo = t; - break; - } - } - - if (!todo) { - // If all todos have titles, use the first one and clear its title - // Safe to access todos[0] since we've checked todos.length > 0 above - todo = todos[0] - // @ts-ignore - const existingLinks = await perspective!.get(new LinkQuery({source: todo.baseExpression, predicate: "todo://has_title"})) - for (const link of existingLinks) { - await perspective!.remove(link) - } - } - - expect(await todo.title).to.be.undefined - - // Use direct assignment + update() pattern (setters are stubs) - todo.title = "new title" - await todo.update() - expect(await todo.title).to.equal("new title") - - //@ts-ignore - let links = await perspective!.get(new LinkQuery({source: todo.baseExpression, predicate: "todo://has_title"})) - expect(links.length).to.equal(1) - let literal = Literal.fromUrl(links[0].data.target).get() - expect(literal.data).to.equal("new title") - }) - - it("can easily be initialized with PerspectiveProxy.ensureSDNASubjectClass()", async () => { - expect(await perspective!.getSdna()).to.have.lengthOf(1) - - @ModelOptions({ - name: "Test" - }) - class Test { - @Property({ - through: "test://test_numer" - }) - number: number = 0 - } - - await perspective!.ensureSDNASubjectClass(Test) - - expect(await perspective!.getSdna()).to.have.lengthOf(2) - //console.log((await perspective!.getSdna())[1]) - }) - - // REMOVED: Custom getter prolog code test - required Prolog-based property getters - // The isLiked property used custom Prolog code for computed values. - // Future: Could be reimplemented with SHACL-based computed properties or SurrealDB queries. - - describe("with Message subject class registered", () => { - before(async () => { - await perspective!.ensureSDNASubjectClass(Message) - }) - - afterEach(async () => { - // Clean up any Message flags created during tests to prevent data contamination - const links = await perspective!.get(new LinkQuery({predicate: "ad4m://type", target: "ad4m://message"})) - for (const link of links) { - await perspective!.remove(link) - } - }) - - it("can find instances through the exact flag link", async() => { - await perspective!.add(new Link({ - source: "test://message", - predicate: "ad4m://type", - target: "ad4m://undefined" - })) - - const first = await Message.all(perspective!) - expect(first.length).to.be.equal(0) - - await perspective!.add(new Link({ - source: "test://message", - predicate: "ad4m://type", - target: "ad4m://message" - })) - - const second = await Message.all(perspective!) - expect(second.length).to.be.equal(1) - }) - - it("can constrain collection entries through 'where' clause", async () => { - let root = Literal.from("Collection where test").toUrl() - let messageEntry = Literal.from("test message").toUrl() - - // Create todo with entries already set - let todo = new Todo(perspective!, root) - todo.entries = [messageEntry] - await todo.save() - - let entries = await todo.entries - expect(entries.length).to.equal(1) - - let messageEntries = await todo.messages - expect(messageEntries.length).to.equal(0) - - let message = new Message(perspective!, messageEntry) - await message.save() - - // Refresh todo data to apply collection filtering - await todo.get() - messageEntries = await todo.messages - expect(messageEntries.length).to.equal(1) - }) - - }) - - describe("Active record implementation", () => { - @ModelOptions({ - name: "Recipe" - }) - class Recipe extends Ad4mModel { - @Flag({ - through: "ad4m://type", - value: "ad4m://recipe" - }) - type: string = "" - - @Optional({ - through: "recipe://plain", - }) - plain: string = "" - - @Optional({ - through: "recipe://name", - resolveLanguage: "literal" - }) - name: string = "" - - @Optional({ - through: "recipe://boolean", - resolveLanguage: "literal" - }) - booleanTest: boolean = false - - @Optional({ - through: "recipe://number", - resolveLanguage: "literal" - }) - number: number = 0 - - @Collection({ through: "recipe://entries" }) - entries: string[] = [] - - @Collection({ through: "recipe://comment" }) - comments: string[] = [] - - @Optional({ - through: "recipe://local", - local: true - }) - local: string = "" - - @Optional({ - through: "recipe://resolve", - resolveLanguage: "literal" - }) - resolve: string = "" - - @Optional({ - through: "recipe://image", - resolveLanguage: "", // Will be set dynamically to note-store language - transform: (data: any) => { - if (data && typeof data === 'object' && data.data_base64) { - return `data:image/png;base64,${data.data_base64}`; - } - return data; - } - } as PropertyOptions) - image: string | any = "" - } - - beforeEach(async () => { - if(perspective) { - await ad4m!.perspective.remove(perspective.uuid) - } - perspective = await ad4m!.perspective.add("active-record-implementation-test") - await perspective!.ensureSDNASubjectClass(Recipe) - }) - - it("save() & get()", async () => { - let root = Literal.from("Active record implementation test").toUrl() - - const recipe = new Recipe(perspective!, root) - recipe.name = "Save and get test"; - recipe.plain = "recipe://test"; - recipe.booleanTest = false; - - await recipe.save(); - - const recipe2 = new Recipe(perspective!, root); - - await recipe2.get(); - - expect(recipe2.name).to.equal("Save and get test") - expect(recipe2.plain).to.equal("recipe://test") - expect(recipe2.booleanTest).to.equal(false) - }) - - it("update()", async () => { - let root = Literal.from("Active record implementation test").toUrl() - - const recipe = new Recipe(perspective!, root) - recipe.name = "Update test"; - recipe.plain = "recipe://update_test"; - - await recipe.update(); - - const recipe2 = new Recipe(perspective!, root); - - await recipe2.get(); - - expect(recipe2.name).to.equal("Update test") - expect(recipe2.plain).to.equal("recipe://update_test") - }) - - it("find()", async () => { - let recipe1 = new Recipe(perspective!, Literal.from("Active record implementation test find").toUrl()); - recipe1.name = "Active record implementation test find"; - await recipe1.save(); - - const recipes = await Recipe.findAll(perspective!); - - expect(recipes.length).to.equal(1) - }) - - it("can constrain collection entries clause", async () => { - let root = Literal.from("Active record implementation collection test").toUrl() - const recipe = new Recipe(perspective!, root) - - recipe.name = "Collection test"; - - recipe.comments = ['recipe://test', 'recipe://test1'] - - await recipe.save() - - const recipe2 = new Recipe(perspective!, root); - - await recipe2.get(); - - expect(recipe2.comments.length).to.equal(2) - }) - - it("save() & get() local", async () => { - let root = Literal.from("Active record implementation test local link").toUrl() - const recipe = new Recipe(perspective!, root) - - recipe.name = "Local test"; - recipe.local = 'recipe://test' - - await recipe.save(); - - const recipe2 = new Recipe(perspective!, root); - - await recipe2.get(); - - expect(recipe2.name).to.equal("Local test") - expect(recipe2.local).to.equal("recipe://test") - - // @ts-ignore - const links = await perspective?.get({ - source: root, - predicate: "recipe://local" - }) - - expect(links!.length).to.equal(1) - expect(links![0].status).to.equal('LOCAL') - }) - - it("delete()", async () => { - let recipe1 = new Recipe(perspective!, Literal.from("Active record implementation test delete1 ").toUrl()); - recipe1.name = "Active record implementation test delete 1"; - await recipe1.save(); - - - let recipe2 = new Recipe(perspective!, Literal.from("Active record implementation test delete2 ").toUrl()); - recipe2.name = "Active record implementation test delete 2"; - await recipe2.save(); - - - let recipe3 = new Recipe(perspective!, Literal.from("Active record implementation test delete3 ").toUrl()); - recipe3.name = "Active record implementation test delete 3"; - await recipe3.save(); - - const recipes = await Recipe.findAll(perspective!); - - expect(recipes.length).to.equal(3) - - await recipes[0].delete(); - - const updatedRecipies = await Recipe.findAll(perspective!); - - expect(updatedRecipies.length).to.equal(2) - }) - - // REMOVED: Collection 'where' clause with prolog condition test - // The ingredients property used Prolog-based where clause filtering. - // The next test demonstrates the modern approach using SurrealDB conditions. - - it("can constrain collection entries through 'where' clause with condition", async () => { - // Define a Recipe model with condition filtering - @ModelOptions({ name: "RecipeWithSurrealFilter" }) - class RecipeWithSurrealFilter extends Ad4mModel { - @Flag({ - through: "ad4m://type", - value: "recipe://instance" - }) - type: string = "" - - @Optional({ - through: "recipe://name", - resolveLanguage: "literal" - }) - name: string = ""; - - @Collection({ through: "recipe://entries" }) - entries: string[] = []; - - @Collection({ - through: "recipe://entries", - where: { - condition: `WHERE in.uri = Target AND predicate = 'recipe://has_ingredient' AND out.uri = 'recipe://test'` - } - }) - ingredients: string[] = []; - } - - // Register the class - await perspective!.ensureSDNASubjectClass(RecipeWithSurrealFilter); - - // Wait for SHACL metadata to be indexed - await sleep(500); - - let root = Literal.from("Active record surreal condition test").toUrl(); - const recipe = new RecipeWithSurrealFilter(perspective!, root); - - let entry1 = Literal.from("entry with ingredient").toUrl(); - let entry2 = Literal.from("entry without ingredient").toUrl(); - - recipe.entries = [entry1, entry2]; - recipe.name = "Condition test"; - - await recipe.save(); - - // Add the ingredient link to entry1 only - await perspective?.add(new Link({ - source: entry1, - predicate: "recipe://has_ingredient", - target: "recipe://test" - })); - - // Small delay for SurrealDB indexing - await sleep(500); - - const recipe2 = new RecipeWithSurrealFilter(perspective!, root); - await recipe2.get(); - - // Should have 2 entries total - expect(recipe2.entries.length).to.equal(2); - - // But only 1 ingredient (entry1 which has the ingredient link) - expect(recipe2.ingredients.length).to.equal(1); - expect(recipe2.ingredients[0]).to.equal(entry1); - }) - - it("can implement the resolveLanguage property type", async () => { - let root = Literal.from("Active record implementation test resolveLanguage").toUrl() - const recipe = new Recipe(perspective!, root) - - recipe.resolve = "Test name literal"; - - await recipe.save(); - - //@ts-ignore - let links = await perspective!.get(new LinkQuery({source: root, predicate: "recipe://resolve"})) - expect(links.length).to.equal(1) - let literal = Literal.fromUrl(links[0].data.target).get() - expect(literal.data).to.equal(recipe.resolve) - - const recipe3 = new Recipe(perspective!, root); - await recipe3.get(); - expect(recipe3.resolve).to.equal("Test name literal"); - }) - - it("can resolve non-literal languages with resolveLanguage and transform", async () => { - // Publish note-store language to use as a non-literal resolveLanguage - const noteLanguage = await ad4m!.languages.publish( - path.join(__dirname, "../languages/note-store/build/bundle.js").replace(/\\/g, "/"), - { name: "note-store-test", description: "Test language for non-literal resolution" } - ); - const noteLangAddress = noteLanguage.address; - - // Create an expression in the note-store language with test data (simulating file data) - const testImageData = { data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" }; - const imageExprUrl = await ad4m!.expression.create(testImageData, noteLangAddress); - - let root = Literal.from("Active record implementation test resolveLanguage non-literal").toUrl(); - const recipe = new Recipe(perspective!, root); - - // Manually add the link instead of using save() to test the query resolution path - recipe.name = "Test with image"; - await recipe.save(); // Save the name - - // Add the image link manually - await perspective!.setSingleTarget(new Link({ - source: root, - predicate: "recipe://image", - target: imageExprUrl - })); - - // Verify the link was created with the expression URL - //@ts-ignore - let links = await perspective!.get(new LinkQuery({source: root, predicate: "recipe://image"})); - expect(links.length).to.equal(1); - expect(links[0].data.target).to.equal(imageExprUrl); - - // Retrieve the recipe and verify the image was resolved and transformed - const results = await Recipe.findAll(perspective!, { where: { name: "Test with image" } }); - const recipe2 = results[0]; - - expect(recipe2.name).to.equal("Test with image"); - // The image should be resolved from the note-store language and transformed to a data URL - expect(recipe2.image).to.equal(`data:image/png;base64,${testImageData.data_base64}`); - }) - - it("works with very long property values", async() => { - let root = Literal.from("Active record implementation test long value").toUrl() - const recipe = new Recipe(perspective!, root) - - const longName = "This is a very long recipe name that goes on and on with many many characters to test that we can handle long property values without any issues whatsoever and keep going even longer to make absolutely sure we hit at least 300 characters in this test string that just keeps getting longer and longer until we are completely satisfied that it works properly with such lengthy content. But wait, there's more! We need to make this string even longer to properly test the system's ability to handle extremely long property values. Let's add some more meaningful content about recipes - ingredients like flour, sugar, eggs, milk, butter, vanilla extract, baking powder, salt, and detailed instructions for mixing them together in just the right way to create the perfect baked goods. We could go on about preheating the oven to the right temperature, greasing the pans properly, checking for doneness with a toothpick, and letting things cool completely before frosting. The possibilities are endless when it comes to recipe details and instructions that could make this string longer and longer. We want to be absolutely certain that our system can handle property values of any reasonable length without truncating or corrupting the data in any way. This is especially important for recipes where precise instructions and ingredient amounts can make the difference between success and failure in the kitchen. Testing with realistically long content helps ensure our system works reliably in real-world usage scenarios where users might enter detailed information that extends well beyond a few simple sentences." - // Use resolve (resolveLanguage: "literal") to store the long string value. - // The plain property is for storing URI addresses; resolve encodes arbitrary strings. - recipe.resolve = longName - - await recipe.save() - - let linksResolve = await perspective!.get(new LinkQuery({source: root, predicate: "recipe://resolve"})) - expect(linksResolve.length).to.equal(1) - let expression = Literal.fromUrl(linksResolve[0].data.target).get() - expect(expression.data).to.equal(longName) - - const recipe2 = new Recipe(perspective!, root) - await recipe2.get() - - expect(recipe2.resolve.length).to.equal(longName.length) - expect(recipe2.resolve).to.equal(longName) - }) - - it("should have author and timestamp properties", async () => { - let root = Literal.from("Author and timestamp test").toUrl() - const recipe = new Recipe(perspective!, root) - - recipe.name = "author and timestamp test"; - await recipe.save(); - - const recipe2 = new Recipe(perspective!, root); - await recipe2.get(); - - const me = await ad4m!.agent.me(); - // @ts-ignore - author and timestamp are added by the system - expect(recipe2.author).to.equal(me!.did) - // @ts-ignore - expect(recipe2.timestamp).to.not.be.undefined; - }) - - it("get() returns all subject entity properties (via getData())", async () => { - let root = Literal.from("getData test").toUrl() - const recipe = new Recipe(perspective!, root) - - recipe.name = "getData all test"; - recipe.booleanTest = true; - recipe.comments = ['recipe://comment1', 'recipe://comment2']; - recipe.local = "recipe://local_test"; - recipe.resolve = "Resolved literal value"; - - await recipe.save(); - - const data = await recipe.get(); - - expect(data.name).to.equal("getData all test"); - expect(data.booleanTest).to.equal(true); - // Collection order might not be preserved when items are added simultaneously - // Check that both items exist rather than exact order - expect(data.comments).to.have.lengthOf(2); - expect(data.comments).to.include('recipe://comment1'); - expect(data.comments).to.include('recipe://comment2'); - expect(data.local).to.equal("recipe://local_test"); - expect(data.resolve).to.equal("Resolved literal value"); - - await recipe.delete(); - }) - - it("findAll() returns properties on instances", async () => { - let root1 = Literal.from("findAll test 1").toUrl() - let root2 = Literal.from("findAll test 2").toUrl() - - const recipe1 = new Recipe(perspective!, root1) - recipe1.name = "findAll test 1"; - recipe1.resolve = "Resolved literal value 1"; - recipe1.plain = "recipe://findAll_test1"; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!, root2) - recipe2.name = "findAll test 2"; - recipe2.resolve = "Resolved literal value 2"; - recipe2.plain = "recipe://findAll_test2"; - await recipe2.save(); - - // Test findAll - const recipes = await Recipe.findAll(perspective!); - - expect(recipes.length).to.equal(2); - expect(recipes[0].name).to.equal("findAll test 1"); - expect(recipes[0].resolve).to.equal("Resolved literal value 1"); - expect(recipes[1].name).to.equal("findAll test 2"); - expect(recipes[1].resolve).to.equal("Resolved literal value 2"); - expect(recipes[0].plain).to.equal("recipe://findAll_test1"); - expect(recipes[1].plain).to.equal("recipe://findAll_test2"); - }) - - it("findAll() returns collections on instances", async () => { - let root1 = Literal.from("findAll test 1").toUrl() - let root2 = Literal.from("findAll test 2").toUrl() - - const recipe1 = new Recipe(perspective!, root1) - recipe1.comments = ["recipe://comment/r1/1", "recipe://comment/r1/2"]; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!, root2) - recipe2.comments = ["recipe://comment/r2/1", "recipe://comment/r2/2"]; - await recipe2.save(); - - // Test findAll - const recipes = await Recipe.findAll(perspective!); - - expect(recipes.length).to.equal(2); - expect(recipes[0].comments.length).to.equal(2); - expect(recipes[0].comments).to.include("recipe://comment/r1/1"); - expect(recipes[0].comments).to.include("recipe://comment/r1/2"); - - expect(recipes[1].comments.length).to.equal(2); - expect(recipes[1].comments).to.include("recipe://comment/r2/1"); - expect(recipes[1].comments).to.include("recipe://comment/r2/2"); - }) - - it("findAll() returns author & timestamp on instances", async () => { - let root1 = Literal.from("findAll test 1").toUrl() - let root2 = Literal.from("findAll test 2").toUrl() - - const recipe1 = new Recipe(perspective!, root1); - recipe1.name = "findAll test 1"; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!, root2); - recipe2.name = "findAll test 2"; - await recipe2.save(); - - const recipes = await Recipe.findAll(perspective!); - const me = await ad4m!.agent.me(); - expect(recipes[0].author).to.equal(me!.did) - expect(recipes[0].timestamp).to.not.be.undefined; - expect(recipes[1].author).to.equal(me!.did) - expect(recipes[1].timestamp).to.not.be.undefined; - }) - - it("findAll() works with source prop", async () => { - const source1 = Literal.from("Source 1").toUrl() - const source2 = Literal.from("Source 2").toUrl() - - const recipe1 = new Recipe(perspective!, undefined, source1) - recipe1.name = "Recipe 1: Name"; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!, undefined, source2) - recipe2.name = "Recipe 2: Name"; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!, undefined, source2) - recipe3.name = "Recipe 3: Name"; - await recipe3.save(); - - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(3); - - const source1Recipes = await Recipe.findAll(perspective!, { source: source1 }); - expect(source1Recipes.length).to.equal(1); - expect(source1Recipes[0].name).to.equal("Recipe 1: Name"); - - const source2Recipes = await Recipe.findAll(perspective!, { source: source2 }); - expect(source2Recipes.length).to.equal(2); - }) - - it("findAll() works with properties query", async () => { - let root = Literal.from("findAll test 1").toUrl() - const recipe = new Recipe(perspective!, root); - recipe.name = "recipe://test_name"; - recipe.booleanTest = true; - await recipe.save(); - - const me = await ad4m!.agent.me(); - - // Test recipes with all properties - const recipesWithAllAttributes = await Recipe.findAll(perspective!); - expect(recipesWithAllAttributes[0].name).to.equal("recipe://test_name") - expect(recipesWithAllAttributes[0].booleanTest).to.equal(true) - expect(recipesWithAllAttributes[0].author).to.equal(me!.did) - - // Test recipes with name only - const recipesWithNameOnly = await Recipe.findAll(perspective!, { properties: ["name"] }); - expect(recipesWithNameOnly[0].name).to.equal("recipe://test_name") - expect(recipesWithNameOnly[0].booleanTest).to.be.undefined - - // Test recipes with name and booleanTest only - const recipesWithTypeAndBooleanTestOnly = await Recipe.findAll(perspective!, { properties: ["name", "booleanTest"] }); - expect(recipesWithTypeAndBooleanTestOnly[0].name).to.equal("recipe://test_name") - expect(recipesWithTypeAndBooleanTestOnly[0].booleanTest).to.equal(true) - - // Test recipes with author only - const recipesWithAuthorOnly = await Recipe.findAll(perspective!, { properties: ["author"] }); - expect(recipesWithAuthorOnly[0].name).to.be.undefined - expect(recipesWithAuthorOnly[0].booleanTest).to.be.undefined - expect(recipesWithAuthorOnly[0].author).to.equal(me!.did) - }) - - it("findAll() works with collections query", async () => { - let root = Literal.from("findAll test 1").toUrl() - const recipe = new Recipe(perspective!, root); - recipe.comments = ["recipe://comment/1", "recipe://comment/2"]; - recipe.entries = ["recipe://entry/1", "recipe://entry/2"]; - await recipe.save(); - - // Test recipes with all collections - const recipesWithAllCollections = await Recipe.findAll(perspective!); - expect(recipesWithAllCollections[0].comments.length).to.equal(2) - expect(recipesWithAllCollections[0].entries.length).to.equal(2) - - // Test recipes with comments only - const recipesWithCommentsOnly = await Recipe.findAll(perspective!, { collections: ["comments"] }); - expect(recipesWithCommentsOnly[0].comments.length).to.equal(2) - expect(recipesWithCommentsOnly[0].entries).to.be.undefined - - // Test recipes with entries only - const recipesWithEntriesOnly = await Recipe.findAll(perspective!, { collections: ["entries"] }); - expect(recipesWithEntriesOnly[0].comments).to.be.undefined - expect(recipesWithEntriesOnly[0].entries.length).to.equal(2) - }) - - it("findAll() works with basic where queries", async () => { - // Create recipies - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.number = 5; - recipe1.booleanTest = true; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.number = 10; - recipe2.booleanTest = true; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - recipe3.number = 15; - recipe3.booleanTest = false; - await recipe3.save(); - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(3) - - // Test where with valid name - const recipes1 = await Recipe.findAll(perspective!, { where: { name: "Recipe 1" } }); - expect(recipes1.length).to.equal(1); - - // Test where with invalid name - const recipes2 = await Recipe.findAll(perspective!, { where: { name: "This name doesn't exist" } }); - expect(recipes2.length).to.equal(0); - - // Test where with boolean - const recipes3 = await Recipe.findAll(perspective!, { where: { booleanTest: true } }); - expect(recipes3.length).to.equal(2); - - // Test where with number - const recipes4 = await Recipe.findAll(perspective!, { where: { number: 5 } }); - expect(recipes4.length).to.equal(1); - - // Test where with an array of possible matches - const recipes5 = await Recipe.findAll(perspective!, { where: { name: ["Recipe 1", "Recipe 2"] } }); - expect(recipes5.length).to.equal(2); - - // Test where with author - const me = await ad4m!.agent.me(); - // Test where with valid author - const recipes6 = await Recipe.findAll(perspective!, { where: { author: me.did } }); - expect(recipes6.length).to.equal(3); - // Test where with invalid author - const recipes7 = await Recipe.findAll(perspective!, { where: { author: "This author doesn't exist" } }); - expect(recipes7.length).to.equal(0); - - // Test where with timestamp - const validTimestamp1 = allRecipes[0].timestamp; - const validTimestamp2 = allRecipes[1].timestamp; - const invalidTimestamp = new Date().getTime(); - // Test where with valid timestamp - const recipes8 = await Recipe.findAll(perspective!, { where: { timestamp: validTimestamp1 } }); - expect(recipes8.length).to.equal(1); - // Test where with invalid timestamp - const recipes9 = await Recipe.findAll(perspective!, { where: { timestamp: invalidTimestamp } }); - expect(recipes9.length).to.equal(0); - // Test where with an array of possible timestamp matches - const recipes10 = await Recipe.findAll(perspective!, { where: { timestamp: [validTimestamp1, validTimestamp2] } }); - expect(recipes10.length).to.equal(2); - }) - - it("findAll() works with where query not operations", async () => { - // Create recipies - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.number = 5; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.number = 10; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - recipe3.number = 15; - await recipe3.save(); - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(3); - - // Store valid timestamps - const validTimestamp1 = allRecipes[0].timestamp; - const validTimestamp2 = allRecipes[1].timestamp; - const validTimestamp3 = allRecipes[2].timestamp; - - // Test not operation on standard property - const recipes1 = await Recipe.findAll(perspective!, { where: { name: { not: "Recipe 1" } } }); - expect(recipes1.length).to.equal(2); - - // Test not operation on author - const me = await ad4m!.agent.me(); - const recipes2 = await Recipe.findAll(perspective!, { where: { author: { not: me.did } } }); - expect(recipes2.length).to.equal(0); - - // Test not operation on timestamp - const recipes3 = await Recipe.findAll(perspective!, { where: { timestamp: { not: validTimestamp1 } } }); - expect(recipes3.length).to.equal(2); - - // Test not operation with an array of possible string matches - const recipes4 = await Recipe.findAll(perspective!, { where: { name: { not: ["Recipe 1", "Recipe 2"] } } }); - expect(recipes4.length).to.equal(1); - expect(recipes4[0].name).to.equal("Recipe 3"); - - // Test not operation with an array of possible timestamp matches - const recipes5 = await Recipe.findAll(perspective!, { where: { timestamp: { not: [validTimestamp1, validTimestamp2] } } }); - expect(recipes5.length).to.equal(1); - expect(recipes5[0].timestamp).to.equal(validTimestamp3); - }) - - it("findAll() works with where query lt, lte, gt, & gte operations", async () => { - // Create recipes - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.number = 5; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.number = 10; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - recipe3.number = 15; - await recipe3.save(); - - const recipe4 = new Recipe(perspective!); - recipe4.name = "Recipe 4"; - recipe4.number = 20; - await recipe4.save(); - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(4); - - // 1. Number properties - // Test less than (lt) operation on number property - const recipes1 = await Recipe.findAll(perspective!, { where: { number: { lt: 10 } } }); - expect(recipes1.length).to.equal(1); - - // Test less than or equal to (lte) operation on number property - const recipes2 = await Recipe.findAll(perspective!, { where: { number: { lte: 10 } } }); - expect(recipes2.length).to.equal(2); - - // Test greater than (gt) operation on number property - const recipes3 = await Recipe.findAll(perspective!, { where: { number: { gt: 10 } } }); - expect(recipes3.length).to.equal(2); - - // Test greater than or equal to (gte) operation on number property - const recipes4 = await Recipe.findAll(perspective!, { where: { number: { gte: 10 } } }); - expect(recipes4.length).to.equal(3); - - // 2. Timestamps - // Sort recipes by timestamp to ensure consistent ordering - const sortedRecipes = [...allRecipes].sort((a, b) => { - const aTime = typeof a.timestamp === 'number' ? a.timestamp : parseInt(a.timestamp); - const bTime = typeof b.timestamp === 'number' ? b.timestamp : parseInt(b.timestamp); - return aTime - bTime; - }); - const recipe2timestamp = typeof sortedRecipes[1].timestamp === 'number' - ? sortedRecipes[1].timestamp - : parseInt(sortedRecipes[1].timestamp); // Second recipe by timestamp - - // Test less than (lt) operation on timestamp - const recipes5 = await Recipe.findAll(perspective!, { where: { timestamp: { lt: recipe2timestamp } } }); - expect(recipes5.length).to.equal(1); - - // Test less than or equal to (lte) operation on timestamp - const recipes6 = await Recipe.findAll(perspective!, { where: { timestamp: { lte: recipe2timestamp } } }); - expect(recipes6.length).to.equal(2); - - // Test greater than (gt) operation on timestamp - const recipes7 = await Recipe.findAll(perspective!, { where: { timestamp: { gt: recipe2timestamp } } }); - expect(recipes7.length).to.equal(2); - - // Test greater than or equal to (gte) operation on timestamp - const recipes8 = await Recipe.findAll(perspective!, { where: { timestamp: { gte: recipe2timestamp } } }); - expect(recipes8.length).to.equal(3); - }) - - it("findAll() works with where query between operations", async () => { - @ModelOptions({ - name: "Task_due" - }) - class TaskDue extends Ad4mModel { - @Property({ - through: "task://title", - resolveLanguage: "literal" - }) - title: string = ""; - - @Property({ - through: "task://priority", - writable: true, - resolveLanguage: "literal" - }) - priority: number = 0; - - @Property({ - through: "task://dueDate", - resolveLanguage: "literal" - }) - dueDate: number = 0; - } - - // Register the Task class - await perspective!.ensureSDNASubjectClass(TaskDue); - - // Create timestamps & tasks - const start = new Date().getTime(); - - const task1 = new TaskDue(perspective!); - task1.title = "Low priority task"; - task1.priority = 2; - task1.dueDate = start; - await task1.save(); - - // Small delay to ensure different timestamps - await sleep(10); - - let mid = new Date().getTime(); - // Ensure mid > start even if system clock resolution is low - if (mid <= start) { - mid = start + 1; - } - - const task2 = new TaskDue(perspective!); - task2.title = "Medium priority task"; - task2.priority = 5; - task2.dueDate = mid + 1; - await task2.save(); - - const task3 = new TaskDue(perspective!); - task3.title = "High priority task"; - task3.priority = 8; - task3.dueDate = mid + 2; - await task3.save(); - - // Small delay to ensure different timestamps - await sleep(10); - - let end = new Date().getTime(); - // Ensure end > mid even if system clock resolution is low - if (end <= mid) { - end = mid + 1; - } - - // Check all tasks are there - const allTasks = await TaskDue.findAll(perspective!); - expect(allTasks.length).to.equal(3); - - // Test between operation on priority - const lowToMediumTasks = await TaskDue.findAll(perspective!, { where: { priority: { between: [1, 5] } } }); - expect(lowToMediumTasks.length).to.equal(2); - - // Test between operation on priority with different values - const mediumToHighTasks = await TaskDue.findAll(perspective!, { where: { priority: { between: [5, 10] } } }); - expect(mediumToHighTasks.length).to.equal(2); - - // Test between operation on dueDate - const earlyTasks = await TaskDue.findAll(perspective!, { where: { dueDate: { between: [start, mid] } } }); - expect(earlyTasks.length).to.equal(1); - - // Test between operation on dueDate with different values - const laterTasks = await TaskDue.findAll(perspective!, { where: { dueDate: { between: [mid, end] } } }); - expect(laterTasks.length).to.equal(2); - - // Clean up - await task1.delete(); - await task2.delete(); - await task3.delete(); - }) - - it("findAll() works with ordering", async () => { - // Clear previous recipes - const oldRecipes = await Recipe.findAll(perspective!); - for (const recipe of oldRecipes) await recipe.delete(); - - // Create recipes - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.number = 10; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.number = 5; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - recipe3.number = 15; - await recipe3.save(); - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(3); - - // Test ordering by number properties - const recipes1 = await Recipe.findAll(perspective!, { order: { number: "ASC" } }); - expect(recipes1[0].number).to.equal(5); - expect(recipes1[1].number).to.equal(10); - expect(recipes1[2].number).to.equal(15); - - const recipes2 = await Recipe.findAll(perspective!, { order: { number: "DESC" } }); - expect(recipes2[0].number).to.equal(15); - expect(recipes2[1].number).to.equal(10); - expect(recipes2[2].number).to.equal(5); - - // Test ordering by timestamp - const recipes3 = await Recipe.findAll(perspective!, { order: { timestamp: "ASC" } }); - expect(recipes3[0].name).to.equal("Recipe 1"); - expect(recipes3[1].name).to.equal("Recipe 2"); - expect(recipes3[2].name).to.equal("Recipe 3"); - - const recipes4 = await Recipe.findAll(perspective!, { order: { timestamp: "DESC" } }); - expect(recipes4[0].name).to.equal("Recipe 3"); - expect(recipes4[1].name).to.equal("Recipe 2"); - expect(recipes4[2].name).to.equal("Recipe 1"); - }) - - it("findAll() works with limit and offset", async () => { - // Create 6 recipe instances with sequential names - for (let i = 1; i <= 6; i++) { - const recipe = new Recipe(perspective!); - recipe.name = `Recipe ${i}`; - await recipe.save(); - } - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(6); - - // Test limit - const recipes1 = await Recipe.findAll(perspective!, { limit: 2 }); - expect(recipes1.length).to.equal(2); - - const recipes2 = await Recipe.findAll(perspective!, { limit: 4 }); - expect(recipes2.length).to.equal(4); - - // Test offset - const recipes3 = await Recipe.findAll(perspective!, { offset: 2 }); - expect(recipes3[0].name).to.equal("Recipe 3"); - - const recipes4 = await Recipe.findAll(perspective!, { offset: 4 }); - expect(recipes4[0].name).to.equal("Recipe 5"); - - // Test limit and offset - const recipes5 = await Recipe.findAll(perspective!, { limit: 2, offset: 1 }); - expect(recipes5.length).to.equal(2); - expect(recipes5[0].name).to.equal("Recipe 2"); - - const recipes6 = await Recipe.findAll(perspective!, { limit: 3, offset: 2 }); - expect(recipes6.length).to.equal(3); - expect(recipes6[0].name).to.equal("Recipe 3"); - }) - - it("findAll() works with a mix of query constraints", async () => { - // Create recipies - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.booleanTest = true; - recipe1.comments = ["recipe://comment/r1/1", "recipe://comment/r1/2"]; - recipe1.entries = ["recipe://entry/r1/1", "recipe://entry/r1/2"]; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.booleanTest = false; - recipe2.comments = ["recipe://comment/r2/1", "recipe://comment/r2/2"]; - recipe2.entries = ["recipe://entry/r2/1", "recipe://entry/r2/2"]; - await recipe2.save(); - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(2); - - // Test with where, properties, and collections - const recipes1 = await Recipe.findAll(perspective!, { where: { name: "Recipe 1" }, properties: ["name"], collections: ["comments"] }); - expect(recipes1.length).to.equal(1); - expect(recipes1[0].name).to.equal("Recipe 1"); - expect(recipes1[0].booleanTest).to.be.undefined; - expect(recipes1[0].comments.length).to.equal(2); - expect(recipes1[0].entries).to.be.undefined; - - // Test with different where, properties, and collections - const recipes2 = await Recipe.findAll(perspective!, { where: { name: "Recipe 2" }, properties: ["booleanTest"], collections: ["entries"] }); - expect(recipes2.length).to.equal(1); - expect(recipes2[0].name).to.be.undefined; - expect(recipes2[0].booleanTest).to.equal(false); - expect(recipes2[0].comments).to.be.undefined; - expect(recipes2[0].entries.length).to.equal(2); - }) - - it("findAll() works with constraining resolved literal properties", async () => { - // Create a recipe with a resolved literal property - const recipe = new Recipe(perspective!); - recipe.resolve = "Hello World" - await recipe.save(); - - // Test with resolved literal property - const recipes1 = await Recipe.findAll(perspective!, { where: { resolve: "Hello World" } }); - expect(recipes1.length).to.equal(1); - expect(recipes1[0].resolve).to.equal("Hello World"); - }) - - it("findAll() works with multiple property constraints in one where clause", async () => { - // Create recipes with different combinations of properties - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.number = 5; - recipe1.booleanTest = true; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.number = 10; - recipe2.booleanTest = true; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - recipe3.number = 15; - recipe3.booleanTest = false; - await recipe3.save(); - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(3); - - // Test where with multiple property constraints - const recipes1 = await Recipe.findAll(perspective!, { - where: { - name: "Recipe 1", - number: 5, - booleanTest: true - } - }); - expect(recipes1.length).to.equal(1); - - // Test where with multiple property constraints that match multiple recipes - const recipes2 = await Recipe.findAll(perspective!, { - where: { - number: { gt: 5 }, - booleanTest: true - } - }); - expect(recipes2.length).to.equal(1); - expect(recipes2[0].name).to.equal("Recipe 2"); - - // Test where with multiple property constraints that match no recipes - const recipes3 = await Recipe.findAll(perspective!, { - where: { - name: "Recipe 1", - booleanTest: false - } - }); - expect(recipes3.length).to.equal(0); - }) - - it("findAllAndCount() returns both the retrived instances and the total count", async () => { - // Create 6 recipe instances with sequential names - for (let i = 1; i <= 6; i++) { - const recipe = new Recipe(perspective!); - recipe.name = `Recipe ${i}`; - recipe.number = 5; - await recipe.save(); - } - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(6); - - // Test count with limit - const { results: recipes1, totalCount: count1 } = await Recipe.findAllAndCount(perspective!, { limit: 2, count: true }); - expect(recipes1.length).to.equal(2); - expect(count1).to.equal(6); - - // Test count with offset & limit - const { results: recipes3, totalCount: count3 } = await Recipe.findAllAndCount(perspective!, { offset: 3, limit: 3, count: true }); - expect(recipes3.length).to.equal(3); - expect(count3).to.equal(6); - - // Test count with where constraints & limit - const { results: recipes2, totalCount: count2 } = await Recipe.findAllAndCount(perspective!, { where: { name: ["Recipe 1", "Recipe 2", "Recipe 3"] }, limit: 2, count: true }); - expect(recipes2.length).to.equal(2); - expect(count2).to.equal(3); - - // Test count with where equality constraint (exists), offset, & limit - const { results: recipes4, totalCount: count4 } = await Recipe.findAllAndCount(perspective!, { where: { number: 5 }, offset: 3, limit: 3, count: true }); - expect(recipes4.length).to.equal(3); - expect(count4).to.equal(6); - - // Test count with where equality constraint (does not exist), offset, & limit - const { results: recipes5, totalCount: count5 } = await Recipe.findAllAndCount(perspective!, { where: { number: 3 }, offset: 3, limit: 3, count: true }); - expect(recipes5.length).to.equal(0); - expect(count5).to.equal(0); - - // Test count with where not constraint & limit - const { results: recipes6, totalCount: count6 } = await Recipe.findAllAndCount(perspective!, { where: { name: { not: "Recipe 1" } }, limit: 3, count: true }); - expect(recipes6.length).to.equal(3); - expect(count6).to.equal(5); - - // Test count with where not constraint, offset, & limit - const { results: recipes7, totalCount: count7 } = await Recipe.findAllAndCount(perspective!, { where: { name: { not: "Recipe 2" } }, offset: 1, limit: 3, count: true }); - expect(recipes7.length).to.equal(3); - expect(count7).to.equal(5); - - // Test count with where not constraint, offset, & limit greater than remaining results - const { results: recipes8, totalCount: count8 } = await Recipe.findAllAndCount(perspective!, { where: { name: { not: "Recipe 4" } }, offset: 3, limit: 3, count: true }); - expect(recipes8.length).to.equal(2); - expect(count8).to.equal(5); - }) - - it("paginate() helper function works with pageNumber & pageSize props", async () => { - // Create 6 recipe instances with sequential names - for (let i = 1; i <= 6; i++) { - const recipe = new Recipe(perspective!); - recipe.name = `Recipe ${i}`; - await recipe.save(); - } - - // Check all recipes are there - const allRecipes = await Recipe.findAll(perspective!); - expect(allRecipes.length).to.equal(6); - - // Test basic pagination (pageSize: 2, pageNumber: 1) - const { results: recipes1, totalCount: count1 } = await Recipe.paginate(perspective!, 2, 1); - expect(recipes1.length).to.equal(2); - expect(count1).to.equal(6); - expect(recipes1[0].name).to.equal("Recipe 1"); - expect(recipes1[1].name).to.equal("Recipe 2"); - - // Test pagination with where constraints (pageSize: 3, pageNumber: 2) - const { results: recipes2, totalCount: count2 } = await Recipe.paginate(perspective!, 3, 2, { where: { name: { not: "Recipe 4" } } }); - expect(recipes2.length).to.equal(2); - expect(count2).to.equal(5); - expect(recipes2[0].name).to.equal("Recipe 5"); - expect(recipes2[1].name).to.equal("Recipe 6"); - }); - - it("count() returns only the count without retrieving instances", async () => { - // Create 6 recipe instances with sequential names - for (let i = 1; i <= 6; i++) { - const recipe = new Recipe(perspective!); - recipe.name = `Recipe ${i}`; - await recipe.save(); - } - - // Test count with no constraints - const count1 = await Recipe.count(perspective!); - expect(count1).to.equal(6); - - // Test count with where constraints - const count2 = await Recipe.count(perspective!, { where: { name: ["Recipe 1", "Recipe 2", "Recipe 3"] } }); - expect(count2).to.equal(3); - - // Test count with more complex constraints - const count3 = await Recipe.count(perspective!, { where: { name: { not: "Recipe 1" } } }); - expect(count3).to.equal(5); - }); - - it("count() and countSubscribe() work on the query builder", async () => { - // Create recipes - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - await recipe3.save(); - - // Test count() on query builder - const query = Recipe.query(perspective!); - const count = await query.count(); - expect(count).to.equal(3); - - // Test count with where clause - const filteredQuery = Recipe.query(perspective!) - .where({ name: ["Recipe 1", "Recipe 2"] }); - const filteredCount = await filteredQuery.count(); - expect(filteredCount).to.equal(2); - - // Test countSubscribe - let lastCount = 0; - const builder = Recipe.query(perspective!); - const subscription = await builder - .countSubscribe((count) => { - lastCount = count; - }); - expect(subscription).to.equal(3); - - // Small delay to ensure subscription is fully registered before triggering changes - await sleep(500); - - // Add another recipe and verify callback is called - const recipe4 = new Recipe(perspective!); - recipe4.name = "Recipe 4"; - await recipe4.save(); - - // Wait for subscription to process with proper condition checking - // Use longer timeout for CI environments which may be slower - await waitForCondition( - () => lastCount === 4, - { - timeoutMs: 15000, - errorMessage: 'Count subscription did not update after recipe save' - } - ); - expect(lastCount).to.equal(4); - - // Dispose the subscription to prevent cross-test interference - builder.dispose(); - }) - - it("count() works with advanced where conditions (gt, between, timestamp)", async () => { - // Create recipes with different numbers - const recipe1 = new Recipe(perspective!); - recipe1.name = "Recipe 1"; - recipe1.number = 1; - await recipe1.save(); - - const recipe2 = new Recipe(perspective!); - recipe2.name = "Recipe 2"; - recipe2.number = 2; - await recipe2.save(); - - const recipe3 = new Recipe(perspective!); - recipe3.name = "Recipe 3"; - recipe3.number = 3; - await recipe3.save(); - - const recipe4 = new Recipe(perspective!); - recipe4.name = "Recipe 4"; - recipe4.number = 4; - await recipe4.save(); - - const recipe5 = new Recipe(perspective!); - recipe5.name = "Recipe 5"; - recipe5.number = 5; - await recipe5.save(); - - // Test count() with gt operator - const countGt3 = await Recipe.count(perspective!, { where: { number: { gt: 3 } } }); - const findAllGt3 = await Recipe.findAll(perspective!, { where: { number: { gt: 3 } } }); - expect(countGt3).to.equal(findAllGt3.length); - expect(countGt3).to.equal(2); // recipes 4 and 5 - - // Test count() with between operator - const countBetween2And4 = await Recipe.count(perspective!, { where: { number: { between: [2, 4] } } }); - const findAllBetween2And4 = await Recipe.findAll(perspective!, { where: { number: { between: [2, 4] } } }); - expect(countBetween2And4).to.equal(findAllBetween2And4.length); - expect(countBetween2And4).to.equal(3); // recipes 2, 3, and 4 - - // Test count() with gte and lte operators - const countGte2Lte4 = await Recipe.count(perspective!, { where: { number: { gte: 2, lte: 4 } } }); - const findAllGte2Lte4 = await Recipe.findAll(perspective!, { where: { number: { gte: 2, lte: 4 } } }); - expect(countGte2Lte4).to.equal(findAllGte2Lte4.length); - expect(countGte2Lte4).to.equal(3); // recipes 2, 3, and 4 - - // Test count() with lt operator - const countLt3 = await Recipe.count(perspective!, { where: { number: { lt: 3 } } }); - const findAllLt3 = await Recipe.findAll(perspective!, { where: { number: { lt: 3 } } }); - expect(countLt3).to.equal(findAllLt3.length); - expect(countLt3).to.equal(2); // recipes 1 and 2 - - // Test query builder count() with gt operator - const queryCountGt3 = await Recipe.query(perspective!) - .where({ number: { gt: 3 } }) - .count(); - const queryGetGt3 = await Recipe.query(perspective!) - .where({ number: { gt: 3 } }) - .get(); - expect(queryCountGt3).to.equal(queryGetGt3.length); - expect(queryCountGt3).to.equal(2); - - // Test query builder count() with between operator - const queryCountBetween = await Recipe.query(perspective!) - .where({ number: { between: [2, 4] } }) - .count(); - const queryGetBetween = await Recipe.query(perspective!) - .where({ number: { between: [2, 4] } }) - .get(); - expect(queryCountBetween).to.equal(queryGetBetween.length); - expect(queryCountBetween).to.equal(3); - - // Test count() with timestamp filtering - // Get the timestamp of recipe3 - const allRecipes = await Recipe.findAll(perspective!); - const recipe3Instance = allRecipes.find((r: any) => r.name === "Recipe 3"); - expect(recipe3Instance).to.not.be.undefined; - - if (recipe3Instance && recipe3Instance.timestamp) { - // Convert timestamp to number if it's a string - const recipe3Timestamp = typeof recipe3Instance.timestamp === 'string' - ? new Date(recipe3Instance.timestamp).getTime() - : recipe3Instance.timestamp; - - // Count recipes with timestamp greater than recipe3's timestamp - const countAfterRecipe3 = await Recipe.count(perspective!, { - where: { timestamp: { gt: recipe3Timestamp } } - }); - const findAllAfterRecipe3 = await Recipe.findAll(perspective!, { - where: { timestamp: { gt: recipe3Timestamp } } - }); - expect(countAfterRecipe3).to.equal(findAllAfterRecipe3.length); - expect(countAfterRecipe3).to.be.at.least(2); // At least recipes 4 and 5 - } - }) - - it("paginate() and paginateSubscribe() work on the query builder", async () => { - // Create test recipes - for (let i = 1; i <= 10; i++) { - const recipe = new Recipe(perspective!); - recipe.name = `Recipe ${i}`; - await recipe.save(); - } - - // Test paginate() - const query = Recipe.query(perspective!); - const page1 = await query.paginate(3, 1); - expect(page1.results.length).to.equal(3); - expect(page1.totalCount).to.equal(10); - expect(page1.results[0].name).to.equal("Recipe 1"); - expect(page1.results[2].name).to.equal("Recipe 3"); - - const page2 = await query.paginate(3, 2); - expect(page2.results.length).to.equal(3); - expect(page2.results[0].name).to.equal("Recipe 4"); - - const lastPage = await query.paginate(3, 4); - expect(lastPage.results.length).to.equal(1); - expect(lastPage.results[0].name).to.equal("Recipe 10"); - - // Test paginateSubscribe() - let lastResult: any = null; - const initialResult = await query.paginateSubscribe(3, 1, (result) => { - lastResult = result; - }); - - expect(initialResult.results.length).to.equal(3); - expect(initialResult.totalCount).to.equal(10); - // Reset lastResult to verify we get an update - lastResult = null; - - // Small delay to ensure subscription is fully registered before triggering changes - await sleep(500); - - // Add a new recipe and verify subscription updates - const newRecipe = new Recipe(perspective!); - newRecipe.name = "Recipe 11"; - await newRecipe.save(); - - // Wait for subscription update with proper condition checking - // Use longer timeout for CI environments which may be slower - await waitForCondition( - () => lastResult !== null, - { - timeoutMs: 15000, - errorMessage: 'Paginate subscription did not update after recipe save' - } - ); - - expect(lastResult.totalCount).to.equal(11); - - // Dispose the subscription to prevent cross-test interference - query.dispose(); - }) - - it("query builder works with subscriptions", async () => { - @ModelOptions({ - name: "Notification" - }) - class Notification extends Ad4mModel { - @Property({ - through: "notification://title", - resolveLanguage: "literal" - }) - title: string = ""; - - @Property({ - through: "notification://priority", - resolveLanguage: "literal" - }) - priority: number = 0; - - @Property({ - through: "notification://read", - resolveLanguage: "literal" - }) - read: boolean = false; - } - - // Register the Notification class - await perspective!.ensureSDNASubjectClass(Notification); - - // Clear any previous notifications - let notifications = await Notification.findAll(perspective!); - for (const notification of notifications) await notification.delete(); - - // Set up subscription for high-priority unread notifications - let updateCount = 0; - const builder = Notification - .query(perspective!) - .where({ - priority: { gt: 5 }, - read: false - }); - const initialResults = await builder - .subscribe((newNotifications) => { - notifications = newNotifications; - updateCount++; - }); - - // Initially no results - expect(initialResults.length).to.equal(0); - expect(updateCount).to.equal(0); - - // Add matching notification - should trigger subscription - const notification1 = new Notification(perspective!); - notification1.title = "High priority notification"; - notification1.priority = 8; - notification1.read = false; - await notification1.save(); - - // Wait for subscription to fire with smart polling - for (let i = 0; i < 30; i++) { - if (updateCount >= 1 && notifications.length === 1) break; - await sleep(50); - } - expect(updateCount).to.be.at.least(1); - expect(notifications.length).to.equal(1); - - // Add another matching notification - should trigger subscription again - const notification2 = new Notification(perspective!); - notification2.title = "Another high priority"; - notification2.priority = 7; - notification2.read = false; - await notification2.save(); - - for (let i = 0; i < 30; i++) { - if (updateCount >= 2 && notifications.length === 2) break; - await sleep(50); - } - expect(updateCount).to.be.at.least(2); - expect(notifications.length).to.equal(2); - - // Add non-matching notification (low priority) - should not trigger subscription - const notification3 = new Notification(perspective!); - notification3.title = "Low priority notification"; - notification3.priority = 3; - notification3.read = false; - await notification3.save(); - - await sleep(200); // Give it time but don't wait the full second - // With SurrealDB we get 3 updates because we do comparison filtering in the client - // and not the query. So the raw query result actually is different, even though - // the ultimate result is the same. - //expect(updateCount).to.equal(2); - expect(notifications.length).to.equal(2); - - // Mark notification1 as read - should trigger subscription to remove it - notification1.read = true; - await notification1.update(); - for (let i = 0; i < 30; i++) { - if (notifications.length === 1) break; - await sleep(50); - } - expect(notifications.length).to.equal(1); - - // Dispose the subscription to prevent cross-test interference - builder.dispose(); - }); - - it("query builder should filter by subject class", async () => { - // Define a second subject class - @ModelOptions({ - name: "Note1" - }) - class Note1 extends Ad4mModel { - @Property({ - through: "note://name", - resolveLanguage: "literal" - }) - name: string = ""; - - @Property({ - through: "note1://content", - resolveLanguage: "literal" - }) - content1: string = ""; - } - - @ModelOptions({ - name: "Note2" - }) - class Note2 extends Ad4mModel { - @Property({ - through: "note://name", - resolveLanguage: "literal" - }) - name: string = ""; - - @Property({ - through: "note2://content", - resolveLanguage: "literal" - }) - content2: string = ""; - } - - // Register the Note class - await perspective!.ensureSDNASubjectClass(Note1); - await perspective!.ensureSDNASubjectClass(Note2); - - // Create instances of both classes with the same name - const note1 = new Note1(perspective!); - note1.name = "Test Item"; - await note1.save(); - - const note2 = new Note2(perspective!); - note2.name = "Test Item"; - await note2.save(); - - // Query for recipes - this should only return the recipe instance - const note1Results = await Note1.query(perspective!).where({ name: "Test Item" }).get() - - //console.log("note1Results: ", note1Results) - // This assertion will fail because the query builder doesn't filter by class - expect(note1Results.length).to.equal(1); - expect(note1Results[0]).to.be.instanceOf(Note1); - }); - - it("query builder works with single query object, complex query and subscriptions", async () => { - @ModelOptions({ - name: "Task" - }) - class Task extends Ad4mModel { - @Property({ - through: "task://description", - resolveLanguage: "literal" - }) - description: string = ""; - - @Property({ - through: "task://dueDate", - resolveLanguage: "literal" - }) - dueDate: number = 0; - - - @Property({ - through: "task://completed", - resolveLanguage: "literal" - }) - completed: boolean = false; - - @Property({ - through: "task://assignee", - resolveLanguage: "literal" - }) - assignee: string = ""; - } - - // Register the Task class - await perspective!.ensureSDNASubjectClass(Task); - - // Clear any previous tasks - let tasks = await Task.findAll(perspective!); - for (const task of tasks) await task.delete(); - - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowTimestamp = tomorrow.getTime(); - - const nextWeek = new Date(); - nextWeek.setDate(nextWeek.getDate() + 7); - const nextWeekTimestamp = nextWeek.getTime(); - - // Set up subscription for upcoming incomplete tasks assigned to "alice" - let updateCount = 0; - const builder = Task.query(perspective!, { - where: { - dueDate: { lte: nextWeekTimestamp }, - completed: false, - assignee: "alice" - } - }); - const initialResults = await builder.subscribe((newTasks) => { - tasks = newTasks; - updateCount++; - }); - - // Initially no results - expect(initialResults.length).to.equal(0); - expect(updateCount).to.equal(0); - - // Add matching task - should trigger subscription - const task1 = new Task(perspective!); - task1.description = "Urgent task for tomorrow"; - task1.dueDate = tomorrowTimestamp; - task1.completed = false; - task1.assignee = "alice"; - await task1.save(); - - await task1.get(); - - // Wait for subscription to fire with proper condition checking - await waitForCondition( - () => updateCount === 1 && tasks.length === 1, - { - timeoutMs: 5000, - errorMessage: 'Subscription did not fire after first task save' - } - ); - - expect(updateCount).to.equal(1); - expect(tasks.length).to.equal(1); - - // Add another matching task - should trigger subscription again - const task2 = new Task(perspective!); - task2.description = "Another task for next week"; - task2.dueDate = nextWeekTimestamp; - task2.completed = false; - task2.assignee = "alice"; - await task2.save(); - - // Wait for subscription to fire with proper condition checking - await waitForCondition( - () => updateCount === 2 && tasks.length === 2, - { - timeoutMs: 5000, - errorMessage: 'Subscription did not fire after second task save' - } - ); - expect(updateCount).to.equal(2); - expect(tasks.length).to.equal(2); - - // Add non-matching task (wrong assignee) - should not trigger subscription - const task3 = new Task(perspective!); - task3.description = "Task assigned to bob"; - task3.dueDate = tomorrowTimestamp; - task3.completed = false; - task3.assignee = "bob"; - await task3.save(); - - await sleep(1000); - expect(updateCount).to.equal(2); - expect(tasks.length).to.equal(2); - - // Mark task1 as completed - should trigger subscription to remove it - task1.completed = true; - await task1.update(); - - // Wait for subscription to fire with proper condition checking - await waitForCondition( - () => tasks.length === 1, - { - timeoutMs: 5000, - errorMessage: 'Subscription did not fire after task update' - } - ); - - expect(tasks.length).to.equal(1); - - // Dispose the subscription to prevent cross-test interference - builder.dispose(); - }); - - it("transform option in property decorators works", async () => { - const transformTestPerspective = await ad4m?.perspective.add("transform-test"); - @ModelOptions({ name: "ImagePost" }) - class ImagePost extends Ad4mModel { - @Property({ - through: "image://data", - resolveLanguage: "literal", - transform: (data: any) => data ? `data:image/png;base64,${data}` : undefined, - } as PropertyOptions) - image: string = ""; - //TODO: having json objects as properties in our new queries breaks the JSON - // construction of Prolog query results. - // Need to find a way to make this work: - //image: { data_base64: string } = { data_base64: "" }; - } - - // Register the ImagePost class - await transformTestPerspective!.ensureSDNASubjectClass(ImagePost); - - // Create a new image post - const post = new ImagePost(transformTestPerspective!); - const imageData = "abc123"; - //const imageData = { data_base64: "abc123" }; - - post.image = imageData; - await post.save(); - - // Retrieve the post and check transformed values - const [retrieved] = await ImagePost.findAll(transformTestPerspective!); - expect(retrieved.image).to.equal("data:image/png;base64,abc123"); - }); - - it("should support batch operations with multiple models", async () => { - let perspective = await ad4m!.perspective.add("batch test") - @ModelOptions({ - name: "BatchRecipe" - }) - class BatchRecipe extends Ad4mModel { - @Property({ - through: "recipe://name", - resolveLanguage: "literal" - }) - name: string = ""; - - @Collection({ through: "recipe://ingredients" }) - ingredients: string[] = []; - } - - @ModelOptions({ - name: "BatchNote" - }) - class BatchNote extends Ad4mModel { - @Property({ - through: "note://title", - resolveLanguage: "literal" - }) - title: string = ""; - - @Property({ - through: "note://content", - resolveLanguage: "literal" - }) - content: string = ""; - } - - // Register the classes - await perspective!.ensureSDNASubjectClass(BatchRecipe); - await perspective!.ensureSDNASubjectClass(BatchNote); - - // Create batch - const batchId = await perspective!.createBatch(); - - // Create and save multiple models in batch - const recipe = new BatchRecipe(perspective!); - recipe.name = "Pasta"; - recipe.ingredients = ["recipe://ingredient/pasta", "recipe://ingredient/sauce", "recipe://ingredient/cheese"]; - await recipe.save(batchId); - - - const note = new BatchNote(perspective!); - note.title = "Recipe Notes"; - note.content = "Make sure to use fresh ingredients"; - await note.save(batchId); - - // Verify models are not visible before commit - const recipesBeforeCommit = await BatchRecipe.findAll(perspective!); - expect(recipesBeforeCommit.length).to.equal(0); - - const notesBeforeCommit = await BatchNote.findAll(perspective!); - expect(notesBeforeCommit.length).to.equal(0); - - // Commit batch - const result = await perspective!.commitBatch(batchId); - expect(result.additions.length).to.be.greaterThan(0); - expect(result.removals.length).to.equal(0); - - // Verify models are now visible - const recipesAfterCommit = await BatchRecipe.findAll(perspective!); - expect(recipesAfterCommit.length).to.equal(1); - expect(recipesAfterCommit[0].name).to.equal("Pasta"); - expect(recipesAfterCommit[0].ingredients).to.have.members(["recipe://ingredient/pasta", "recipe://ingredient/sauce", "recipe://ingredient/cheese"]); - - const notesAfterCommit = await BatchNote.findAll(perspective!); - expect(notesAfterCommit.length).to.equal(1); - expect(notesAfterCommit[0].title).to.equal("Recipe Notes"); - expect(notesAfterCommit[0].content).to.equal("Make sure to use fresh ingredients"); - - // Test updating models in batch - const updateBatchId = await perspective!.createBatch(); - recipe.ingredients.push("recipe://ingredient/garlic"); - await recipe.update(updateBatchId); - - note.content = "Updated: Use fresh ingredients and add garlic"; - await note.update(updateBatchId); - - // Verify models haven't changed before commit - const recipesBeforeUpdate = await BatchRecipe.findAll(perspective!); - expect(recipesBeforeUpdate[0].ingredients).to.have.members(["recipe://ingredient/pasta", "recipe://ingredient/sauce", "recipe://ingredient/cheese"]); - - const notesBeforeUpdate = await BatchNote.findAll(perspective!); - expect(notesBeforeUpdate[0].content).to.equal("Make sure to use fresh ingredients"); - - // Commit update batch - const updateResult = await perspective!.commitBatch(updateBatchId); - expect(updateResult.additions.length).to.be.greaterThan(0); - - // Verify models are updated - const recipesAfterUpdate = await BatchRecipe.findAll(perspective!); - expect(recipesAfterUpdate[0].ingredients.length).to.equal(4); - expect(recipesAfterUpdate[0].ingredients.includes("recipe://ingredient/pasta")).to.be.true; - expect(recipesAfterUpdate[0].ingredients.includes("recipe://ingredient/sauce")).to.be.true; - expect(recipesAfterUpdate[0].ingredients.includes("recipe://ingredient/cheese")).to.be.true; - expect(recipesAfterUpdate[0].ingredients.includes("recipe://ingredient/garlic")).to.be.true; - - const notesAfterUpdate = await BatchNote.findAll(perspective!); - expect(notesAfterUpdate[0].content).to.equal("Updated: Use fresh ingredients and add garlic"); - - // Test deleting models in batch - const deleteBatchId = await perspective!.createBatch(); - - await recipesAfterUpdate[0].delete(deleteBatchId); - await notesAfterUpdate[0].delete(deleteBatchId); - - // Verify models still exist before commit - const recipesBeforeDelete = await BatchRecipe.findAll(perspective!); - expect(recipesBeforeDelete.length).to.equal(1); - - const notesBeforeDelete = await BatchNote.findAll(perspective!); - expect(notesBeforeDelete.length).to.equal(1); - - // Commit delete batch - const deleteResult = await perspective!.commitBatch(deleteBatchId); - expect(deleteResult.removals.length).to.be.greaterThan(0); - - // Verify models are deleted - const recipesAfterDelete = await BatchRecipe.findAll(perspective!); - expect(recipesAfterDelete.length).to.equal(0); - - const notesAfterDelete = await BatchNote.findAll(perspective!); - expect(notesAfterDelete.length).to.equal(0); - }); - - describe("SurrealDB vs Prolog Subscriptions", () => { - let perspective: PerspectiveProxy; - - @ModelOptions({ name: "SubscriptionTestModel" }) - class TestModel extends Ad4mModel { - @Property({ - through: "test://name", - resolveLanguage: "literal" - }) - name: string = ""; - - @Property({ - through: "test://status", - resolveLanguage: "literal" - }) - status: string = ""; - } - - beforeEach(async () => { - perspective = await ad4m!.perspective.add("subscription-parity-test"); - await perspective!.ensureSDNASubjectClass(TestModel); - }); - - afterEach(async () => { - if (perspective) { - await ad4m!.perspective.remove(perspective.uuid); - } - }); - - // REMOVED: SurrealDB vs Prolog parity test - // This test compared SurrealDB and Prolog subscription results. - // With SHACL migration, SurrealDB is now the primary query engine. - // Prolog subscriptions are deprecated - no need for parity testing. - - it("should demonstrate SurrealDB subscription performance", async () => { - // Measure latency of update - const surrealCallback = sinon.fake(); - const surrealBuilder = TestModel.query(perspective).where({ status: "perf-test" }); - await surrealBuilder.subscribe(surrealCallback); - - const start = Date.now(); - const model = new TestModel(perspective); - model.name = "Perf Item"; - model.status = "perf-test"; - await model.save(); - const saveTime = Date.now(); - - // Poll until callback called - while (!surrealCallback.called) { - await sleep(10); - if (Date.now() - saveTime > 5000) throw new Error("Timeout waiting for subscription update"); - } - - const saveLatency = saveTime - start; - const subscriptionLatency = Date.now() - saveTime; - console.log(`TestModel.save() latency: ${saveLatency}ms`); - console.log(`SurrealDB subscription update latency: ${subscriptionLatency}ms`); - - surrealBuilder.dispose(); - }); - }); - - describe('ModelQueryBuilder', () => { - let perspective: PerspectiveProxy; - - // Define a simple test model - @ModelOptions({ name: "TestModel" }) - class TestModel extends Ad4mModel { - @Property({ - through: "test://name", - resolveLanguage: "literal" - }) - name: string = ""; - - @Property({ - through: "test://status", - resolveLanguage: "literal" - }) - status: string = ""; - } - - beforeEach(async () => { - perspective = await ad4m!.perspective.add("query-builder-test"); - await perspective!.ensureSDNASubjectClass(TestModel); - }); - - afterEach(async () => { - // Clean up perspective to prevent cross-test interference - if (perspective) { - await ad4m!.perspective.remove(perspective.uuid); - } - }); - - it('handles subscriptions and disposal correctly', async () => { - // Create a query builder - const builder = TestModel.query(perspective) - .where({ status: "active" }); - - // Set up callback spies - const callback1 = sinon.fake(); - const callback2 = sinon.fake(); - - // Create first subscription - const initialResults1 = await builder.subscribe(callback1); - expect(initialResults1).to.be.an('array'); - expect(initialResults1.length).to.equal(0); - - // Add a matching model - const model1 = new TestModel(perspective); - model1.name = "Test 1"; - model1.status = "active"; - await model1.save(); - - // Wait for subscription update with proper condition checking - await waitForCondition( - () => callback1.called, - { - timeoutMs: 5000, - errorMessage: 'First callback was not called after model save' - } - ); - - // Verify callback was called - expect(callback1.called).to.be.true; - expect(callback1.lastCall.args[0]).to.be.an('array'); - expect(callback1.lastCall.args[0].length).to.equal(1); - expect(callback1.lastCall.args[0][0].name).to.equal("Test 1"); - - // Create second subscription (should dispose first one) - const initialResults2 = await builder.subscribe(callback2); - expect(initialResults2).to.be.an('array'); - expect(initialResults2.length).to.equal(1); - - // Add another matching model - const model2 = new TestModel(perspective); - model2.name = "Test 2"; - model2.status = "active"; - await model2.save(); - - // Wait for subscription update with proper condition checking - await waitForCondition( - () => callback2.called, - { - timeoutMs: 5000, - errorMessage: 'Second callback was not called after model save' - } - ); - - // Verify only second callback was called - expect(callback1.callCount).to.equal(1); // No new calls - expect(callback2.called).to.be.true; - expect(callback2.lastCall.args[0]).to.be.an('array'); - expect(callback2.lastCall.args[0].length).to.equal(2); - - // Dispose subscription - builder.dispose(); - - // Add another model - should not trigger callback - const model3 = new TestModel(perspective); - model3.name = "Test 3"; - model3.status = "active"; - await model3.save(); - - // Wait to ensure no callbacks - await sleep(1000); - - // Verify no new callbacks - expect(callback1.callCount).to.equal(1); - expect(callback2.callCount).to.equal(1); - }); - - it('handles count subscriptions and disposal', async () => { - const builder = TestModel.query(perspective) - .where({ status: "active" }); - - const countCallback = sinon.fake(); - const initialCount = await builder.countSubscribe(countCallback); - expect(initialCount).to.equal(0); - - // Small delay to ensure subscription is fully registered before triggering changes - await sleep(500); - - // Add a matching model - const model = new TestModel(perspective); - model.name = "Test"; - model.status = "active"; - await model.save(); - - // Wait for subscription update with proper condition checking - // Use longer timeout for CI environments which may be slower - await waitForCondition( - () => countCallback.called, - { - timeoutMs: 15000, - errorMessage: 'Count callback was not called after model save' - } - ); - - // Verify callback was called with new count - expect(countCallback.called).to.be.true; - expect(countCallback.lastCall.args[0]).to.equal(1); - let count = countCallback.callCount - - // Dispose subscription - builder.dispose(); - - // Add another model - should not trigger callback - const model2 = new TestModel(perspective); - model2.name = "Test 2"; - model2.status = "active"; - await model2.save(); - - // Wait to ensure no callback (still using sleep since we're verifying no change) - await sleep(1000); - - // Verify no new callbacks - expect(countCallback.callCount).to.equal(count); - }); - - it('handles paginated subscriptions and disposal', async () => { - const builder = TestModel.query(perspective) - .where({ status: "active" }); - - const pageCallback = sinon.fake(); - const initialPage = await builder.paginateSubscribe(2, 1, pageCallback); - expect(initialPage.results.length).to.equal(0); - expect(initialPage.totalCount).to.equal(0); - - // Small delay to ensure subscription is fully registered before triggering changes - await sleep(500); - - // Add models - const model1 = new TestModel(perspective); - model1.name = "Test 1"; - model1.status = "active"; - await model1.save(); - - const model2 = new TestModel(perspective); - model2.name = "Test 2"; - model2.status = "active"; - await model2.save(); - - // Wait for subscription updates with proper condition checking - // Use longer timeout for CI environments which may be slower - await waitForCondition( - () => pageCallback.called && pageCallback.lastCall.args[0].results.length >= 2, - { - timeoutMs: 15000, - errorMessage: 'Paginate callback was not called with expected results after model saves' - } - ); - - // Verify callback was called with updated page - expect(pageCallback.called).to.be.true; - expect(pageCallback.lastCall.args[0].results.length).to.equal(2); - expect(pageCallback.lastCall.args[0].totalCount).to.equal(2); - - console.log("countCallback", pageCallback.lastCall.args[0]) - let count = pageCallback.callCount - - // Dispose subscription - builder.dispose(); - - // Add another model - should not trigger callback - const model3 = new TestModel(perspective); - model3.name = "Test 3"; - model3.status = "active"; - await model3.save(); - - // Wait to ensure no callback - await sleep(1000); - - // Verify no new callbacks - expect(pageCallback.callCount).to.equal(count); - }); - }); - - describe("Emoji and Special Character Handling", () => { - @ModelOptions({ - name: "Message" - }) - class EmojiMessage extends Ad4mModel { - @Flag({ - through: "ad4m://entry_type", - value: "flux://message" - }) - type: string = "" - - @Property({ - through: "flux://body", - writable: true, - resolveLanguage: "literal" - }) - body: string = "" - } - - - // before(async () => { - // // Add a small delay to ensure Prolog engine is stable - // await sleep(2000); - - // // Register the EmojiMessage class using ensureSDNASubjectClass - // await perspective!.ensureSDNASubjectClass(EmojiMessage); - - // // Clear any existing EmojiMessage instances to start fresh - // const existingMessages = await EmojiMessage.findAll(perspective!); - // for (const msg of existingMessages) { - // await msg.delete(); - // } - // }); - - beforeEach(async () => { - // Register the EmojiMessage class using ensureSDNASubjectClass - await perspective!.ensureSDNASubjectClass(EmojiMessage); - // Clean up any messages from previous tests - const existingMessages = await EmojiMessage.findAll(perspective!); - for (const msg of existingMessages) { - await msg.delete(); - } - }); - - it("should correctly create and retrieve messages with emoji content", async () => { - // Create a message with emoji content using Active Record - const emojiMessage = new EmojiMessage(perspective!); - emojiMessage.body = "

👋

"; - await emojiMessage.save(); - - // Retrieve using findAll to test the full Prolog → Ad4mModel pipeline - const messages = await EmojiMessage.findAll(perspective!); - const retrievedMessage = messages.find((m: EmojiMessage) => m.body === "

👋

"); - - expect(retrievedMessage).to.not.be.undefined; - expect(retrievedMessage!.body).to.equal("

👋

"); - }); - - it("should handle complex emoji sequences in Active Record properties", async () => { - // Test with complex emoji sequences - const complexMessage = new EmojiMessage(perspective!); - complexMessage.body = "

🏳️‍🌈 Complex emoji with modifiers 👨‍👩‍👧‍👦

"; - await complexMessage.save(); - - // Test retrieval with findAll - const messages = await EmojiMessage.findAll(perspective!); - const foundMessage = messages.find((m: EmojiMessage) => m.body === "

🏳️‍🌈 Complex emoji with modifiers 👨‍👩‍👧‍👦

"); - - expect(foundMessage).to.not.be.undefined; - expect(foundMessage!.body).to.equal("

🏳️‍🌈 Complex emoji with modifiers 👨‍👩‍👧‍👦

"); - }); - - it("should correctly handle special characters and Unicode", async () => { - // Test with various special characters that could break URL encoding - const specialMessage = new EmojiMessage(perspective!); - specialMessage.body = "

Special chars: àáâãäåæçèéêë ñ © ® ™ €

"; - await specialMessage.save(); - - // Verify retrieval through findAll - const messages = await EmojiMessage.findAll(perspective!); - const special = messages.find((m: EmojiMessage) => m.body === "

Special chars: àáâãäåæçèéêë ñ © ® ™ €

"); - - expect(special).to.not.be.undefined; - expect(special!.body).to.equal("

Special chars: àáâãäåæçèéêë ñ © ® ™ €

"); - }); - - it("should handle mixed content with emojis and HTML entities", async () => { - // Test HTML entities mixed with emojis - const mixedMessage = new EmojiMessage(perspective!); - mixedMessage.body = "

Mixed: <emoji> 😊 & "quotes" 🎉

"; - await mixedMessage.save(); - - // Test direct property access after save/reload cycle - const allMessages = await EmojiMessage.findAll(perspective!); - const mixedMsg = allMessages.find((m: EmojiMessage) => m.body === "

Mixed: <emoji> 😊 & "quotes" 🎉

"); - - expect(mixedMsg).to.not.be.undefined; - expect(mixedMsg!.body).to.equal("

Mixed: <emoji> 😊 & "quotes" 🎉

"); - }); - - // it("should preserve UTF-8 byte sequences through Prolog query system", async () => { - // // Test edge case UTF-8 sequences that previously caused issues - // const utf8Message = new EmojiMessage(perspective!); - // utf8Message.body = "UTF-8 test: 🌍🌎🌏 💫⭐✨ 🔥💯🚀 with metadata: {\"tags\": [\"🏷️\", \"📝\"], \"priority\": \"🔴\"}"; - // await utf8Message.save(); - - // // Query using findAll to test the exact pipeline that was broken - // const messages = await EmojiMessage.findAll(perspective!); - // const testMsg = messages.find((m: EmojiMessage) => m.body === "UTF-8 test: 🌍🌎🌏 💫⭐✨ 🔥💯🚀 with metadata: {\"tags\": [\"🏷️\", \"📝\"], \"priority\": \"🔴\"}"); - - // expect(testMsg).to.not.be.undefined; - // // These assertions test the exact issue that was fixed: - // // Previously these would return undefined due to Prolog URL decoding issues - // expect(testMsg!.body).to.not.be.undefined; - // expect(testMsg!.body).to.equal("UTF-8 test: 🌍🌎🌏 💫⭐✨ 🔥💯🚀 with metadata: {\"tags\": [\"🏷️\", \"📝\"], \"priority\": \"🔴\"}"); - // }); - - it("should handle subscription-based queries with emoji content", async () => { - // Clear any previous messages - let existingMessages = await EmojiMessage.findAll(perspective!); - for (const msg of existingMessages) await msg.delete(); - - // Set up subscription for emoji content - let updateCount = 0; - let subscriptionResults: EmojiMessage[] = []; - const builder = EmojiMessage.query(perspective!); - const initialResults = await builder.subscribe((messages: EmojiMessage[]) => { - subscriptionResults = messages; - updateCount++; - }); - - // Initially no results - expect(initialResults.length).to.equal(0); - expect(updateCount).to.equal(0); - - // Create a message after setting up subscription - should trigger callback - const subscriptionMessage = new EmojiMessage(perspective!); - subscriptionMessage.body = "Subscription test with emoji: 🎯✅"; - await subscriptionMessage.save(); - - // Wait for subscription to process with proper condition checking - await waitForCondition( - () => updateCount === 1, - { - timeoutMs: 5000, - errorMessage: 'Subscription did not fire after first message save' - } - ); - - // Verify subscription callback was called - expect(updateCount).to.equal(1); - expect(subscriptionResults.length).to.equal(1); - expect(subscriptionResults[0].body).to.equal("Subscription test with emoji: 🎯✅"); - - // Add another message with emojis - should trigger subscription again - const secondMessage = new EmojiMessage(perspective!); - secondMessage.body = "Another emoji message: 🚀💯"; - await secondMessage.save(); - - // Wait for subscription to process with proper condition checking - await waitForCondition( - () => updateCount === 2, - { - timeoutMs: 5000, - errorMessage: 'Subscription did not fire after second message save' - } - ); - - // Verify subscription was called again - expect(updateCount).to.equal(2); - expect(subscriptionResults.length).to.equal(2); - const foundSecond = subscriptionResults.find(m => m.body === "Another emoji message: 🚀💯"); - expect(foundSecond).to.not.be.undefined; - - // Also verify the message exists through direct query - const messages = await EmojiMessage.findAll(perspective!); - const found = messages.find((m: EmojiMessage) => m.body === "Subscription test with emoji: 🎯✅"); - expect(found).to.not.be.undefined; - expect(found!.body).to.equal("Subscription test with emoji: 🎯✅"); - - // Dispose the subscription to prevent cross-test interference - builder.dispose(); - }); - }); - }) - - describe("getter feature tests", () => { - @ModelOptions({ name: "BlogPost" }) - class BlogPost extends Ad4mModel { - @Property({ - through: "blog://title", - resolveLanguage: "literal" - }) - title: string = ""; - - @Optional({ - through: "blog://parent", - getter: "(->link[WHERE perspective = $perspective AND predicate = 'blog://reply_to'].out.uri)[0]" - }) - parentPost: string | undefined; - - @Collection({ - through: "blog://tags", - getter: "(->link[WHERE perspective = $perspective AND predicate = 'blog://tagged_with'].out.uri)" - }) - tags: string[] = []; - } - - beforeEach(async () => { - if(perspective) { - await ad4m!.perspective.remove(perspective.uuid) - } - perspective = await ad4m!.perspective.add("getter-test") - await perspective!.ensureSDNASubjectClass(BlogPost) - }); - - it("should evaluate getter for property", async () => { - const postRoot = Literal.from("Blog post for getter property test").toUrl(); - const parentRoot = Literal.from("Parent blog post").toUrl(); - - const post = new BlogPost(perspective!, postRoot); - post.title = "Reply Post"; - await post.save(); - - const parent = new BlogPost(perspective!, parentRoot); - parent.title = "Original Post"; - await parent.save(); - - // Create the link that getter should find - await perspective!.add(new Link({ - source: postRoot, - predicate: "blog://reply_to", - target: parentRoot - })); - - // Get the post and check if getter resolved the parent - const retrievedPost = new BlogPost(perspective!, postRoot); - await retrievedPost.get(); - - expect(retrievedPost.parentPost).to.equal(parentRoot); - }); - - it("should evaluate getter for collection", async () => { - const postRoot = Literal.from("Blog post for getter collection test").toUrl(); - const tag1 = Literal.from("tag:javascript").toUrl(); - const tag2 = Literal.from("tag:typescript").toUrl(); - - const post = new BlogPost(perspective!, postRoot); - post.title = "Test Post"; - await post.save(); - - // Create links that getter should find - await perspective!.add(new Link({ - source: postRoot, - predicate: "blog://tagged_with", - target: tag1 - })); - await perspective!.add(new Link({ - source: postRoot, - predicate: "blog://tagged_with", - target: tag2 - })); - - // Get the post and check if getter resolved the tags - const retrievedPost = new BlogPost(perspective!, postRoot); - await retrievedPost.get(); - - expect(retrievedPost.tags).to.include(tag1); - expect(retrievedPost.tags).to.include(tag2); - expect(retrievedPost.tags.length).to.equal(2); - }); - - it("should filter out 'None' and empty values from getter results", async () => { - const postRoot = Literal.from("Blog post for None filtering test").toUrl(); - - const post = new BlogPost(perspective!, postRoot); - post.title = "Post without parent"; - await post.save(); - - // Don't create any reply_to link, so getter should return None/empty - - const retrievedPost = new BlogPost(perspective!, postRoot); - await retrievedPost.get(); - - // Property should be undefined, not 'None' or empty string - expect(retrievedPost.parentPost).to.be.undefined; - }); - }) - - describe("isInstance filtering tests", () => { - @ModelOptions({ name: "Comment" }) - class Comment extends Ad4mModel { - @Flag({ - through: "ad4m://type", - value: "ad4m://comment" - }) - type!: string; - - @Property({ - through: "comment://text", - resolveLanguage: "literal" - }) - text: string = ""; - } - - @ModelOptions({ name: "Article" }) - class Article extends Ad4mModel { - @Property({ - through: "article://title", - resolveLanguage: "literal" - }) - title: string = ""; - - @Collection({ - through: "article://has_comment", - where: { isInstance: Comment } - }) - comments: string[] = []; - } - - @ModelOptions({ name: "ArticleWithString" }) - class ArticleWithString extends Ad4mModel { - @Property({ - through: "article://title", - resolveLanguage: "literal" - }) - title: string = ""; - - @Collection({ - through: "article://has_comment", - where: { isInstance: "Comment" } - }) - comments: string[] = []; - } - - beforeEach(async () => { - if(perspective) { - await ad4m!.perspective.remove(perspective.uuid) - } - perspective = await ad4m!.perspective.add("isInstance-test") - - // Register both Comment and Article classes using ensureSDNASubjectClass - await perspective!.ensureSDNASubjectClass(Comment); - await perspective!.ensureSDNASubjectClass(Article); - await perspective!.ensureSDNASubjectClass(ArticleWithString); - - // Give perspective time to fully index the SDNA classes - await sleep(200); - }); - - it("should filter collection by isInstance with class reference", async () => { - const articleRoot = Literal.from("Article for isInstance test").toUrl(); - const validComment1 = Literal.from("Valid comment 1").toUrl(); - const validComment2 = Literal.from("Valid comment 2").toUrl(); - const invalidItem = Literal.from("Invalid item").toUrl(); - - const article = new Article(perspective!, articleRoot); - article.title = "Test Article"; - await article.save(); - - // Create valid comments - const comment1 = new Comment(perspective!, validComment1); - comment1.text = "This is a valid comment"; - await comment1.save(); - - const comment2 = new Comment(perspective!, validComment2); - comment2.text = "This is another valid comment"; - await comment2.save(); - - // Add delay to allow SurrealDB to finish indexing - await sleep(1500); - - // Add links to article - await perspective!.add(new Link({ - source: articleRoot, - predicate: "article://has_comment", - target: validComment1 - })); - await perspective!.add(new Link({ - source: articleRoot, - predicate: "article://has_comment", - target: invalidItem - })); - await perspective!.add(new Link({ - source: articleRoot, - predicate: "article://has_comment", - target: validComment2 - })); - - const retrievedArticle = new Article(perspective!, articleRoot); - await retrievedArticle.get(); - - // Should only contain valid Comments, not the invalid item - expect(retrievedArticle.comments).to.have.lengthOf(2); - expect(retrievedArticle.comments).to.include(validComment1); - expect(retrievedArticle.comments).to.include(validComment2); - expect(retrievedArticle.comments).to.not.include(invalidItem); - }); - - it("should filter collection by isInstance with string class name", async () => { - const articleRoot = Literal.from("Article for string isInstance test").toUrl(); - const validComment = Literal.from("Valid comment").toUrl(); - const invalidItem = Literal.from("Invalid item").toUrl(); - - const article = new ArticleWithString(perspective!, articleRoot); - article.title = "Test Article with String"; - await article.save(); - - // Create one valid comment - const comment = new Comment(perspective!, validComment); - comment.text = "Valid comment text"; - await comment.save(); - - // Add both to article - await perspective!.add(new Link({ - source: articleRoot, - predicate: "article://has_comment", - target: validComment - })); - await perspective!.add(new Link({ - source: articleRoot, - predicate: "article://has_comment", - target: invalidItem - })); - - const retrievedArticle = new ArticleWithString(perspective!, articleRoot); - await retrievedArticle.get(); - - expect(retrievedArticle.comments).to.have.lengthOf(1); - expect(retrievedArticle.comments[0]).to.equal(validComment); - }); - - it("should filter results in findAll() by isInstance", async () => { - // Create two articles - const article1Root = Literal.from("Article 1 for findAll isInstance").toUrl(); - const article2Root = Literal.from("Article 2 for findAll isInstance").toUrl(); - - const comment1 = Literal.from("Comment 1").toUrl(); - const invalid1 = Literal.from("Invalid 1").toUrl(); - const comment2 = Literal.from("Comment 2").toUrl(); - const invalid2 = Literal.from("Invalid 2").toUrl(); - - // Create articles - const article1 = new Article(perspective!, article1Root); - article1.title = "Article 1"; - await article1.save(); - - const article2 = new Article(perspective!, article2Root); - article2.title = "Article 2"; - await article2.save(); - - // Create valid comments - const c1 = new Comment(perspective!, comment1); - c1.text = "Comment 1 text"; - await c1.save(); - - const c2 = new Comment(perspective!, comment2); - c2.text = "Comment 2 text"; - await c2.save(); - - // Add comments to articles (mix of valid and invalid) - await perspective!.add(new Link({ - source: article1Root, - predicate: "article://has_comment", - target: comment1 - })); - await perspective!.add(new Link({ - source: article1Root, - predicate: "article://has_comment", - target: invalid1 - })); - await perspective!.add(new Link({ - source: article2Root, - predicate: "article://has_comment", - target: comment2 - })); - await perspective!.add(new Link({ - source: article2Root, - predicate: "article://has_comment", - target: invalid2 - })); - - // Use findAll and verify filtering - const articles = await Article.findAll(perspective!); - - expect(articles).to.have.lengthOf(2); - - const foundArticle1 = articles.find(a => a.title === "Article 1"); - const foundArticle2 = articles.find(a => a.title === "Article 2"); - - expect(foundArticle1).to.not.be.undefined; - expect(foundArticle2).to.not.be.undefined; - - // Each article should only have valid comments - expect(foundArticle1!.comments).to.have.lengthOf(1); - expect(foundArticle1!.comments[0]).to.equal(comment1); - - expect(foundArticle2!.comments).to.have.lengthOf(1); - expect(foundArticle2!.comments[0]).to.equal(comment2); - }); - }) - }) - }) - - describe("Smart Literal", () => { - let perspective: PerspectiveProxy | null = null - - before(async () => { - perspective = await ad4m!.perspective.add("smart literal test") - // for test debugging: - //console.log("UUID: " + perspective.uuid) - }) - - it("can create and use a new smart literal", async () => { - let sl = await SmartLiteral.create(perspective!, "Hello World") - let base = sl.base - - expect(await sl.get()).to.equal("Hello World") - - let links = await perspective!.get(new LinkQuery({predicate: SMART_LITERAL_CONTENT_PREDICATE})) - expect(links.length).to.equal(1) - expect(links[0].data.source).to.equal(base) - let literal = Literal.fromUrl(links[0].data.target) - expect(literal.get()).to.equal("Hello World") - - await sl.set(5) - expect(await sl.get()).to.equal(5) - - links = await perspective!.get(new LinkQuery({predicate: SMART_LITERAL_CONTENT_PREDICATE})) - expect(links.length).to.equal(1) - expect(links[0].data.source).to.equal(base) - literal = Literal.fromUrl(links[0].data.target) - expect(literal.get()).to.equal(5) - }) - - - it("can instantiate smart literal from perspective", async () => { - let source = Literal.from("base").toUrl() - let target = Literal.from("Hello World 2").toUrl() - await perspective!.add({source, predicate: SMART_LITERAL_CONTENT_PREDICATE, target}) - - let sl = new SmartLiteral(perspective!, source) - expect(await sl.get()).to.equal("Hello World 2") - }) - - it("can get all smart literals in a perspective",async () => { - let all = await SmartLiteral.getAllSmartLiterals(perspective!) - expect(all.length).to.equal(2) - expect(all[1].base).to.equal(Literal.from("base").toUrl()) - expect(await all[0].get()).to.equal(5) - expect(await all[1].get()).to.equal("Hello World 2") - }) - - }) - - // SKIPPED: Embedding cache tests - only applies to Prolog-pooled mode - // These tests verify embedding URL post-processing with Prolog infer() queries. - // With SHACL migration, embedding queries should use SurrealDB vector search instead. - // Keeping as reference for future SurrealDB vector embedding implementation. - describe.skip('Embedding cache', () => { - let perspective: PerspectiveProxy | null = null; - const EMBEDDING_LANG = "QmzSYwdbqjGGbYbWJvdKA4WnuFwmMx3AsTfgg7EwbeNUGyE555c"; - - before(async () => { - perspective = await ad4m!.perspective.add("embedding-cache-test"); - }); - - it('correctly post-processes nested query results containing embedding URLs', async () => { - // Create some links with embedding URLs - const embeddingUrl1 = `${EMBEDDING_LANG}://vector1/1.2,3.4,5.6`; - const embeddingUrl2 = `${EMBEDDING_LANG}://vector2/7.8,9.0,1.2`; - const embeddingUrl3 = `${EMBEDDING_LANG}://vector3/2.3,4.5,6.7`; - - // Create a link structure that will produce nested results - await perspective!.add({ - source: "test://root", - predicate: "test://has-vector", - target: embeddingUrl1 - }); - - await perspective!.add({ - source: embeddingUrl1, - predicate: "test://related-to", - target: embeddingUrl2 - }); - - await perspective!.add({ - source: embeddingUrl2, - predicate: "test://points-to", - target: embeddingUrl3 - }); - - // Query that will produce nested results with embedding URLs at different levels - const result = await perspective!.infer(` - % Find all vectors connected to root - findall( - [FirstVector, RelatedVectors], - ( - % Get first vector from root - triple("test://root", "test://has-vector", FirstVector), - % Find all vectors related to the first one - findall( - [SecondVector, ThirdVector], - ( - triple(FirstVector, "test://related-to", SecondVector), - triple(SecondVector, "test://points-to", ThirdVector) - ), - RelatedVectors - ) - ), - Results - ). - `); - - // The query should return a deeply nested structure: - // Results = [ - // [embeddingUrl1, [ - // [embeddingUrl2, embeddingUrl3] - // ]] - // ] - console.log("result", result) - expect(result).to.be.an('array') - expect(result.length).to.be.greaterThan(0) - - let binding = result[0] - expect(binding.Results).to.be.an('array'); - expect(binding.Results).to.have.lengthOf(1); - - const [firstLevel] = binding.Results; - expect(firstLevel).to.be.an('array'); - expect(firstLevel[0]).to.equal(embeddingUrl1); - expect(firstLevel[1]).to.be.an('array'); - - const relatedVectors = firstLevel[1]; - expect(relatedVectors).to.have.lengthOf(1); - expect(relatedVectors[0]).to.be.an('array'); - expect(relatedVectors[0][0]).to.equal(embeddingUrl2); - expect(relatedVectors[0][1]).to.equal(embeddingUrl3); - }); - }); - - describe("Ad4mModel.fromJSONSchema", () => { - let perspective: PerspectiveProxy | null = null - - beforeEach(async () => { - perspective = await ad4m!.perspective.add("json-schema-test") - }) - - describe("with explicit configuration", () => { - it("should create Ad4mModel class from JSON Schema with explicit namespace", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Person", - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" }, - "email": { "type": "string" } - }, - "required": ["name"] - } - - const PersonClass = Ad4mModel.fromJSONSchema(schema, { - name: "Person", - namespace: "person://", - resolveLanguage: "literal" - }) - - expect(PersonClass).to.be.a('function') - // @ts-ignore - className is added dynamically - expect(PersonClass.className).to.equal("Person") - - // Test instance creation - const person = new PersonClass(perspective!) - expect(person).to.be.instanceOf(Ad4mModel) - expect(person.baseExpression).to.be.a('string') - - // Test property assignment - // @ts-ignore - properties are added dynamically from JSON Schema - person.name = "Alice Johnson" - // @ts-ignore - properties are added dynamically from JSON Schema - person.age = 30 - // @ts-ignore - properties are added dynamically from JSON Schema - person.email = "alice.johnson@example.com" - - await perspective!.ensureSDNASubjectClass(PersonClass) - await person.save() - - // Create a second person to test multiple instances - const person2 = new PersonClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - person2.name = "Bob Smith" - // @ts-ignore - properties are added dynamically from JSON Schema - person2.age = 25 - // @ts-ignore - properties are added dynamically from JSON Schema - person2.email = "bob.smith@example.com" - await person2.save() - - // Verify data was saved and can be retrieved - const savedPeople = await PersonClass.findAll(perspective!) - expect(savedPeople).to.have.lengthOf(2) - - // Find Alice - // @ts-ignore - properties are added dynamically from JSON Schema - const alice = savedPeople.find(p => p.name === "Alice Johnson") - expect(alice).to.exist - // @ts-ignore - properties are added dynamically from JSON Schema - expect(alice!.name).to.equal("Alice Johnson") - // @ts-ignore - properties are added dynamically from JSON Schema - expect(alice!.age).to.equal(30) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(alice!.email).to.equal("alice.johnson@example.com") - - // Find Bob - // @ts-ignore - properties are added dynamically from JSON Schema - const bob = savedPeople.find(p => p.name === "Bob Smith") - expect(bob).to.exist - // @ts-ignore - properties are added dynamically from JSON Schema - expect(bob!.age).to.equal(25) - - // Test querying with where clauses - const adults = await PersonClass.findAll(perspective!, { - where: { age: { gt: 28 } } - }) - expect(adults).to.have.lengthOf(1) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(adults[0].name).to.equal("Alice Johnson") - }) - - it("should support property mapping overrides", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Contact", - "type": "object", - "properties": { - "name": { "type": "string" }, - "email": { "type": "string" } - }, - "required": ["name"] - } - - const ContactClass = Ad4mModel.fromJSONSchema(schema, { - name: "Contact", - namespace: "contact://", - propertyMapping: { - "name": "foaf://name", - "email": "foaf://mbox" - }, - resolveLanguage: "literal" - }) - - // @ts-ignore - className is added dynamically - expect(ContactClass.className).to.equal("Contact") - - // Test that custom predicates are used - const contact = new ContactClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - contact.name = "Bob Wilson" - // @ts-ignore - properties are added dynamically from JSON Schema - contact.email = "bob.wilson@company.com" - - await perspective!.ensureSDNASubjectClass(ContactClass) - await contact.save() - - // Create second contact to test multiple instances - const contact2 = new ContactClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - contact2.name = "Carol Davis" - // @ts-ignore - properties are added dynamically from JSON Schema - contact2.email = "carol.davis@company.com" - await contact2.save() - - // Verify data retrieval works with custom predicates - const savedContacts = await ContactClass.findAll(perspective!) - expect(savedContacts).to.have.lengthOf(2) - - // @ts-ignore - properties are added dynamically from JSON Schema - const bob = savedContacts.find(c => c.name === "Bob Wilson") - expect(bob).to.exist - // @ts-ignore - properties are added dynamically from JSON Schema - expect(bob!.email).to.equal("bob.wilson@company.com") - - // Verify the custom predicates were used by checking the generated SDNA - // @ts-ignore - generateSDNA is added dynamically - const sdna = ContactClass.generateSDNA() - expect(sdna.sdna).to.include("foaf://name") - expect(sdna.sdna).to.include("foaf://mbox") - - // Test querying works with custom predicates - const bobQuery = await ContactClass.findAll(perspective!, { - where: { name: "Bob Wilson" } - }) - expect(bobQuery).to.have.lengthOf(1) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(bobQuery[0].email).to.equal("bob.wilson@company.com") - }) - }) - - describe("with JSON Schema x-ad4m metadata", () => { - it("should use x-ad4m metadata when available", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Product", - "type": "object", - "x-ad4m": { - "namespace": "product://", - "className": "Product" - }, - "properties": { - "name": { - "type": "string", - "x-ad4m": { - "through": "product://title", - "resolveLanguage": "literal" - } - }, - "price": { - "type": "number", - "x-ad4m": { - "through": "product://cost" - } - }, - "description": { - "type": "string", - "x-ad4m": { - "resolveLanguage": "literal" - } - } - }, - "required": ["name"] - } - - const ProductClass = Ad4mModel.fromJSONSchema(schema, { - name: "ProductOverride" // This should take precedence - }) - - // @ts-ignore - className is added dynamically - expect(ProductClass.className).to.equal("ProductOverride") - - const product = new ProductClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - product.name = "Gaming Laptop" - // @ts-ignore - properties are added dynamically from JSON Schema - product.price = 1299.99 - // @ts-ignore - properties are added dynamically from JSON Schema - product.description = "A high-performance gaming laptop with RTX graphics" - - await perspective!.ensureSDNASubjectClass(ProductClass) - await product.save() - - // Create a second product with different pricing - const product2 = new ProductClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - product2.name = "Office Laptop" - // @ts-ignore - properties are added dynamically from JSON Schema - product2.price = 799.99 - // @ts-ignore - properties are added dynamically from JSON Schema - product2.description = "A reliable laptop for office work" - await product2.save() - - // Test data retrieval and validation - const savedProducts = await ProductClass.findAll(perspective!) - expect(savedProducts).to.have.lengthOf(2) - - // Verify x-ad4m custom predicates work for data retrieval - // @ts-ignore - properties are added dynamically from JSON Schema - const gamingLaptop = savedProducts.find(p => p.name === "Gaming Laptop") - expect(gamingLaptop).to.exist - // @ts-ignore - properties are added dynamically from JSON Schema - expect(gamingLaptop!.price).to.equal(1299.99) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(gamingLaptop!.description).to.equal("A high-performance gaming laptop with RTX graphics") - - // Test querying with price ranges - const expensiveProducts = await ProductClass.findAll(perspective!, { - where: { price: { gt: 1000 } } - }) - expect(expensiveProducts).to.have.lengthOf(1) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(expensiveProducts[0].name).to.equal("Gaming Laptop") - - // Verify custom predicates from x-ad4m were used - // @ts-ignore - generateSDNA is added dynamically - const sdna = ProductClass.generateSDNA() - expect(sdna.sdna).to.include("product://title") // custom predicate for name - expect(sdna.sdna).to.include("product://cost") // custom predicate for price - expect(sdna.sdna).to.include("product://description") // inferred from namespace + property - }) - }) - - describe("with title-based inference", () => { - it("should infer namespace from schema title when no explicit config", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Book", - "type": "object", - "properties": { - "title": { "type": "string" }, - // Avoid reserved top-level "author" which conflicts with Ad4mModel built-in - "writer": { "type": "string" }, - "isbn": { "type": "string" } - }, - "required": ["title"] - } - - const BookClass = Ad4mModel.fromJSONSchema(schema, { - name: "Book", - resolveLanguage: "literal" - }) - - // @ts-ignore - className is added dynamically - expect(BookClass.className).to.equal("Book") - - const book = new BookClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - book.title = "The Great Gatsby" - // @ts-ignore - properties are added dynamically from JSON Schema - // @ts-ignore - properties are added dynamically from JSON Schema - book.writer = "F. Scott Fitzgerald" - // @ts-ignore - properties are added dynamically from JSON Schema - book.isbn = "978-0-7432-7356-5" - - await perspective!.ensureSDNASubjectClass(BookClass) - await book.save() - - // Add a second book - const book2 = new BookClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - book2.title = "To Kill a Mockingbird" - // @ts-ignore - properties are added dynamically from JSON Schema - // @ts-ignore - properties are added dynamically from JSON Schema - book2.writer = "Harper Lee" - // @ts-ignore - properties are added dynamically from JSON Schema - book2.isbn = "978-0-06-112008-4" - await book2.save() - - // Test data retrieval with inferred predicates - const savedBooks = await BookClass.findAll(perspective!) - expect(savedBooks).to.have.lengthOf(2) - - // @ts-ignore - properties are added dynamically from JSON Schema - // @ts-ignore - properties are added dynamically from JSON Schema - const gatsby = savedBooks.find(b => b.title === "The Great Gatsby") - expect(gatsby).to.exist - // @ts-ignore - properties are added dynamically from JSON Schema - // @ts-ignore - properties are added dynamically from JSON Schema - expect(gatsby!.writer).to.equal("F. Scott Fitzgerald") - // @ts-ignore - properties are added dynamically from JSON Schema - expect(gatsby!.isbn).to.equal("978-0-7432-7356-5") - - // Test querying by author - const fitzgeraldBooks = await BookClass.findAll(perspective!, { - where: { writer: "F. Scott Fitzgerald" } - }) - expect(fitzgeraldBooks).to.have.lengthOf(1) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(fitzgeraldBooks[0].title).to.equal("The Great Gatsby") - - // Verify inferred predicates (should be book://title, book://author, etc.) - // @ts-ignore - generateSDNA is added dynamically - const sdna = BookClass.generateSDNA() - expect(sdna.sdna).to.include("book://title") - expect(sdna.sdna).to.include("book://writer") - expect(sdna.sdna).to.include("book://isbn") - }) - }) - - describe("error handling", () => { - it("should throw error when no title and no namespace provided", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "value": { "type": "string" } - }, - "required": ["value"] // Add required property to avoid constructor error - } - - expect(() => { - Ad4mModel.fromJSONSchema(schema, { name: "Test" }) - }).to.throw(/Cannot infer namespace/) - }) - - it("should automatically add type flag when no required properties are provided", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "OptionalOnly", - "type": "object", - "properties": { - "optionalValue": { "type": "string" }, - "anotherOptional": { "type": "number" } - } - // No required array - all properties are optional - } - - // Should not throw error - instead adds automatic type flag - const OptionalClass = Ad4mModel.fromJSONSchema(schema, { - name: "OptionalOnly", - namespace: "test://" - }); - - expect(OptionalClass).to.be.a('function') - // @ts-ignore - className is added dynamically - expect(OptionalClass.className).to.equal("OptionalOnly") - - // Should have automatic type flag - const instance = new OptionalClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(instance.__ad4m_type).to.equal("test://instance") - - // Verify SDNA includes the automatic type flag - // @ts-ignore - generateSDNA is added dynamically - const sdna = OptionalClass.generateSDNA() - expect(sdna.sdna).to.include('ad4m://type') - expect(sdna.sdna).to.include('test://instance') - }) - - it("should work when properties have explicit initial values even if not required", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "WithInitials", - "type": "object", - "properties": { - "status": { "type": "string" }, - "count": { "type": "number" } - } - // No required array, but we'll provide initial values - } - - // This should work because we provide initial values - const TestClass = Ad4mModel.fromJSONSchema(schema, { - name: "WithInitials", - namespace: "test://", - propertyOptions: { - "status": { initial: "test://active" }, - "count": { initial: "literal://number:0" } - } - }) - - expect(TestClass).to.be.a('function') - // @ts-ignore - className is added dynamically - expect(TestClass.className).to.equal("WithInitials") - - // Verify SDNA has constructor actions - // @ts-ignore - generateSDNA is added dynamically - const sdna = TestClass.generateSDNA() - expect(sdna.sdna).to.include('constructor(') - expect(sdna.sdna).to.include('test://active') - expect(sdna.sdna).to.include('literal://number:0') - }) - - it("should handle complex property types with full data storage and retrieval", async () => { - const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "BlogPost", - "type": "object", - "properties": { - "title": { "type": "string" }, - "tags": { - "type": "array", - "items": { "type": "string" } - }, - "metadata": { - "type": "object", - "properties": { - "created": { "type": "string" }, - "author": { "type": "string" }, - "views": { "type": "number" } - } - }, - "categories": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["title"] - } - - const BlogPostClass = Ad4mModel.fromJSONSchema(schema, { - name: "BlogPost", - resolveLanguage: "literal" - }) - - // @ts-ignore - className is added dynamically - expect(BlogPostClass.className).to.equal("BlogPost") - - await perspective!.ensureSDNASubjectClass(BlogPostClass) - - // Create a blog post with complex data - const post1 = new BlogPostClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - post1.title = "Getting Started with AD4M" - - // Test array/collection handling - // @ts-ignore - properties are added dynamically from JSON Schema - post1.tags = ["tag://ad4m", "tag://tutorial", "tag://blockchain"] - // @ts-ignore - properties are added dynamically from JSON Schema - post1.categories = ["category://technology", "category://development"] - - // Test complex object handling (should be stored as JSON) - // @ts-ignore - properties are added dynamically from JSON Schema - post1.metadata = { - created: "2025-09-22T10:00:00Z", - author: "Alice", - views: 42 - } - - await post1.save() - - // Create a second post - const post2 = new BlogPostClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - post2.title = "Advanced AD4M Patterns" - // @ts-ignore - properties are added dynamically from JSON Schema - post2.tags = ["tag://ad4m", "tag://advanced", "tag://patterns"] - // @ts-ignore - properties are added dynamically from JSON Schema - post2.categories = ["category://technology"] - // @ts-ignore - properties are added dynamically from JSON Schema - post2.metadata = { - created: "2025-09-22T11:00:00Z", - author: "Bob", - views: 15 - } - await post2.save() - - // Test data retrieval - const savedPosts = await BlogPostClass.findAll(perspective!) - expect(savedPosts).to.have.lengthOf(2) - - // Verify complex object data is preserved - // @ts-ignore - properties are added dynamically from JSON Schema - const tutorialPost = savedPosts.find(p => p.title === "Getting Started with AD4M") - expect(tutorialPost).to.exist - - // @ts-ignore - properties are added dynamically from JSON Schema - expect(tutorialPost!.tags).to.be.an('array') - // @ts-ignore - properties are added dynamically from JSON Schema - expect(tutorialPost!.tags).to.include.members(["tag://ad4m", "tag://tutorial", "tag://blockchain"]) - - // @ts-ignore - properties are added dynamically from JSON Schema - expect(tutorialPost!.metadata).to.be.an('object') - // @ts-ignore - properties are added dynamically from JSON Schema - expect(tutorialPost!.metadata.author).to.equal("Alice") - // @ts-ignore - properties are added dynamically from JSON Schema - expect(tutorialPost!.metadata.views).to.equal(42) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(tutorialPost!.metadata.created).to.equal("2025-09-22T10:00:00Z") - - // Test querying by title - const advancedPosts = await BlogPostClass.findAll(perspective!, { - where: { title: "Advanced AD4M Patterns" } - }) - expect(advancedPosts).to.have.lengthOf(1) - // @ts-ignore - properties are added dynamically from JSON Schema - expect(advancedPosts[0].metadata.author).to.equal("Bob") - - // Verify SDNA structure for complex types - // @ts-ignore - generateSDNA is added dynamically - const sdna = BlogPostClass.generateSDNA() - expect(sdna.sdna).to.include('collection(') // tags and categories should be collections - expect(sdna.sdna).to.include('property(') // title and metadata should be properties - expect(sdna.sdna).to.include('blogpost://title') - expect(sdna.sdna).to.include('blogpost://tags') - expect(sdna.sdna).to.include('blogpost://metadata') - expect(sdna.sdna).to.include('blogpost://categories') - }) - - it("should handle realistic Holon-like schema with nested objects", async () => { - const holonSchema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "PersonHolon", - "type": "object", - "properties": { - "name": { "type": "string" }, - "email": { "type": "string" }, - "profile": { - "type": "object", - "properties": { - "bio": { "type": "string" }, - "location": { "type": "string" } - } - }, - "skills": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["name", "email"] - } - - const PersonHolonClass = Ad4mModel.fromJSONSchema(holonSchema, { - name: "PersonHolon", - namespace: "holon://person/", - resolveLanguage: "literal" - }) - - - await perspective!.ensureSDNASubjectClass(PersonHolonClass) - - // Test with realistic data - const person = new PersonHolonClass(perspective!) - // @ts-ignore - properties are added dynamically from JSON Schema - person.name = "Alice Cooper" - // @ts-ignore - properties are added dynamically from JSON Schema - person.email = "alice@example.com" - // @ts-ignore - properties are added dynamically from JSON Schema - person.skills = ["skill://javascript", "skill://typescript", "skill://ad4m"] - // @ts-ignore - properties are added dynamically from JSON Schema - person.profile = { - bio: "Software developer passionate about decentralized systems", - location: "San Francisco" - } - await person.save() - - // Verify retrieval preserves nested structure - const retrieved = await PersonHolonClass.findAll(perspective!) - expect(retrieved).to.have.lengthOf(1) - - const alice = retrieved[0] - // @ts-ignore - properties are added dynamically from JSON Schema - expect(alice.profile).to.be.an('object') - // @ts-ignore - properties are added dynamically from JSON Schema - expect(alice.profile.bio).to.equal("Software developer passionate about decentralized systems") - // @ts-ignore - properties are added dynamically from JSON Schema - expect(alice.skills).to.include.members(["skill://javascript", "skill://typescript", "skill://ad4m"]) - }) - }) - }) - -}) - -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Wait for a condition to become true with exponential backoff. - * This is more reliable than fixed sleep() for async operations. - */ -async function waitForCondition( - condition: () => boolean, - options: { - timeoutMs?: number, - checkIntervalMs?: number, - errorMessage?: string - } = {} -): Promise { - const { - timeoutMs = 5000, - checkIntervalMs = 50, - errorMessage = 'Condition was not met within timeout' - } = options; - - const startTime = Date.now(); - - while (!condition()) { - if (Date.now() - startTime > timeoutMs) { - throw new Error(`${errorMessage} (timeout: ${timeoutMs}ms)`); - } - await sleep(checkIntervalMs); - } -} \ No newline at end of file diff --git a/tests/js/tests/runtime.ts b/tests/js/tests/runtime.ts deleted file mode 100644 index 04cbcc61b..000000000 --- a/tests/js/tests/runtime.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { TestContext } from './integration.test' -import fs from "fs"; -import { expect } from "chai"; -import { NotificationInput, TriggeredNotification } from '@coasys/ad4m/lib/src/runtime/RuntimeResolver'; -import sinon from 'sinon'; -import { sleep } from '../utils/utils'; -import { ExceptionType, Link } from '@coasys/ad4m'; -// Imports needed for webhook tests: -// (deactivated for now because these imports break the test suite on CI) -// (( local execution works - I leave this here for manualy local testing )) -//import express from 'express'; -//import bodyParser from 'body-parser'; -//import { Server } from 'http'; - -const PERSPECT3VISM_AGENT = "did:key:zQ3shkkuZLvqeFgHdgZgFMUx8VGkgVWsLA83w2oekhZxoCW2n" -const DIFF_SYNC_OFFICIAL = fs.readFileSync("./scripts/perspective-diff-sync-hash").toString(); -const PUBLISHING_AGENT = JSON.parse(fs.readFileSync("./tst-tmp/agents/p/ad4m/agent.json").toString())["did"]; - -export default function runtimeTests(testContext: TestContext) { - return () => { - it('Trusted Agents CRUD', async () => { - const ad4mClient = testContext.ad4mClient! - const { did } = await ad4mClient.agent.status() - - const initalAgents = await ad4mClient.runtime.getTrustedAgents(); - console.warn(initalAgents); - console.warn([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT ]); - expect(initalAgents).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT ].sort()) - - const addAgents = await ad4mClient.runtime.addTrustedAgents(["agentPubKey", "agentPubKey2"]); - expect(addAgents).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, 'agentPubKey', 'agentPubKey2' ].sort()) - - //Add the agents again to be sure we cannot get any duplicates - const addAgentsDuplicate = await ad4mClient.runtime.addTrustedAgents(["agentPubKey", "agentPubKey2"]); - expect(addAgentsDuplicate).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, 'agentPubKey', 'agentPubKey2' ].sort()) - - const getAgents = await ad4mClient.runtime.getTrustedAgents(); - expect(getAgents).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, 'agentPubKey', 'agentPubKey2' ].sort()) - - const deleteAgents1 = await ad4mClient.runtime.deleteTrustedAgents(["agentPubKey2"]) - expect(deleteAgents1).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, "agentPubKey" ].sort()) - - const deleteAgents2 = await ad4mClient.runtime.deleteTrustedAgents(["agentPubKey", "agentPubKey2"]) - expect(deleteAgents2).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT ].sort()) - - const getAgentsPostDelete = await ad4mClient.runtime.getTrustedAgents(); - expect(getAgentsPostDelete).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT ].sort()) - }) - - - it('CRUD for known LinkLanguage templates', async () => { - const ad4mClient = testContext.ad4mClient! - - const addresses = await ad4mClient.runtime.knownLinkLanguageTemplates(); - expect(addresses).to.eql([ DIFF_SYNC_OFFICIAL ]) - - const addAddresses = await ad4mClient.runtime.addKnownLinkLanguageTemplates(["Qm123", "Qmabc"]); - expect(addAddresses).to.eql([ DIFF_SYNC_OFFICIAL, 'Qm123', 'Qmabc' ].sort()) - - //Add the agents again to be sure we cannot get any duplicates - const addDuplicate = await ad4mClient.runtime.addKnownLinkLanguageTemplates(["Qm123", "Qmabc"]); - expect(addDuplicate).to.eql([ DIFF_SYNC_OFFICIAL, 'Qm123', 'Qmabc' ].sort()) - - const get = await ad4mClient.runtime.knownLinkLanguageTemplates(); - expect(get).to.eql([ DIFF_SYNC_OFFICIAL, 'Qm123', 'Qmabc' ].sort()) - - const deleted = await ad4mClient.runtime.removeKnownLinkLanguageTemplates(["Qm123"]) - expect(deleted).to.eql([ DIFF_SYNC_OFFICIAL, "Qmabc" ].sort()) - - const deleted2 = await ad4mClient.runtime.removeKnownLinkLanguageTemplates(["Qm123", "Qmabc"]) - expect(deleted2).to.eql([ DIFF_SYNC_OFFICIAL ]) - - const getPostDelete = await ad4mClient.runtime.knownLinkLanguageTemplates(); - expect(getPostDelete).to.eql([ DIFF_SYNC_OFFICIAL ]) - }) - - it('CRUD for friends', async () => { - const ad4mClient = testContext.ad4mClient! - - const dids = await ad4mClient.runtime.friends(); - expect(dids).to.eql([ ]) - - const added = await ad4mClient.runtime.addFriends(["did:test:1", "did:test:2"]); - expect(added).to.eql(["did:test:1", "did:test:2"]) - - //Add the agents again to be sure we cannot get any duplicates - const addDuplicate = await ad4mClient.runtime.addFriends(["did:test:1", "did:test:2"]); - expect(addDuplicate).to.eql(["did:test:1", "did:test:2"]) - - const get = await ad4mClient.runtime.friends(); - expect(get).to.eql(["did:test:1", "did:test:2"]) - - const deleted = await ad4mClient.runtime.removeFriends(["did:test:1"]) - expect(deleted).to.eql([ "did:test:2" ]) - - const deleted2 = await ad4mClient.runtime.removeFriends(["did:test:1", "did:test:2"]) - expect(deleted2).to.eql([]) - - const getPostDelete = await ad4mClient.runtime.friends(); - expect(getPostDelete).to.eql([ ]) - }) - - it("doesn't mix up stores", async () => { - const ad4mClient = testContext.ad4mClient! - const { did } = await ad4mClient.agent.status() - - await ad4mClient.runtime.addFriends(["did:test:1", "did:test:2"]); - - const addresses = await ad4mClient.runtime.knownLinkLanguageTemplates(); - expect(addresses).to.eql([ DIFF_SYNC_OFFICIAL ]) - - const initalAgents = await ad4mClient.runtime.getTrustedAgents(); - expect(initalAgents).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT ].sort()) - - - const addAddresses = await ad4mClient.runtime.addKnownLinkLanguageTemplates(["Qm123", "Qmabc"]); - expect(addAddresses).to.eql([ DIFF_SYNC_OFFICIAL, 'Qm123', 'Qmabc' ].sort()) - - const addAgents = await ad4mClient.runtime.addTrustedAgents(["agentPubKey", "agentPubKey2"]); - expect(addAgents).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, 'agentPubKey', 'agentPubKey2' ].sort()) - - const dids = await ad4mClient.runtime.friends() - expect(dids).to.eql(["did:test:1", "did:test:2"].sort()) - - - const deleted = await ad4mClient.runtime.removeFriends(["did:test:1", "agentPubKey", "Qm123"]) - expect(deleted).to.eql(["did:test:2" ]) - - const postDeleteAddresses = await ad4mClient.runtime.knownLinkLanguageTemplates() - expect(postDeleteAddresses).to.eql([ DIFF_SYNC_OFFICIAL, 'Qm123', 'Qmabc' ].sort()) - - const postDeleteAgents = await ad4mClient.runtime.getTrustedAgents() - expect(postDeleteAgents).to.eql([ did, PERSPECT3VISM_AGENT, PUBLISHING_AGENT, 'agentPubKey', 'agentPubKey2' ].sort()) - }) - - it("can deal with Holochain's agent_infos", async () => { - const ad4mClient = testContext.ad4mClient! - // @ts-ignore - const agentInfos = await ad4mClient.runtime.hcAgentInfos() - // @ts-ignore - expect(await ad4mClient.runtime.hcAddAgentInfos(agentInfos)).to.be.true; - }) - - it("can get runtimeInfo", async () => { - const ad4mClient = testContext.ad4mClient! - const runtimeInfo = await ad4mClient.runtime.info(); - expect(runtimeInfo.ad4mExecutorVersion).to.be.equal(process.env.npm_package_version); - expect(runtimeInfo.isUnlocked).to.be.true; - expect(runtimeInfo.isInitialized).to.be.true; - }) - - it("can handle notifications", async () => { - const ad4mClient = testContext.ad4mClient! - - const notification: NotificationInput = { - description: "Test Description", - appName: "Test App Name", - appUrl: "Test App URL", - appIconPath: "Test App Icon Path", - trigger: "SELECT * FROM link WHERE predicate = 'test://never-matches'", - perspectiveIds: ["Test Perspective ID"], - webhookUrl: "Test Webhook URL", - webhookAuth: "Test Webhook Auth" - } - - const mockFunction = sinon.stub(); - - let ignoreRequest = false - - // Setup the stub to automatically resolve when called - mockFunction.callsFake((exception) => { - if(ignoreRequest) return - - if (exception.type === ExceptionType.InstallNotificationRequest) { - const requestedNotification = JSON.parse(exception.addon); - - // Only check assertions for THIS test's notification - if (requestedNotification.description === notification.description) { - expect(requestedNotification.appName).to.equal(notification.appName); - expect(requestedNotification.appUrl).to.equal(notification.appUrl); - expect(requestedNotification.appIconPath).to.equal(notification.appIconPath); - expect(requestedNotification.trigger).to.equal(notification.trigger); - expect(requestedNotification.perspectiveIds).to.eql(notification.perspectiveIds); - expect(requestedNotification.webhookUrl).to.equal(notification.webhookUrl); - expect(requestedNotification.webhookAuth).to.equal(notification.webhookAuth); - } - // Automatically resolve without needing to manually manage a Promise - return null; - } - }); - - await ad4mClient.runtime.addExceptionCallback(mockFunction); - - // Request to install a new notification - const notificationId = await ad4mClient.runtime.requestInstallNotification(notification); - - await sleep(2000) - - // Use sinon's assertions to wait for the stub to be called - await sinon.assert.calledOnce(mockFunction); - ignoreRequest = true; - - // Check if the notification is in the list of notifications - const notificationsBeforeGrant = await ad4mClient.runtime.notifications() - expect(notificationsBeforeGrant.length).to.equal(1) - const notificationInList = notificationsBeforeGrant[0] - expect(notificationInList).to.exist - expect(notificationInList?.granted).to.be.false - - // Grant the notification - const granted = await ad4mClient.runtime.grantNotification(notificationId) - expect(granted).to.be.true - - // Check if the notification is updated - const updatedNotification: NotificationInput = { - description: "Update Test Description", - appName: "Test App Name", - appUrl: "Test App URL", - appIconPath: "Test App Icon Path", - trigger: "SELECT * FROM link WHERE predicate = 'test://updated'", - perspectiveIds: ["Test Perspective ID"], - webhookUrl: "Test Webhook URL", - webhookAuth: "Test Webhook Auth" - } - const updated = await ad4mClient.runtime.updateNotification(notificationId, updatedNotification) - expect(updated).to.be.true - - const updatedNotificationCheck = await ad4mClient.runtime.notifications() - const updatedNotificationInList = updatedNotificationCheck.find((n) => n.id === notificationId) - expect(updatedNotificationInList).to.exist - // after changing a notification it needs to be granted again - expect(updatedNotificationInList?.granted).to.be.false - expect(updatedNotificationInList?.description).to.equal(updatedNotification.description) - - // Check if the notification is removed - const removed = await ad4mClient.runtime.removeNotification(notificationId) - expect(removed).to.be.true - }) - - it("can trigger notifications", async () => { - const ad4mClient = testContext.ad4mClient! - - let triggerPredicate = "ad4m://notification" - - let notificationPerspective = await ad4mClient.perspective.add("notification test perspective") - let otherPerspective = await ad4mClient.perspective.add("other perspective") - - const notification: NotificationInput = { - description: "ad4m://notification predicate used", - appName: "ADAM tests", - appUrl: "Test App URL", - appIconPath: "Test App Icon Path", - trigger: `SELECT source, target, predicate FROM link WHERE predicate = '${triggerPredicate}'`, - perspectiveIds: [notificationPerspective.uuid], - webhookUrl: "Test Webhook URL", - webhookAuth: "Test Webhook Auth" - } - - // Request to install a new notification - const notificationId = await ad4mClient.runtime.requestInstallNotification(notification); - sleep(1000) - // Grant the notification - const granted = await ad4mClient.runtime.grantNotification(notificationId) - expect(granted).to.be.true - - const mockFunction = sinon.stub(); - await ad4mClient.runtime.addNotificationTriggeredCallback(mockFunction) - - // Ensuring no false positives - await notificationPerspective.add(new Link({source: "control://source", target: "control://target"})) - await sleep(1000) - expect(mockFunction.called).to.be.false - - // Ensuring only selected perspectives will trigger - await otherPerspective.add(new Link({source: "control://source", predicate: triggerPredicate, target: "control://target"})) - await sleep(1000) - expect(mockFunction.called).to.be.false - - // Happy path - await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target1"})) - await sleep(7000) - expect(mockFunction.called).to.be.true - let triggeredNotification = mockFunction.getCall(0).args[0] as TriggeredNotification - expect(triggeredNotification.notification.description).to.equal(notification.description) - let triggerMatch = JSON.parse(triggeredNotification.triggerMatch) - expect(triggerMatch.length).to.equal(1) - let match = triggerMatch[0] - //@ts-ignore - expect(match.source).to.equal("test://source") - //@ts-ignore - expect(match.target).to.equal("test://target1") - - // Ensuring we don't get old data on a new trigger - await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target2"})) - await sleep(7000) - expect(mockFunction.callCount).to.equal(2) - triggeredNotification = mockFunction.getCall(1).args[0] as TriggeredNotification - triggerMatch = JSON.parse(triggeredNotification.triggerMatch) - expect(triggerMatch.length).to.equal(1) - match = triggerMatch[0] - //@ts-ignore - expect(match.source).to.equal("test://source") - //@ts-ignore - expect(match.target).to.equal("test://target2") - }) - - it("can detect mentions in notifications (Flux example)", async () => { - const ad4mClient = testContext.ad4mClient! - const agentStatus = await ad4mClient.agent.status() - const agentDid = agentStatus.did - - let notificationPerspective = await ad4mClient.perspective.add("flux mention test") - - const notification: NotificationInput = { - description: "You were mentioned in a message", - appName: "Flux Mentions", - appUrl: "https://flux.app", - appIconPath: "/flux-icon.png", - // Extract multiple data points from the match - trigger: `SELECT - source as message_id, - fn::parse_literal(target) as message_content, - fn::strip_html(fn::parse_literal(target)) as plain_text, - $agentDid as mentioned_agent, - $perspectiveId as perspective_id - FROM link - WHERE predicate = 'rdf://content' - AND fn::contains(fn::parse_literal(target), $agentDid)`, - perspectiveIds: [notificationPerspective.uuid], - webhookUrl: "https://test.webhook", - webhookAuth: "test-auth" - } - - const notificationId = await ad4mClient.runtime.requestInstallNotification(notification); - await sleep(1000) - const granted = await ad4mClient.runtime.grantNotification(notificationId) - expect(granted).to.be.true - - const mockFunction = sinon.stub(); - await ad4mClient.runtime.addNotificationTriggeredCallback(mockFunction) - - // Add a message that doesn't mention the agent - await notificationPerspective.add(new Link({ - source: "message://1", - predicate: "rdf://content", - target: "literal://string:Hello%20world" - })) - await sleep(2000) - expect(mockFunction.called).to.be.false - - // Add a message that mentions the agent (with HTML formatting) - const messageWithMention = `

Hey ${agentDid!}, how are you?

` - await notificationPerspective.add(new Link({ - source: "message://2", - predicate: "rdf://content", - target: `literal://string:${encodeURIComponent(messageWithMention)}` - })) - await sleep(7000) - expect(mockFunction.called).to.be.true - - let triggeredNotification = mockFunction.getCall(0).args[0] as TriggeredNotification - expect(triggeredNotification.notification.description).to.equal(notification.description) - let triggerMatch = JSON.parse(triggeredNotification.triggerMatch) - expect(triggerMatch.length).to.equal(1) - - // Verify all extracted data points - //@ts-ignore - expect(triggerMatch[0].message_id).to.equal("message://2") - //@ts-ignore - expect(triggerMatch[0].message_content).to.include(agentDid) - //@ts-ignore - expect(triggerMatch[0].message_content).to.include("") - //@ts-ignore - expect(triggerMatch[0].plain_text).to.include(agentDid) - //@ts-ignore - expect(triggerMatch[0].plain_text).to.not.include("") - //@ts-ignore - expect(triggerMatch[0].mentioned_agent).to.equal(agentDid) - //@ts-ignore - expect(triggerMatch[0].perspective_id).to.equal(notificationPerspective.uuid) - }) - - it("can export and import database", async () => { - const ad4mClient = testContext.ad4mClient! - const exportPath = "./tst-tmp/db_export.json" - const importPath = "./tst-tmp/db_import.json" - - // Add some test data - await ad4mClient.runtime.addTrustedAgents(["test-agent-1", "test-agent-2"]) - await ad4mClient.runtime.addFriends(["test-friend-1", "test-friend-2"]) - - // Export the database - const exported = await ad4mClient.runtime.exportDb(exportPath) - expect(exported).to.be.true - - // Verify export file exists - expect(fs.existsSync(exportPath)).to.be.true - - // Clear some data - await ad4mClient.runtime.removeFriends(["test-friend-1", "test-friend-2"]) - await ad4mClient.runtime.deleteTrustedAgents(["test-agent-1", "test-agent-2"]) - - // Import the database - const imported = await ad4mClient.runtime.importDb(exportPath) - expect(imported).to.have.property('perspectives') - expect(imported).to.have.property('links') - expect(imported).to.have.property('expressions') - expect(imported).to.have.property('perspectiveDiffs') - expect(imported).to.have.property('notifications') - expect(imported).to.have.property('models') - expect(imported).to.have.property('defaultModels') - expect(imported).to.have.property('tasks') - expect(imported).to.have.property('friends') - expect(imported).to.have.property('trustedAgents') - expect(imported).to.have.property('knownLinkLanguages') - - // Each property should have the ImportStats structure - const checkImportStats = (stats: any) => { - expect(stats).to.have.property('total') - expect(stats).to.have.property('imported') - expect(stats).to.have.property('failed') - expect(stats).to.have.property('omitted') - expect(stats).to.have.property('errors') - expect(stats.errors).to.be.an('array') - } - - Object.values(imported).forEach(checkImportStats) - - // Verify data was restored - const trustedAgents = await ad4mClient.runtime.getTrustedAgents() - expect(trustedAgents).to.include.members(["test-agent-1", "test-agent-2"]) - - const friends = await ad4mClient.runtime.friends() - expect(friends).to.include.members(["test-friend-1", "test-friend-2"]) - - // Clean up test files - fs.unlinkSync(exportPath) - }) - - - // See comments on the imports at the top - // breaks CI for some reason but works locally - // leaving this here for manual local testing - /* - it("should trigger a notification and call the webhook", async () => { - const ad4mClient = testContext.ad4mClient! - const webhookUrl = 'http://localhost:8080/webhook'; - const webhookAuth = 'Test Webhook Auth' - // Setup Express server - const app = express(); - app.use(bodyParser.json()); - - let webhookCalled = false; - let webhookGotAuth = "" - let webhookGotBody = null - - app.post('/webhook', (req, res) => { - webhookCalled = true; - webhookGotAuth = req.headers['authorization']?.substring("Bearer ".length)||""; - webhookGotBody = req.body; - res.status(200).send({ success: true }); - }); - - let server: Server|void - let serverRunning = new Promise((done) => { - server = app.listen(8080, () => { - console.log('Test server running on port 8080'); - done() - }); - }) - - await serverRunning - - - let triggerPredicate = "ad4m://notification_webhook" - let notificationPerspective = await ad4mClient.perspective.add("notification test perspective") - let otherPerspective = await ad4mClient.perspective.add("other perspective") - - const notification: NotificationInput = { - description: "ad4m://notification predicate used", - appName: "ADAM tests", - appUrl: "Test App URL", - appIconPath: "Test App Icon Path", - trigger: `triple(Source, "${triggerPredicate}", Target)`, - perspectiveIds: [notificationPerspective.uuid], - webhookUrl: webhookUrl, - webhookAuth: webhookAuth - } - - // Request to install a new notification - const notificationId = await ad4mClient.runtime.requestInstallNotification(notification); - sleep(1000) - // Grant the notification - const granted = await ad4mClient.runtime.grantNotification(notificationId) - expect(granted).to.be.true - - // Ensuring no false positives - await notificationPerspective.add(new Link({source: "control://source", target: "control://target"})) - await sleep(1000) - expect(webhookCalled).to.be.false - - // Ensuring only selected perspectives will trigger - await otherPerspective.add(new Link({source: "control://source", predicate: triggerPredicate, target: "control://target"})) - await sleep(1000) - expect(webhookCalled).to.be.false - - // Happy path - await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target1"})) - await sleep(1000) - expect(webhookCalled).to.be.true - expect(webhookGotAuth).to.equal(webhookAuth) - expect(webhookGotBody).to.be.not.be.null - let triggeredNotification = webhookGotBody as unknown as TriggeredNotification - let triggerMatch = JSON.parse(triggeredNotification.triggerMatch) - expect(triggerMatch.length).to.equal(1) - let match = triggerMatch[0] - //@ts-ignore - expect(match.Source).to.equal("test://source") - //@ts-ignore - expect(match.Target).to.equal("test://target1") - - // Reset webhookCalled for the next test - webhookCalled = false; - webhookGotAuth = "" - webhookGotBody = null - - await notificationPerspective.add(new Link({source: "test://source", predicate: triggerPredicate, target: "test://target2"})) - await sleep(1000) - expect(webhookCalled).to.be.true - expect(webhookGotAuth).to.equal(webhookAuth) - triggeredNotification = webhookGotBody as unknown as TriggeredNotification - triggerMatch = JSON.parse(triggeredNotification.triggerMatch) - expect(triggerMatch.length).to.equal(1) - match = triggerMatch[0] - //@ts-ignore - expect(match.Source).to.equal("test://source") - //@ts-ignore - expect(match.Target).to.equal("test://target2") - - // Close the server after the test - //@ts-ignore - server!.close() - }) - */ - } -} diff --git a/tests/js/tests/sdna/sdna-core.test.ts b/tests/js/tests/sdna/sdna-core.test.ts new file mode 100644 index 000000000..2c630143d --- /dev/null +++ b/tests/js/tests/sdna/sdna-core.test.ts @@ -0,0 +1,374 @@ +import { expect } from "chai"; +import { + Ad4mClient, + Link, + LinkQuery, + Literal, + PerspectiveProxy, + Ad4mModel, + Flag, + Property, + HasMany, + Model, +} from "@coasys/ad4m"; +import { startAgent } from "../../helpers/executor"; +import type { AgentHandle } from "../../helpers/executor"; + +describe("SDNA", () => { + let ad4m: Ad4mClient | null = null; + let agent: AgentHandle | null = null; + + before(async () => { + agent = await startAgent("prolog-agent", { passphrase: "secret" }); + ad4m = agent.client; + }); + + after(async () => { + await agent?.stop(); + }); + + it("should get agent status", async () => { + let result = await ad4m!.agent.status(); + expect(result).to.not.be.null; + expect(result!.isInitialized).to.be.true; + }); + + describe("Subjects (SHACL-based API)", () => { + let perspective: PerspectiveProxy | null = null; + + before(async () => { + perspective = await ad4m!.perspective.add("test"); + // for test debugging: + //console.log("UUID: " + perspective.uuid) + }); + + describe("SDNA creation decorators", () => { + @Model({ + name: "Message", + }) + class Message extends Ad4mModel { + @Flag({ + through: "ad4m://type", + value: "ad4m://message", + }) + type: string = ""; + + static async all(perspective: PerspectiveProxy): Promise { + return Message.findAll(perspective); + } + + @Property({ + through: "todo://state", + }) + body?: string; + } + + // This class matches the SDNA in ./sdna/subject.pl + // and this test proves the decorators create the exact same SDNA code + @Model({ + name: "Todo", + }) + class Todo extends Ad4mModel { + // Setting this member "subjectConstructer" allows for adding custom + // actions that will be run when a subject is constructed. + // + // In this test, we don't need to use it, because the used "initial" + // parameter on "state" below will have the same effect as the following: + // subjectConstructor = [addLink("this", "todo://state", "todo://ready")] + + // Setting this member "isSubjectInstance" allows for adding custom clauses + // to the instance check. + // + // In this test, we don't need to use it, because the used "required" + // parameter on "state" below will have the same effect as the following: + // isSubjectInstance = [hasLink("todo://state")] + + static async all(perspective: PerspectiveProxy): Promise { + return Todo.findAll(perspective); + } + + static async allReady(perspective: PerspectiveProxy): Promise { + return Todo.findAll(perspective, { + where: { state: "todo://ready" }, + }); + } + + static async allDone(perspective: PerspectiveProxy): Promise { + return Todo.findAll(perspective, { where: { state: "todo://done" } }); + } + + //@ts-ignore + @Property({ + through: "todo://state", + initial: "todo://ready", + }) + state!: string; + + @Property({ + through: "todo://has_title", + + }) + title?: string; + + // Plain untyped relation – simplest HasMany case + @HasMany({ through: "todo://comment" }) + comments: string[] = []; + } + + before(async () => { + // Register SHACL SDNA once for all tests in this block + await perspective!.ensureSDNASubjectClass(Todo); + }); + + it("should find the TODO subject class from the test SDNA", async () => { + let classes = await perspective!.subjectClasses(); + + expect(classes.length).to.equal(1); + expect(classes[0]).to.equal("Todo"); + }); + + it("should generate correct SHACL shape from a JS class", async () => { + // generateSDNA() was the legacy Prolog-based method; the modern API is + // generateSHACL() which returns a W3C SHACL shape with AD4M action + // definitions instead of raw Prolog code. + const { name, shape } = Todo.generateSHACL(); + + expect(name).to.equal("Todo"); + + // --- Constructor action --- + // The `state` property has `initial: "todo://ready"`, so a constructor + // action that writes that link should be present. + expect(shape.constructor_actions) + .to.be.an("array") + .with.length.greaterThan(0); + const constructorAction = shape.constructor_actions!.find( + (a: any) => a.predicate === "todo://state", + ); + expect(constructorAction, "constructor action for todo://state").to + .exist; + expect(constructorAction!.target).to.equal("todo://ready"); + + // --- Properties --- + const stateProp = shape.properties.find( + (p: any) => p.path === "todo://state", + ); + expect(stateProp, "state property").to.exist; + + const titleProp = shape.properties.find( + (p: any) => p.path === "todo://has_title", + ); + expect(titleProp, "title property").to.exist; + expect(titleProp!.resolveLanguage).to.equal("literal"); // auto-injected into SHACL shape — users don't write it in @Property + + // --- Relations (formerly collections – HasMany generates adder/remover, no maxCount:1) --- + const commentsColl = shape.properties.find( + (p: any) => p.path === "todo://comment", + ); + expect(commentsColl, "comments relation").to.exist; + // A HasMany relation shape has adder/remover actions and no maxCount constraint + expect(commentsColl!.adder, "comments relation adder").to.be.an( + "array", + ); + expect(commentsColl!.remover, "comments relation remover").to.be.an( + "array", + ); + expect(commentsColl!.maxCount).to.be.undefined; + }); + + it("should be possible to use that class for type-safe interaction with subject instances", async () => { + // Create additional todos for the following tests + // Todo 1: stays at initial "ready" state + let root1 = Literal.from("Ready todo").toUrl(); + let todo1 = new Todo(perspective!, root1); + await todo1.save(); + + // Todo 2 & 3: set to "done" state + let root2 = Literal.from("Done todo 1").toUrl(); + let todo2 = new Todo(perspective!, root2); + await todo2.save(); + todo2.state = "todo://done"; + await todo2.save(); + + let root3 = Literal.from("Done todo 2").toUrl(); + let todo3 = new Todo(perspective!, root3); + await todo3.save(); + todo3.state = "todo://done"; + await todo3.save(); + + // construct new subject intance using Ad4mModel API + let root = Literal.from("Decorated class construction test").toUrl(); + + let todo = new Todo(perspective!, root); + await todo.save(); + + // Verify the instance was created with required links + const stateLinks = await perspective!.get( + new LinkQuery({ source: root, predicate: "todo://state" }), + ); + expect(stateLinks.length).to.equal(1); + expect(stateLinks[0].data.target).to.equal("todo://ready"); + + // Check name mapping + const nameMappingUrl = Literal.fromUrl( + `literal://string:shacl://Todo`, + ).toUrl(); + const nameMappingLinks = await perspective!.get( + new LinkQuery({ source: nameMappingUrl }), + ); + nameMappingLinks.forEach((link) => + console.log(" ", link.data.predicate, "->", link.data.target), + ); + + const isInstance = await perspective!.isSubjectInstance(root, Todo); + expect(isInstance).to.not.be.false; + + // Ad4mModel API - use the todo instance directly (no need for getSubjectProxy) + expect(todo).to.have.property("state"); + expect(todo).to.have.property("title"); + expect(todo).to.have.property("comments"); + + todo.state = "todo://review"; + await todo.save(); + const stateAfter = todo.state; + + expect(stateAfter).to.equal("todo://review"); + expect(todo.comments).to.be.empty; + + let comment = Literal.from("new comment").toUrl(); + todo.comments = [comment]; + await todo.save(); + expect(todo.comments).to.deep.equal([comment]); + }); + + it("can retrieve all instances through instaceQuery decoratored all()", async () => { + let todos = await Todo.all(perspective!); + expect(todos.length).to.equal(4); + }); + + it("can retrieve all mathching instance through InstanceQuery(where: ..)", async () => { + let todos = await Todo.allReady(perspective!); + expect(todos.length).to.equal(1); + expect(todos[0].state).to.equal("todo://ready"); + + todos = await Todo.allDone(perspective!); + expect(todos.length).to.equal(2); + expect(todos[0].state).to.equal("todo://done"); + }); + + it("can deal with properties that resolve the URI and create Expressions", async () => { + let todos = await Todo.all(perspective!); + + // Guard: If no todos exist, create one for this test + if (todos.length === 0) { + throw new Error( + "Test prerequisite failed: No todos available. Please ensure todos are created in the setup or earlier tests.", + ); + } + + // Find a todo without a title (to avoid data contamination from other tests) + let todo = null; + for (const t of todos) { + const title = t.title; + if (title === undefined || title === null || title === "") { + todo = t; + break; + } + } + + if (!todo) { + // If all todos have titles, use the first one and clear its title + // Safe to access todos[0] since we've checked todos.length > 0 above + todo = todos[0]; + const existingLinks = await perspective!.get( + new LinkQuery({ + source: todo.id, + predicate: "todo://has_title", + }), + ); + for (const link of existingLinks) { + await perspective!.remove(link); + } + } + + expect(todo.title).to.be.undefined; + + // Use direct assignment + update() pattern (setters are stubs) + todo.title = "new title"; + await todo.save(); + expect(todo.title).to.equal("new title"); + + let links = await perspective!.get( + new LinkQuery({ + source: todo.id, + predicate: "todo://has_title", + }), + ); + expect(links.length).to.equal(1); + let literalValue = Literal.fromUrl(links[0].data.target).get(); + expect(literalValue).to.equal("new title"); + }); + + it("can easily be initialized with PerspectiveProxy.ensureSDNASubjectClass()", async () => { + expect(await perspective!.getSdna()).to.have.lengthOf(1); + + @Model({ + name: "Test", + }) + class Test { + @Property({ + through: "test://test_numer", + }) + number: number = 0; + } + + await perspective!.ensureSDNASubjectClass(Test); + + expect(await perspective!.getSdna()).to.have.lengthOf(2); + //console.log((await perspective!.getSdna())[1]) + }); + + describe("with Message subject class registered", () => { + before(async () => { + await perspective!.ensureSDNASubjectClass(Message); + }); + + afterEach(async () => { + // Clean up any Message flags created during tests to prevent data contamination + const links = await perspective!.get( + new LinkQuery({ + predicate: "ad4m://type", + target: "ad4m://message", + }), + ); + for (const link of links) { + await perspective!.remove(link); + } + }); + + it("can find instances through the exact flag link", async () => { + await perspective!.add( + new Link({ + source: "test://message", + predicate: "ad4m://type", + target: "ad4m://undefined", + }), + ); + + const first = await Message.all(perspective!); + expect(first.length).to.be.equal(0); + + await perspective!.add( + new Link({ + source: "test://message", + predicate: "ad4m://type", + target: "ad4m://message", + }), + ); + + const second = await Message.all(perspective!); + expect(second.length).to.be.equal(1); + }); + }); + }); + }); +}); diff --git a/tests/js/tests/sdna/sdna-smart-literal.test.ts b/tests/js/tests/sdna/sdna-smart-literal.test.ts new file mode 100644 index 000000000..eaf3e2093 --- /dev/null +++ b/tests/js/tests/sdna/sdna-smart-literal.test.ts @@ -0,0 +1,92 @@ +/** + * SmartLiteral — integration tests + * + * Tests for the SmartLiteral utility class: creation, instantiation, + * and enumeration of smart literals inside a perspective. + * + * Run with: + * pnpm ts-mocha -p tsconfig.json --timeout 1200000 --serial --exit tests/smart-literal.test.ts + */ + +import { expect } from "chai"; +import { + Ad4mClient, + LinkQuery, + Literal, + PerspectiveProxy, + SmartLiteral, + SMART_LITERAL_CONTENT_PREDICATE, +} from "@coasys/ad4m"; +import { startAgent } from "../../helpers/index.js"; +import type { AgentHandle } from "../../helpers/executor.js"; + +describe("Smart Literal", () => { + let agent: AgentHandle; + let ad4m: Ad4mClient; + + before(async () => { + agent = await startAgent("smart-literal"); + ad4m = agent.client; + }); + + after(async () => { + await agent.stop(); + }); + + describe("SmartLiteral operations", () => { + let perspective: PerspectiveProxy | null = null; + + before(async () => { + perspective = await ad4m.perspective.add("smart literal test"); + // for test debugging: + //console.log("UUID: " + perspective.uuid) + }); + + it("can create and use a new smart literal", async () => { + let sl = await SmartLiteral.create(perspective!, "Hello World"); + let base = sl.base; + + expect(await sl.get()).to.equal("Hello World"); + + let links = await perspective!.get( + new LinkQuery({ predicate: SMART_LITERAL_CONTENT_PREDICATE }), + ); + expect(links.length).to.equal(1); + expect(links[0].data.source).to.equal(base); + let literal = Literal.fromUrl(links[0].data.target); + expect(literal.get()).to.equal("Hello World"); + + await sl.set(5); + expect(await sl.get()).to.equal(5); + + links = await perspective!.get( + new LinkQuery({ predicate: SMART_LITERAL_CONTENT_PREDICATE }), + ); + expect(links.length).to.equal(1); + expect(links[0].data.source).to.equal(base); + literal = Literal.fromUrl(links[0].data.target); + expect(literal.get()).to.equal(5); + }); + + it("can instantiate smart literal from perspective", async () => { + let source = Literal.from("base").toUrl(); + let target = Literal.from("Hello World 2").toUrl(); + await perspective!.add({ + source, + predicate: SMART_LITERAL_CONTENT_PREDICATE, + target, + }); + + let sl = new SmartLiteral(perspective!, source); + expect(await sl.get()).to.equal("Hello World 2"); + }); + + it("can get all smart literals in a perspective", async () => { + let all = await SmartLiteral.getAllSmartLiterals(perspective!); + expect(all.length).to.equal(2); + expect(all[1].base).to.equal(Literal.from("base").toUrl()); + expect(await all[0].get()).to.equal(5); + expect(await all[1].get()).to.equal("Hello World 2"); + }); + }); +}); diff --git a/tests/js/tests/setup.ts b/tests/js/tests/setup.ts new file mode 100644 index 000000000..dbd043a8b --- /dev/null +++ b/tests/js/tests/setup.ts @@ -0,0 +1,37 @@ +/** + * Mocha global setup — loaded via --require in all test scripts. + * + * Polyfills global.fetch with node-fetch so test files don't each need to + * import and assign it individually. + * + * Also installs a process-level unhandledRejection guard for WebSocket close + * code 1006 (abnormal closure). When the executor is killed in after-all hooks, + * all active GraphQL subscriptions close with 1006. graphql-ws fires a + * Promise rejection for each active subscription; if any subscription's + * zen-observable observer has already been cleaned up (a race that only + * manifests on slower CI machines), the rejection escapes our per-subscription + * error handlers. Without this guard, mocha attributes those stray rejections + * to whichever after-all hook happens to be running — failing the run despite + * all tests passing. + */ +import fetch from "node-fetch"; + +// @ts-ignore — node-fetch v3 type is close enough for runtime use +(global as any).fetch = fetch; + +/** + * Swallow unhandled Promise rejections that are simply WebSocket 1006 close + * events. Re-throw everything else so mocha still catches real errors. + */ +function isSocketCloseError(reason: any): boolean { + if (!reason) return false; + if (typeof reason.code === "number" && reason.code === 1006) return true; + const msg = String(reason?.message ?? reason); + return msg.includes("Socket closed with event 1006"); +} + +process.on("unhandledRejection", (reason: any) => { + if (isSocketCloseError(reason)) return; // expected on executor shutdown + // Re-throw so mocha still catches genuine unhandled rejections as failures + throw reason; +}); diff --git a/tests/js/tests/simple.test.ts b/tests/js/tests/simple.test.ts deleted file mode 100644 index af4208a7b..000000000 --- a/tests/js/tests/simple.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect } from "chai"; -import { ChildProcess } from 'node:child_process'; -import { Ad4mClient } from "@coasys/ad4m"; -import { startExecutor, apolloClient, sleep } from "../utils/utils"; -import path from "path"; -import fetch from 'node-fetch' -import { fileURLToPath } from 'url'; - -//@ts-ignore -global.fetch = fetch - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Integration", () => { - const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); - const appDataPath = path.join(TEST_DIR, "agents", "simpleAlice"); - const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); - const gqlPort = 15600 - const hcAdminPort = 15601 - const hcAppPort = 15602 - - let ad4m: Ad4mClient | null = null - let executorProcess: ChildProcess | null = null - - before(async () => { - executorProcess = await startExecutor(appDataPath, bootstrapSeedPath, - gqlPort, hcAdminPort, hcAppPort); - - console.log("Creating ad4m client") - ad4m = new Ad4mClient(apolloClient(gqlPort)) - console.log("Generating agent") - await ad4m.agent.generate("secret") - console.log("Done") - }) - - after(async () => { - if (executorProcess) { - while (!executorProcess?.killed) { - let status = executorProcess?.kill(); - console.log("killed executor with", status); - await sleep(500); - } - } - }) - - it("should get agent status", async () => { - let result = await ad4m!.agent.status() - expect(result).to.not.be.null - expect(result!.isInitialized).to.be.true - }) -}) \ No newline at end of file diff --git a/tests/js/tests/smoke.test.ts b/tests/js/tests/smoke.test.ts new file mode 100644 index 000000000..9bcb3e179 --- /dev/null +++ b/tests/js/tests/smoke.test.ts @@ -0,0 +1,49 @@ +import { expect } from "chai"; +import { ChildProcess } from "node:child_process"; +import { Ad4mClient } from "@coasys/ad4m"; +import { startExecutor, apolloClient, waitForExit } from "../utils/utils"; +import { getFreePorts } from "../helpers/ports"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("Integration", () => { + const TEST_DIR = path.join(`${__dirname}/../tst-tmp`); + const appDataPath = path.join(TEST_DIR, "agents", "simpleAlice"); + const bootstrapSeedPath = path.join(`${__dirname}/../bootstrapSeed.json`); + let gqlPort: number; + let hcAdminPort: number; + let hcAppPort: number; + + let ad4m: Ad4mClient | null = null; + let executorProcess: ChildProcess | null = null; + + before(async () => { + [gqlPort, hcAdminPort, hcAppPort] = await getFreePorts(3); + executorProcess = await startExecutor( + appDataPath, + bootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + ); + + console.log("Creating ad4m client"); + ad4m = new Ad4mClient(apolloClient(gqlPort)); + console.log("Generating agent"); + await ad4m.agent.generate("secret"); + console.log("Done"); + }); + + after(async () => { + await waitForExit(executorProcess); + }); + + it("should get agent status", async () => { + let result = await ad4m!.agent.status(); + expect(result).to.not.be.null; + expect(result!.isInitialized).to.be.true; + }); +}); diff --git a/tests/js/tests/social-dna-flow.ts b/tests/js/tests/social-dna-flow.ts deleted file mode 100644 index 3fc9b5012..000000000 --- a/tests/js/tests/social-dna-flow.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Link, LinkQuery, Literal } from "@coasys/ad4m"; -import { TestContext } from './integration.test' -import { expect } from "chai"; -import { sleep } from "../utils/utils"; - -export default function socialDNATests(testContext: TestContext) { - return () => { - describe("There is a SDNA test exercising an example TODO SDNA", () => { - // SdnaOnly doesn't load links into prolog engine - it.skip('can add social DNA to perspective and go through flow', async () => { - const sdna = [ - // The name of our SDNA flow: "TODO" - 'register_sdna_flow("TODO", t).', - - // What expressions can be used to start this flow? -> all - 'flowable(_, t).', - - // This Flow has 3 states (0=ready, 0.5=doing, 1=done), - // which are represented by links with predicate 'todo://state' - 'flow_state(ExprAddr, 0, t) :- triple(ExprAddr, "todo://state", "todo://ready").', - 'flow_state(ExprAddr, 0.5, t) :- triple(ExprAddr, "todo://state", "todo://doing").', - 'flow_state(ExprAddr, 1, t) :- triple(ExprAddr, "todo://state", "todo://done").', - - // Initial action renders any expression into a todo item by adding a state link to 'ready' - `start_action('[{action: "addLink", source: "this", predicate: "todo://state", target: "todo://ready"}]', t).`, - // A ready todo can be 'started' = commencing work on it. Removes 'ready' link and replaces it by 'doing' link - `action(0, "Start", 0.5, '[{action: "addLink", source: "this", predicate: "todo://state", target: "todo://doing"}, {action: "removeLink", source: "this", predicate: "todo://state", target: "todo://ready"}]').`, - // A todo in doing can be 'finished' - `action(0.5, "Finish", 1, '[{action: "addLink", source: "this", predicate: "todo://state", target: "todo://done"}, {action: "removeLink", source: "this", predicate: "todo://state", target: "todo://doing"}]').`, - ] - - const ad4mClient = testContext.ad4mClient! - - const perspective = await ad4mClient.perspective.add("sdna-test"); - expect(perspective.name).to.be.equal("sdna-test"); - - await perspective.addSdna("Todo", sdna.join('\n'), "flow"); - - - let sDNAFacts = await ad4mClient!.perspective.queryLinks(perspective.uuid, new LinkQuery({source: "ad4m://self", predicate: "ad4m://has_flow"})); - expect(sDNAFacts.length).to.be.equal(1); - let flows = await perspective.sdnaFlows() - expect(flows[0]).to.be.equal('TODO') - - await perspective.add(new Link({source: 'ad4m://self', target: 'test-lang://1234'})) - let availableFlows = await perspective.availableFlows('test-lang://1234') - expect(availableFlows.length).to.be.equal(1) - expect(availableFlows[0]).to.be.equal('TODO') - let startAction = await perspective.infer(`start_action(Action, F), register_sdna_flow("TODO", F)`) - await perspective.startFlow('TODO', 'test-lang://1234') - - let flowLinks = await ad4mClient!.perspective.queryLinks(perspective.uuid, new LinkQuery({source: "test-lang://1234", predicate: "todo://state"})) - expect(flowLinks.length).to.be.equal(1) - expect(flowLinks[0].data.target).to.be.equal("todo://ready") - - let todoState = await perspective.flowState('TODO', 'test-lang://1234') - expect(todoState).to.be.equal(0) - - let expressionsInTodo = await perspective.expressionsInFlowState('TODO', 0) - expect(expressionsInTodo.length).to.be.equal(1) - expect(expressionsInTodo[0]).to.be.equal('test-lang://1234') - - - // continue flow - let flowActions = await perspective.flowActions('TODO', 'test-lang://1234') - expect(flowActions.length).to.be.equal(1) - expect(flowActions[0]).to.be.equal("Start") - - - await perspective.runFlowAction('TODO', 'test-lang://1234', "Start") - await sleep(100) - todoState = await perspective.flowState('TODO', 'test-lang://1234') - expect(todoState).to.be.equal(0.5) - - flowLinks = await ad4mClient!.perspective.queryLinks(perspective.uuid, new LinkQuery({source: "test-lang://1234", predicate: "todo://state"})) - expect(flowLinks.length).to.be.equal(1) - expect(flowLinks[0].data.target).to.be.equal("todo://doing") - - expressionsInTodo = await perspective.expressionsInFlowState('TODO', 0.5) - expect(expressionsInTodo.length).to.be.equal(1) - expect(expressionsInTodo[0]).to.be.equal('test-lang://1234') - - // continue flow - flowActions = await perspective.flowActions('TODO', 'test-lang://1234') - expect(flowActions.length).to.be.equal(1) - expect(flowActions[0]).to.be.equal("Finish") - - - await perspective.runFlowAction('TODO', 'test-lang://1234', "Finish") - await sleep(100) - todoState = await perspective.flowState('TODO', 'test-lang://1234') - expect(todoState).to.be.equal(1) - - flowLinks = await ad4mClient!.perspective.queryLinks(perspective.uuid, new LinkQuery({source: "test-lang://1234", predicate: "todo://state"})) - expect(flowLinks.length).to.be.equal(1) - expect(flowLinks[0].data.target).to.be.equal("todo://done") - expressionsInTodo = await perspective.expressionsInFlowState('TODO', 1) - expect(expressionsInTodo.length).to.be.equal(1) - expect(expressionsInTodo[0]).to.be.equal('test-lang://1234') - - }) - }) - } -} diff --git a/tests/js/tests/triple-agent-test.ts b/tests/js/tests/triple-agent-test.ts deleted file mode 100644 index f9549d8bc..000000000 --- a/tests/js/tests/triple-agent-test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Link, Perspective, LinkExpression, ExpressionProof, LinkQuery, PerspectiveState, NeighbourhoodProxy, PerspectiveUnsignedInput, PerspectiveProxy, PerspectiveHandle } from "@coasys/ad4m"; -import fs from "fs"; -import { TestContext } from './integration.test' -import { sleep } from '../utils/utils' -import { expect } from "chai"; -import { v4 as uuidv4 } from 'uuid'; - -const DIFF_SYNC_OFFICIAL = fs.readFileSync("./scripts/perspective-diff-sync-hash").toString(); - -export default function tripleAgentTests(testContext: TestContext) { - return () => { - it("three agents can join and use a neighbourhood", async () => { - const alice = testContext.alice - const bob = testContext.bob - const jim = testContext.jim - - const aliceP1 = await alice.perspective.add("three-agents") - const socialContext = await alice.languages.applyTemplateAndPublish(DIFF_SYNC_OFFICIAL, JSON.stringify({uid: uuidv4(), name: "Alice's neighbourhood with Bob"})); - expect(socialContext.name).to.be.equal("Alice's neighbourhood with Bob"); - const neighbourhoodUrl = await alice.neighbourhood.publishFromPerspective(aliceP1.uuid, socialContext.address, new Perspective()) - - let bobP1 = await bob.neighbourhood.joinFromUrl(neighbourhoodUrl); - let jimP1 = await jim.neighbourhood.joinFromUrl(neighbourhoodUrl); - - await testContext.makeAllThreeNodesKnown() - - expect(bobP1!.name).not.to.be.undefined; - expect(bobP1!.sharedUrl).to.be.equal(neighbourhoodUrl) - expect(bobP1!.neighbourhood).not.to.be.undefined;; - expect(bobP1!.neighbourhood!.linkLanguage).to.be.equal(socialContext.address); - expect(bobP1!.neighbourhood!.meta.links.length).to.be.equal(0); - - expect(jimP1!.name).not.to.be.undefined; - expect(jimP1!.sharedUrl).to.be.equal(neighbourhoodUrl) - expect(jimP1!.neighbourhood).not.to.be.undefined;; - expect(jimP1!.neighbourhood!.linkLanguage).to.be.equal(socialContext.address); - expect(jimP1!.neighbourhood!.meta.links.length).to.be.equal(0); - - await sleep(1000) - - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - - await sleep(1000) - - let bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - let tries = 1 - - while(bobLinks.length < 10 && tries < 20) { - console.log("Bob retrying getting links..."); - await sleep(1000) - bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(bobLinks.length).to.be.equal(10) - - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - - let jimLinks = await jim.perspective.queryLinks(jimP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - let jimRetries = 1 - - while(jimLinks.length < 20 && jimRetries < 20) { - console.log("Jim retrying getting links..."); - await sleep(1000) - jimLinks = await jim.perspective.queryLinks(jimP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - jimRetries++ - } - - expect(jimLinks.length).to.be.equal(20) - - //Alice bob and jim all collectively add 10 links and then check can be received by all agents - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await jim.perspective.addLink(jimP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await jim.perspective.addLink(jimP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await alice.perspective.addLink(aliceP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await bob.perspective.addLink(bobP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await jim.perspective.addLink(jimP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - await jim.perspective.addLink(jimP1.uuid, {source: 'ad4m://root', target: 'test://test'}) - - let aliceLinks = await alice.perspective.queryLinks(aliceP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries = 1 - - while(aliceLinks.length < 30 && tries < 20) { - console.log("Alice retrying getting links..."); - await sleep(1000) - aliceLinks = await alice.perspective.queryLinks(aliceP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(aliceLinks.length).to.be.equal(30) - - - - - bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries = 1 - - while(bobLinks.length < 30 && tries < 20) { - console.log("Bob retrying getting links..."); - await sleep(1000) - bobLinks = await bob.perspective.queryLinks(bobP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(bobLinks.length).to.be.equal(30) - - - - - jimLinks = await jim.perspective.queryLinks(jimP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries = 1 - - while(jimLinks.length < 30 && tries < 20) { - console.log("Jim retrying getting links..."); - await sleep(1000) - jimLinks = await jim.perspective.queryLinks(jimP1!.uuid, new LinkQuery({source: 'ad4m://root'})) - tries++ - } - - expect(jimLinks.length).to.be.equal(30) - - }) - } -} \ No newline at end of file diff --git a/tests/js/tsconfig.json b/tests/js/tsconfig.json index 0a475c334..29f4aa8c2 100644 --- a/tests/js/tsconfig.json +++ b/tests/js/tsconfig.json @@ -1,15 +1,21 @@ { - "include": [ - "*.ts", "tests/agent-language.ts", "tests/agent.ts", "tests/app.test.ts", "tests/authentication.test.ts", "tests/direct-messages.ts", "tests/expression.ts", "tests/integration.test.ts", "tests/prolog-and-literals.test.ts", "tests/language.ts", "tests/neighbourhood.ts", "tests/perspective.ts", "tests/runtime.ts", "tests/simple.test.ts", "tests/social-dna-flow.ts", "tests/multi-user-simple.test.ts", "utils/utils.ts" ], - "exclude": ["./src/tests/*", "./src/**/*.test.ts", "./src/testsutils/*"], + "include": ["**/*.ts"], + "exclude": [ + "node_modules", + "dist", + "tst-tmp", + "./src/tests/*", + "./src/**/*.test.ts", + "./src/testsutils/*", + ], "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "ESNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'es2015', or 'ESNEXT'. */ + "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'es2015', or 'ESNEXT'. */, "lib": ["ESNext", "dom"], - "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ @@ -18,8 +24,8 @@ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ - "outDir": ".", /* Redirect output structure to the directory. */ - "rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + "outDir": "." /* Redirect output structure to the directory. */, + "rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, // "composite": true, /* Enable project compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ // "removeComments": true, /* Do not emit comments to output. */ @@ -29,7 +35,7 @@ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ - "strict": true, /* Enable all strict type-checking options. */ + "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ @@ -47,14 +53,14 @@ // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ @@ -69,16 +75,16 @@ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ /* Advanced Options */ - "skipLibCheck": true, /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, - "declaration": true + "declaration": true, }, - "typedef": [ true, "call-signature", "arrow-call-signature" ], + "typedef": [true, "call-signature", "arrow-call-signature"], "ts-node": { - "files": true + "files": true, }, } diff --git a/tests/js/utils/publishTestLangs.ts b/tests/js/utils/publishTestLangs.ts index 8d807fd23..941cc4178 100644 --- a/tests/js/utils/publishTestLangs.ts +++ b/tests/js/utils/publishTestLangs.ts @@ -3,133 +3,220 @@ import { Ad4mClient, LanguageMetaInput } from "@coasys/ad4m"; import fs from "fs-extra"; import { exit } from "process"; import { execSync } from "child_process"; -import { fileURLToPath } from 'url'; +import { fileURLToPath } from "url"; import { apolloClient, sleep, startExecutor } from "./utils"; -import fetch from 'node-fetch' +import fetch from "node-fetch"; //@ts-ignore -global.fetch = fetch +global.fetch = fetch; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const TEST_DIR = path.resolve(__dirname, '..', 'tst-tmp'); +const TEST_DIR = path.resolve(__dirname, "..", "tst-tmp"); const appDataPath = path.resolve(TEST_DIR, "agents", "p"); const publishLanguagesPath = path.resolve(TEST_DIR, "languages"); -const publishingBootstrapSeedPath = path.resolve(__dirname, '..', 'publishBootstrapSeed.json'); -const bootstrapSeedPath = path.resolve(__dirname, '..', 'bootstrapSeed.json'); -const perspectiveDiffSyncHashPath = path.resolve(__dirname, '..', 'scripts', 'perspective-diff-sync-hash'); +const publishingBootstrapSeedPath = path.resolve( + __dirname, + "..", + "publishBootstrapSeed.json", +); +const bootstrapSeedPath = path.resolve(__dirname, "..", "bootstrapSeed.json"); +const perspectiveDiffSyncHashPath = path.resolve( + __dirname, + "..", + "scripts", + "perspective-diff-sync-hash", +); // Allow env-var override so concurrent CI jobs can each use a unique port range // and avoid stomping on each other during the setup phase. // Defaults: 15700/15701/15702 (used by integration-tests-js / test-main) -const gqlPort = parseInt(process.env.AD4M_SETUP_GQL_PORT || '15700', 10); -const hcAdminPort = parseInt(process.env.AD4M_SETUP_HC_ADMIN_PORT || '15701', 10); -const hcAppPort = parseInt(process.env.AD4M_SETUP_HC_APP_PORT || '15702', 10); +const gqlPort = parseInt(process.env.AD4M_SETUP_GQL_PORT || "15700", 10); +const hcAdminPort = parseInt( + process.env.AD4M_SETUP_HC_ADMIN_PORT || "15701", + 10, +); +const hcAppPort = parseInt(process.env.AD4M_SETUP_HC_APP_PORT || "15702", 10); //Update this as new languages are needed within testing code const languagesToPublish = { - "agent-expression-store": {name: "agent-expression-store", description: "", possibleTemplateParams: ["uid", "name", "description"]} as LanguageMetaInput, - "direct-message-language": {name: "direct-message-language", description: "", possibleTemplateParams: ["uid", "recipient_did", "recipient_hc_agent_pubkey"]} as LanguageMetaInput, - "neighbourhood-store": {name: "neighbourhood-store", description: "", possibleTemplateParams: ["uid", "name", "description"]} as LanguageMetaInput, - "perspective-diff-sync": {name: "perspective-diff-sync", description: "", possibleTemplateParams: ["uid", "name", "description"]} as LanguageMetaInput, - "perspective-language": {name: "perspective-language", description: "", possibleTemplateParams: ["uid", "name", "description"]} as LanguageMetaInput, -} + "agent-expression-store": { + name: "agent-expression-store", + description: "", + possibleTemplateParams: ["uid", "name", "description"], + } as LanguageMetaInput, + "direct-message-language": { + name: "direct-message-language", + description: "", + possibleTemplateParams: [ + "uid", + "recipient_did", + "recipient_hc_agent_pubkey", + ], + } as LanguageMetaInput, + "neighbourhood-store": { + name: "neighbourhood-store", + description: "", + possibleTemplateParams: ["uid", "name", "description"], + } as LanguageMetaInput, + "perspective-diff-sync": { + name: "perspective-diff-sync", + description: "", + possibleTemplateParams: ["uid", "name", "description"], + } as LanguageMetaInput, + "perspective-language": { + name: "perspective-language", + description: "", + possibleTemplateParams: ["uid", "name", "description"], + } as LanguageMetaInput, +}; const languageHashes = { - "directMessageLanguage": "", - "agentLanguage": "", - "perspectiveLanguage": "", - "neighbourhoodLanguage": "", - "perspectiveDiffSync": "" -} + directMessageLanguage: "", + agentLanguage: "", + perspectiveLanguage: "", + neighbourhoodLanguage: "", + perspectiveDiffSync: "", +}; // Kill the listening process on each port (TCP:LISTEN filter ensures we only // kill the executor server, NOT this node process which has a *connection* to it). function killExecutorPorts(ports: number[]) { - for (const port of ports) { - try { - execSync(`lsof -ti TCP:${port} -s TCP:LISTEN | xargs -r kill -9`, { stdio: 'ignore' }); - } catch (e) { - console.warn(`Port cleanup warning for ${port}:`, e); - } + for (const port of ports) { + try { + execSync(`lsof -ti TCP:${port} -s TCP:LISTEN | xargs -r kill -9`, { + stdio: "ignore", + }); + } catch (e) { + console.warn(`Port cleanup warning for ${port}:`, e); } + } } -function createTestingAgent() { - if (!fs.existsSync(appDataPath)) { - fs.mkdirSync(appDataPath); +// Poll until no process is listening on the port (or maxWaitMs exceeded). +// This closes the race window between kill -9 and the next executor start. +async function waitForPortFree(port: number, maxWaitMs = 10000): Promise { + const deadline = Date.now() + maxWaitMs; + while (Date.now() < deadline) { + try { + const out = execSync(`lsof -ti TCP:${port} -s TCP:LISTEN 2>/dev/null`, { + encoding: "utf8", + }).trim(); + if (!out) return; // nothing listening — port is free + } catch { + return; // lsof exited non-zero = no match = port is free } + await sleep(200); + } + console.warn( + `waitForPortFree: port ${port} still in use after ${maxWaitMs}ms — proceeding anyway`, + ); +} + +function createTestingAgent() { + if (!fs.existsSync(appDataPath)) { + fs.mkdirSync(appDataPath); + } } function injectSystemLanguages() { - if (fs.existsSync(bootstrapSeedPath)) { - const bootstrapSeed = JSON.parse(fs.readFileSync(bootstrapSeedPath).toString()); - bootstrapSeed["directMessageLanguage"] = languageHashes["directMessageLanguage"]; - bootstrapSeed["agentLanguage"] = languageHashes["agentLanguage"]; - bootstrapSeed["perspectiveLanguage"] = languageHashes["perspectiveLanguage"]; - bootstrapSeed["neighbourhoodLanguage"] = languageHashes["neighbourhoodLanguage"]; - bootstrapSeed["knownLinkLanguages"] = [languageHashes["perspectiveDiffSync"]]; - fs.writeFileSync(bootstrapSeedPath, JSON.stringify(bootstrapSeed)); - } else { - throw new Error(`Could not find boostrapSeed at path: ${bootstrapSeedPath}`) - } + if (fs.existsSync(bootstrapSeedPath)) { + const bootstrapSeed = JSON.parse( + fs.readFileSync(bootstrapSeedPath).toString(), + ); + bootstrapSeed["directMessageLanguage"] = + languageHashes["directMessageLanguage"]; + bootstrapSeed["agentLanguage"] = languageHashes["agentLanguage"]; + bootstrapSeed["perspectiveLanguage"] = + languageHashes["perspectiveLanguage"]; + bootstrapSeed["neighbourhoodLanguage"] = + languageHashes["neighbourhoodLanguage"]; + bootstrapSeed["knownLinkLanguages"] = [ + languageHashes["perspectiveDiffSync"], + ]; + fs.writeFileSync(bootstrapSeedPath, JSON.stringify(bootstrapSeed)); + } else { + throw new Error( + `Could not find boostrapSeed at path: ${bootstrapSeedPath}`, + ); + } } function injectLangAliasHashes() { - fs.writeFileSync(perspectiveDiffSyncHashPath, languageHashes["perspectiveDiffSync"]); + fs.writeFileSync( + perspectiveDiffSyncHashPath, + languageHashes["perspectiveDiffSync"], + ); } async function publish() { - const setupPorts = [gqlPort, hcAdminPort, hcAppPort]; - - // Pre-clean: kill any orphaned executor from a previous CI job that may be - // squatting on our ports. Self-hosted runners reuse workdirs between jobs - // and don't clean up automatically. - console.log(`Pre-cleaning ports ${setupPorts.join('/')} before starting executor...`); - killExecutorPorts(setupPorts); - await sleep(500); - - createTestingAgent(); - - const executorProcess = await startExecutor(appDataPath, publishingBootstrapSeedPath, gqlPort, hcAdminPort, hcAppPort, true); - - try { - const ad4mClient = new Ad4mClient(apolloClient(gqlPort)); - await ad4mClient.agent.generate("passphrase"); - - for (const [language, languageMeta] of Object.entries(languagesToPublish)) { - let bundlePath = path.join(publishLanguagesPath, language, "build", "bundle.js").replace(/\\/g, "/"); - console.log("Attempting to publish language", bundlePath); - let publishedLang = await ad4mClient.languages.publish(bundlePath, languageMeta); - console.log("Published with result", publishedLang); - if (language === "agent-expression-store") { - languageHashes["agentLanguage"] = publishedLang.address; - } - if (language === "neighbourhood-store") { - languageHashes["neighbourhoodLanguage"] = publishedLang.address; - } - if (language === "direct-message-language") { - languageHashes["directMessageLanguage"] = publishedLang.address; - } - if (language === "perspective-language") { - languageHashes["perspectiveLanguage"] = publishedLang.address; - } - if (language === "perspective-diff-sync") { - languageHashes["perspectiveDiffSync"] = publishedLang.address; - } - } - injectSystemLanguages(); - injectLangAliasHashes(); - } finally { - // Always kill the executor on the way out — success or failure. - // Uses TCP:LISTEN filter so we only kill the listening server (the executor), - // NOT this node process which has an outbound connection to that port. - console.log(`Killing executor on ports ${setupPorts.join('/')}...`); - killExecutorPorts(setupPorts); - await sleep(1000); + const setupPorts = [gqlPort, hcAdminPort, hcAppPort]; + + // Pre-clean: kill any orphaned executor from a previous CI job that may be + // squatting on our ports. Self-hosted runners reuse workdirs between jobs + // and don't clean up automatically. + console.log( + `Pre-cleaning ports ${setupPorts.join("/")} before starting executor...`, + ); + killExecutorPorts(setupPorts); + // Wait until every port is confirmed free — avoids the race where kill -9 + // was sent but the OS hasn't fully released the socket yet. + await Promise.all(setupPorts.map((p) => waitForPortFree(p, 10000))); + console.log(`All setup ports free, starting executor...`); + + createTestingAgent(); + + const executorProcess = await startExecutor( + appDataPath, + publishingBootstrapSeedPath, + gqlPort, + hcAdminPort, + hcAppPort, + true, + ); + + try { + const ad4mClient = new Ad4mClient(apolloClient(gqlPort)); + await ad4mClient.agent.generate("passphrase"); + + for (const [language, languageMeta] of Object.entries(languagesToPublish)) { + let bundlePath = path + .join(publishLanguagesPath, language, "build", "bundle.js") + .replace(/\\/g, "/"); + console.log("Attempting to publish language", bundlePath); + let publishedLang = await ad4mClient.languages.publish( + bundlePath, + languageMeta, + ); + console.log("Published with result", publishedLang); + if (language === "agent-expression-store") { + languageHashes["agentLanguage"] = publishedLang.address; + } + if (language === "neighbourhood-store") { + languageHashes["neighbourhoodLanguage"] = publishedLang.address; + } + if (language === "direct-message-language") { + languageHashes["directMessageLanguage"] = publishedLang.address; + } + if (language === "perspective-language") { + languageHashes["perspectiveLanguage"] = publishedLang.address; + } + if (language === "perspective-diff-sync") { + languageHashes["perspectiveDiffSync"] = publishedLang.address; + } } + injectSystemLanguages(); + injectLangAliasHashes(); + } finally { + // Always kill the executor on the way out — success or failure. + // Uses TCP:LISTEN filter so we only kill the listening server (the executor), + // NOT this node process which has an outbound connection to that port. + console.log(`Killing executor on ports ${setupPorts.join("/")}...`); + killExecutorPorts(setupPorts); + await sleep(1000); + } - exit(); + exit(); } -publish() \ No newline at end of file +publish(); diff --git a/tests/js/utils/utils.ts b/tests/js/utils/utils.ts index bc5d81c54..3918adc09 100644 --- a/tests/js/utils/utils.ts +++ b/tests/js/utils/utils.ts @@ -1,274 +1,406 @@ -import { ChildProcess, exec, ExecException, execSync, spawn } from "node:child_process"; -import { rmSync } from "node:fs"; +import { + ChildProcess, + exec, + ExecException, + execSync, + spawn, +} from "node:child_process"; +import { rmSync, mkdirSync, writeFileSync } from "node:fs"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions/index.js"; import { ApolloClient, InMemoryCache } from "@apollo/client/core/index.js"; import Websocket from "ws"; import { createClient } from "graphql-ws"; import path from "path"; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { fileURLToPath } from "url"; +import { dirname } from "path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export async function isProcessRunning(processName: string): Promise { - const cmd = (() => { - switch (process.platform) { - case 'win32': return `tasklist` - case 'darwin': return `ps -ax | grep ${processName}` - case 'linux': return `ps -A` - default: return false - } - })() + const cmd = (() => { + switch (process.platform) { + case "win32": + return `tasklist`; + case "darwin": + return `ps -ax | grep ${processName}`; + case "linux": + return `ps -A`; + default: + return false; + } + })(); - if (!cmd) throw new Error("Invalid OS"); + if (!cmd) throw new Error("Invalid OS"); - return new Promise((resolve, reject) => { - //@ts-ignore - exec(cmd, (err: ExecException, stdout: string, stderr: string) => { - if (err) reject(err) + return new Promise((resolve, reject) => { + //@ts-ignore + exec(cmd, (err: ExecException, stdout: string, stderr: string) => { + if (err) reject(err); - resolve(stdout.toLowerCase().indexOf(processName.toLowerCase()) > -1) - }) - }) + resolve(stdout.toLowerCase().indexOf(processName.toLowerCase()) > -1); + }); + }); } -export async function runHcLocalServices(): Promise<{proxyUrl: string | null, bootstrapUrl: string | null, relayUrl: string | null, process: ChildProcess}> { - let servicesProcess = exec(`kitsune2-bootstrap-srv`); - - let proxyUrl: string | null = null; - let bootstrapUrl: string | null = null; - let relayUrl: string | null = null; - let bootstrapPort: string | null = null; - let relayPort: string | null = null; - - let servicesReady = new Promise((resolve, reject) => { - const SERVICES_READY_TIMEOUT_MS = 60000; // 60 seconds timeout - const stdoutBuffer: string[] = []; - const stderrBuffer: string[] = []; - let timeoutId: NodeJS.Timeout | null = null; - let resolved = false; - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - servicesProcess.stdout!.removeListener('data', stdoutHandler); - servicesProcess.stderr!.removeListener('data', stderrHandler); - }; +export async function runHcLocalServices(): Promise<{ + proxyUrl: string | null; + bootstrapUrl: string | null; + relayUrl: string | null; + process: ChildProcess; +}> { + let servicesProcess = exec(`kitsune2-bootstrap-srv`); + + // Write the PID to a known location so cleanup.js can kill this specific + // instance by PID — safe for concurrent CI jobs (each checkout has its own + // tst-tmp/, and each run overwrites the file before starting the next suite). + if (servicesProcess.pid !== undefined) { + try { + const tstTmpDir = path.join(__dirname, "..", "tst-tmp"); + mkdirSync(tstTmpDir, { recursive: true }); + writeFileSync( + path.join(tstTmpDir, "kitsune2-bootstrap.pid"), + String(servicesProcess.pid), + ); + } catch (_) {} + } + + let proxyUrl: string | null = null; + let bootstrapUrl: string | null = null; + let relayUrl: string | null = null; + let bootstrapPort: string | null = null; + let relayPort: string | null = null; + + let servicesReady = new Promise((resolve, reject) => { + const SERVICES_READY_TIMEOUT_MS = 120000; // 120 seconds timeout + const stdoutBuffer: string[] = []; + const stderrBuffer: string[] = []; + let timeoutId: NodeJS.Timeout | null = null; + let resolved = false; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + servicesProcess.stdout!.removeListener("data", stdoutHandler); + servicesProcess.stderr!.removeListener("data", stderrHandler); + }; + + const stdoutHandler = (data: Buffer) => { + const dataStr = data.toString(); + stdoutBuffer.push(dataStr); + console.log("Bootstrap server output: ", dataStr); + + // Look for the bootstrap server listening message + if (dataStr.includes("#kitsune2_bootstrap_srv#listening#")) { + const lines = dataStr.split("\n"); + //@ts-ignore + const portLine = lines.find((line) => + line.includes("#kitsune2_bootstrap_srv#listening#"), + ); + if (portLine) { + const parts = portLine.split("#"); + const portPart = parts[3]; // "127.0.0.1:36353" + bootstrapPort = portPart.split(":")[1]; + console.log("Bootstrap Port: ", bootstrapPort); + bootstrapUrl = `https://127.0.0.1:${bootstrapPort}`; + proxyUrl = `wss://127.0.0.1:${bootstrapPort}`; + console.log("Bootstrap URL: ", bootstrapUrl); + console.log("Proxy URL: ", proxyUrl); + } + } - const stdoutHandler = (data: Buffer) => { - const dataStr = data.toString(); - stdoutBuffer.push(dataStr); - console.log("Bootstrap server output: ", dataStr); - - // Look for the bootstrap server listening message - if (dataStr.includes("#kitsune2_bootstrap_srv#listening#")) { - const lines = dataStr.split("\n"); - //@ts-ignore - const portLine = lines.find(line => line.includes("#kitsune2_bootstrap_srv#listening#")); - if (portLine) { - const parts = portLine.split('#'); - const portPart = parts[3]; // "127.0.0.1:36353" - bootstrapPort = portPart.split(':')[1]; - console.log("Bootstrap Port: ", bootstrapPort); - bootstrapUrl = `https://127.0.0.1:${bootstrapPort}`; - proxyUrl = `wss://127.0.0.1:${bootstrapPort}`; - console.log("Bootstrap URL: ", bootstrapUrl); - console.log("Proxy URL: ", proxyUrl); - } - } - - // Look for the iroh relay server message - if (dataStr.includes("Internal iroh relay server started at")) { - const match = dataStr.match(/Internal iroh relay server started at ([\d.]+:\d+)/); - if (match) { - const address = match[1]; - relayPort = address.split(':')[1]; - console.log("Iroh Relay Port: ", relayPort); - relayUrl = `http://127.0.0.1:${relayPort}`; - console.log("Relay URL: ", relayUrl); - } - } - - // Resolve when we have both ports - if (bootstrapPort && relayPort && !resolved) { - resolved = true; - cleanup(); - resolve(); - } - }; + // Look for the iroh relay server message + if (dataStr.includes("Internal iroh relay server started at")) { + const match = dataStr.match( + /Internal iroh relay server started at ([\d.]+:\d+)/, + ); + if (match) { + const address = match[1]; + relayPort = address.split(":")[1]; + console.log("Iroh Relay Port: ", relayPort); + relayUrl = `http://127.0.0.1:${relayPort}`; + console.log("Relay URL: ", relayUrl); + } + } - const stderrHandler = (data: Buffer) => { - const dataStr = data.toString(); - stderrBuffer.push(dataStr); - console.log("Bootstrap server stderr: ", dataStr); - }; + // Bootstrap is sufficient — resolve as soon as it's ready. + // Relay is optional; capture it if it arrives but don't block on it. + if (bootstrapPort && !resolved) { + resolved = true; + cleanup(); + resolve(); + } + }; + + const stderrHandler = (data: Buffer) => { + const dataStr = data.toString(); + stderrBuffer.push(dataStr); + console.log("Bootstrap server stderr: ", dataStr); + }; + + servicesProcess.stdout!.on("data", stdoutHandler); + servicesProcess.stderr!.on("data", stderrHandler); + + // Set up timeout to prevent hanging forever + timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + + console.error("=== Services startup timeout ==="); + console.error( + `Timeout after ${SERVICES_READY_TIMEOUT_MS}ms waiting for bootstrap and relay services`, + ); + console.error(`Bootstrap port found: ${bootstrapPort ?? "NO"}`); + console.error(`Relay port found: ${relayPort ?? "NO"}`); + console.error("--- Collected stdout ---"); + console.error(stdoutBuffer.join("")); + console.error("--- Collected stderr ---"); + console.error(stderrBuffer.join("")); + console.error("========================"); + + // Kill the services process + try { + servicesProcess.kill("SIGKILL"); + } catch (killErr) { + console.error("Error killing services process:", killErr); + } - servicesProcess.stdout!.on('data', stdoutHandler); - servicesProcess.stderr!.on('data', stderrHandler); - - // Set up timeout to prevent hanging forever - timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - - console.error("=== Services startup timeout ==="); - console.error(`Timeout after ${SERVICES_READY_TIMEOUT_MS}ms waiting for bootstrap and relay services`); - console.error(`Bootstrap port found: ${bootstrapPort ?? 'NO'}`); - console.error(`Relay port found: ${relayPort ?? 'NO'}`); - console.error("--- Collected stdout ---"); - console.error(stdoutBuffer.join('')); - console.error("--- Collected stderr ---"); - console.error(stderrBuffer.join('')); - console.error("========================"); - - // Kill the services process - try { - servicesProcess.kill('SIGKILL'); - } catch (killErr) { - console.error("Error killing services process:", killErr); - } - - reject(new Error(`Services startup timeout: bootstrapPort=${bootstrapPort}, relayPort=${relayPort}`)); - } - }, SERVICES_READY_TIMEOUT_MS); - }); + // Bootstrap is sufficient; relay is optional for single-agent tests. + // Resolve without relay rather than failing the whole suite. + if (bootstrapPort) { + console.warn( + `Relay server did not start within timeout — continuing without relay URL`, + ); + resolve(); + } else { + reject( + new Error( + `Services startup timeout: bootstrapPort=${bootstrapPort}, relayPort=${relayPort}`, + ), + ); + } + } + }, SERVICES_READY_TIMEOUT_MS); + }); - await servicesReady; - return {proxyUrl, bootstrapUrl, relayUrl, process: servicesProcess}; + await servicesReady; + return { proxyUrl, bootstrapUrl, relayUrl, process: servicesProcess }; } -export async function startExecutor(dataPath: string, - bootstrapSeedPath: string, - gqlPort: number, - hcAdminPort: number, - hcAppPort: number, - languageLanguageOnly: boolean = false, - adminCredential?: string, - proxyUrl: string = "wss://dev-test-bootstrap2.holochain.org", - bootstrapUrl: string = "https://dev-test-bootstrap2.holochain.org", - relayUrl?: string, +export async function startExecutor( + dataPath: string, + bootstrapSeedPath: string, + gqlPort: number, + hcAdminPort: number, + hcAppPort: number, + languageLanguageOnly: boolean = false, + adminCredential?: string, + proxyUrl: string = "wss://dev-test-bootstrap2.holochain.org", + bootstrapUrl: string = "https://dev-test-bootstrap2.holochain.org", + relayUrl?: string, ): Promise { - const command = path.resolve(__dirname, '..', '..', '..','target', 'release', 'ad4m-executor'); - - console.log(bootstrapSeedPath); - console.log(dataPath); - let executorProcess = null as ChildProcess | null; - rmSync(dataPath, { recursive: true, force: true }) - execSync(`${command} init --data-path ${dataPath} --network-bootstrap-seed ${bootstrapSeedPath}`, {cwd: process.cwd()}) - - console.log("Starting executor") - - console.log("USING LOCAL BOOTSTRAP & PROXY URL: ", bootstrapUrl, proxyUrl); - if (relayUrl) { - console.log("USING RELAY URL: ", relayUrl); - } - - // Build args array explicitly so spawn() can run the executor directly - // (no shell wrapper). exec() spawns `sh -c "..."` — kill() only kills - // the shell, leaving the actual executor running as an orphan. - // spawn() runs the binary directly so kill()/SIGKILL actually reach it. - const args = [ - 'run', - '--app-data-path', dataPath, - '--gql-port', String(gqlPort), - '--hc-admin-port', String(hcAdminPort), - '--hc-app-port', String(hcAppPort), - '--hc-proxy-url', proxyUrl, - '--hc-bootstrap-url', bootstrapUrl, - '--hc-use-bootstrap', 'true', - '--hc-use-proxy', 'true', - '--hc-use-local-proxy', 'true', - '--hc-use-mdns', 'true', - '--language-language-only', String(languageLanguageOnly), - '--run-dapp-server', 'false', - ]; - if (relayUrl) { args.push('--hc-relay-url', relayUrl); } - if (adminCredential) { args.push('--admin-credential', adminCredential); } - - executorProcess = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let executorReady = new Promise((resolve, reject) => { - executorProcess!.stdout!.on('data', (data) => { - if (data.includes(`listening on http://127.0.0.1:${gqlPort}`)) { - resolve() - } - }); - executorProcess!.stderr!.on('data', (data) => { - if (data.includes(`listening on http://127.0.0.1:${gqlPort}`)) { - resolve() - } - }); - }) - - executorProcess!.stdout!.on('data', (data) => { - console.log(`${data}`); - }); - executorProcess!.stderr!.on('data', (data) => { - console.log(`${data}`); - }); - - console.log("Waiting for executor to settle...") - await executorReady - return executorProcess; + const command = path.resolve( + __dirname, + "..", + "..", + "..", + "target", + "release", + "ad4m-executor", + ); + + console.log(bootstrapSeedPath); + console.log(dataPath); + let executorProcess = null as ChildProcess | null; + rmSync(dataPath, { recursive: true, force: true }); + execSync( + `${command} init --data-path ${dataPath} --network-bootstrap-seed ${bootstrapSeedPath}`, + { cwd: process.cwd() }, + ); + + console.log("Starting executor"); + + console.log("USING LOCAL BOOTSTRAP & PROXY URL: ", bootstrapUrl, proxyUrl); + if (relayUrl) { + console.log("USING RELAY URL: ", relayUrl); + } + + // Build args array so spawn() targets the binary directly — exec() wraps in + // a shell, meaning kill() only kills the shell and the executor becomes an + // orphan that holds ports and keeps Node's event loop alive. + const spawnArgs = [ + "run", + "--app-data-path", + dataPath, + "--gql-port", + String(gqlPort), + "--hc-admin-port", + String(hcAdminPort), + "--hc-app-port", + String(hcAppPort), + "--hc-proxy-url", + proxyUrl, + "--hc-bootstrap-url", + bootstrapUrl, + "--hc-use-bootstrap", + "true", + "--hc-use-proxy", + "true", + "--hc-use-local-proxy", + "true", + "--hc-use-mdns", + "true", + "--language-language-only", + String(languageLanguageOnly), + "--run-dapp-server", + "false", + ]; + if (relayUrl) { + spawnArgs.push("--hc-relay-url", relayUrl); + } + if (adminCredential) { + spawnArgs.push("--admin-credential", adminCredential); + } + executorProcess = spawn(command, spawnArgs); + const EXECUTOR_READY_TIMEOUT_MS = 180_000; + let executorReady = new Promise((resolve, reject) => { + const onData = (data: Buffer | string) => { + if (String(data).includes(`listening on http://127.0.0.1:${gqlPort}`)) { + clearTimeout(tid); + resolve(); + } + }; + executorProcess!.stdout!.on("data", onData); + executorProcess!.stderr!.on("data", onData); + const tid = setTimeout(() => { + reject( + new Error( + `Executor did not start within ${EXECUTOR_READY_TIMEOUT_MS}ms (port ${gqlPort})`, + ), + ); + }, EXECUTOR_READY_TIMEOUT_MS); + }); + + executorProcess!.stdout!.on("data", (data) => { + console.log(`${data}`); + }); + executorProcess!.stderr!.on("data", (data) => { + console.log(`${data}`); + }); + + console.log("Waiting for executor to settle..."); + await executorReady; + return executorProcess; } export function apolloClient(port: number, token?: string): ApolloClient { - //@ts-ignore - const wsLink = new GraphQLWsLink(createClient({ - url: `ws://127.0.0.1:${port}/graphql`, - webSocketImpl: Websocket, - connectionParams: () => { - return { - headers: { - authorization: token || "" - } - } - }, - })); - wsLink.client.on('message' as any, (data: any) => { - if (data.payload) { - if (data.payload.errors) { - console.dir(data.payload.errors, { depth: null }); - } - } - }); - - let client = new ApolloClient({ - link: wsLink, - cache: new InMemoryCache({ resultCaching: false, addTypename: false }), - defaultOptions: { - watchQuery: { - fetchPolicy: "no-cache", - }, - query: { - fetchPolicy: "no-cache", - }, - mutate: { - fetchPolicy: "no-cache" - } - }, - }); - - return client; + //@ts-ignore + const wsLink = new GraphQLWsLink( + createClient({ + url: `ws://127.0.0.1:${port}/graphql`, + webSocketImpl: Websocket, + // Zero retries: a connection drop fires exactly one error per pending + // operation rather than an infinite retry storm. + retryAttempts: 0, + connectionParams: () => { + return { + headers: { + authorization: token || "", + }, + }; + }, + }), + ); + wsLink.client.on("message" as any, (data: any) => { + if (data.payload) { + if (data.payload.errors) { + console.dir(data.payload.errors, { depth: null }); + } + } + }); + + let client = new ApolloClient({ + link: wsLink, + cache: new InMemoryCache({ resultCaching: false, addTypename: false }), + defaultOptions: { + watchQuery: { + fetchPolicy: "no-cache", + }, + query: { + fetchPolicy: "no-cache", + }, + mutate: { + fetchPolicy: "no-cache", + }, + }, + }); + + return client; } export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Kill a child process and wait for it to fully exit before resolving. + * + * `ChildProcess.killed` is set to `true` as soon as the kill signal is + * *delivered* (not when the process exits), so `while (!p.killed)` loops + * return almost immediately and the next test starts while the Rust executor + * still holds its SurrealDB lock / ports. + * + * This helper attaches an `'exit'` listener first, then sends SIGTERM. + * If the process hasn't exited after 10 s it escalates to SIGKILL. + */ +export function waitForExit(p: ChildProcess | null | undefined): Promise { + if (!p) return Promise.resolve(); + if (p.exitCode !== null || p.killed) { + // Already gone — wait one tick to let any pending close events fire + return new Promise((resolve) => setImmediate(resolve)); + } + return new Promise((resolve) => { + p.once("exit", resolve); + p.kill("SIGTERM"); + // Escalate to SIGKILL after 10 s if SIGTERM is ignored + const tid = setTimeout(() => { + try { + p.kill("SIGKILL"); + } catch {} + }, 10_000); + p.once("exit", () => clearTimeout(tid)); + }); +} + +/** + * Clears all links in a perspective in a single removeLinks() batch call. + * Import from here or from helpers/assertions to get a clean slate before each test. + */ +export async function wipePerspective( + perspective: import("@coasys/ad4m").PerspectiveProxy, +): Promise { + const { LinkQuery } = await import("@coasys/ad4m"); + const links = await perspective.get(new LinkQuery({})); + if (links.length > 0) { + await perspective.removeLinks(links); + } +} + /** * Kill any process listening on the given ports. * Use as a safety net in after() hooks — catches executors that survived kill(). */ export function killByPorts(ports: number[]): void { - for (const port of ports) { - try { - execSync(`lsof -ti TCP:${port} -s TCP:LISTEN | xargs -r kill -9`, { stdio: 'ignore' }); - } catch (e) { - // Port not in use — fine - } + for (const port of ports) { + try { + execSync(`lsof -ti TCP:${port} -s TCP:LISTEN | xargs -r kill -9`, { + stdio: "ignore", + }); + } catch (e) { + // Port not in use — fine } -} \ No newline at end of file + } +} diff --git a/turbo.json b/turbo.json index 90a8aef2e..9c5242038 100644 --- a/turbo.json +++ b/turbo.json @@ -1,15 +1,12 @@ { "$schema": "https://turborepo.org/schema.json", - "pipeline": { + "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", "lib/**", "build/**"] }, "build-npm-packages": { - "dependsOn": [ - "@coasys/ad4m#build", - "@coasys/ad4m-connect#build" - ] + "dependsOn": ["@coasys/ad4m#build", "@coasys/ad4m-connect#build"] }, "build-libs": { "dependsOn": [ @@ -21,6 +18,7 @@ ], "outputs": ["dist/**", "lib/**", "build/**"] }, + "build-languages": { "dependsOn": [ "@coasys/perspective-diff-sync#build", @@ -30,7 +28,7 @@ "@coasys/language-language#build", "@coasys/neighbourhood-language#build", "@coasys/file-storage#build", - + "@coasys/centralized-perspective-diff-sync#build", "@coasys/centralized-agent-language#build", "@coasys/centralized-file-storage#build", @@ -41,7 +39,11 @@ }, "build-dapp": { - "dependsOn": ["@coasys/ad4m#build", "@coasys/ad4m-connect#build", "@coasys/dapp#build"], + "dependsOn": [ + "@coasys/ad4m#build", + "@coasys/ad4m-connect#build", + "@coasys/dapp#build" + ], "outputs": ["dist/**", "lib/**", "build/**"] }, @@ -76,6 +78,7 @@ "cache": false }, "test-main": {}, + "test:ci": {}, "test": { "outputs": [] },