diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3392d9b..f2abb23 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -29,9 +29,11 @@ npm install cp .env.example .env source .env -# Start verisimdb (in another terminal) -cd /var$REPOS_DIR/verisimdb -cargo run -p verisim-api +# Start the pinned VeriSimDB server (in another terminal) +git clone https://github.com/hyperpolymath/verisimdb.git +cd verisimdb +git checkout 10f8a21f4d0f2eacce84eee29e504f66fbe9e332 +VERISIM_HOST=127.0.0.1 VERISIM_ENABLE_IPV4=true cargo run -p verisim-api # Start the API deno run --watch=apps/api --allow-net --allow-env apps/api/main.ts diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 953b472..db9dbe4 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -13,8 +13,8 @@ workflows: - 'actions/upload-pages-artifact@v3.0.1' - 'haskell-actions/setup@v2.7.5' '.github/workflows/ci.yml': - - 'actions/checkout@v6.0.2' - - 'denoland/setup-deno@v2.0.4' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' '.github/workflows/codeql.yml': - 'actions/checkout@v7.0.1' - 'github/codeql-action@v4.39.0' @@ -37,6 +37,11 @@ dependencies: commit: 'sha1-0057852bfaa89a56745cba8c7296529d2fc39830' owner_id: 44036562 repo_id: 215566462 + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'actions/checkout@v4.1.1': ref: 'v4.1.1' commit: 'sha1-b4ffde65f46336ab88eb53be808477a3936bae11' @@ -79,9 +84,9 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@v4' - 'denoland/setup-deno@v2.0.4': - ref: 'v2.0.4' - commit: 'sha1-667a34cdef165d8d2b2e98dde39547c9daac7282' + 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': + ref: 'v2.0.5' + commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' owner_id: 42048915 repo_id: 356423100 'github/codeql-action@v4.39.0': diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bc77ef..3a43e94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,30 +1,77 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: CI -on: - pull_request: - push: - branches: [main] -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -permissions: - actions: read - contents: read -jobs: - engine: - name: Matching engine + API (fmt, lint, typecheck, tests) - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7.0.1 - - uses: denoland/setup-deno@v2.0.5 - with: - deno-version: v2.x - - name: Format check (engine) - run: deno task fmt:check - - name: Lint + typecheck (engine, legacy domain, API) - run: deno task check - - name: Engine unit tests - run: deno task test +--- +{ + name: "CI", + on: { + pull_request: null, + push: { + branches: [ + "main", + ], + }, + }, + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + # Checkout and Actions metadata are read-only; no write token is needed. + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + engine: { + name: "Matching engine + API contract (lint, typecheck, tests)", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + persist-credentials: false, + }, + }, + { + uses: "denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed", # v2.0.5 + with: { + deno-version: "2.9.6", + }, + }, + { + name: "Format check (changed JavaScript)", + run: "deno fmt --check packages/shared/src/encoding.js apps/api/src/verisimTransport.js apps/api/src/verisimTransport.contract.js tests/integration/verisimdb_contract.js", + }, + { + name: "Lint + typecheck (engine, legacy domain, API transport)", + run: "deno lint packages/shared/src apps/api/src/verisimTransport.js apps/api/src/verisimTransport.contract.js tests/integration/verisimdb_contract.js && deno check packages/shared/src/index.js packages/shared/src/matching/index.js apps/api/src/verisimTransport.js", + }, + { + name: "Engine unit tests", + run: "deno test packages/shared/src", + }, + { + name: "Install verified Bun 1.4.2", + run: "set -euo pipefail\ncurl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64.zip --output \"$RUNNER_TEMP/bun.zip\"\necho \"36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 $RUNNER_TEMP/bun.zip\" | sha256sum --check --strict\nunzip -q \"$RUNNER_TEMP/bun.zip\" -d \"$RUNNER_TEMP\"\necho \"$RUNNER_TEMP/bun-linux-x64\" >> \"$GITHUB_PATH\"\n", + }, + { + name: "Install contract-test dependencies", + run: "bun install --frozen-lockfile --ignore-scripts --cwd apps/api", + }, + { + name: "VeriSimDB v0.2.0 consumer contract tests", + run: "cd apps/api && bun test ./src/verisimTransport.contract.js", + }, + { + name: "Install pinned ijson-jcs", + run: "cargo install --git https://github.com/hyperpolymath/ijson-jcs --rev 0fdeea716fd558e9807362acf6f8202d56d8d88f --locked ijson-jcs", + }, + { + name: "New JSON is I-JSON + JCS", + run: "ijson-jcs check apps/api/package.json\nijson-jcs check tests/contracts/verisimdb/v0.2.0/manifest.json\nijson-jcs check tests/contracts/verisimdb/v0.2.0/octad-input.json\n", + }, + ], + }, + }, +} diff --git a/.machine_readable/integrations/verisimdb.a2ml b/.machine_readable/integrations/verisimdb.a2ml index 164c522..519fe40 100644 --- a/.machine_readable/integrations/verisimdb.a2ml +++ b/.machine_readable/integrations/verisimdb.a2ml @@ -8,6 +8,7 @@ repository = "https://github.com/hyperpolymath/nextgen-databases" data-store = "verisimdb-data" [feed-config] +# Legacy analytics-feed format only; not the flat-mate app's VeriSimDB API contract. emit-scan-results = true emit-build-metrics = true emit-dependency-graph = true diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index 48cdfa7..20e562e 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -8,7 +8,7 @@ (project "flat-mate") (description "London-first flatmate matching and room listings for students. Three-app monorepo: Deno API, React web, Expo mobile, with shared domain logic. - Uses verisimdb hexads for persistence via vector and text search.") + Uses pinned VeriSimDB v0.2.0 octads for persistence via vector and text search.") (canonical-locations (scm-files ".machine_readable/" @@ -19,8 +19,8 @@ (mobile "apps/mobile/" (runtime "expo") (language "jsx")) (shared "packages/shared/" (language "javascript"))) (documentation "docs/") - (architecture "docs/architecture.md") - (topology "TOPOLOGY.md") + (architecture "docs/architecture.adoc") + (topology "TOPOLOGY.adoc") (license "LICENSE" (spdx "MPL-2.0")) (security "SECURITY.md") (well-known ".well-known/")) diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index 577e8be..332a305 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -12,9 +12,11 @@ listing (where to live). flat-mate handles both in one flow, with profile compat and listing discovery centered around London boroughs and universities. ____ -Profiles, listings, and swipe events are stored as hexads in VeriSimDB. The API runs -on Deno; the web client uses React + Vite; the mobile client uses Expo. See -link:docs/architecture.md[] and link:TOPOLOGY.md[] for the full architecture. +Profiles, listings, and swipe events in the legacy v0 flow are stored as octads +through the pinned VeriSimDB v0.2.0 API. The API runs on Deno; the web client +uses React + Vite; the mobile client uses Expo. See link:docs/architecture.adoc[], +link:TOPOLOGY.adoc[], and link:docs/verisimdb-contract.adoc[] for the architecture +and persistence contract. **Caveat:** This is an MVP. University email validation (`.ac.uk`) *is* enforced server-side — every profile write runs `validateProfileInput` @@ -46,6 +48,6 @@ the Squad Audit — see link:docs/design/squad-audit-v1.adoc[]. | `packages/shared/` | Shared domain models and VeriSimDB encoding helpers -| `docs/architecture.md` +| `docs/architecture.adoc` | Architecture is documented |=== diff --git a/README.adoc b/README.adoc index 8d41ec8..cc19cf0 100644 --- a/README.adoc +++ b/README.adoc @@ -37,10 +37,13 @@ link:docs/design/squad-audit-v1.adoc[]. == Architecture -`flat-mate` writes entities as hexads in `verisimdb` (`POST /hexads`, -`GET /search/text`, `POST /search/vector`). Because current `verisimdb` API -responses are compact, the app stores encoded payloads in searchable document -titles as an MVP strategy. +The legacy v0 flow writes entities as octads through the pinned VeriSimDB +v0.2.0 API (`POST /api/v1/octads`, `GET /api/v1/search/text`, +`POST /api/v1/search/vector`). App-facing `OctadInput` payloads are adapted to +that release's actual REST `OctadRequest`; this is deliberately more than a +route rename. Because the server's search responses are compact, the app +stores encoded payloads in searchable document titles as an MVP strategy. +See link:docs/verisimdb-contract.adoc[the verified contract and test guide]. The in-tree profile/listing/swipe write paths are the legacy v0 flow. The v1 target is an epistemic-typed server: per-field governance metadata, @@ -48,16 +51,18 @@ conceal-lattice disclosure tiers, no special-category fields by schema construction — see the design doc. Matching rules live in `packages/shared/src/matching/`, never in UI components. -See `docs/architecture.md`, link:TOPOLOGY.md[], and link:EXPLAINME.adoc[]. +See link:docs/architecture.adoc[], link:TOPOLOGY.adoc[], and link:EXPLAINME.adoc[]. == Quick Start -. Start verisimdb in another terminal: +. Start the pinned VeriSimDB server in another terminal: + [source,bash] ---- -cd /var/mnt/eclipse/repos/verisimdb -cargo run -p verisim-api +git clone https://github.com/hyperpolymath/verisimdb.git +cd verisimdb +git checkout 10f8a21f4d0f2eacce84eee29e504f66fbe9e332 +VERISIM_HOST=127.0.0.1 VERISIM_ENABLE_IPV4=true cargo run -p verisim-api ---- . Install web/mobile dependencies: diff --git a/TOPOLOGY.adoc b/TOPOLOGY.adoc index 3fe4c65..9a1d5d5 100644 --- a/TOPOLOGY.adoc +++ b/TOPOLOGY.adoc @@ -35,7 +35,7 @@ not yet wired into any app. ▼ ▼ ┌───────────────────────┐ ┌────────────────────────────────┐ │ SHARED PACKAGE │ │ VERISIMDB INTERFACE │ - │ - Domain Models │ │ - Hexad encoding │ + │ - Domain Models │ │ - Octad document encoding │ │ - Compatibility Logic │ │ - Vector/Text search │ └──────────┬────────────┘ └──────────┬─────────────────────┘ │ │ @@ -78,8 +78,9 @@ tests exist (`+*.test.js+`; run `+deno test packages/shared+`). |`+apps/mobile+` (Expo) |*LEGACY* v0 only |Wired to the legacy flow; not wired to the matching engine. -|Test coverage |Matching-engine unit tests only |`+tests/+` contains a -fuzz placeholder; no API/web/mobile tests exist. +|Test coverage |Matching-engine unit tests and legacy API contract tests +|`+tests/+` contains a fuzz placeholder and an opt-in VeriSimDB live +integration test; no web/mobile tests exist. |`+.machine_readable/+` |Present |STATE.a2ml is the source of truth for completion status. @@ -88,7 +89,7 @@ completion status. === Key Dependencies .... -User Profile ───► Hexad Encoding ───► verisimdb (Write) +User Profile ───► Octad Document Encoding ───► verisimdb (Write) │ │ │ ▼ ▼ ▼ Matching UI ◄───► API Layer ◄───────► Vector Search diff --git a/apps/api/bun.lock b/apps/api/bun.lock new file mode 100644 index 0000000..c24b3e8 --- /dev/null +++ b/apps/api/bun.lock @@ -0,0 +1,23 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "flat-mate-api-contract-tests", + "devDependencies": { + "ajv": "8.17.1", + }, + }, + }, + "packages": { + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..8957d98 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1 @@ +{"devDependencies":{"ajv":"8.17.1"},"name":"flat-mate-api-contract-tests","private":true,"version":"0.0.0"} diff --git a/apps/api/src/repository.affine b/apps/api/src/repository.affine index 3ed18db..0e2cca9 100644 --- a/apps/api/src/repository.affine +++ b/apps/api/src/repository.affine @@ -6,7 +6,8 @@ module repository; // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell // Repository layer for flat-mate entities. Manages profiles, listings, swipes, and matches -// using verisimdb hexads as the underlying persistence mechanism. +// using VeriSimDB octads as the underlying persistence mechanism. Application user profiles +// are domain records, not VeriSimDB modality profiles. import { buildTitle, @@ -59,7 +60,7 @@ struct FlatMateRepository { updatedAt: new Date().toISOString(), }; - let persisted = await this.persistProfile(record, existing?.hexadId); + let persisted = await this.persistProfile(record, existing?.octadId); return { value: persisted }; } @@ -131,10 +132,14 @@ struct FlatMateRepository { createdAt: new Date().toISOString(), }; - let created = await this.client.createHexad({ - title: buildTitle(ENTITY_KINDS.LISTING, listing), - body: JSON.stringify(listing), - types: ["flatmate:Listing", "student:London"], + let created = await this.client.createOctad({ + document: { + title: buildTitle(ENTITY_KINDS.LISTING, listing), + body: JSON.stringify(listing), + }, + semantic: { + types: ["flatmate:Listing", "student:London"], + }, metadata: { entityKind: ENTITY_KINDS.LISTING, borough: (listing as ListingRecord).borough, @@ -142,7 +147,7 @@ struct FlatMateRepository { }, }); - return { value: { ...listing, hexadId: created.id } }; + return { value: { ...listing, octadId: created.id } }; } async listListings( @@ -189,10 +194,14 @@ struct FlatMateRepository { createdAt: new Date().toISOString(), }; - let created = await this.client.createHexad({ - title: buildTitle(ENTITY_KINDS.SWIPE, swipe), - body: JSON.stringify(swipe), - types: ["flatmate:Swipe", "student:London"], + let created = await this.client.createOctad({ + document: { + title: buildTitle(ENTITY_KINDS.SWIPE, swipe), + body: JSON.stringify(swipe), + }, + semantic: { + types: ["flatmate:Swipe", "student:London"], + }, metadata: { entityKind: ENTITY_KINDS.SWIPE, fromUserId: swipe.fromUserId, @@ -201,7 +210,7 @@ struct FlatMateRepository { }, }); - return { value: { ...swipe, hexadId: created.id } }; + return { value: { ...swipe, octadId: created.id } }; } async listMatches(userId: string) { @@ -243,26 +252,32 @@ struct FlatMateRepository { private async persistProfile( record: Record, - hexadId?: string | null, + octadId?: string | null, ) { let payload = { - title: buildTitle(ENTITY_KINDS.PROFILE, record), - body: JSON.stringify(record), - embedding: profileToVector(record, this.vectorDimension), - types: ["flatmate:Profile", "student:London"], + document: { + title: buildTitle(ENTITY_KINDS.PROFILE, record), + body: JSON.stringify(record), + }, + vector: { + embedding: profileToVector(record, this.vectorDimension), + }, + semantic: { + types: ["flatmate:Profile", "student:London"], + }, metadata: { entityKind: ENTITY_KINDS.PROFILE, userId: (record as ProfileRecord).userId, }, }; - if (hexadId) { - let updated = await this.client.updateHexad(hexadId, payload); - return { ...record, hexadId: updated.id }; + if (octadId) { + let updated = await this.client.updateOctad(octadId, payload); + return { ...record, octadId: updated.id }; } - let created = await this.client.createHexad(payload); - return { ...record, hexadId: created.id }; + let created = await this.client.createOctad(payload); + return { ...record, octadId: created.id }; } private decodeEntities(results: unknown, expectedKind: string) { @@ -286,7 +301,7 @@ struct FlatMateRepository { return { ...payload, - hexadId: result.id, + octadId: result.id, score: result.score ?? null, }; }) diff --git a/apps/api/src/verisimClient.affine b/apps/api/src/verisimClient.affine index a5c7af1..ca6db69 100644 --- a/apps/api/src/verisimClient.affine +++ b/apps/api/src/verisimClient.affine @@ -5,52 +5,32 @@ module verisimClient; // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -// HTTP client for the verisimdb API. Wraps hexad CRUD, text search, and vector search endpoints. +// HTTP client for the pinned VeriSimDB v0.2.0 API. OctadInput payloads are +// adapted to that release's actual OctadRequest wire format by the JS transport. + +import { + requestCreateOctad, + requestTextSearch, + requestUpdateOctad, + requestVectorSearch, +} from "./verisimTransport.js"; struct VerisimClient { constructor(private options: { baseUrl: string }) {} - private baseUrl() { - return this.options.baseUrl.replace(/\/$/, ""); - } - - async createHexad(payload: unknown) { - return await this.request("POST", "/api/v1/hexads", payload); + async createOctad(payload: unknown) { + return await requestCreateOctad(this.options.baseUrl, payload); } - async updateHexad(id: string, payload: unknown) { - return await this.request("PUT", `/api/v1/hexads/${id}`, payload); + async updateOctad(id: string, payload: unknown) { + return await requestUpdateOctad(this.options.baseUrl, id, payload); } async textSearch(query: string, limit = 100) { - let q = encodeURIComponent(query); - let path = `/api/v1/search/text?q=${q}&limit=${limit}`; - return await this.request("GET", path); + return await requestTextSearch(this.options.baseUrl, query, limit); } async vectorSearch(vector: number[], k = 15) { - return await this.request("POST", "/api/v1/search/vector", { vector, k }); - } - - private async request(method: string, path: string, payload?: unknown) { - let url = `${this.baseUrl()}${path}`; - let response = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - }, - body: payload ? JSON.stringify(payload) : undefined, - }); - - let text = await response.text(); - let data = text ? JSON.parse(text) : null; - - if (!response.ok) { - let message = data?.error ?? response.statusText; - throw new Error(`VerisimDB ${method} ${path} failed: ${message}`); - } - - return data; + return await requestVectorSearch(this.options.baseUrl, vector, k); } } - diff --git a/apps/api/src/verisimTransport.contract.js b/apps/api/src/verisimTransport.contract.js new file mode 100644 index 0000000..3db5dce --- /dev/null +++ b/apps/api/src/verisimTransport.contract.js @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// Consumer contract tests for the pinned VeriSimDB v0.2.0 REST API. + +import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { test } from "bun:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import manifest from "../../../tests/contracts/verisimdb/v0.2.0/manifest.json" with { + type: "json", +}; +import octadInputSchema from "../../../tests/contracts/verisimdb/v0.2.0/octad-input.json" with { + type: "json", +}; +import { + requestCreateOctad, + requestTextSearch, + requestUpdateOctad, + requestVectorSearch, + toOctadRequest, + VERISIMDB_CONTRACT, +} from "./verisimTransport.js"; + +const ajv = new Ajv2020({ allErrors: true, strict: false }); +const validateOctadInput = ajv.compile(octadInputSchema); + +function assertOctadInput(value) { + assert.equal( + validateOctadInput(value), + true, + `OctadInput schema mismatch: ${ajv.errorsText(validateOctadInput.errors)}`, + ); +} + +function captureJson(body, status = 200) { + const calls = []; + const fetchImpl = (url, options) => { + calls.push({ url, options }); + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + }; + return { calls, fetchImpl }; +} + +function requestBody(call) { + return JSON.parse(call.options.body); +} + +const PROFILE_INPUT = { + document: { + title: "flatmate_profile london_student user_alice encoded-payload", + body: JSON.stringify({ userId: "alice", name: "Alice" }), + }, + vector: { embedding: [0.1, -0.2, 0.3] }, + semantic: { types: ["flatmate:Profile", "student:London"] }, + metadata: { entityKind: "flatmate_profile", userId: "alice" }, +}; + +test("the schema, transport, and manifest pin the same VeriSimDB release", () => { + assert.deepEqual(VERISIMDB_CONTRACT, { + serverTag: "v0.2.0", + serverCommit: "10f8a21f4d0f2eacce84eee29e504f66fbe9e332", + apiPrefix: "/api/v1", + inputSchema: "OctadInput", + wireRequest: "OctadRequest", + }); + assert.equal(manifest.serverCommit, VERISIMDB_CONTRACT.serverCommit); + assert.equal( + manifest.octadInput.sourceSha256, + "0b7570a1b17dcf6fa376fc1a19e554953c0d700136da95e6eaa470db66356946", + ); + const schemaBytes = readFileSync( + new URL( + "../../../tests/contracts/verisimdb/v0.2.0/octad-input.json", + import.meta.url, + ), + ); + assert.equal(schemaBytes[schemaBytes.length - 1], 10, "one trailing LF"); + assert.notEqual(schemaBytes[schemaBytes.length - 2], 10); + const canonicalBytes = schemaBytes.subarray(0, schemaBytes.length - 1); + assert.equal( + createHash("sha256").update(canonicalBytes).digest("hex"), + manifest.octadInput.canonicalSha256, + ); + assert.equal(octadInputSchema.title, "OctadInput"); + assert.equal(octadInputSchema.additionalProperties, false); +}); + +test("create uses /octads and adapts a user profile's three input modalities to OctadRequest", async () => { + assertOctadInput(PROFILE_INPUT); + const { calls, fetchImpl } = captureJson({ id: "octad-123" }, 201); + + const created = await requestCreateOctad( + "http://verisim.test/", + PROFILE_INPUT, + fetchImpl, + ); + + assert.equal(created.id, "octad-123"); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://verisim.test/api/v1/octads"); + assert.equal(calls[0].options.method, "POST"); + assert.ok(!calls[0].url.includes("/hexads")); + assert.deepEqual(requestBody(calls[0]), { + title: PROFILE_INPUT.document.title, + body: PROFILE_INPUT.document.body, + embedding: PROFILE_INPUT.vector.embedding, + types: PROFILE_INPUT.semantic.types, + metadata: PROFILE_INPUT.metadata, + }); + + // The domain user profile remains intact in document.body/metadata. Its + // document+vector+semantic selection is the VeriSimDB modality profile. + assert.equal(JSON.parse(requestBody(calls[0]).body).userId, "alice"); + assert.equal(requestBody(calls[0]).metadata.userId, "alice"); +}); + +test("a listing write selects document+semantic and does not invent a vector modality", async () => { + const input = { + document: { + title: "flatmate_listing london_student", + body: '{"listingId":"listing-1"}', + }, + semantic: { types: ["flatmate:Listing", "student:London"] }, + metadata: { entityKind: "flatmate_listing", borough: "camden" }, + }; + assertOctadInput(input); + const { calls, fetchImpl } = captureJson({ id: "octad-listing" }, 201); + + await requestCreateOctad("http://verisim.test", input, fetchImpl); + + assert.deepEqual(requestBody(calls[0]), { + title: input.document.title, + body: input.document.body, + types: input.semantic.types, + metadata: input.metadata, + }); + assert.equal("embedding" in requestBody(calls[0]), false); +}); + +test("update uses the octad route and leaves omitted modalities absent", async () => { + const input = { + document: { title: "replacement title", body: "replacement body" }, + }; + assertOctadInput(input); + const { calls, fetchImpl } = captureJson({ id: "octad-123" }); + + await requestUpdateOctad( + "http://verisim.test", + "octad-123", + input, + fetchImpl, + ); + + assert.equal(calls[0].url, "http://verisim.test/api/v1/octads/octad-123"); + assert.equal(calls[0].options.method, "PUT"); + assert.deepEqual(requestBody(calls[0]), { + title: "replacement title", + body: "replacement body", + }); +}); + +test("the adapter covers the other lossless OctadInput modality mappings", () => { + const input = { + graph: { relationships: [{ predicate: "knows", target_id: "octad-456" }] }, + tensor: { shape: [2, 2], data: [1, 2, 3, 4] }, + provenance: { + event_type: "imported", + actor: "contract-test", + source: "fixture", + description: "Contract fixture import", + }, + spatial: { + latitude: 51.5074, + longitude: -0.1278, + geometry_type: "Point", + srid: 4326, + properties: { city: "London" }, + }, + }; + assertOctadInput(input); + + assert.deepEqual(toOctadRequest(input), { + relationships: [["knows", "octad-456"]], + tensor: { shape: [2, 2], data: [1, 2, 3, 4] }, + provenance: input.provenance, + spatial: input.spatial, + }); +}); + +test("unrepresentable optional data fails instead of being silently dropped", () => { + const invalidCases = [ + { document: { title: "title", body: "body", fields: { author: "Alice" } } }, + { vector: { embedding: [0.1], model: "unrepresented-model" } }, + { semantic: { types: [], properties: { key: "unrepresented-value" } } }, + ]; + + for (const input of invalidCases) { + assertOctadInput(input); + assert.throws(() => toOctadRequest(input), /cannot preserve/); + } +}); + +test("text and vector search retain the verified v0.2.0 request/result shapes", async () => { + const searchResults = [{ + id: "octad-123", + score: 0.75, + title: "encoded title", + }]; + const text = captureJson(searchResults); + const textResult = await requestTextSearch( + "http://verisim.test", + "flat mate & friends", + 25, + text.fetchImpl, + ); + + assert.equal( + text.calls[0].url, + "http://verisim.test/api/v1/search/text?q=flat%20mate%20%26%20friends&limit=25", + ); + assert.equal(text.calls[0].options.method, "GET"); + assert.equal(text.calls[0].options.body, undefined); + assert.deepEqual(textResult, searchResults); + assert.equal(textResult[0].title, "encoded title"); + + const vector = captureJson(searchResults); + const vectorResult = await requestVectorSearch( + "http://verisim.test", + [0.1, 0.2], + 5, + vector.fetchImpl, + ); + assert.equal(vector.calls[0].url, "http://verisim.test/api/v1/search/vector"); + assert.deepEqual(requestBody(vector.calls[0]), { vector: [0.1, 0.2], k: 5 }); + assert.deepEqual(vectorResult, searchResults); +}); + +test("the actual v0.2.0 error envelope is surfaced", async () => { + const { fetchImpl } = captureJson({ + error: "Bad request: invalid vector", + code: 400, + }, 400); + + await assert.rejects( + requestVectorSearch("http://verisim.test", [Number.NaN], 1, fetchImpl), + /VerisimDB POST \/api\/v1\/search\/vector failed: Bad request: invalid vector/, + ); +}); diff --git a/apps/api/src/verisimTransport.js b/apps/api/src/verisimTransport.js new file mode 100644 index 0000000..5118e55 --- /dev/null +++ b/apps/api/src/verisimTransport.js @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// VeriSimDB v0.2.0 REST transport. See docs/verisimdb-contract.adoc. + +export const VERISIMDB_CONTRACT = Object.freeze({ + serverTag: "v0.2.0", + serverCommit: "10f8a21f4d0f2eacce84eee29e504f66fbe9e332", + apiPrefix: "/api/v1", + inputSchema: "OctadInput", + wireRequest: "OctadRequest", +}); + +const INPUT_KEYS = [ + "graph", + "vector", + "tensor", + "semantic", + "document", + "provenance", + "spatial", + "metadata", +]; + +/** Treat null and undefined as omitted modality values. */ +const present = (value) => value !== undefined && value !== null; + +/** Return value unchanged; reject null, arrays and non-objects with a path-labelled Error. */ +function record(value, path) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error( + `${path} must be an object for ${VERISIMDB_CONTRACT.serverTag}.`, + ); + } + return value; +} + +/** Throw a path-labelled Error for an enumerable own string key absent from allowed. */ +function onlyKeys(value, allowed, path) { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + throw new Error( + `${path}.${key} is not in the pinned ${VERISIMDB_CONTRACT.serverTag} contract.`, + ); + } + } +} + +/** Copy a string array, or throw a path-labelled Error for invalid input. */ +function strings(value, path) { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new Error(`${path} must be an array of strings.`); + } + return [...value]; +} + +/** Copy string-valued own enumerable entries; throw a path-labelled Error for invalid input. */ +function stringMap(value, path) { + record(value, path); + for (const [key, item] of Object.entries(value)) { + if (typeof item !== "string") { + throw new Error(`${path}.${key} must be a string.`); + } + } + return { ...value }; +} + +/** Copy a finite-number array, or throw a path-labelled Error for invalid input. */ +function numbers(value, path) { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== "number" || !Number.isFinite(item)) + ) { + throw new Error(`${path} must be an array of finite numbers.`); + } + return [...value]; +} + +/** + * Convert the published OctadInput data schema to the flat OctadRequest that + * the v0.2.0 REST handler actually deserialises. Do not post OctadInput + * directly to that release: unknown nested fields are ignored by Serde. + * + * Returns a new request object, omitting null or undefined modalities. A present + * semantic block without types emits an empty types array. This is an adapter, + * not a complete schema validator; provenance and spatial values are otherwise + * forwarded without validation, except for spatial.properties. + * + * Throws an Error for unknown keys, invalid checked fields, non-empty + * document.fields or semantic.properties, or any defined vector.model. + * Metadata, when supplied, must be an object of string values. + */ +export function toOctadRequest(input) { + record(input, "OctadInput"); + onlyKeys(input, INPUT_KEYS, "OctadInput"); + const output = {}; + + if (present(input.document)) { + const document = record(input.document, "OctadInput.document"); + onlyKeys(document, ["title", "body", "fields"], "OctadInput.document"); + if ( + typeof document.title !== "string" || typeof document.body !== "string" + ) { + throw new Error( + "OctadInput.document requires string title and body fields.", + ); + } + if ( + document.fields !== undefined && + Object.keys(stringMap(document.fields, "OctadInput.document.fields")) + .length + ) { + throw new Error( + "VeriSimDB v0.2.0 OctadRequest cannot preserve document.fields.", + ); + } + output.title = document.title; + output.body = document.body; + } + + if (present(input.vector)) { + const vector = record(input.vector, "OctadInput.vector"); + onlyKeys(vector, ["embedding", "model"], "OctadInput.vector"); + if (vector.model !== undefined) { + throw new Error( + "VeriSimDB v0.2.0 OctadRequest cannot preserve vector.model.", + ); + } + output.embedding = numbers(vector.embedding, "OctadInput.vector.embedding"); + } + + if (present(input.tensor)) { + const tensor = record(input.tensor, "OctadInput.tensor"); + onlyKeys(tensor, ["shape", "data"], "OctadInput.tensor"); + if ( + !Array.isArray(tensor.shape) || + tensor.shape.some((n) => !Number.isInteger(n) || n < 0) + ) { + throw new Error( + "OctadInput.tensor.shape must contain non-negative integers.", + ); + } + output.tensor = { + shape: [...tensor.shape], + data: numbers(tensor.data, "OctadInput.tensor.data"), + }; + } + + if (present(input.semantic)) { + const semantic = record(input.semantic, "OctadInput.semantic"); + onlyKeys(semantic, ["types", "properties"], "OctadInput.semantic"); + if ( + semantic.properties !== undefined && + Object.keys( + stringMap(semantic.properties, "OctadInput.semantic.properties"), + ).length + ) { + throw new Error( + "VeriSimDB v0.2.0 OctadRequest cannot preserve semantic.properties.", + ); + } + output.types = semantic.types === undefined + ? [] + : strings(semantic.types, "OctadInput.semantic.types"); + } + + if (present(input.graph)) { + const graph = record(input.graph, "OctadInput.graph"); + onlyKeys(graph, ["relationships"], "OctadInput.graph"); + if (!Array.isArray(graph.relationships)) { + throw new Error("OctadInput.graph.relationships must be an array."); + } + output.relationships = graph.relationships.map((relationship, index) => { + const path = `OctadInput.graph.relationships[${index}]`; + record(relationship, path); + onlyKeys(relationship, ["predicate", "target_id"], path); + if ( + typeof relationship.predicate !== "string" || + typeof relationship.target_id !== "string" + ) { + throw new Error( + `${path} requires string predicate and target_id fields.`, + ); + } + return [relationship.predicate, relationship.target_id]; + }); + } + + if (present(input.provenance)) { + const provenance = record(input.provenance, "OctadInput.provenance"); + onlyKeys( + provenance, + ["event_type", "actor", "source", "description"], + "OctadInput.provenance", + ); + output.provenance = { ...provenance }; + } + + if (present(input.spatial)) { + const spatial = record(input.spatial, "OctadInput.spatial"); + onlyKeys( + spatial, + [ + "latitude", + "longitude", + "altitude", + "geometry_type", + "srid", + "properties", + ], + "OctadInput.spatial", + ); + output.spatial = { ...spatial }; + if (spatial.properties !== undefined) { + output.spatial.properties = stringMap( + spatial.properties, + "OctadInput.spatial.properties", + ); + } + } + + if (input.metadata !== undefined) { + output.metadata = stringMap(input.metadata, "OctadInput.metadata"); + } + + return output; +} + +/** + * Send JSON to a path relative to the server root, ignoring trailing baseUrl slashes. + * fetchImpl overrides globalThis.fetch; an undefined payload omits the body. + * Resolves to parsed response JSON, or null for an empty successful response. + * + * Rejects when Fetch is unavailable, an HTTP status is unsuccessful, or a + * successful response contains invalid JSON. Unsuccessful responses use their + * JSON error/message, raw text (including invalid JSON), or status text as the + * error message. Fetch, response-reading and JSON serialisation errors propagate. + */ +async function request(baseUrl, method, path, payload, fetchImpl) { + const fetcher = fetchImpl ?? globalThis.fetch; + if (typeof fetcher !== "function") { + throw new Error( + "A Fetch API implementation is required for VeriSimDB requests.", + ); + } + + const url = `${baseUrl.replace(/\/+$/, "")}${path}`; + const response = await fetcher(url, { + method, + headers: { "Content-Type": "application/json" }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + const text = await response.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + if (response.ok) { + throw new Error(`VerisimDB ${method} ${path} returned invalid JSON.`); + } + } + } + + if (!response.ok) { + const message = (data?.error ?? data?.message ?? text) || + response.statusText; + throw new Error(`VerisimDB ${method} ${path} failed: ${message}`); + } + return data; +} + +/** + * Create an octad from nested OctadInput at the server root baseUrl. + * Throws synchronously if toOctadRequest rejects the payload. Returns the + * request promise with parsed JSON (or null for an empty body); {@link request} errors + * propagate. fetchImpl optionally replaces globalThis.fetch. + */ +export function requestCreateOctad(baseUrl, payload, fetchImpl) { + return request( + baseUrl, + "POST", + "/api/v1/octads", + toOctadRequest(payload), + fetchImpl, + ); +} + +/** + * Update an octad by URL-encoded id using nested OctadInput; omitted modalities + * are absent from the request. baseUrl is the server root; fetchImpl optionally + * replaces globalThis.fetch. Payload adaptation and id encoding errors throw + * synchronously. Returns the request promise with parsed JSON (or null for an + * empty body), propagating {@link request} errors. + */ +export function requestUpdateOctad(baseUrl, id, payload, fetchImpl) { + const path = `/api/v1/octads/${encodeURIComponent(id)}`; + return request(baseUrl, "PUT", path, toOctadRequest(payload), fetchImpl); +} + +/** + * Search text at the server root baseUrl, passing limit through without validation. + * Resolves to the server's JSON results unchanged, or null for an empty body. + * Query encoding errors throw synchronously; {@link request} errors propagate through + * the returned promise. fetchImpl optionally replaces globalThis.fetch. + */ +export function requestTextSearch(baseUrl, query, limit = 100, fetchImpl) { + const q = encodeURIComponent(query); + return request( + baseUrl, + "GET", + `/api/v1/search/text?q=${q}&limit=${limit}`, + undefined, + fetchImpl, + ); +} + +/** + * Search by vector at the server root baseUrl, requesting k neighbours without + * validating vector or k locally. Resolves to the server's JSON results unchanged, + * or null for an empty body. {@link request} errors propagate through the returned + * promise. fetchImpl optionally replaces globalThis.fetch. + */ +export function requestVectorSearch(baseUrl, vector, k = 15, fetchImpl) { + return request( + baseUrl, + "POST", + "/api/v1/search/vector", + { vector, k }, + fetchImpl, + ); +} diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 83be6e7..c1d65d8 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -27,10 +27,15 @@ directly. * Shared legacy modules (`+packages/shared/src/domain.js+`, `+encoding.js+`, `+constants.js+`) own validation, the v1 compatibility scoring, and vectorization. -* The apps have *no tests*. +* The web and mobile apps have *no tests*. The legacy API has pinned +VeriSimDB transport contract tests; its live integration test is opt-in. -Persistence model: entities are stored as VerisimDB `+hexads+` using the -`+document+` + optional `+vector+` modalities. +Persistence model: entities are stored as VeriSimDB `+octads+` through the +pinned v0.2.0 REST contract. Listing/swipe inputs use `+document+` and +`+semantic+`; user-profile inputs add `+vector+`. This modality selection is +distinct from the application's user-profile domain record. See +link:verisimdb-contract.adoc[`+docs/verisimdb-contract.adoc+`] for the +`+OctadInput+` to `+OctadRequest+` compatibility boundary. Entity kinds: `+flatmate_profile+`, `+flatmate_listing+`, `+flatmate_swipe+`. @@ -39,7 +44,7 @@ Payload encoding strategy (intentionally pragmatic for current VerisimDB API constraints): * Add searchable tokens (`+flatmate_profile+`, `+borough_camden+`, etc.) -to the hexad title. +to the octad's document title. * Append a base64url payload to the title for deterministic decode on read. * Keep the body as human-readable JSON for audit/debug. diff --git a/docs/verisimdb-contract.adoc b/docs/verisimdb-contract.adoc new file mode 100644 index 0000000..92490bd --- /dev/null +++ b/docs/verisimdb-contract.adoc @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell + += VeriSimDB API contract (legacy v0 app) +:toc: preamble + +== Target decision + +The old client had only `VERISIMDB_BASE_URL`: it did not pin or probe a server +version, so a running deployment's version cannot be inferred retrospectively +from this repository. There is also no configuration or documentation for an +intentionally legacy hexad service. The quick start instead instructed the +operator to run `verisim-api` from an upstream source checkout. Thus the +intended target was current upstream, not a declared legacy compatibility +mode. "Legacy v0" describes flat-mate's swipe/listings product flow; it does +not identify a VeriSimDB server version. + +This client now targets **VeriSimDB v0.2.0**, Git commit +`10f8a21f4d0f2eacce84eee29e504f66fbe9e332`, with the `/api/v1` REST prefix. The +pin is recorded in `apps/api/src/verisimTransport.js` and +`tests/contracts/verisimdb/v0.2.0/manifest.json`. The vendored +`octad-input.json` preserves the upstream parsed schema but is serialised as +I-JSON + JCS under estate rule D306. The manifest records both the original +upstream byte hash (`sourceSha256`, +`0b7570a1b17dcf6fa376fc1a19e554953c0d700136da95e6eaa470db66356946`) and the +local canonical hash (`canonicalSha256`, +`9e769ed30ebac70419e325839e712673b8cf6f116f2f6ad8f65fd27cd5925aa7`). The latter +covers the exact JCS bytes without the file's single trailing LF. + +The target was checked on 2026-10-24. Upstream `main` was then at +`72837ebc169c75ae678a33d21f7813bec8dd4405`; its relevant `OctadInput`, +`OctadRequest`, octad handlers, and search result shapes were unchanged from +v0.2.0. The release pin, rather than floating `main`, remains authoritative. + +== The two upstream shapes are not interchangeable + +The route change alone is not a safe migration. At the pinned commit these +sources describe different layers: + +* link:https://github.com/hyperpolymath/verisimdb/blob/10f8a21f4d0f2eacce84eee29e504f66fbe9e332/connectors/shared/json-schema/octad-input.json[`octad-input.json`] + publishes the nested `OctadInput` data schema. +* link:https://github.com/hyperpolymath/verisimdb/blob/10f8a21f4d0f2eacce84eee29e504f66fbe9e332/rust-core/verisim-api/src/lib.rs[`rust-core/verisim-api/src/lib.rs`] + defines the executable REST contract. Its `create_octad_handler` and + `update_octad_handler` deserialize **`OctadRequest`**, then call + `to_octad_input()` internally. The upstream handler tests also construct + this flat request type. + +[cols="1,2,2",options="header"] +|=== +|Operation |Published data shape |Actual v0.2.0 REST wire shape + +|Create +|`OctadInput`; `POST /api/v1/octads`; 201 +|Flat `OctadRequest`; compact `OctadResponse` + +|Update +|`OctadInput`; `PUT /api/v1/octads/{id}`; 200 +|Flat `OctadRequest`; compact `OctadResponse` + +|Text search +|`GET /api/v1/search/text?q=...&limit=...` +|Array of `{id, score, title}` (`title` is nullable) + +|Vector search +|`POST /api/v1/search/vector` with `{vector, k}` +|Array of `{id, score, title}` (`title` is nullable) +|=== + +For example, the application's nested input is: + +[source,json] +---- +{ + "document": {"title": "searchable title", "body": "{...}"}, + "vector": {"embedding": [0.1, 0.2]}, + "semantic": {"types": ["flatmate:Profile"]}, + "metadata": {"userId": "user-123"} +} +---- + +The v0.2.0 REST handler instead requires this wire representation to populate +the same modalities: + +[source,json] +---- +{ + "title": "searchable title", + "body": "{...}", + "embedding": [0.1, 0.2], + "types": ["flatmate:Profile"], + "metadata": {"userId": "user-123"} +} +---- + +Posting the first object directly to the pinned REST handler is **not** a +valid migration. That handler does not deny unknown fields; the nested +`document`, `vector`, and `semantic` keys can therefore be ignored while all +flat `Option` fields remain empty, risking a successful but empty write. +Likewise, the OpenAPI response references must not be used to replace the +client's compact search-result decoder with a full-octad decoder without an +upstream server change. + +`toOctadRequest()` in `verisimTransport.js` is the explicit compatibility +boundary. Repositories construct schema-valid `OctadInput`; the transport +emits the verified flat `OctadRequest`. Fields that are valid in the published +schema but cannot be represented by this REST handler (`document.fields`, +`vector.model`, and non-empty `semantic.properties`) are rejected rather than +silently discarded. The old `/api/v1/hexads` routes and `createHexad` / +`updateHexad` methods are not retained as aliases: no hexad service is a +supported target. + +== Modality profiles vs application profiles + +A **modality profile** names the selected VeriSimDB input modalities and their +constraints. It is not a flat-mate user/profile concept. + +* A flat-mate **user profile** is a domain record containing identity, + preferences, and matching data. Its current persistence input selects + `document + semantic + vector`: three of the eight Octad modality slots. +* A listing or swipe persistence input selects `document + semantic`: two + input modality slots. It does not acquire a vector merely because user + profiles have one. +* `metadata` is an entity-level string map, not a ninth modality. Temporal and + provenance behaviour managed by the server is not counted in these input + selections. + +These are application-specific, versioned choices, not a claim that arbitrary +modality subsets are accepted by every VeriSimDB API. **Octad** remains the +name of the exact eight-modality v0.2.0 entity/API; neither "hexad" nor +"modality profile" is used as a number-agnostic alias for it. + +== Tests and upgrade rule + +Fast consumer contract tests validate representative inputs against the +vendored schema, assert the actual flat wire payloads and endpoints, cover +partial updates and compact search results, and reject lossy mappings: + +[source,bash] +---- +bun install --frozen-lockfile --ignore-scripts --cwd apps/api +bun test ./apps/api/src/verisimTransport.contract.js +---- + +Bun 1.4.2 and the lockfile make this test path reproducible. Ajv 8.17.1 remains +the JSON Schema validator; `ijson-jcs` has the separate job of enforcing I-JSON +and JCS on the manifest, vendored schema, and Bun manifest. CI installs the +pinned `hyperpolymath/ijson-jcs` CLI and checks those files. The CI workflow +keeps GitHub's required `.yml` filename but its content is KYAML. To apply the +same JSON checks locally, install that CLI and run `ijson-jcs check FILE` on +each path. The `.contract.js` suffix keeps this Bun test explicit and outside +Deno's default discovery for the legacy suite. + +An opt-in live integration test verifies create, text search, vector search, +partial update, and delete against real storage. Start `verisim-api` from the +pinned commit, then run: + +[source,bash] +---- +VERISIMDB_BASE_URL=http://127.0.0.1:8080 \ +VERISIMDB_CONTRACT_COMMIT=10f8a21f4d0f2eacce84eee29e504f66fbe9e332 \ +bun test ./tests/integration/verisimdb_contract.js +---- + +The commit environment variable is an operator attestation for the disposable +test server, not automatic version detection. The v0.2.0 health endpoint's +package version is not a Git commit attestation. + +For an upgrade, change the pin only after comparing the new executable REST +handlers and schemas, updating the vendored contract, and passing both test +layers against that server. If upstream later accepts nested `OctadInput` +natively, remove the flattening adapter as part of that separately pinned +migration; do not infer wire compatibility from a route name or from the core +`OctadInput` type alone. diff --git a/mise.toml b/mise.toml index 6dd983f..86f7fc0 100644 --- a/mise.toml +++ b/mise.toml @@ -6,7 +6,7 @@ rust = "latest" go = "latest" zig = "latest" java = "latest" -bun = "latest" +bun = "1.4.2" denojs = "latest" # Package managers diff --git a/packages/shared/src/encoding.js b/packages/shared/src/encoding.js index faf5512..0b3026b 100644 --- a/packages/shared/src/encoding.js +++ b/packages/shared/src/encoding.js @@ -3,7 +3,7 @@ // LEGACY v0 — retained pending a quarantine/removal decision (see // docs/design/squad-audit-v1.adoc); the title-encoding scheme is a // self-declared disposable MVP strategy. Do not extend this module. -// Entity encoding for verisimdb hexad titles. Packs entity payloads into base64url tokens +// Entity encoding for VeriSimDB octad document titles. Packs entity payloads into base64url tokens // embedded within searchable title strings, enabling text-based entity retrieval. const safeToken = (value) => @@ -15,7 +15,7 @@ const safeToken = (value) => export const ENTITY_KINDS = { PROFILE: "flatmate_profile", LISTING: "flatmate_listing", - SWIPE: "flatmate_swipe" + SWIPE: "flatmate_swipe", }; function base64Encode(bytes) { @@ -37,8 +37,12 @@ function base64Decode(value) { return buffer; } +/** Encode bytes as base64url without trailing padding. */ function toBase64Url(bytes) { - return base64Encode(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64Encode(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace( + /=+$/, + "", + ); } function fromBase64Url(value) { @@ -59,6 +63,12 @@ function decodePayload(value) { return JSON.parse(text); } +/** + * Build a searchable title ending in the JSON payload encoded as base64url. + * Profile and listing kinds select their respective search tokens; every other + * kind uses swipe tokens. JSON serialisation errors, such as circular references + * or BigInt values, propagate to the caller. + */ export function buildTitle(kind, payload) { const encoded = encodePayload(payload); @@ -70,7 +80,7 @@ export function buildTitle(kind, payload) { `uni_${safeToken(payload.university)}`, `borough_${safeToken(payload.preferredBorough)}`, `rent_${payload.minRent}_${payload.maxRent}`, - encoded + encoded, ].join(" "); } @@ -82,7 +92,7 @@ export function buildTitle(kind, payload) { `owner_${safeToken(payload.ownerUserId)}`, `borough_${safeToken(payload.borough)}`, `rent_${payload.rentPcm}`, - encoded + encoded, ].join(" "); } @@ -92,7 +102,7 @@ export function buildTitle(kind, payload) { `from_${safeToken(payload.fromUserId)}`, `to_${safeToken(payload.toUserId)}`, `like_${payload.liked ? "1" : "0"}`, - encoded + encoded, ].join(" "); } diff --git a/tests/contracts/verisimdb/v0.2.0/manifest.json b/tests/contracts/verisimdb/v0.2.0/manifest.json new file mode 100644 index 0000000..b3d1a12 --- /dev/null +++ b/tests/contracts/verisimdb/v0.2.0/manifest.json @@ -0,0 +1 @@ +{"apiPrefix":"/api/v1","inputSchema":"OctadInput","note":"OctadInput is the published data schema. The pinned REST handler deserializes flat OctadRequest and converts it internally; verisimTransport.js performs that mapping explicitly.","octadInput":{"canonicalSha256":"9e769ed30ebac70419e325839e712673b8cf6f116f2f6ad8f65fd27cd5925aa7","canonicalization":"I-JSON (RFC 7493) and JCS (RFC 8785); canonicalSha256 excludes the single trailing LF","source":"https://github.com/hyperpolymath/verisimdb/blob/10f8a21f4d0f2eacce84eee29e504f66fbe9e332/connectors/shared/json-schema/octad-input.json","sourceSha256":"0b7570a1b17dcf6fa376fc1a19e554953c0d700136da95e6eaa470db66356946"},"restImplementation":"https://github.com/hyperpolymath/verisimdb/blob/10f8a21f4d0f2eacce84eee29e504f66fbe9e332/rust-core/verisim-api/src/lib.rs","serverCommit":"10f8a21f4d0f2eacce84eee29e504f66fbe9e332","serverTag":"v0.2.0","wireRequest":"OctadRequest"} diff --git a/tests/contracts/verisimdb/v0.2.0/octad-input.json b/tests/contracts/verisimdb/v0.2.0/octad-input.json new file mode 100644 index 0000000..1ff040a --- /dev/null +++ b/tests/contracts/verisimdb/v0.2.0/octad-input.json @@ -0,0 +1 @@ +{"$comment":"SPDX-License-Identifier: MPL-2.0","$id":"https://verisim.db/schema/octad-input.json","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Input payload for creating or updating a VeriSimDB octad entity. All modality fields are optional -- only the modalities provided will be populated or updated. Omitted modalities are left unchanged on update, or unpopulated on create.","properties":{"document":{"description":"Document content to set. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Document modality input. Full-text content for indexing and search.","properties":{"body":{"description":"Document body. Full-text indexed for search.","type":"string"},"fields":{"additionalProperties":{"type":"string"},"description":"Additional searchable fields beyond title and body (e.g. 'author', 'category').","type":"object"},"title":{"description":"Document title. Used for display and search ranking.","type":"string"}},"required":["title","body"],"type":"object"}]},"graph":{"description":"Graph relationships to create. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Graph modality input. Defines outgoing relationships from this entity to other entities.","properties":{"relationships":{"description":"List of outgoing edges. Each relationship is a (predicate, target_id) pair.","items":{"properties":{"predicate":{"description":"Edge label or relationship type (IRI or plain string, e.g. 'relates_to', 'https://schema.org/knows').","type":"string"},"target_id":{"description":"Target entity octad ID or IRI.","type":"string"}},"required":["predicate","target_id"],"type":"object"},"type":"array"}},"required":["relationships"],"type":"object"}]},"metadata":{"additionalProperties":{"type":"string"},"description":"Arbitrary key-value metadata. Not indexed by any specific modality but stored alongside the entity.","type":"object"},"provenance":{"description":"Provenance event to record. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Provenance modality input. Records a lineage event in the entity's hash chain.","properties":{"actor":{"description":"Who or what caused this event (user ID, system component, bot name).","type":"string"},"description":{"description":"Human-readable description of what happened.","type":"string"},"event_type":{"description":"Classification of the provenance event.","enum":["created","modified","imported","normalized","drift_repaired","deleted","merged"],"type":"string"},"source":{"description":"Optional source identifier (URL, file path, upstream entity ID).","type":"string"}},"required":["event_type","actor","description"],"type":"object"}]},"semantic":{"description":"Semantic annotations to set. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Semantic modality input. Ontological type annotations and property maps.","properties":{"properties":{"additionalProperties":{"type":"string"},"description":"Semantic key-value properties. Keys are property IRIs or plain strings; values are string literals.","type":"object"},"types":{"description":"Type IRIs from ontologies (e.g. ['https://schema.org/Person', 'http://xmlns.com/foaf/0.1/Agent']).","items":{"type":"string"},"type":"array"}},"type":"object"}]},"spatial":{"description":"Spatial data to set. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Spatial modality input. Geospatial coordinates and geometry.","properties":{"altitude":{"description":"Altitude in metres above the WGS84 ellipsoid. Optional.","type":"number"},"geometry_type":{"description":"OGC Simple Features geometry type. Defaults to 'Point' if omitted.","enum":["Point","LineString","Polygon","MultiPoint","MultiPolygon"],"type":"string"},"latitude":{"description":"Latitude in decimal degrees (WGS84). North is positive.","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (WGS84). East is positive.","maximum":180,"minimum":-180,"type":"number"},"properties":{"additionalProperties":{"type":"string"},"description":"Arbitrary spatial metadata (address, region, accuracy, etc.).","type":"object"},"srid":{"description":"Spatial Reference System Identifier. Defaults to 4326 (WGS84) if omitted.","minimum":0,"type":"integer"}},"required":["latitude","longitude"],"type":"object"}]},"tensor":{"description":"Tensor data to set. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Tensor modality input. Multi-dimensional numeric data with shape metadata.","properties":{"data":{"description":"Flattened tensor values in row-major order (float64).","items":{"type":"number"},"type":"array"},"shape":{"description":"Tensor dimensions (e.g. [3, 4] for a 3x4 matrix). The product of all values must equal the length of the data array.","items":{"minimum":0,"type":"integer"},"minItems":1,"type":"array"}},"required":["shape","data"],"type":"object"}]},"vector":{"description":"Vector embedding to set. Null or omitted to skip.","oneOf":[{"type":"null"},{"description":"Vector modality input. Provides a dense embedding for similarity search.","properties":{"embedding":{"description":"Dense embedding vector (float32 values). Length must match the store's configured vector_dimension.","items":{"type":"number"},"type":"array"},"model":{"description":"Name or identifier of the embedding model used (e.g. 'all-MiniLM-L6-v2'). Optional, for provenance tracking.","type":"string"}},"required":["embedding"],"type":"object"}]}},"title":"OctadInput","type":"object"} diff --git a/tests/integration/verisimdb_contract.js b/tests/integration/verisimdb_contract.js new file mode 100644 index 0000000..5ca4bfc --- /dev/null +++ b/tests/integration/verisimdb_contract.js @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// Opt-in live integration test. Run explicitly against the v0.2.0 checkout; +// this file is intentionally not matched by Bun's default *.test discovery. + +import { strict as assert } from "node:assert"; +import { test } from "bun:test"; +import { + requestCreateOctad, + requestTextSearch, + requestUpdateOctad, + requestVectorSearch, + VERISIMDB_CONTRACT, +} from "../../apps/api/src/verisimTransport.js"; + +const baseUrl = Bun.env.VERISIMDB_BASE_URL; +const attestedCommit = Bun.env.VERISIMDB_CONTRACT_COMMIT; + +if (!baseUrl) { + throw new Error( + "Set VERISIMDB_BASE_URL to the pinned VeriSimDB test server.", + ); +} +if (attestedCommit !== VERISIMDB_CONTRACT.serverCommit) { + throw new Error( + `Start VeriSimDB at ${VERISIMDB_CONTRACT.serverTag} (${VERISIMDB_CONTRACT.serverCommit}) ` + + "and set VERISIMDB_CONTRACT_COMMIT to that full commit. The health endpoint cannot attest a Git commit.", + ); +} + +test("live v0.2.0 OctadInput -> OctadRequest -> search round trip", async () => { + const health = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/v1/health`); + assert.equal(health.status, 200, "pinned VeriSimDB health endpoint"); + await health.body?.cancel(); + + const token = `contract${Date.now()}`; + const userId = `user-${token}`; + const originalTitle = `flatmate_profile london_student ${token} original`; + const updatedTitle = `flatmate_profile london_student ${token} updated`; + const embedding = Array.from( + { length: 384 }, + (_, index) => index === 0 ? 1 : 0, + ); + let octadId; + + try { + const created = await requestCreateOctad(baseUrl, { + document: { + title: originalTitle, + body: JSON.stringify({ userId, name: "Contract Test" }), + }, + vector: { embedding }, + semantic: { types: ["flatmate:Profile", "student:London"] }, + metadata: { entityKind: "flatmate_profile", userId }, + }); + + octadId = created.id; + assert.equal(typeof octadId, "string"); + assert.equal( + created.has_document, + true, + "document must not be dropped by the REST adapter", + ); + assert.equal( + created.has_vector, + true, + "vector must not be dropped by the REST adapter", + ); + assert.equal( + created.has_semantic, + true, + "semantic types must not be dropped by the REST adapter", + ); + + const textResults = await requestTextSearch(baseUrl, token, 100); + const textHit = textResults.find((result) => result.id === octadId); + assert.ok(textHit, "created octad must be visible through text search"); + assert.equal(textHit.title, originalTitle); + assert.equal(typeof textHit.score, "number"); + + const vectorResults = await requestVectorSearch(baseUrl, embedding, 100); + const vectorHit = vectorResults.find((result) => result.id === octadId); + assert.ok(vectorHit, "created octad must be visible through vector search"); + assert.equal(vectorHit.title, originalTitle); + + const updated = await requestUpdateOctad(baseUrl, octadId, { + document: { + title: updatedTitle, + body: JSON.stringify({ userId, name: "Contract Test Updated" }), + }, + }); + assert.equal(updated.id, octadId); + assert.equal(updated.has_document, true); + + const updatedResults = await requestTextSearch(baseUrl, token, 100); + assert.equal( + updatedResults.find((result) => result.id === octadId)?.title, + updatedTitle, + ); + + // OctadResponse flags describe the returned modality payloads, not all + // modalities retained by the store. Verify partial-update retention by + // searching with the omitted vector instead of relying on those flags. + const retainedVectorResults = await requestVectorSearch( + baseUrl, + embedding, + 100, + ); + const retainedVectorHit = retainedVectorResults.find((result) => + result.id === octadId + ); + assert.ok( + retainedVectorHit, + "document-only update must retain the vector modality", + ); + assert.equal(retainedVectorHit.title, updatedTitle); + } finally { + if (octadId) { + const response = await fetch( + `${baseUrl.replace(/\/+$/, "")}/api/v1/octads/${ + encodeURIComponent(octadId) + }`, + { method: "DELETE" }, + ); + assert.equal( + response.status, + 204, + "contract test must clean up its octad", + ); + await response.body?.cancel(); + } + } +});