diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index 0634d2bd..3e3096c3 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -2,7 +2,7 @@ This directory contains the tooling for building and testing a Hypercerts XRPC API on HappyView. It provides a reusable installer for HappyView admin assets (Lexicons and Lua scripts), pinned Hypercerts Lexicon dependencies, and test fixtures for checking record behavior. The installer lets API modules declare their assets together, install missing assets in dependency order, and refuse to overwrite assets that differ from what is already installed. -**Current status:** The root `manifest.json` includes the location, actor-follow, profile, and organization query slices. Build their Lua handlers and run the offline checks before installing the bundle on an approved HappyView target. Only the location and actor-follow slices have runtime validation at the target revision; profile and organization query HTTP proof remains unrun. +**Current status:** The root `manifest.json` includes the location, actor-follow, profile, organization, and activity query slices. Build their Lua handlers and run the offline checks before installing the bundle on an approved HappyView target. Only the location and actor-follow slices have runtime validation at the target revision; profile, organization, and activity query HTTP proof remains unrun. The actor-follow slice exposes `app.certified.graph.getFollow`, `app.certified.graph.listActorFollowers`, and `app.certified.graph.listActorFollowing`, backed by the pinned `app.certified.graph.follow` record Lexicon. Both listing queries return a required `totalCount` for the complete deduplicated relationship set, independent of cursor and limit. Lookup and listings sort follows by `createdAt`; if it is missing or malformed, they use `indexed_at` (or the database insertion time when `indexed_at` is absent) so those follows remain visible and counted. @@ -10,6 +10,10 @@ The profile slice exposes `app.certified.actor.getProfile`, `app.certified.actor The organization slice exposes `app.certified.actor.getOrganization`, `app.certified.actor.listOrganizations`, and `app.certified.actor.searchOrganizations`. Each result includes the original organization sidecar with record metadata and publisher DID, plus the associated profile or explicit `null`; a missing sidecar fails singular lookup and is omitted from listings. Listing filters combine with AND, while values within `actors` and `organizationTypes` use OR. A supplied visibility is an exact match; omitting it includes public, unlisted, and unspecified values. Search matches the complete trimmed, case-insensitive literal text within the associated profile's `displayName` or `description`; an organization without a profile cannot match nonblank text. Both listing endpoints use `(createdAt, uri)` ordering and direction-bound cursors. Founding-date range parameters are not part of this API. +The activity slice exposes `org.hypercerts.claim.getActivity`, `org.hypercerts.claim.listActivities`, and `org.hypercerts.claim.searchActivities`. Singular lookup uses an exact activity AT-URI. All three responses preserve the original activity record and hydrate the publisher's Certified profile and organization sidecar. Contributor hydration is returned as a sibling projection: it preserves source order and duplicates, omits the projection only when `record.contributors` is absent, and keeps an explicit empty array empty. Contributor-information strong references resolve only by exact URI and CID; a newer version at the same URI is never substituted. DID and AT-URI contributor identifiers hydrate Certified profiles when resolvable; external or unrecognized identifiers remain visible with `actor: null`. Missing related records remain nullable, while query or required hydration failures fail the request. Activity and hydrated exact-version contributor-information views always include `indexedAt`: SQL `NULL` in `indexed_at` is returned as JSON `null`, while non-null timestamps are strings. + +`listActivities` accepts `authors`, `hasOrganizationRecord`, `contributors`, `involvedActors`, and activity `uris`; different filters combine with AND and values within each array use OR. `hasOrganizationRecord=true` requires an `app.certified.actor.organization/self` record, while `false` requires its absence; neither value depends on whether the author has a profile. `searchActivities` has those filters plus required text search over literal, case-insensitive substrings in `title` or `shortDescription`. Listings default to 25 results and cap pages and each filter array at 100 values. They sort by `(createdAt, uri)` and use an opaque cursor bound to sort direction. Use repeated, unbracketed query keys for arrays. + ## Get started From `hypercerts-api/`: @@ -20,7 +24,7 @@ pnpm build:lua pnpm check ``` -These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location, actor-follow, profile, and organization handlers in `lua/endpoints/`. `pnpm check` verifies the generated Lua bundles are current, runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests; run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. Run `pnpm check:generated` to check bundle freshness on its own; it does not modify files. If it fails, run `pnpm build:lua` and review the generated changes. The bundle combines local API Lexicons and scripts with schemas from the pinned `@hypercerts-org/lexicon` package. HTTP contract tests require an installed bundle and a separately approved disposable target. Only location and actor-follow slices have runtime validation at the target revision; profile and organization query HTTP proof remains unrun. +These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location, actor-follow, profile, organization, and activity handlers in `lua/endpoints/`. `pnpm check` verifies the generated Lua bundles are current, runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests; run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. Run `pnpm check:generated` to check bundle freshness on its own; it does not modify files. If it fails, run `pnpm build:lua` and review the generated changes. The bundle combines local API Lexicons and scripts with schemas from the pinned `@hypercerts-org/lexicon` package. HTTP contract tests require an installed bundle and a separately approved disposable target. Only location and actor-follow slices have runtime validation at the target revision; profile, organization, and activity query HTTP proof remains unrun. ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. It also rejects direct `eval`, implied evaluation (such as string-based timers), and `Function` constructors. Console output is allowed only in CLI tooling, and unused ESLint disable directives fail lint. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals, sandbox-removed Lua APIs, and unused arguments. It rejects `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`, `collectgarbage`, and unsafe `os` functions; only `os.time`, `os.date`, `os.difftime`, and `os.clock` are allowed from `os`. Only the HappyView `handle` global is writable; `db`, `http`, `json`, `params`, and `toarray` are read-only. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. Branches without Lua files skip Luacheck. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first. @@ -99,11 +103,11 @@ The root manifest's `validationLexicons` is the complete local schema closure us The bootstrap command above should target only an explicitly approved HappyView instance. It sends `Authorization: Bearer `; session-cookie authentication is not supported. -The token must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. For a bundle containing `getProfile`, it needs `script-variables:read` to check whether `HYPERCERTS_HANDLE_RESOLVER_URL` exists, and `script-variables:create` if that key is absent and must be set. HappyView's list response contains only keys and masked previews. The installer lists keys before prompting or creating the resolver variable and never deletes or intentionally overwrites an existing key. The HappyView POST endpoint is an upsert, so a concurrent change after the list preflight can still race with creation and overwrite the setting; avoid changing this key concurrently during installation. If a later asset write fails after setting creation, the installer reports that the setting remains and does not attempt rollback. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Remote admin targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. Admin URL credentials and redirects are rejected. Resolver URLs must be an HTTPS origin with no credentials, path, query, or fragment. Hosts may be DNS names, IPv4 addresses, or syntactically valid bracketed IPv6 addresses; optional ports must be between 1 and 65535. Resolve installed-asset conflicts manually. The location, actor-follow, profile, and organization handlers, and the fixtures, require PostgreSQL. +The token must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. For a bundle containing `getProfile`, it needs `script-variables:read` to check whether `HYPERCERTS_HANDLE_RESOLVER_URL` exists, and `script-variables:create` if that key is absent and must be set. HappyView's list response contains only keys and masked previews. The installer lists keys before prompting or creating the resolver variable and never deletes or intentionally overwrites an existing key. The HappyView POST endpoint is an upsert, so a concurrent change after the list preflight can still race with creation and overwrite the setting; avoid changing this key concurrently during installation. If a later asset write fails after setting creation, the installer reports that the setting remains and does not attempt rollback. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Remote admin targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. Admin URL credentials and redirects are rejected. Resolver URLs must be an HTTPS origin with no credentials, path, query, or fragment. Hosts may be DNS names, IPv4 addresses, or syntactically valid bracketed IPv6 addresses; optional ports must be between 1 and 65535. Resolve installed-asset conflicts manually. The location, actor-follow, profile, organization, and activity handlers, and the fixtures, require PostgreSQL. ## Test fixtures (disposable databases only) -The fixture tools seed records directly into PostgreSQL, bypassing HappyView ingestion. Use them **only with a separately approved, disposable loopback test database**, never persistent data. The `seed:test` and `seed:bad-dates` scripts require `HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES`, a loopback `PGHOST`, a `PGDATABASE` name containing a `test` marker, and an absolute `PSQL_PATH` to a trusted `psql` executable. Standard `PGPORT` and `PGUSER` can select the test instance and user. The normal `seed:test` command seeds location, profile, organization, and actor-follow examples by default; `seed:bad-dates` seeds only location examples and remains separate. `pnpm test:contracts` runs the location, actor-follow, and organization HTTP contract suites; `pnpm test:bad-dates` remains a separate opt-in suite. Both seed tools also accept supplied record rows through `buildSeedInput` and `buildBadDateSeedInput` in `tooling/seed.js`. +The fixture tools seed records directly into PostgreSQL, bypassing HappyView ingestion. Use them **only with a separately approved, disposable loopback test database**, never persistent data. The `seed:test` and `seed:bad-dates` scripts require `HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES`, a loopback `PGHOST`, a `PGDATABASE` name containing a `test` marker, and an absolute `PSQL_PATH` to a trusted `psql` executable. Standard `PGPORT` and `PGUSER` can select the test instance and user. The normal `seed:test` command seeds location, profile, organization, actor-follow, and activity/contributor-information examples by default; `seed:bad-dates` seeds only location examples and remains separate. `pnpm test:contracts` runs the location, actor-follow, organization, and activity HTTP contract suites; `pnpm test:bad-dates` remains a separate opt-in suite. Both seed tools also accept supplied record rows through `buildSeedInput` and `buildBadDateSeedInput` in `tooling/seed.js`. Fixture CIDs are computed from stored record JSON with `@atcute/cbor` and `@atcute/cid`. If you seeded an older version of the blob-backed fixture, reseed the **disposable test database** before comparing CIDs: the earlier `jsonToLex` conversion produced a different CID. diff --git a/hypercerts-api/lexicons/org.hypercerts.claim.getActivity.json b/hypercerts-api/lexicons/org.hypercerts.claim.getActivity.json new file mode 100644 index 00000000..e61af645 --- /dev/null +++ b/hypercerts-api/lexicons/org.hypercerts.claim.getActivity.json @@ -0,0 +1,98 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.getActivity", + "defs": { + "main": { + "type": "query", + "description": "Public lookup of one indexed activity by full AT-URI. Authentication is not required.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full AT-URI of the activity record, using a DID authority." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "The URI is not a full activity AT-URI or the request contains an unsupported parameter." }, + { "name": "RecordNotFound", "description": "No indexed activity exists at this AT-URI." }, + { "name": "ActivityQueryFailed", "description": "The activity or required hydration lookup failed; retry after checking the HappyView PostgreSQL service." } + ] + }, + "output": { + "type": "object", + "required": ["activity"], + "properties": { + "activity": { "type": "ref", "ref": "#activityView" } + } + }, + "activityView": { + "type": "object", + "description": "Activity record with metadata, unchanged source payload, hydrated author, and optional contributor projection.", + "required": ["uri", "cid", "indexedAt", "did", "author", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did" }, + "author": { "type": "ref", "ref": "org.hypercerts.api.defs#actorView" }, + "record": { "type": "ref", "ref": "org.hypercerts.claim.activity" }, + "contributors": { + "type": "array", + "description": "Hydrated contributor projection in the same order and with the same multiplicity as record.contributors; omitted only when the source field is absent.", + "items": { "type": "ref", "ref": "#activityContributorView" } + } + } + }, + "activityContributorView": { + "type": "object", + "description": "Original activity contributor fields with exact-version contributor information and nullable actor hydration.", + "required": ["contributorIdentity", "contributorInformation", "actor"], + "nullable": ["contributorInformation", "actor"], + "properties": { + "contributorIdentity": { + "type": "union", + "refs": ["org.hypercerts.claim.activity#contributorIdentity", "com.atproto.repo.strongRef"] + }, + "contributionWeight": { "type": "string", "maxLength": 100 }, + "contributionDetails": { + "type": "union", + "refs": ["org.hypercerts.claim.activity#contributorRole", "com.atproto.repo.strongRef"] + }, + "contributorInformation": { "type": "ref", "ref": "#contributorInformationView" }, + "actor": { "type": "ref", "ref": "#contributorActorView" } + } + }, + "contributorInformationView": { + "type": "object", + "description": "The exact indexed contributor-information version identified by the source strong reference.", + "required": ["uri", "cid", "indexedAt", "did", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did" }, + "record": { "type": "ref", "ref": "org.hypercerts.claim.contributorInformation" } + } + }, + "contributorActorView": { + "type": "object", + "description": "Contributor DID with nullable current Certified profile.", + "required": ["did", "profile"], + "nullable": ["profile"], + "properties": { + "did": { "type": "string", "format": "did" }, + "profile": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" } + } + } + } +} diff --git a/hypercerts-api/lexicons/org.hypercerts.claim.listActivities.json b/hypercerts-api/lexicons/org.hypercerts.claim.listActivities.json new file mode 100644 index 00000000..4883c45c --- /dev/null +++ b/hypercerts-api/lexicons/org.hypercerts.claim.listActivities.json @@ -0,0 +1,77 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.listActivities", + "defs": { + "main": { + "type": "query", + "description": "Lists indexed activities with AND between filters and OR within each array filter. Repeated unbracketed array values accept at most 100 supplied entries; duplicates are removed. Defaults are limit=25 and sortDirection=desc; limit is 1..100. Results sort by (createdAt, uri) and use a direction-bound opaque cursor; keep all other parameters unchanged between pages. Authentication is not required.", + "parameters": { + "type": "params", + "properties": { + "authors": { + "type": "array", + "maxLength": 100, + "description": "Repository-owner DIDs to include; values use OR.", + "items": { "type": "string", "format": "did" } + }, + "hasOrganizationRecord": { + "type": "boolean", + "description": "Whether each activity author has an app.certified.actor.organization/self record. False matches authors without that record regardless of profile presence." + }, + "contributors": { + "type": "array", + "maxLength": 100, + "description": "Contributor DIDs to match against inline identities and exact referenced contributor-information versions; values use OR.", + "items": { "type": "string", "format": "did" } + }, + "involvedActors": { + "type": "array", + "maxLength": 100, + "description": "DIDs matching either the activity repository owner or a contributor; values use OR, then combine with other filters using AND.", + "items": { "type": "string", "format": "did" } + }, + "uris": { + "type": "array", + "maxLength": 100, + "description": "Activity AT-URIs for bounded batch hydration; input order is not preserved.", + "items": { "type": "string", "format": "at-uri" } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Sort direction for (createdAt, uri); default desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum activities in this page; default 25." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor bound to sortDirection." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "A filter, organization-record flag, page bound, cursor, repeated scalar, or query parameter is invalid." }, + { "name": "ActivityQueryFailed", "description": "The activity query or required hydration lookup failed; retry after checking the HappyView PostgreSQL service." } + ] + }, + "output": { + "type": "object", + "required": ["activities"], + "properties": { + "activities": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.claim.getActivity#activityView" } + }, + "cursor": { "type": "string", "description": "Opaque cursor for the next page; omitted when pagination is complete." } + } + } + } +} diff --git a/hypercerts-api/lexicons/org.hypercerts.claim.searchActivities.json b/hypercerts-api/lexicons/org.hypercerts.claim.searchActivities.json new file mode 100644 index 00000000..8951e8d8 --- /dev/null +++ b/hypercerts-api/lexicons/org.hypercerts.claim.searchActivities.json @@ -0,0 +1,82 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.searchActivities", + "defs": { + "main": { + "type": "query", + "description": "Searches indexed activities by a case-insensitive literal substring in title or shortDescription. Surrounding whitespace is trimmed; blank search imposes no text restriction. Other filters combine with search using AND and retain their listing semantics. Repeated unbracketed array values accept at most 100 supplied entries; duplicates are removed. Defaults are limit=25 and sortDirection=desc; limit is 1..100. Results sort by (createdAt, uri) and use a direction-bound opaque cursor. Authentication is not required.", + "parameters": { + "type": "params", + "required": ["search"], + "properties": { + "search": { + "type": "string", + "description": "Complete text substring to find in title or shortDescription; literal %, _, and other characters are not wildcards." + }, + "authors": { + "type": "array", + "maxLength": 100, + "description": "Repository-owner DIDs to include; values use OR.", + "items": { "type": "string", "format": "did" } + }, + "hasOrganizationRecord": { + "type": "boolean", + "description": "Whether each activity author has an app.certified.actor.organization/self record. False matches authors without that record regardless of profile presence." + }, + "contributors": { + "type": "array", + "maxLength": 100, + "description": "Contributor DIDs to match against inline identities and exact referenced contributor-information versions; values use OR.", + "items": { "type": "string", "format": "did" } + }, + "involvedActors": { + "type": "array", + "maxLength": 100, + "description": "DIDs matching either the activity repository owner or a contributor; values use OR, then combine with other filters using AND.", + "items": { "type": "string", "format": "did" } + }, + "uris": { + "type": "array", + "maxLength": 100, + "description": "Activity AT-URIs for bounded batch hydration; input order is not preserved.", + "items": { "type": "string", "format": "at-uri" } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Sort direction for (createdAt, uri); default desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum activities in this page; default 25." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor bound to sortDirection." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "A filter, organization-record flag, page bound, cursor, repeated scalar, or query parameter is invalid." }, + { "name": "ActivityQueryFailed", "description": "The activity query or required hydration lookup failed; retry after checking the HappyView PostgreSQL service." } + ] + }, + "output": { + "type": "object", + "required": ["activities"], + "properties": { + "activities": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.claim.getActivity#activityView" } + }, + "cursor": { "type": "string", "description": "Opaque cursor for the next page; omitted when pagination is complete." } + } + } + } +} diff --git a/hypercerts-api/lua/endpoints/getActivity.lua b/hypercerts-api/lua/endpoints/getActivity.lua new file mode 100644 index 00000000..a06b4482 --- /dev/null +++ b/hypercerts-api/lua/endpoints/getActivity.lua @@ -0,0 +1,231 @@ +local ACTIVITY = "org.hypercerts.claim.activity" +local CONTRIBUTOR_INFORMATION = "org.hypercerts.claim.contributorInformation" +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" +local NULL = json.decode("null") + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter: " .. key) end + end +end + +local function scalar(values, key) + local value = values[key] + if value == nil then return nil end + if key == "hasOrganizationRecord" and type(value) == "boolean" then return tostring(value) end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ActivityQueryFailed: activity API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ActivityQueryFailed: activity lookup failed", 0) + end + return result +end + +local function record_view(row, nullable_indexed_at) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and nullable_indexed_at and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function identity_did(identifier) + if valid_did(identifier) then return identifier end + local valid, _, authority = valid_record_uri(identifier) + if valid then return authority end + return nil +end + +local function identity_key(uri, cid) + return uri .. "\0" .. cid +end + +local function load_contributor_information(references) + local rows_by_version = {} + for first = 1, #references, 500 do + local values, predicates = { CONTRIBUTOR_INFORMATION }, {} + local last = math.min(first + 499, #references) + for index = first, last do + local reference = references[index] + values[#values + 1] = reference.uri + local uri_parameter = "$" .. #values + values[#values + 1] = reference.cid + local cid_parameter = "$" .. #values + predicates[#predicates + 1] = "(uri = " .. uri_parameter .. " AND cid = " .. cid_parameter .. ")" + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND (" .. table.concat(predicates, " OR ") .. ")", + values) + for _, row in ipairs(rows) do rows_by_version[identity_key(row.uri, row.cid)] = row end + end + return rows_by_version +end + +local function load_actor_records(collection, dids) + if #dids == 0 then return {} end + local values, placeholders = { collection }, {} + for _, did in ipairs(dids) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(placeholders, ", ") .. ")", + values) + local by_did = {} + for _, row in ipairs(rows) do by_did[row.did] = row end + return by_did +end + +local function add_unique(values, seen, value) + if not seen[value] then + seen[value] = true + values[#values + 1] = value + end +end + +local function activity_view(row) + local record = json.decode(row.record) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = record, + } +end + +local function hydrate_activity_views(views) + local references, seen_references = {}, {} + local author_dids, seen_authors = {}, {} + local profile_dids, seen_profiles = {}, {} + local projected_contributors, contributor_resolutions = {}, {} + + for view_index, view in ipairs(views) do + add_unique(author_dids, seen_authors, view.did) + add_unique(profile_dids, seen_profiles, view.did) + local source_contributors = view.record.contributors + if source_contributors ~= nil then + if type(source_contributors) ~= "table" then + error("ActivityQueryFailed: activity contributors are not an array", 0) + end + local projections = {} + for contributor_index, source in ipairs(source_contributors) do + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = NULL + projection.actor = NULL + projections[contributor_index] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity + end + contributor_resolutions[#contributor_resolutions + 1] = resolution + end + projected_contributors[view_index] = projections + end + end + + local contributor_information = load_contributor_information(references) + for _, resolution in ipairs(contributor_resolutions) do + local projection = resolution.projection + local identifier = resolution.identifier + if resolution.information_key then + local row = contributor_information[resolution.information_key] + if row then + local information = record_view(row, true) + projection.contributorInformation = information + identifier = information.record.identifier + end + end + local did = identity_did(identifier) + if did then + projection.actor = { did = did } + add_unique(profile_dids, seen_profiles, did) + end + end + + local profiles = load_actor_records(PROFILE, profile_dids) + local organizations = load_actor_records(ORGANIZATION, author_dids) + for _, view in ipairs(views) do + local author = view.author + author.profile = profiles[author.did] and record_view(profiles[author.did]) or NULL + author.organization = organizations[author.did] and record_view(organizations[author.did]) or NULL + end + for _, projections in pairs(projected_contributors) do + for _, projection in ipairs(projections) do + local actor = projection.actor + if actor ~= NULL then + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + end + end + end + + for view_index, projections in pairs(projected_contributors) do + views[view_index].contributors = toarray(projections) + end +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + local valid, collection = valid_record_uri(uri) + if not uri or not valid or collection ~= ACTIVITY then + invalid("uri must be a full org.hypercerts.claim.activity AT-URI with a DID authority") + end + + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND uri = $2 LIMIT 1", + { ACTIVITY, uri }) + if #rows == 0 then error("RecordNotFound: activity record is not indexed", 0) end + + local view = activity_view(rows[1]) + hydrate_activity_views({ view }) + return { activity = view } +end diff --git a/hypercerts-api/lua/endpoints/listActivities.lua b/hypercerts-api/lua/endpoints/listActivities.lua new file mode 100644 index 00000000..0ab8006e --- /dev/null +++ b/hypercerts-api/lua/endpoints/listActivities.lua @@ -0,0 +1,472 @@ +local ACTIVITY = "org.hypercerts.claim.activity" +local CONTRIBUTOR_INFORMATION = "org.hypercerts.claim.contributorInformation" +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" +local NULL = json.decode("null") + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter: " .. key) end + end +end + +local function scalar(values, key) + local value = values[key] + if value == nil then return nil end + if key == "hasOrganizationRecord" and type(value) == "boolean" then return tostring(value) end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ActivityQueryFailed: activity API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ActivityQueryFailed: activity lookup failed", 0) + end + return result +end + +local function record_view(row, nullable_indexed_at) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and nullable_indexed_at and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function identity_did(identifier) + if valid_did(identifier) then return identifier end + local valid, _, authority = valid_record_uri(identifier) + if valid then return authority end + return nil +end + +local function identity_key(uri, cid) + return uri .. "\0" .. cid +end + +local function load_contributor_information(references) + local rows_by_version = {} + for first = 1, #references, 500 do + local values, predicates = { CONTRIBUTOR_INFORMATION }, {} + local last = math.min(first + 499, #references) + for index = first, last do + local reference = references[index] + values[#values + 1] = reference.uri + local uri_parameter = "$" .. #values + values[#values + 1] = reference.cid + local cid_parameter = "$" .. #values + predicates[#predicates + 1] = "(uri = " .. uri_parameter .. " AND cid = " .. cid_parameter .. ")" + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND (" .. table.concat(predicates, " OR ") .. ")", + values) + for _, row in ipairs(rows) do rows_by_version[identity_key(row.uri, row.cid)] = row end + end + return rows_by_version +end + +local function load_actor_records(collection, dids) + if #dids == 0 then return {} end + local values, placeholders = { collection }, {} + for _, did in ipairs(dids) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(placeholders, ", ") .. ")", + values) + local by_did = {} + for _, row in ipairs(rows) do by_did[row.did] = row end + return by_did +end + +local function add_unique(values, seen, value) + if not seen[value] then + seen[value] = true + values[#values + 1] = value + end +end + +local function activity_view(row) + local record = json.decode(row.record) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = record, + } +end + +local function hydrate_activity_views(views) + local references, seen_references = {}, {} + local author_dids, seen_authors = {}, {} + local profile_dids, seen_profiles = {}, {} + local projected_contributors, contributor_resolutions = {}, {} + + for view_index, view in ipairs(views) do + add_unique(author_dids, seen_authors, view.did) + add_unique(profile_dids, seen_profiles, view.did) + local source_contributors = view.record.contributors + if source_contributors ~= nil then + if type(source_contributors) ~= "table" then + error("ActivityQueryFailed: activity contributors are not an array", 0) + end + local projections = {} + for contributor_index, source in ipairs(source_contributors) do + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = NULL + projection.actor = NULL + projections[contributor_index] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity + end + contributor_resolutions[#contributor_resolutions + 1] = resolution + end + projected_contributors[view_index] = projections + end + end + + local contributor_information = load_contributor_information(references) + for _, resolution in ipairs(contributor_resolutions) do + local projection = resolution.projection + local identifier = resolution.identifier + if resolution.information_key then + local row = contributor_information[resolution.information_key] + if row then + local information = record_view(row, true) + projection.contributorInformation = information + identifier = information.record.identifier + end + end + local did = identity_did(identifier) + if did then + projection.actor = { did = did } + add_unique(profile_dids, seen_profiles, did) + end + end + + local profiles = load_actor_records(PROFILE, profile_dids) + local organizations = load_actor_records(ORGANIZATION, author_dids) + for _, view in ipairs(views) do + local author = view.author + author.profile = profiles[author.did] and record_view(profiles[author.did]) or NULL + author.organization = organizations[author.did] and record_view(organizations[author.did]) or NULL + end + for _, projections in pairs(projected_contributors) do + for _, projection in ipairs(projections) do + local actor = projection.actor + if actor ~= NULL then + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + end + end + end + + for view_index, projections in pairs(projected_contributors) do + views[view_index].contributors = toarray(projections) + end +end + +local function activity_array(key, format) + local value = params[key] + if value == nil then return nil end + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if format == "did" and not valid_did(item) then + invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") + elseif format == "activityUri" then + local valid, collection = valid_record_uri(item) + if not valid or collection ~= ACTIVITY then + invalid("each uris value must be a full org.hypercerts.claim.activity AT-URI with a DID authority") + end + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function integer_limit() + local value = scalar(params, "limit") + if value == nil then return 25 end + if not value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local number = tonumber(value) + if not number or number < 1 or number > 100 then + invalid("limit must be an integer from 1 through 100") + end + return number +end + +local function valid_activity_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function decode_activity_cursor(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, collection = valid_record_uri(value.u) + if not valid_activity_datetime(value.t) or not valid or collection ~= ACTIVITY then + invalid("cursor is malformed") + end + return value +end + +local function encode_activity_cursor(value) + return (json.encode(value):gsub(".", function(char) + return string.format("%02x", string.byte(char)) + end)) +end + +local function bind_values(values, items) + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + return placeholders +end + +local function activity_contributor_match(values, dids) + local marks = bind_values(values, dids) + local set = table.concat(marks, ", ") + local identity = "contributor.value->'contributorIdentity'->>'identity'" + local information_identifier = "ci.record::jsonb->>'identifier'" + return "EXISTS (SELECT 1 FROM jsonb_array_elements(" .. + "CASE WHEN jsonb_typeof(activity.record::jsonb->'contributors') = 'array' " .. + "THEN activity.record::jsonb->'contributors' ELSE '[]'::jsonb END) AS contributor(value) " .. + "LEFT JOIN happyview_records AS ci ON ci.collection = 'org.hypercerts.claim.contributorInformation' " .. + "AND ci.uri = (contributor.value->'contributorIdentity'->>'uri') " .. + "AND ci.cid = (contributor.value->'contributorIdentity'->>'cid') " .. + "WHERE (" .. identity .. " IN (" .. set .. ") " .. + "OR substring(" .. identity .. " from '^at://([^/]+)/') IN (" .. set .. ") " .. + "OR " .. information_identifier .. " IN (" .. set .. ") " .. + "OR substring(" .. information_identifier .. " from '^at://([^/]+)/') IN (" .. set .. ")))" +end + +local function activity_sort_expression() + local created = "activity.record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(activity.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz) END" +end + +local function activity_query(authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) + local where, values = { "activity.collection = $1" }, { ACTIVITY } + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + where[#where + 1] = "activity.did IN (" .. table.concat(bind_values(values, authors), ", ") .. ")" + end + end + if has_organization_record ~= nil then + local predicate = has_organization_record and "EXISTS" or "NOT EXISTS" + where[#where + 1] = predicate .. " (SELECT 1 FROM happyview_records AS organization " .. + "WHERE organization.collection = 'app.certified.actor.organization' AND organization.rkey = 'self' " .. + "AND organization.did = activity.did)" + end + if contributors then + if #contributors == 0 then where[#where + 1] = "FALSE" + else where[#where + 1] = activity_contributor_match(values, contributors) end + end + if involved_actors then + if #involved_actors == 0 then + where[#where + 1] = "FALSE" + else + local marks = bind_values(values, involved_actors) + where[#where + 1] = "(activity.did IN (" .. table.concat(marks, ", ") .. ") OR " .. + activity_contributor_match(values, involved_actors) .. ")" + end + end + if uris then + if #uris == 0 then where[#where + 1] = "FALSE" + else where[#where + 1] = "activity.uri IN (" .. table.concat(bind_values(values, uris), ", ") .. ")" end + end + if search ~= nil then + values[#values + 1] = search + local pattern = "$" .. #values + where[#where + 1] = "(strpos(lower(COALESCE(activity.record::jsonb->>'title', '')), lower(" .. pattern .. ")) > 0 " .. + "OR strpos(lower(COALESCE(activity.record::jsonb->>'shortDescription', '')), lower(" .. pattern .. ")) > 0)" + end + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, activity.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT activity.uri, activity.did, activity.cid, activity.indexed_at::text AS indexed_at, " .. + "activity.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. + "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. + "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. activity_sort_expression() .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", activity.uri " .. ordering .. " LIMIT $" .. #values + local rows = query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views = {} + for _, row in ipairs(rows) do views[#views + 1] = activity_view(row) end + hydrate_activity_views(views) + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = encode_activity_cursor({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +local function activity_list_response(search_enabled) + local allowed = { + authors = true, + hasOrganizationRecord = true, + contributors = true, + involvedActors = true, + uris = true, + sortDirection = true, + limit = true, + cursor = true, + } + if search_enabled then allowed.search = true end + keys_only(params, allowed) + + local authors = activity_array("authors", "did") + local has_organization_record = scalar(params, "hasOrganizationRecord") + if has_organization_record ~= nil then + if has_organization_record == "true" then + has_organization_record = true + elseif has_organization_record == "false" then + has_organization_record = false + else + invalid("hasOrganizationRecord must be true or false") + end + end + local contributors = activity_array("contributors", "did") + local involved_actors = activity_array("involvedActors", "did") + local uris = activity_array("uris", "activityUri") + local search + if search_enabled then + search = scalar(params, "search") + if search == nil then invalid("search is required") end + search = search:match("^%s*(.-)%s*$") + if search == "" then search = nil end + end + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local limit = integer_limit() + local cursor = decode_activity_cursor(scalar(params, "cursor"), direction) + local activities, next_cursor = activity_query( + authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) + local response = { activities = toarray(activities) } + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return activity_list_response(false) +end diff --git a/hypercerts-api/lua/endpoints/searchActivities.lua b/hypercerts-api/lua/endpoints/searchActivities.lua new file mode 100644 index 00000000..1f264a99 --- /dev/null +++ b/hypercerts-api/lua/endpoints/searchActivities.lua @@ -0,0 +1,472 @@ +local ACTIVITY = "org.hypercerts.claim.activity" +local CONTRIBUTOR_INFORMATION = "org.hypercerts.claim.contributorInformation" +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" +local NULL = json.decode("null") + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter: " .. key) end + end +end + +local function scalar(values, key) + local value = values[key] + if value == nil then return nil end + if key == "hasOrganizationRecord" and type(value) == "boolean" then return tostring(value) end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ActivityQueryFailed: activity API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ActivityQueryFailed: activity lookup failed", 0) + end + return result +end + +local function record_view(row, nullable_indexed_at) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and nullable_indexed_at and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function identity_did(identifier) + if valid_did(identifier) then return identifier end + local valid, _, authority = valid_record_uri(identifier) + if valid then return authority end + return nil +end + +local function identity_key(uri, cid) + return uri .. "\0" .. cid +end + +local function load_contributor_information(references) + local rows_by_version = {} + for first = 1, #references, 500 do + local values, predicates = { CONTRIBUTOR_INFORMATION }, {} + local last = math.min(first + 499, #references) + for index = first, last do + local reference = references[index] + values[#values + 1] = reference.uri + local uri_parameter = "$" .. #values + values[#values + 1] = reference.cid + local cid_parameter = "$" .. #values + predicates[#predicates + 1] = "(uri = " .. uri_parameter .. " AND cid = " .. cid_parameter .. ")" + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND (" .. table.concat(predicates, " OR ") .. ")", + values) + for _, row in ipairs(rows) do rows_by_version[identity_key(row.uri, row.cid)] = row end + end + return rows_by_version +end + +local function load_actor_records(collection, dids) + if #dids == 0 then return {} end + local values, placeholders = { collection }, {} + for _, did in ipairs(dids) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(placeholders, ", ") .. ")", + values) + local by_did = {} + for _, row in ipairs(rows) do by_did[row.did] = row end + return by_did +end + +local function add_unique(values, seen, value) + if not seen[value] then + seen[value] = true + values[#values + 1] = value + end +end + +local function activity_view(row) + local record = json.decode(row.record) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = record, + } +end + +local function hydrate_activity_views(views) + local references, seen_references = {}, {} + local author_dids, seen_authors = {}, {} + local profile_dids, seen_profiles = {}, {} + local projected_contributors, contributor_resolutions = {}, {} + + for view_index, view in ipairs(views) do + add_unique(author_dids, seen_authors, view.did) + add_unique(profile_dids, seen_profiles, view.did) + local source_contributors = view.record.contributors + if source_contributors ~= nil then + if type(source_contributors) ~= "table" then + error("ActivityQueryFailed: activity contributors are not an array", 0) + end + local projections = {} + for contributor_index, source in ipairs(source_contributors) do + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = NULL + projection.actor = NULL + projections[contributor_index] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity + end + contributor_resolutions[#contributor_resolutions + 1] = resolution + end + projected_contributors[view_index] = projections + end + end + + local contributor_information = load_contributor_information(references) + for _, resolution in ipairs(contributor_resolutions) do + local projection = resolution.projection + local identifier = resolution.identifier + if resolution.information_key then + local row = contributor_information[resolution.information_key] + if row then + local information = record_view(row, true) + projection.contributorInformation = information + identifier = information.record.identifier + end + end + local did = identity_did(identifier) + if did then + projection.actor = { did = did } + add_unique(profile_dids, seen_profiles, did) + end + end + + local profiles = load_actor_records(PROFILE, profile_dids) + local organizations = load_actor_records(ORGANIZATION, author_dids) + for _, view in ipairs(views) do + local author = view.author + author.profile = profiles[author.did] and record_view(profiles[author.did]) or NULL + author.organization = organizations[author.did] and record_view(organizations[author.did]) or NULL + end + for _, projections in pairs(projected_contributors) do + for _, projection in ipairs(projections) do + local actor = projection.actor + if actor ~= NULL then + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + end + end + end + + for view_index, projections in pairs(projected_contributors) do + views[view_index].contributors = toarray(projections) + end +end + +local function activity_array(key, format) + local value = params[key] + if value == nil then return nil end + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if format == "did" and not valid_did(item) then + invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") + elseif format == "activityUri" then + local valid, collection = valid_record_uri(item) + if not valid or collection ~= ACTIVITY then + invalid("each uris value must be a full org.hypercerts.claim.activity AT-URI with a DID authority") + end + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function integer_limit() + local value = scalar(params, "limit") + if value == nil then return 25 end + if not value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local number = tonumber(value) + if not number or number < 1 or number > 100 then + invalid("limit must be an integer from 1 through 100") + end + return number +end + +local function valid_activity_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function decode_activity_cursor(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, collection = valid_record_uri(value.u) + if not valid_activity_datetime(value.t) or not valid or collection ~= ACTIVITY then + invalid("cursor is malformed") + end + return value +end + +local function encode_activity_cursor(value) + return (json.encode(value):gsub(".", function(char) + return string.format("%02x", string.byte(char)) + end)) +end + +local function bind_values(values, items) + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + return placeholders +end + +local function activity_contributor_match(values, dids) + local marks = bind_values(values, dids) + local set = table.concat(marks, ", ") + local identity = "contributor.value->'contributorIdentity'->>'identity'" + local information_identifier = "ci.record::jsonb->>'identifier'" + return "EXISTS (SELECT 1 FROM jsonb_array_elements(" .. + "CASE WHEN jsonb_typeof(activity.record::jsonb->'contributors') = 'array' " .. + "THEN activity.record::jsonb->'contributors' ELSE '[]'::jsonb END) AS contributor(value) " .. + "LEFT JOIN happyview_records AS ci ON ci.collection = 'org.hypercerts.claim.contributorInformation' " .. + "AND ci.uri = (contributor.value->'contributorIdentity'->>'uri') " .. + "AND ci.cid = (contributor.value->'contributorIdentity'->>'cid') " .. + "WHERE (" .. identity .. " IN (" .. set .. ") " .. + "OR substring(" .. identity .. " from '^at://([^/]+)/') IN (" .. set .. ") " .. + "OR " .. information_identifier .. " IN (" .. set .. ") " .. + "OR substring(" .. information_identifier .. " from '^at://([^/]+)/') IN (" .. set .. ")))" +end + +local function activity_sort_expression() + local created = "activity.record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(activity.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz) END" +end + +local function activity_query(authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) + local where, values = { "activity.collection = $1" }, { ACTIVITY } + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + where[#where + 1] = "activity.did IN (" .. table.concat(bind_values(values, authors), ", ") .. ")" + end + end + if has_organization_record ~= nil then + local predicate = has_organization_record and "EXISTS" or "NOT EXISTS" + where[#where + 1] = predicate .. " (SELECT 1 FROM happyview_records AS organization " .. + "WHERE organization.collection = 'app.certified.actor.organization' AND organization.rkey = 'self' " .. + "AND organization.did = activity.did)" + end + if contributors then + if #contributors == 0 then where[#where + 1] = "FALSE" + else where[#where + 1] = activity_contributor_match(values, contributors) end + end + if involved_actors then + if #involved_actors == 0 then + where[#where + 1] = "FALSE" + else + local marks = bind_values(values, involved_actors) + where[#where + 1] = "(activity.did IN (" .. table.concat(marks, ", ") .. ") OR " .. + activity_contributor_match(values, involved_actors) .. ")" + end + end + if uris then + if #uris == 0 then where[#where + 1] = "FALSE" + else where[#where + 1] = "activity.uri IN (" .. table.concat(bind_values(values, uris), ", ") .. ")" end + end + if search ~= nil then + values[#values + 1] = search + local pattern = "$" .. #values + where[#where + 1] = "(strpos(lower(COALESCE(activity.record::jsonb->>'title', '')), lower(" .. pattern .. ")) > 0 " .. + "OR strpos(lower(COALESCE(activity.record::jsonb->>'shortDescription', '')), lower(" .. pattern .. ")) > 0)" + end + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, activity.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT activity.uri, activity.did, activity.cid, activity.indexed_at::text AS indexed_at, " .. + "activity.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. + "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. + "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. activity_sort_expression() .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", activity.uri " .. ordering .. " LIMIT $" .. #values + local rows = query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views = {} + for _, row in ipairs(rows) do views[#views + 1] = activity_view(row) end + hydrate_activity_views(views) + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = encode_activity_cursor({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +local function activity_list_response(search_enabled) + local allowed = { + authors = true, + hasOrganizationRecord = true, + contributors = true, + involvedActors = true, + uris = true, + sortDirection = true, + limit = true, + cursor = true, + } + if search_enabled then allowed.search = true end + keys_only(params, allowed) + + local authors = activity_array("authors", "did") + local has_organization_record = scalar(params, "hasOrganizationRecord") + if has_organization_record ~= nil then + if has_organization_record == "true" then + has_organization_record = true + elseif has_organization_record == "false" then + has_organization_record = false + else + invalid("hasOrganizationRecord must be true or false") + end + end + local contributors = activity_array("contributors", "did") + local involved_actors = activity_array("involvedActors", "did") + local uris = activity_array("uris", "activityUri") + local search + if search_enabled then + search = scalar(params, "search") + if search == nil then invalid("search is required") end + search = search:match("^%s*(.-)%s*$") + if search == "" then search = nil end + end + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local limit = integer_limit() + local cursor = decode_activity_cursor(scalar(params, "cursor"), direction) + local activities, next_cursor = activity_query( + authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) + local response = { activities = toarray(activities) } + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return activity_list_response(true) +end diff --git a/hypercerts-api/lua/shared/activity.lua b/hypercerts-api/lua/shared/activity.lua new file mode 100644 index 00000000..c9048310 --- /dev/null +++ b/hypercerts-api/lua/shared/activity.lua @@ -0,0 +1,212 @@ +local ACTIVITY = "org.hypercerts.claim.activity" +local CONTRIBUTOR_INFORMATION = "org.hypercerts.claim.contributorInformation" +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" +local NULL = json.decode("null") + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter: " .. key) end + end +end + +local function scalar(values, key) + local value = values[key] + if value == nil then return nil end + if key == "hasOrganizationRecord" and type(value) == "boolean" then return tostring(value) end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ActivityQueryFailed: activity API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ActivityQueryFailed: activity lookup failed", 0) + end + return result +end + +local function record_view(row, nullable_indexed_at) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and nullable_indexed_at and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function identity_did(identifier) + if valid_did(identifier) then return identifier end + local valid, _, authority = valid_record_uri(identifier) + if valid then return authority end + return nil +end + +local function identity_key(uri, cid) + return uri .. "\0" .. cid +end + +local function load_contributor_information(references) + local rows_by_version = {} + for first = 1, #references, 500 do + local values, predicates = { CONTRIBUTOR_INFORMATION }, {} + local last = math.min(first + 499, #references) + for index = first, last do + local reference = references[index] + values[#values + 1] = reference.uri + local uri_parameter = "$" .. #values + values[#values + 1] = reference.cid + local cid_parameter = "$" .. #values + predicates[#predicates + 1] = "(uri = " .. uri_parameter .. " AND cid = " .. cid_parameter .. ")" + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND (" .. table.concat(predicates, " OR ") .. ")", + values) + for _, row in ipairs(rows) do rows_by_version[identity_key(row.uri, row.cid)] = row end + end + return rows_by_version +end + +local function load_actor_records(collection, dids) + if #dids == 0 then return {} end + local values, placeholders = { collection }, {} + for _, did in ipairs(dids) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(placeholders, ", ") .. ")", + values) + local by_did = {} + for _, row in ipairs(rows) do by_did[row.did] = row end + return by_did +end + +local function add_unique(values, seen, value) + if not seen[value] then + seen[value] = true + values[#values + 1] = value + end +end + +local function activity_view(row) + local record = json.decode(row.record) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = record, + } +end + +local function hydrate_activity_views(views) + local references, seen_references = {}, {} + local author_dids, seen_authors = {}, {} + local profile_dids, seen_profiles = {}, {} + local projected_contributors, contributor_resolutions = {}, {} + + for view_index, view in ipairs(views) do + add_unique(author_dids, seen_authors, view.did) + add_unique(profile_dids, seen_profiles, view.did) + local source_contributors = view.record.contributors + if source_contributors ~= nil then + if type(source_contributors) ~= "table" then + error("ActivityQueryFailed: activity contributors are not an array", 0) + end + local projections = {} + for contributor_index, source in ipairs(source_contributors) do + local projection = {} + for key, value in pairs(source) do projection[key] = value end + projection.contributorInformation = NULL + projection.actor = NULL + projections[contributor_index] = projection + + local identity = source.contributorIdentity + local resolution = { projection = projection } + if type(identity) == "table" and type(identity.uri) == "string" and type(identity.cid) == "string" then + local key = identity_key(identity.uri, identity.cid) + resolution.information_key = key + if not seen_references[key] then + seen_references[key] = true + references[#references + 1] = { uri = identity.uri, cid = identity.cid } + end + elseif type(identity) == "table" then + resolution.identifier = identity.identity + end + contributor_resolutions[#contributor_resolutions + 1] = resolution + end + projected_contributors[view_index] = projections + end + end + + local contributor_information = load_contributor_information(references) + for _, resolution in ipairs(contributor_resolutions) do + local projection = resolution.projection + local identifier = resolution.identifier + if resolution.information_key then + local row = contributor_information[resolution.information_key] + if row then + local information = record_view(row, true) + projection.contributorInformation = information + identifier = information.record.identifier + end + end + local did = identity_did(identifier) + if did then + projection.actor = { did = did } + add_unique(profile_dids, seen_profiles, did) + end + end + + local profiles = load_actor_records(PROFILE, profile_dids) + local organizations = load_actor_records(ORGANIZATION, author_dids) + for _, view in ipairs(views) do + local author = view.author + author.profile = profiles[author.did] and record_view(profiles[author.did]) or NULL + author.organization = organizations[author.did] and record_view(organizations[author.did]) or NULL + end + for _, projections in pairs(projected_contributors) do + for _, projection in ipairs(projections) do + local actor = projection.actor + if actor ~= NULL then + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + end + end + end + + for view_index, projections in pairs(projected_contributors) do + views[view_index].contributors = toarray(projections) + end +end diff --git a/hypercerts-api/lua/shared/activityList.lua b/hypercerts-api/lua/shared/activityList.lua new file mode 100644 index 00000000..8e43891a --- /dev/null +++ b/hypercerts-api/lua/shared/activityList.lua @@ -0,0 +1,255 @@ +local function activity_array(key, format) + local value = params[key] + if value == nil then return nil end + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if format == "did" and not valid_did(item) then + invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") + elseif format == "activityUri" then + local valid, collection = valid_record_uri(item) + if not valid or collection ~= ACTIVITY then + invalid("each uris value must be a full org.hypercerts.claim.activity AT-URI with a DID authority") + end + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function integer_limit() + local value = scalar(params, "limit") + if value == nil then return 25 end + if not value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local number = tonumber(value) + if not number or number < 1 or number > 100 then + invalid("limit must be an integer from 1 through 100") + end + return number +end + +local function valid_activity_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function decode_activity_cursor(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, collection = valid_record_uri(value.u) + if not valid_activity_datetime(value.t) or not valid or collection ~= ACTIVITY then + invalid("cursor is malformed") + end + return value +end + +local function encode_activity_cursor(value) + return (json.encode(value):gsub(".", function(char) + return string.format("%02x", string.byte(char)) + end)) +end + +local function bind_values(values, items) + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + return placeholders +end + +local function activity_contributor_match(values, dids) + local marks = bind_values(values, dids) + local set = table.concat(marks, ", ") + local identity = "contributor.value->'contributorIdentity'->>'identity'" + local information_identifier = "ci.record::jsonb->>'identifier'" + return "EXISTS (SELECT 1 FROM jsonb_array_elements(" .. + "CASE WHEN jsonb_typeof(activity.record::jsonb->'contributors') = 'array' " .. + "THEN activity.record::jsonb->'contributors' ELSE '[]'::jsonb END) AS contributor(value) " .. + "LEFT JOIN happyview_records AS ci ON ci.collection = 'org.hypercerts.claim.contributorInformation' " .. + "AND ci.uri = (contributor.value->'contributorIdentity'->>'uri') " .. + "AND ci.cid = (contributor.value->'contributorIdentity'->>'cid') " .. + "WHERE (" .. identity .. " IN (" .. set .. ") " .. + "OR substring(" .. identity .. " from '^at://([^/]+)/') IN (" .. set .. ") " .. + "OR " .. information_identifier .. " IN (" .. set .. ") " .. + "OR substring(" .. information_identifier .. " from '^at://([^/]+)/') IN (" .. set .. ")))" +end + +local function activity_sort_expression() + local created = "activity.record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(activity.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz) END" +end + +local function activity_query(authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) + local where, values = { "activity.collection = $1" }, { ACTIVITY } + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + where[#where + 1] = "activity.did IN (" .. table.concat(bind_values(values, authors), ", ") .. ")" + end + end + if has_organization_record ~= nil then + local predicate = has_organization_record and "EXISTS" or "NOT EXISTS" + where[#where + 1] = predicate .. " (SELECT 1 FROM happyview_records AS organization " .. + "WHERE organization.collection = 'app.certified.actor.organization' AND organization.rkey = 'self' " .. + "AND organization.did = activity.did)" + end + if contributors then + if #contributors == 0 then where[#where + 1] = "FALSE" + else where[#where + 1] = activity_contributor_match(values, contributors) end + end + if involved_actors then + if #involved_actors == 0 then + where[#where + 1] = "FALSE" + else + local marks = bind_values(values, involved_actors) + where[#where + 1] = "(activity.did IN (" .. table.concat(marks, ", ") .. ") OR " .. + activity_contributor_match(values, involved_actors) .. ")" + end + end + if uris then + if #uris == 0 then where[#where + 1] = "FALSE" + else where[#where + 1] = "activity.uri IN (" .. table.concat(bind_values(values, uris), ", ") .. ")" end + end + if search ~= nil then + values[#values + 1] = search + local pattern = "$" .. #values + where[#where + 1] = "(strpos(lower(COALESCE(activity.record::jsonb->>'title', '')), lower(" .. pattern .. ")) > 0 " .. + "OR strpos(lower(COALESCE(activity.record::jsonb->>'shortDescription', '')), lower(" .. pattern .. ")) > 0)" + end + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, activity.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT activity.uri, activity.did, activity.cid, activity.indexed_at::text AS indexed_at, " .. + "activity.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. + "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. + "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. activity_sort_expression() .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", activity.uri " .. ordering .. " LIMIT $" .. #values + local rows = query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views = {} + for _, row in ipairs(rows) do views[#views + 1] = activity_view(row) end + hydrate_activity_views(views) + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = encode_activity_cursor({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +local function activity_list_response(search_enabled) + local allowed = { + authors = true, + hasOrganizationRecord = true, + contributors = true, + involvedActors = true, + uris = true, + sortDirection = true, + limit = true, + cursor = true, + } + if search_enabled then allowed.search = true end + keys_only(params, allowed) + + local authors = activity_array("authors", "did") + local has_organization_record = scalar(params, "hasOrganizationRecord") + if has_organization_record ~= nil then + if has_organization_record == "true" then + has_organization_record = true + elseif has_organization_record == "false" then + has_organization_record = false + else + invalid("hasOrganizationRecord must be true or false") + end + end + local contributors = activity_array("contributors", "did") + local involved_actors = activity_array("involvedActors", "did") + local uris = activity_array("uris", "activityUri") + local search + if search_enabled then + search = scalar(params, "search") + if search == nil then invalid("search is required") end + search = search:match("^%s*(.-)%s*$") + if search == "" then search = nil end + end + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local limit = integer_limit() + local cursor = decode_activity_cursor(scalar(params, "cursor"), direction) + local activities, next_cursor = activity_query( + authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) + local response = { activities = toarray(activities) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/hypercerts-api/lua/src/getActivity.lua b/hypercerts-api/lua/src/getActivity.lua new file mode 100644 index 00000000..e7527efe --- /dev/null +++ b/hypercerts-api/lua/src/getActivity.lua @@ -0,0 +1,18 @@ +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + local valid, collection = valid_record_uri(uri) + if not uri or not valid or collection ~= ACTIVITY then + invalid("uri must be a full org.hypercerts.claim.activity AT-URI with a DID authority") + end + + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND uri = $2 LIMIT 1", + { ACTIVITY, uri }) + if #rows == 0 then error("RecordNotFound: activity record is not indexed", 0) end + + local view = activity_view(rows[1]) + hydrate_activity_views({ view }) + return { activity = view } +end diff --git a/hypercerts-api/lua/src/listActivities.lua b/hypercerts-api/lua/src/listActivities.lua new file mode 100644 index 00000000..fbb760ea --- /dev/null +++ b/hypercerts-api/lua/src/listActivities.lua @@ -0,0 +1,3 @@ +function handle() + return activity_list_response(false) +end diff --git a/hypercerts-api/lua/src/searchActivities.lua b/hypercerts-api/lua/src/searchActivities.lua new file mode 100644 index 00000000..eab3d660 --- /dev/null +++ b/hypercerts-api/lua/src/searchActivities.lua @@ -0,0 +1,3 @@ +function handle() + return activity_list_response(true) +end diff --git a/hypercerts-api/manifest.json b/hypercerts-api/manifest.json index 5beb8b79..99070a4c 100644 --- a/hypercerts-api/manifest.json +++ b/hypercerts-api/manifest.json @@ -1,9 +1,9 @@ { - "name": "hypercerts-location-actor-follow-profile-organization-api", + "name": "hypercerts-location-actor-follow-profile-organization-activity-api", "collection": "app.certified.location", "targetHappyViewRevision": "a2f347e605d7cd9c11669b320e43c10dbfe982e3", "runtimeCompatibility": "compatible-at-target-revision", - "status": "handlers-implemented; location-and-actor-follow-runtime-validated-at-target-revision; organization-runtime-validation-pending", + "status": "handlers-implemented; location-and-actor-follow-runtime-validated-at-target-revision; organization-and-activity-runtime-validation-pending", "handlerStatus": { "getLocation": "implemented", "listLocations": "implemented", @@ -16,12 +16,15 @@ "searchProfiles": "implemented", "getOrganization": "implemented", "listOrganizations": "implemented", - "searchOrganizations": "implemented" + "searchOrganizations": "implemented", + "getActivity": "implemented", + "listActivities": "implemented", + "searchActivities": "implemented" }, "authentication": { "decision": "public", "unresolved": false, - "note": "The location, actor-follow, actor-profile, and organization query endpoints expose public record reads and require no caller authentication." + "note": "The location, actor-follow, actor-profile, organization, and activity query endpoints expose public record reads and require no caller authentication." }, "luaBuild": { "command": "pnpm build:lua", @@ -39,9 +42,14 @@ "lua/shared/profileLookup.lua", "lua/shared/profileList.lua", "lua/shared/organization.lua", - "lua/shared/organizationList.lua" + "lua/shared/organizationList.lua", + "lua/shared/activity.lua", + "lua/shared/activityList.lua" ], "endpointSources": [ + "lua/src/getActivity.lua", + "lua/src/listActivities.lua", + "lua/src/searchActivities.lua", "lua/src/getLocation.lua", "lua/src/listLocations.lua", "lua/src/getFollow.lua", @@ -122,6 +130,18 @@ "id": "app.certified.actor.searchOrganizations", "path": "lexicons/app.certified.actor.searchOrganizations.json" }, + { + "id": "org.hypercerts.claim.getActivity", + "path": "lexicons/org.hypercerts.claim.getActivity.json" + }, + { + "id": "org.hypercerts.claim.listActivities", + "path": "lexicons/org.hypercerts.claim.listActivities.json" + }, + { + "id": "org.hypercerts.claim.searchActivities", + "path": "lexicons/org.hypercerts.claim.searchActivities.json" + }, { "id": "app.certified.actor.organization", "packagePath": "lexicons/app/certified/actor/organization.json" @@ -142,6 +162,18 @@ "id": "org.hypercerts.defs", "packagePath": "lexicons/org/hypercerts/defs.json" }, + { + "id": "org.hypercerts.workscope.cel", + "packagePath": "lexicons/org/hypercerts/workscope/cel.json" + }, + { + "id": "org.hypercerts.claim.activity", + "packagePath": "lexicons/org/hypercerts/claim/activity.json" + }, + { + "id": "org.hypercerts.claim.contributorInformation", + "packagePath": "lexicons/org/hypercerts/claim/contributorInformation.json" + }, { "id": "pub.leaflet.blocks.blockquote", "packagePath": "lexicons/pub/leaflet/blocks/blockquote.json" @@ -247,6 +279,7 @@ "modules/location/manifest.json", "modules/actor-follow/manifest.json", "modules/profile/manifest.json", - "modules/organization/manifest.json" + "modules/organization/manifest.json", + "modules/activity/manifest.json" ] } diff --git a/hypercerts-api/modules/activity/manifest.json b/hypercerts-api/modules/activity/manifest.json new file mode 100644 index 00000000..a9f6cde0 --- /dev/null +++ b/hypercerts-api/modules/activity/manifest.json @@ -0,0 +1,76 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "org.hypercerts.claim.activity", + "packagePath": "lexicons/org/hypercerts/claim/activity.json", + "config": { "backfill": true }, + "dependsOn": [] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.contributorInformation", + "packagePath": "lexicons/org/hypercerts/claim/contributorInformation.json", + "config": { "backfill": true }, + "dependsOn": [] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.getActivity", + "path": "../../lexicons/org.hypercerts.claim.getActivity.json", + "config": { + "backfill": false, + "target_collection": "org.hypercerts.claim.activity" + }, + "dependsOn": [ + "org.hypercerts.claim.activity", + "org.hypercerts.claim.contributorInformation", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.getActivity", + "path": "../../lua/endpoints/getActivity.lua", + "sourcePath": "../../lua/src/getActivity.lua", + "sharedSourcePaths": ["../../lua/shared/activity.lua"], + "config": { + "script_type": "lua", + "description": "Hypercerts activity lookup handler" + }, + "dependsOn": ["org.hypercerts.claim.getActivity"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.listActivities", + "path": "../../lexicons/org.hypercerts.claim.listActivities.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.activity" }, + "dependsOn": ["org.hypercerts.claim.getActivity"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.searchActivities", + "path": "../../lexicons/org.hypercerts.claim.searchActivities.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.activity" }, + "dependsOn": ["org.hypercerts.claim.getActivity"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.listActivities", + "path": "../../lua/endpoints/listActivities.lua", + "sourcePath": "../../lua/src/listActivities.lua", + "sharedSourcePaths": ["../../lua/shared/activity.lua", "../../lua/shared/activityList.lua"], + "config": { "script_type": "lua", "description": "Hypercerts activity listing handler" }, + "dependsOn": ["org.hypercerts.claim.listActivities"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.searchActivities", + "path": "../../lua/endpoints/searchActivities.lua", + "sourcePath": "../../lua/src/searchActivities.lua", + "sharedSourcePaths": ["../../lua/shared/activity.lua", "../../lua/shared/activityList.lua"], + "config": { "script_type": "lua", "description": "Hypercerts activity search handler" }, + "dependsOn": ["org.hypercerts.claim.searchActivities"] + } + ] +} diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json index 8a66482e..c95ccd36 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -18,7 +18,7 @@ "check": "pnpm run check:generated && pnpm run lint && pnpm run typecheck && pnpm run test:unit", "build:lua": "node tooling/build-lua.js", "test:unit": "node --test tooling/*.test.js tests/fixtures/*.test.js tests/contracts/helpers.test.js tests/contracts/location-filters.test.js", - "test:contracts": "node --test tests/contracts/location.contract.test.js tests/contracts/actor-follow.contract.test.js tests/contracts/organization.contract.test.js", + "test:contracts": "node --test tests/contracts/location.contract.test.js tests/contracts/actor-follow.contract.test.js tests/contracts/organization.contract.test.js tests/contracts/activity.contract.test.js", "test:bad-dates": "node --test tests/contracts/location-bad-dates.contract.test.js", "install:api": "node tooling/installer.js", "seed:test": "node tooling/seed.js", diff --git a/hypercerts-api/tests/contracts/activity.contract.test.js b/hypercerts-api/tests/contracts/activity.contract.test.js new file mode 100644 index 00000000..e9e547e3 --- /dev/null +++ b/hypercerts-api/tests/contracts/activity.contract.test.js @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + activityContributorProfile, + activityRecord, + activityAuthorProfile, + organizationOnlyActivityRecord, + profilelessActivityRecord, + staleOnlyActivityRecord, +} from '../fixtures/activities.js'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const baseUrl = requireContractTarget(); +const authorDid = activityRecord.did; +const contributorDid = activityContributorProfile.did; + +function recordView(record) { + return { + uri: record.uri, + cid: record.cid, + indexedAt: record.indexedAt, + did: record.did, + record: record.record, + }; +} + +async function get(nsid, params) { + const response = await fetch(contractUrl(baseUrl, nsid, params)); + const body = await response.json(); + assert.equal(response.status, 200, `${nsid}: ${JSON.stringify(body)}`); + return body; +} + +async function assertDomainError(response, code) { + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.match(body.message, new RegExp(`^runtime error: ${code}:`)); + assert.doesNotMatch(body.message, /SELECT|happyview_records|at:\/\//); +} + +test('getActivity preserves its source record and hydrates contributor versions and actors faithfully', async () => { + const result = await get('org.hypercerts.claim.getActivity', { uri: activityRecord.uri }); + assert.deepEqual(result.activity.record, activityRecord.record); + assert.deepEqual(result.activity.author, { + did: authorDid, + profile: recordView(activityAuthorProfile), + organization: null, + }); + + const contributors = result.activity.contributors; + assert.equal(contributors.length, activityRecord.record.contributors.length); + assert.deepEqual(contributors.map(({ contributionWeight }) => contributionWeight), ['first', 'second', 'inline', 'external']); + for (const stale of contributors.slice(0, 2)) { + assert.equal(stale.contributorInformation, null); + assert.equal(stale.actor, null, 'the newer CID at the same URI must not supply an identity'); + } + assert.equal(contributors[2].contributorInformation, null); + assert.deepEqual(contributors[2].actor, { + did: contributorDid, + profile: recordView(activityContributorProfile), + }); + assert.equal(contributors[3].contributorInformation, null); + assert.equal(contributors[3].actor, null); +}); + +test('listActivities filters by contributor DID only when the exact referenced version is available', async () => { + const result = await get('org.hypercerts.claim.listActivities', { + authors: [authorDid], + hasOrganizationRecord: false, + contributors: [contributorDid], + involvedActors: [contributorDid], + uris: [activityRecord.uri, staleOnlyActivityRecord.uri], + sortDirection: 'asc', + limit: 100, + }); + assert.deepEqual(result.activities.map(({ uri }) => uri), [activityRecord.uri]); + + const staleOnly = await get('org.hypercerts.claim.listActivities', { + contributors: [contributorDid], uris: [staleOnlyActivityRecord.uri], + }); + assert.deepEqual(staleOnly.activities, []); +}); + +test('listActivities filters by organization self-record presence independently of profiles', async () => { + const organizationAuthor = await get('org.hypercerts.claim.listActivities', { + authors: [organizationOnlyActivityRecord.did], hasOrganizationRecord: true, + }); + assert.deepEqual(organizationAuthor.activities.map(({ uri }) => uri), [organizationOnlyActivityRecord.uri]); + assert.equal(organizationAuthor.activities[0].author.profile, null); + assert.equal(organizationAuthor.activities[0].author.organization.did, organizationOnlyActivityRecord.did); + assert.equal(organizationAuthor.activities[0].author.organization.record.organizationType[0], 'community'); + + const organizationExcluded = await get('org.hypercerts.claim.listActivities', { + authors: [organizationOnlyActivityRecord.did], hasOrganizationRecord: false, + }); + assert.deepEqual(organizationExcluded.activities, []); + + const profilelessAuthor = await get('org.hypercerts.claim.listActivities', { + authors: [profilelessActivityRecord.did], hasOrganizationRecord: false, + }); + assert.deepEqual(profilelessAuthor.activities.map(({ uri }) => uri), [profilelessActivityRecord.uri]); + assert.equal(profilelessAuthor.activities[0].author.profile, null); + assert.equal(profilelessAuthor.activities[0].author.organization, null); + + const profilelessExcluded = await get('org.hypercerts.claim.listActivities', { + authors: [profilelessActivityRecord.did], hasOrganizationRecord: true, + }); + assert.deepEqual(profilelessExcluded.activities, []); +}); + +test('searchActivities treats wildcard characters literally and applies URI filters', async () => { + const result = await get('org.hypercerts.claim.searchActivities', { + search: ' pinned %_ activity ', + uris: [activityRecord.uri, staleOnlyActivityRecord.uri], + }); + assert.deepEqual(result.activities.map(({ uri }) => uri), [activityRecord.uri]); + assert.deepEqual(result.activities[0].author.profile, recordView(activityAuthorProfile)); +}); + +test('getActivity distinguishes an unindexed URI from a successful empty listing', async () => { + const missing = await fetch(contractUrl(baseUrl, 'org.hypercerts.claim.getActivity', { + uri: `at://${authorDid}/org.hypercerts.claim.activity/missing`, + })); + await assertDomainError(missing, 'RecordNotFound'); + + const empty = await get('org.hypercerts.claim.listActivities', { + uris: [`at://${authorDid}/org.hypercerts.claim.activity/missing`], + }); + assert.deepEqual(empty.activities, []); + assert.equal(empty.cursor, undefined); +}); diff --git a/hypercerts-api/tests/fixtures/activities.js b/hypercerts-api/tests/fixtures/activities.js new file mode 100644 index 00000000..98f1548e --- /dev/null +++ b/hypercerts-api/tests/fixtures/activities.js @@ -0,0 +1,72 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const ACTIVITY = 'org.hypercerts.claim.activity'; +const CONTRIBUTOR_INFORMATION = 'org.hypercerts.claim.contributorInformation'; +const PROFILE = 'app.certified.actor.profile'; +const authorDid = 'did:plc:gggggggggggggggggggggggg'; +const contributorDid = 'did:plc:hhhhhhhhhhhhhhhhhhhhhhhh'; +const indexedAt = '2025-01-02T03:04:05.000Z'; + +async function row(collection, rkey, fields, did) { + const record = { $type: collection, ...fields }; + const uri = `at://${did}/${collection}/${rkey}`; + const cid = CID.toString(await CID.create(0x71, encode(record))); + return { uri, did, collection, rkey, cid, indexedAt, record }; +} + +const staleContributorInformation = await row(CONTRIBUTOR_INFORMATION, '3jzfcijpj2z2h', { + identifier: contributorDid, displayName: 'Old contributor name', createdAt: '2024-01-01T00:00:00Z', +}, authorDid); + +export const latestContributorInformation = await row(CONTRIBUTOR_INFORMATION, '3jzfcijpj2z2h', { + identifier: contributorDid, displayName: 'New contributor name', createdAt: '2025-01-01T00:00:00Z', +}, authorDid); + +export const activityRecord = await row(ACTIVITY, '3jzfcijpj2z2i', { + title: 'Version-pinned %_ activity fixture', + shortDescription: 'Tests exact contributor-information version resolution.', + createdAt: '2025-01-01T00:00:00Z', + contributors: [ + { contributorIdentity: { uri: staleContributorInformation.uri, cid: staleContributorInformation.cid }, contributionWeight: 'first' }, + { contributorIdentity: { uri: staleContributorInformation.uri, cid: staleContributorInformation.cid }, contributionWeight: 'second' }, + { contributorIdentity: { identity: contributorDid }, contributionWeight: 'inline' }, + { contributorIdentity: { identity: 'https://example.org/manual-contributor' }, contributionWeight: 'external' }, + ], +}, authorDid); + +export const staleOnlyActivityRecord = await row(ACTIVITY, '3jzfcijpj2z2j', { + title: 'Stale contributor reference', + shortDescription: 'The only contributor identity is pinned to a missing older version.', + createdAt: '2025-01-02T00:00:00Z', + contributors: [{ contributorIdentity: { uri: staleContributorInformation.uri, cid: staleContributorInformation.cid } }], +}, authorDid); + +export const organizationOnlyActivityRecord = await row(ACTIVITY, '3jzfcijpj2z2k', { + title: 'Organization-only author', shortDescription: 'The author has an organization self record and no profile.', + createdAt: '2025-01-03T00:00:00Z', +}, 'did:web:organization-only.example'); + +export const profilelessActivityRecord = await row(ACTIVITY, '3jzfcijpj2z2l', { + title: 'Profileless author', shortDescription: 'The author has neither an organization nor a profile record.', + createdAt: '2025-01-04T00:00:00Z', +}, 'did:web:no-relations.example'); + +export const activityAuthorProfile = await row(PROFILE, 'self', { + displayName: 'Activity fixture author', createdAt: indexedAt, +}, authorDid); + +export const activityContributorProfile = await row(PROFILE, 'self', { + displayName: 'Inline activity contributor', createdAt: indexedAt, +}, contributorDid); + +export const activityContributorInformationVersions = [staleContributorInformation, latestContributorInformation]; +export const activityFixtureRows = [ + activityRecord, + staleOnlyActivityRecord, + organizationOnlyActivityRecord, + profilelessActivityRecord, + latestContributorInformation, + activityAuthorProfile, + activityContributorProfile, +]; diff --git a/hypercerts-api/tests/fixtures/fixtures.test.js b/hypercerts-api/tests/fixtures/fixtures.test.js index 99c6c652..ca5d766c 100644 --- a/hypercerts-api/tests/fixtures/fixtures.test.js +++ b/hypercerts-api/tests/fixtures/fixtures.test.js @@ -4,6 +4,7 @@ import { encode } from '@atcute/cbor'; import * as CID from '@atcute/cid'; import { isValidDid, isValidTid } from '@atproto/syntax'; import { locationRecords, profileRecords, organizationRecords, seedSql } from './records.js'; +import { activityContributorInformationVersions, activityFixtureRows } from './activities.js'; import { actorFollowDids, actorFollowOrganizationRecords, @@ -15,12 +16,13 @@ test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, type const records = [ ...locationRecords, ...profileRecords, ...organizationRecords, ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, + ...activityFixtureRows, ]; for (const row of records) { assert.equal(row.uri, `at://${row.did}/${row.collection}/${row.rkey}`); assert.equal(row.record.$type, row.collection); assert.equal(isValidDid(row.did), true); - if (row.collection === 'app.certified.location' || row.collection === 'app.certified.graph.follow') assert.equal(isValidTid(row.rkey), true); + if (['app.certified.location', 'app.certified.graph.follow', 'org.hypercerts.claim.activity', 'org.hypercerts.claim.contributorInformation'].includes(row.collection)) assert.equal(isValidTid(row.rkey), true); const cid = CID.fromString(row.cid); assert.equal(cid.version, 1); assert.equal(cid.codec, 0x71); @@ -58,6 +60,17 @@ test('actor-follow fixtures isolate publishers and cover date precedence, URI ti } }); +test('activity fixtures preserve a stale strong reference while seeding only the newer URI version', async () => { + const [stale, latest] = activityContributorInformationVersions; + assert.equal(stale.uri, latest.uri); + assert.notEqual(stale.cid, latest.cid); + assert.equal(activityFixtureRows.some(({ uri, cid }) => uri === stale.uri && cid === stale.cid), false); + assert.equal(activityFixtureRows.some(({ uri, cid }) => uri === latest.uri && cid === latest.cid), true); + for (const fixture of activityFixtureRows) { + assert.equal(CID.toString(await CID.create(0x71, encode(fixture.record))), fixture.cid); + } +}); + test('location fixtures cover the smallBlob union variant with a shaped blob reference', () => { const fixture = locationRecords.find(({ record }) => record.location?.$type === 'org.hypercerts.defs#smallBlob'); assert.ok(fixture, 'expected a location fixture using the smallBlob union variant'); @@ -71,7 +84,7 @@ test('location fixtures cover the smallBlob union variant with a shaped blob ref }); test('fixture loader requires explicit disposable-target opt-in and parameterizes records only', () => { - const records = [...locationRecords, ...profileRecords, ...organizationRecords]; + const records = [...locationRecords, ...profileRecords, ...organizationRecords, ...activityFixtureRows]; assert.throws(() => seedSql(records, {}), /explicit disposable-test target opt-in/); const statements = seedSql(records, { disposableTestTarget: true }); assert.equal(statements.length, records.length); diff --git a/hypercerts-api/tooling/activity.test.js b/hypercerts-api/tooling/activity.test.js new file mode 100644 index 00000000..3118b404 --- /dev/null +++ b/hypercerts-api/tooling/activity.test.js @@ -0,0 +1,647 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const ACTIVITY = 'org.hypercerts.claim.activity'; +const CONTRIBUTOR_INFORMATION = 'org.hypercerts.claim.contributorInformation'; +const PROFILE = 'app.certified.actor.profile'; +const ORGANIZATION = 'app.certified.actor.organization'; +const authorDid = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'; +const contributorDid = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; +const activityUri = `at://${authorDid}/${ACTIVITY}/activity-one`; +const contributorInformationUri = `at://${authorDid}/${CONTRIBUTOR_INFORMATION}/3jzfcijpj2z2a`; +const oldCid = 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const latestCid = 'bafyreibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const indexedAt = '2025-01-02T03:04:05.000Z'; +const activityRecord = { + $type: ACTIVITY, + title: 'Version-pinned activity', + shortDescription: 'Contributor information must match the referenced CID.', + createdAt: indexedAt, + contributors: [ + { contributorIdentity: { uri: contributorInformationUri, cid: oldCid }, contributionWeight: 'first' }, + { contributorIdentity: { uri: contributorInformationUri, cid: oldCid }, contributionWeight: 'second' }, + { contributorIdentity: { identity: contributorDid }, contributionWeight: 'third' }, + ], +}; +const rows = [ + { + uri: activityUri, did: authorDid, collection: ACTIVITY, cid: 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc', + indexed_at: indexedAt, record: 'activity', record_json: activityRecord, + }, + { + uri: `at://${authorDid}/${PROFILE}/self`, did: authorDid, collection: PROFILE, cid: 'bafyreidddddddddddddddddddddddddddddddddddddddddddddddddddd', + indexed_at: indexedAt, record: 'author-profile', record_json: { $type: PROFILE, displayName: 'Activity author', createdAt: indexedAt }, + }, + { + uri: contributorInformationUri, did: authorDid, collection: CONTRIBUTOR_INFORMATION, cid: latestCid, + indexed_at: indexedAt, record: 'latest-contributor-information', record_json: { + $type: CONTRIBUTOR_INFORMATION, identifier: contributorDid, displayName: 'Newer version only', createdAt: indexedAt, + }, + }, + { + uri: `at://${contributorDid}/${PROFILE}/self`, did: contributorDid, collection: PROFILE, cid: 'bafyreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + indexed_at: indexedAt, record: 'contributor-profile', record_json: { $type: PROFILE, displayName: 'Inline contributor', createdAt: indexedAt }, + }, +]; + +function lua(value) { + if (value === null) return 'nil'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `{${value.map(lua).join(',')}}`; + if (value && typeof value === 'object') { + return `{${Object.entries(value).map(([key, item]) => `[${JSON.stringify(key)}]=${lua(item)}`).join(',')}}`; + } + throw new TypeError(`Cannot encode ${typeof value} as a Lua fixture`); +} + +function withoutRecordJson(row) { + const databaseRow = { ...row }; + delete databaseRow.record_json; + return databaseRow; +} + +test('activity API manifests and Lexicons declare the implemented endpoint contracts', async () => { + const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url), 'utf8')); + const module = JSON.parse(await readFile(new URL('../modules/activity/manifest.json', import.meta.url), 'utf8')); + assert.deepEqual({ + getActivity: manifest.handlerStatus.getActivity, + listActivities: manifest.handlerStatus.listActivities, + searchActivities: manifest.handlerStatus.searchActivities, + }, { getActivity: 'implemented', listActivities: 'implemented', searchActivities: 'implemented' }); + assert.ok(manifest.modules.includes('modules/activity/manifest.json')); + for (const nsid of ['org.hypercerts.claim.activity', 'org.hypercerts.claim.contributorInformation']) { + assert.ok(manifest.validationLexicons.some(({ id, packagePath }) => id === nsid && packagePath)); + } + + const endpointNames = ['getActivity', 'listActivities', 'searchActivities']; + for (const name of endpointNames) { + const nsid = `org.hypercerts.claim.${name}`; + const lexiconPath = `../lexicons/${nsid}.json`; + const schema = JSON.parse(await readFile(new URL(lexiconPath, import.meta.url), 'utf8')); + assert.equal(schema.id, nsid); + assert.ok(manifest.validationLexicons.some(({ id, path }) => id === nsid && path)); + const script = module.assets.find(({ kind, id }) => kind === 'script' && id === `xrpc.query:${nsid}`); + assert.ok(script, `missing handler declaration for ${nsid}`); + assert.equal(script.sourcePath, `../../lua/src/${name}.lua`); + assert.ok(manifest.luaBuild.endpointSources.includes(`lua/src/${name}.lua`)); + } + + const getSchema = JSON.parse(await readFile(new URL('../lexicons/org.hypercerts.claim.getActivity.json', import.meta.url), 'utf8')); + assert.deepEqual(getSchema.defs.main.parameters.required, ['uri']); + assert.deepEqual(getSchema.defs.activityView.required, ['uri', 'cid', 'indexedAt', 'did', 'author', 'record']); + assert.deepEqual(getSchema.defs.activityView.nullable, ['indexedAt']); + assert.deepEqual(getSchema.defs.contributorInformationView.required, ['uri', 'cid', 'indexedAt', 'did', 'record']); + assert.deepEqual(getSchema.defs.contributorInformationView.nullable, ['indexedAt']); + assert.deepEqual(getSchema.defs.activityContributorView.required, ['contributorIdentity', 'contributorInformation', 'actor']); + assert.deepEqual(getSchema.defs.activityContributorView.nullable, ['contributorInformation', 'actor']); + assert.deepEqual(getSchema.defs.contributorActorView.required, ['did', 'profile']); + assert.deepEqual(getSchema.defs.contributorActorView.nullable, ['profile']); + + const listSchema = JSON.parse(await readFile(new URL('../lexicons/org.hypercerts.claim.listActivities.json', import.meta.url), 'utf8')); + const searchSchema = JSON.parse(await readFile(new URL('../lexicons/org.hypercerts.claim.searchActivities.json', import.meta.url), 'utf8')); + assert.equal(listSchema.defs.main.parameters.properties.uris.maxLength, 100); + assert.equal(listSchema.defs.main.parameters.properties.limit.maximum, 100); + assert.deepEqual(searchSchema.defs.main.parameters.required, ['search']); + assert.equal(searchSchema.defs.main.parameters.properties.contributors.maxLength, 100); + for (const schema of [listSchema, searchSchema]) { + const properties = schema.defs.main.parameters.properties; + assert.equal(properties.hasOrganizationRecord.type, 'boolean'); + assert.match(properties.hasOrganizationRecord.description, /organization\/self/i); + assert.match(properties.hasOrganizationRecord.description, /regardless of profile/i); + assert.equal(Object.hasOwn(properties, 'authorType'), false); + } + assert.equal(listSchema.defs.output.properties.activities.items.ref, `${getSchema.id}#activityView`); + assert.equal(searchSchema.defs.output.properties.activities.items.ref, `${getSchema.id}#activityView`); +}); + +function runGetActivity({ + uri = activityUri, + recordJson = activityRecord, + assertions = '', + activityIndexedAt = indexedAt, + extraRows = [], + serialize = false, +} = {}) { + const activityDatabaseRow = { ...rows[0], uri, indexed_at: activityIndexedAt, record_json: recordJson }; + const scenarioRows = [activityDatabaseRow, ...rows.slice(1), ...extraRows]; + const records = Object.fromEntries(scenarioRows.map(({ record, record_json }) => [record, record_json])); + const databaseRows = scenarioRows.map(withoutRecordJson); + const source = ` +local NULL = {} +local RECORDS = ${lua(records)} +local ROWS = ${lua(databaseRows)} +local calls = {} +local function encode_json_string(value) + local output, backslash = {'"'}, string.char(92) + for index = 1, #value do + local byte = value:byte(index) + local character = value:sub(index, index) + if byte == 34 then output[#output + 1] = backslash .. '"' + elseif byte == 92 then output[#output + 1] = backslash .. backslash + elseif byte == 8 then output[#output + 1] = backslash .. 'b' + elseif byte == 9 then output[#output + 1] = backslash .. 't' + elseif byte == 10 then output[#output + 1] = backslash .. 'n' + elseif byte == 12 then output[#output + 1] = backslash .. 'f' + elseif byte == 13 then output[#output + 1] = backslash .. 'r' + elseif byte < 32 then output[#output + 1] = backslash .. 'u' .. string.format('%04x', byte) + else output[#output + 1] = character end + end + output[#output + 1] = '"' + return table.concat(output) +end +local function encode_json(value) + if value == NULL then return 'null' end + local kind = type(value) + if kind == 'string' then return encode_json_string(value) end + if kind == 'number' then + assert(value == value and value ~= math.huge and value ~= -math.huge, 'invalid JSON number') + return tostring(value) + end + if kind == 'boolean' then return tostring(value) end + assert(kind == 'table', 'unsupported JSON value: ' .. kind) + local count, max_index, is_array = 0, 0, true + for key in pairs(value) do + if type(key) == 'number' and key >= 1 and key % 1 == 0 then + count = count + 1 + if key > max_index then max_index = key end + else + is_array = false + end + end + if is_array and count == max_index then + local items = {} + for index = 1, max_index do items[index] = encode_json(value[index]) end + return '[' .. table.concat(items, ',') .. ']' + end + local keys = {} + for key in pairs(value) do + assert(type(key) == 'string', 'JSON object keys must be strings') + keys[#keys + 1] = key + end + table.sort(keys) + local fields = {} + for _, key in ipairs(keys) do + fields[#fields + 1] = encode_json_string(key) .. ':' .. encode_json(value[key]) + end + return '{' .. table.concat(fields, ',') .. '}' +end +json = { + decode = function(value) if value == 'null' then return NULL end return RECORDS[value] end, + encode = encode_json, +} +toarray = function(value) return value end +params = { uri = '${uri}' } +db = { + backend = function() return 'postgres' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + local results = {} + for _, row in ipairs(ROWS) do + if row.collection == values[1] then + local matches = true + if sql:find('uri = $2', 1, true) and row.uri ~= values[2] then matches = false end + if sql:find('cid = $3', 1, true) and row.cid ~= values[3] then matches = false end + if sql:find('did = $2', 1, true) and row.did ~= values[2] then matches = false end + if sql:find('did IN (', 1, true) then + local found = false + for index = 2, #values do if row.did == values[index] then found = true end end + if not found then matches = false end + end + if matches then results[#results + 1] = row end + end + end + return results + end, +} +dofile('lua/endpoints/getActivity.lua') +local result = handle() +local activity = result.activity +assert(activity.uri == '${uri}') +${assertions} +${serialize ? 'print(json.encode(result))' : ''} +`; + return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); +} + +test('getActivity serializes a null activity indexed_at as explicit JSON null', () => { + const result = runGetActivity({ activityIndexedAt: null, serialize: true }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + + const body = JSON.parse(result.stdout); + assert.equal(Object.hasOwn(body.activity, 'indexedAt'), true); + assert.equal(body.activity.indexedAt, null); +}); + +test('getActivity serializes exact-version contributor indexed_at null and timestamp strings', () => { + const exactInformation = { + ...rows[2], cid: oldCid, record: 'exact-contributor-information', + record_json: { + $type: CONTRIBUTOR_INFORMATION, identifier: contributorDid, displayName: 'Exact version', createdAt: indexedAt, + }, + }; + const serializeActivity = (informationIndexedAt) => { + const result = runGetActivity({ + extraRows: [{ ...exactInformation, indexed_at: informationIndexedAt }], + serialize: true, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + return JSON.parse(result.stdout).activity; + }; + + const nullable = serializeActivity(null); + assert.equal(nullable.indexedAt, indexedAt); + assert.equal(nullable.contributors[0].contributorInformation.cid, oldCid); + assert.equal(Object.hasOwn(nullable.contributors[0].contributorInformation, 'indexedAt'), true); + assert.equal(nullable.contributors[0].contributorInformation.indexedAt, null); + + const timestamped = serializeActivity(indexedAt); + assert.equal(timestamped.indexedAt, indexedAt); + assert.equal(timestamped.contributors[0].contributorInformation.indexedAt, indexedAt); +}); + +test('getActivity includes an indexed organization sidecar in the fully hydrated author', () => { + const uri = `at://${authorDid}/${ACTIVITY}/organization-author`; + const activity = activityRow({ + uri, cid: 'bafyreillllllllllllllllllllllllllllllllllllllllllllllllllll', + record: { title: 'Organization author', shortDescription: 'Author hydration', createdAt: indexedAt }, + }); + const profile = { + uri: `at://${authorDid}/${PROFILE}/self`, did: authorDid, collection: PROFILE, cid: 'bafyreimmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm', + indexed_at: indexedAt, record: 'organization-author-profile', record_json: { $type: PROFILE, displayName: 'Organization author profile', createdAt: indexedAt }, + }; + const organization = { + uri: `at://${authorDid}/${ORGANIZATION}/self`, did: authorDid, collection: ORGANIZATION, cid: 'bafyreinnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn', + indexed_at: indexedAt, record: 'organization-author-sidecar', record_json: { $type: ORGANIZATION, organizationType: ['nonprofit'], createdAt: indexedAt }, + }; + const result = runLuaEndpoint({ + endpoint: 'getActivity', params: { uri }, queryResults: [[activity], [profile], [organization]], + assertions: ` +assert(result.activity.author.profile.record.displayName == 'Organization author profile') +assert(result.activity.author.organization.uri == '${organization.uri}') +assert(result.activity.author.organization.cid == '${organization.cid}') +assert(result.activity.author.organization.record.organizationType[1] == 'nonprofit') +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('getActivity does not substitute a newer contributor-information record at the same URI', () => { + const result = runGetActivity({ assertions: ` +assert(activity.record.title == 'Version-pinned activity') +assert(#activity.record.contributors == 3) +assert(activity.record.contributors[1].contributorIdentity.uri == '${contributorInformationUri}') +assert(activity.record.contributors[1].contributorIdentity.cid == '${oldCid}') +assert(activity.record.contributors[1].contributorInformation == nil and activity.record.contributors[1].actor == nil, + 'source record must remain unchanged') +assert(activity.contributors[1].contributorInformation == NULL, 'newer contributor-information CID must not hydrate') +assert(activity.contributors[1].actor == NULL, 'identity from a different CID must not hydrate') +assert(activity.contributors[1].contributionWeight == 'first' and activity.contributors[2].contributionWeight == 'second') +assert(activity.contributors[2].contributorInformation == NULL and activity.contributors[2].actor == NULL) +assert(activity.contributors[1].contributorIdentity.uri == '${contributorInformationUri}' and activity.contributors[2].contributorIdentity.cid == '${oldCid}') +assert(activity.contributors[3].actor.did == '${contributorDid}') +assert(activity.contributors[3].actor.profile.record.displayName == 'Inline contributor') +assert(activity.author.did == '${authorDid}') +assert(activity.author.profile.record.displayName == 'Activity author') +assert(activity.author.organization == NULL) +local checkedExactCid = false +for _, call in ipairs(calls) do + if call.values[1] == '${CONTRIBUTOR_INFORMATION}' then + assert(call.values[2] == '${contributorInformationUri}') + assert(call.values[3] == '${oldCid}') + assert(call.sql:find('cid = $3', 1, true), 'contributor-information lookup must constrain both URI and CID') + checkedExactCid = true + end +end +assert(checkedExactCid, 'expected an exact contributor-information version lookup') +` }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +function runLuaEndpoint({ endpoint, params, queryResults, assertions = '', expectError, expectedCalls = 0, backend = 'postgres', queryFailureAt = 0 }) { + const recordMap = Object.fromEntries(queryResults.flat().map(({ record, record_json }) => [record, record_json])); + const rowsByCall = queryResults.map((result) => result.map(withoutRecordJson)); + const source = ` +local RECORDS = ${lua(recordMap)} +local RESULTS = ${lua(rowsByCall)} +local NULL = {} +local calls = {} +local function decode_cursor(value) + return { + v = tonumber(value:match('"v":(%d+)')), + d = value:match('"d":"([^"]*)"'), + t = value:match('"t":"([^"]*)"'), + u = value:match('"u":"([^"]*)"'), + } +end +json = { + decode = function(value) + if value == 'null' then return NULL end + if RECORDS[value] then return RECORDS[value] end + if value:sub(1, 1) == '{' then return decode_cursor(value) end + return decode_cursor(value:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end)) + end, + encode = function(value) + return string.format('{"v":%d,"d":"%s","t":"%s","u":"%s"}', value.v, value.d, value.t, value.u) + end, +} +toarray = function(value) return value end +params = ${lua(params)} +db = { + backend = function() return '${backend}' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if ${queryFailureAt} == #calls then error('fixture database failure') end + return RESULTS[#calls] or {} + end, +} +dofile('lua/endpoints/${endpoint}.lua') +local ok, result = pcall(handle) +${expectError + ? `assert(not ok, 'expected request to fail')\nassert(tostring(result):find('${expectError}', 1, true), tostring(result))\nassert(#calls == ${expectedCalls}, 'unexpected query count')` + : `assert(ok, tostring(result))\n${assertions}`} +`; + return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); +} + +function activityRow({ uri, did = authorDid, record, cid, sortTimestamp }) { + return { + uri, did, collection: ACTIVITY, cid, indexed_at: indexedAt, + record: uri, record_json: { $type: ACTIVITY, ...record }, + ...(sortTimestamp ? { sort_timestamp: sortTimestamp } : {}), + }; +} + +test('listActivities combines actor, organization-record, URI, and contributor filters before paging and hydrates only the page', () => { + const firstUri = `at://${authorDid}/${ACTIVITY}/first`; + const lookaheadUri = `at://${authorDid}/${ACTIVITY}/lookahead`; + const exactInfoCid = 'bafyreiffffffffffffffffffffffffffffffffffffffffffffffffffff'; + const lookaheadInfoUri = `at://${authorDid}/${CONTRIBUTOR_INFORMATION}/lookahead`; + const exactInfoUri = `at://${authorDid}/${CONTRIBUTOR_INFORMATION}/exact`; + const first = activityRow({ + uri: firstUri, cid: 'bafyreiggggggggggggggggggggggggggggggggggggggggggggggggggg', + sortTimestamp: '2025-01-01T00:00:00.000000Z', + record: { + title: 'First activity', shortDescription: 'Matching contributor', createdAt: '2025-01-01T00:00:00Z', + contributors: [{ contributorIdentity: { uri: exactInfoUri, cid: exactInfoCid } }], + }, + }); + const lookahead = activityRow({ + uri: lookaheadUri, cid: 'bafyreihhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh', + sortTimestamp: '2025-01-02T00:00:00.000000Z', + record: { + title: 'Lookahead activity', shortDescription: 'Must not be hydrated', createdAt: '2025-01-02T00:00:00Z', + contributors: [{ contributorIdentity: { uri: lookaheadInfoUri, cid: 'bafyreijjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj' } }], + }, + }); + const info = { + uri: exactInfoUri, did: authorDid, collection: CONTRIBUTOR_INFORMATION, cid: exactInfoCid, + indexed_at: indexedAt, record: 'exact-info', record_json: { + $type: CONTRIBUTOR_INFORMATION, identifier: contributorDid, displayName: 'Exact contributor', createdAt: indexedAt, + }, + }; + const authorProfile = { + uri: `at://${authorDid}/${PROFILE}/self`, did: authorDid, collection: PROFILE, cid: 'bafyreikkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk', + indexed_at: indexedAt, record: 'list-author-profile', record_json: { $type: PROFILE, displayName: 'List author', createdAt: indexedAt }, + }; + const contributorProfile = { + uri: `at://${contributorDid}/${PROFILE}/self`, did: contributorDid, collection: PROFILE, cid: 'bafyreillllllllllllllllllllllllllllllllllllllllllllllllllll', + indexed_at: indexedAt, record: 'list-contributor-profile', record_json: { $type: PROFILE, displayName: 'List contributor', createdAt: indexedAt }, + }; + const result = runLuaEndpoint({ + endpoint: 'listActivities', + params: { + authors: [authorDid, authorDid], hasOrganizationRecord: 'false', contributors: [contributorDid, contributorDid], + involvedActors: [contributorDid, contributorDid], uris: [firstUri, lookaheadUri, firstUri], sortDirection: 'asc', limit: '1', + }, + queryResults: [[first, lookahead], [info], [authorProfile, contributorProfile], []], + assertions: ` +assert(#result.activities == 1 and result.activities[1].uri == '${firstUri}') +assert(result.cursor ~= nil, 'lookahead row must produce a next-page cursor') +local cursorJson = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) +local token = json.decode(cursorJson) +assert(token.v == 1 and token.d == 'asc' and token.t == '2025-01-01T00:00:00.000000Z' and token.u == '${firstUri}') +assert(result.activities[1].author.profile.record.displayName == 'List author') +assert(result.activities[1].contributors[1].contributorInformation.record.displayName == 'Exact contributor') +assert(result.activities[1].contributors[1].actor.profile.record.displayName == 'List contributor') +local sql = calls[1].sql +assert(sql:find('activity.did IN ($2)', 1, true), 'duplicate author DIDs must be removed before binding') +assert(sql:find('activity.uri IN ($6, $7)', 1, true), 'duplicate URIs must be removed before binding') +assert(sql:find("NOT EXISTS (SELECT 1 FROM happyview_records AS organization", 1, true), 'false must require no organization self record') +assert(sql:find("organization.collection = 'app.certified.actor.organization'", 1, true)) +assert(sql:find("organization.rkey = 'self'", 1, true) and sql:find('organization.did = activity.did', 1, true)) +assert(not sql:find("app.certified.actor.profile", 1, true), 'organization-record filtering must not depend on a profile') +assert(sql:find('jsonb_array_elements', 1, true), 'contributor matching must inspect each contributor entry') +assert(sql:find('ci.cid', 1, true) and sql:find('ci.uri', 1, true), 'contributor references must join by exact CID and URI') +assert(sql:find('ORDER BY sorted.sort_at ASC, activity.uri ASC', 1, true)) +assert(sql:find(' OR EXISTS (SELECT', 1, true), 'involvedActors must match authors or contributors') +local hydrationValues = table.concat(calls[2].values, '|') +assert(hydrationValues:find('${exactInfoUri}', 1, true) and hydrationValues:find('${exactInfoCid}', 1, true)) +assert(not hydrationValues:find('${lookaheadInfoUri}', 1, true), 'lookahead contributor information must not be hydrated') +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('searchActivities binds trimmed text literally and combines it with filters', () => { + const uri = `at://${authorDid}/${ACTIVITY}/searched`; + const row = activityRow({ + uri, cid: 'bafyreimmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm', + record: { + title: 'Forest %_ Initiative', shortDescription: 'River monitoring', createdAt: indexedAt, + contributors: [{ contributorIdentity: { identity: contributorDid } }], + }, + }); + const profile = { + uri: `at://${authorDid}/${PROFILE}/self`, did: authorDid, collection: PROFILE, cid: 'bafyreinnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn', + indexed_at: indexedAt, record: 'search-author-profile', record_json: { $type: PROFILE, displayName: 'Search author', createdAt: indexedAt }, + }; + const contributorProfile = { + uri: `at://${contributorDid}/${PROFILE}/self`, did: contributorDid, collection: PROFILE, cid: 'bafyreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + indexed_at: indexedAt, record: 'search-contributor-profile', record_json: { $type: PROFILE, displayName: 'Search contributor', createdAt: indexedAt }, + }; + const result = runLuaEndpoint({ + endpoint: 'searchActivities', + params: { search: ' FOREST %_ Initiative ', authors: [authorDid], limit: '10' }, + queryResults: [[row], [profile, contributorProfile], []], + assertions: ` +assert(#result.activities == 1 and result.activities[1].uri == '${uri}') +assert(result.activities[1].author.profile.record.displayName == 'Search author') +assert(result.activities[1].contributors[1].actor.profile.record.displayName == 'Search contributor') +local sql = calls[1].sql +assert(sql:find('strpos(lower', 1, true), 'search must use a literal substring comparison') +assert(not sql:find('LIKE', 1, true), 'search must not treat % or _ as wildcards') +assert(not sql:find('FOREST', 1, true), 'search text must be bound rather than interpolated') +local values = table.concat(calls[1].values, '|') +assert(values:find('FOREST %_ Initiative', 1, true), 'trimmed complete search text must remain a bound value') +assert(sql:find('activity.did IN', 1, true), 'authors filter must remain present with search') +assert(sql:find(' AND ', 1, true), 'search and authors must combine with AND') +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('listActivities and searchActivities filter by self organization-record presence', () => { + for (const { endpoint, params, predicate } of [ + { endpoint: 'listActivities', params: { hasOrganizationRecord: 'true' }, predicate: 'EXISTS' }, + { endpoint: 'searchActivities', params: { search: 'forest', hasOrganizationRecord: 'false' }, predicate: 'NOT EXISTS' }, + { endpoint: 'listActivities', params: { hasOrganizationRecord: true }, predicate: 'EXISTS' }, + { endpoint: 'searchActivities', params: { search: 'forest', hasOrganizationRecord: false }, predicate: 'NOT EXISTS' }, + ]) { + const result = runLuaEndpoint({ + endpoint, params, queryResults: [[]], + assertions: ` +assert(#result.activities == 0 and result.cursor == nil) +local sql = calls[1].sql +assert(sql:find("${predicate} (SELECT 1 FROM happyview_records AS organization", 1, true)) +assert(sql:find("organization.collection = 'app.certified.actor.organization'", 1, true)) +assert(sql:find("organization.rkey = 'self'", 1, true) and sql:find('organization.did = activity.did', 1, true)) +assert(not sql:find("app.certified.actor.profile", 1, true), 'the filter must not depend on profiles') +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + } +}); + +test('hasOrganizationRecord=false retains activity authors that have no profile', () => { + const profilelessDid = 'did:web:no-relations.example'; + const uri = `at://${profilelessDid}/${ACTIVITY}/profileless-author`; + const row = activityRow({ + uri, did: profilelessDid, cid: 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc', + record: { title: 'Profileless author', shortDescription: 'Organization absence is independent of profile presence', createdAt: indexedAt }, + }); + const result = runLuaEndpoint({ + endpoint: 'listActivities', params: { authors: [profilelessDid], hasOrganizationRecord: false }, + queryResults: [[row], [], []], + assertions: ` +assert(#result.activities == 1 and result.activities[1].uri == '${uri}') +assert(result.activities[1].author.profile == NULL and result.activities[1].author.organization == NULL) +assert(calls[1].sql:find("NOT EXISTS (SELECT 1 FROM happyview_records AS organization", 1, true)) +assert(not calls[1].sql:find("app.certified.actor.profile", 1, true)) +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('activity listings use direction-bound tuple cursors and preserve empty results', () => { + const beforeUri = `at://${authorDid}/${ACTIVITY}/before`; + const cursor = Buffer.from(JSON.stringify({ v: 1, d: 'asc', t: '2025-01-01T00:00:00.000000Z', u: beforeUri })).toString('hex'); + const afterUri = `at://${authorDid}/${ACTIVITY}/after`; + const after = activityRow({ + uri: afterUri, cid: 'bafyreioooooooooooooooooooooooooooooooooooooooooooooooooo', + sortTimestamp: '2025-01-02T00:00:00.000000Z', + record: { title: 'After cursor', shortDescription: 'Next page', createdAt: '2025-01-02T00:00:00Z' }, + }); + const profile = { + uri: `at://${authorDid}/${PROFILE}/self`, did: authorDid, collection: PROFILE, cid: 'bafyreipppppppppppppppppppppppppppppppppppppppppppppppppp', + indexed_at: indexedAt, record: 'cursor-author-profile', record_json: { $type: PROFILE, displayName: 'Cursor author', createdAt: indexedAt }, + }; + const page = runLuaEndpoint({ + endpoint: 'listActivities', params: { sortDirection: 'asc', cursor, limit: '1' }, + queryResults: [[after], [profile], []], + assertions: ` +assert(#result.activities == 1 and result.activities[1].uri == '${afterUri}') +assert(result.cursor == nil) +assert(calls[1].sql:find('(sorted.sort_at, activity.uri) >', 1, true)) +assert(calls[1].sql:find('ORDER BY sorted.sort_at ASC, activity.uri ASC', 1, true)) +assert(calls[1].values[2] == '2025-01-01T00:00:00.000000Z') +assert(calls[1].values[3] == '${beforeUri}' and calls[1].values[4] == 2) +`, + }); + assert.equal(page.status, 0, `${page.stderr}${page.stdout}`); + + const empty = runLuaEndpoint({ + endpoint: 'listActivities', params: {}, queryResults: [[]], + assertions: ` +assert(type(result.activities) == 'table' and #result.activities == 0 and result.cursor == nil) +assert(calls[1].values[1] == '${ACTIVITY}' and calls[1].values[2] == 26) +assert(calls[1].sql:find('ORDER BY sorted.sort_at DESC, activity.uri DESC', 1, true)) +assert(#calls == 1, 'an empty page must not trigger hydration') +`, + }); + assert.equal(empty.status, 0, `${empty.stderr}${empty.stdout}`); +}); + +test('activity queries reject malformed filters and bounded-input violations before querying', () => { + const badCursor = Buffer.from(JSON.stringify({ v: 1, d: 'desc', t: '2025-01-01T00:00:00Z', u: activityUri })).toString('hex'); + const invalidLists = [ + { search: 'forest' }, + { unknown: 'value' }, + { authors: ['alice.example'] }, + { authors: Array(101).fill(authorDid) }, + { contributors: ['did:plc:invalid:'] }, + { involvedActors: ['did:1:bad'] }, + { uris: ['at://alice.example/org.hypercerts.claim.activity/x'] }, + { uris: ['at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/app.certified.location/x'] }, + { authorType: 'person' }, + { hasOrganizationRecord: 'sometimes' }, + { limit: '0' }, + { limit: '101' }, + { limit: ['1', '2'] }, + { sortDirection: 'sideways' }, + { cursor: 'bad!' }, + { cursor: badCursor, sortDirection: 'asc' }, + ]; + for (const params of invalidLists) { + const result = runLuaEndpoint({ endpoint: 'listActivities', params, queryResults: [], expectError: 'InvalidRequest:', expectedCalls: 0 }); + assert.equal(result.status, 0, `${JSON.stringify(params)}\n${result.stderr}${result.stdout}`); + } + const repeatedOrganizationRecord = runLuaEndpoint({ + endpoint: 'listActivities', params: { hasOrganizationRecord: ['true', 'false'] }, queryResults: [], + expectError: 'hasOrganizationRecord must occur once', expectedCalls: 0, + }); + assert.equal(repeatedOrganizationRecord.status, 0, `${repeatedOrganizationRecord.stderr}${repeatedOrganizationRecord.stdout}`); + + const missingSearch = runLuaEndpoint({ endpoint: 'searchActivities', params: {}, queryResults: [], expectError: 'InvalidRequest:', expectedCalls: 0 }); + assert.equal(missingSearch.status, 0, `${missingSearch.stderr}${missingSearch.stdout}`); + const unacceptedListSearch = runLuaEndpoint({ endpoint: 'listActivities', params: { search: 'forest' }, queryResults: [], expectError: 'InvalidRequest:', expectedCalls: 0 }); + assert.equal(unacceptedListSearch.status, 0, `${unacceptedListSearch.stderr}${unacceptedListSearch.stdout}`); +}); + +test('missing records, missing results, and operational activity-query failures stay distinct', () => { + const missing = runLuaEndpoint({ + endpoint: 'getActivity', params: { uri: activityUri }, queryResults: [[]], + expectError: 'RecordNotFound:', expectedCalls: 1, + }); + assert.equal(missing.status, 0, `${missing.stderr}${missing.stdout}`); + + const listFailure = runLuaEndpoint({ + endpoint: 'listActivities', params: {}, queryResults: [], backend: 'sqlite', + expectError: 'ActivityQueryFailed:', expectedCalls: 0, + }); + assert.equal(listFailure.status, 0, `${listFailure.stderr}${listFailure.stdout}`); + + const row = activityRow({ + uri: `at://${authorDid}/${ACTIVITY}/hydration-failure`, cid: 'bafyreijjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj', + record: { title: 'Hydration failure', shortDescription: 'Must not return partial data', createdAt: indexedAt }, + }); + const hydrationFailure = runLuaEndpoint({ + endpoint: 'listActivities', params: {}, queryResults: [[row]], queryFailureAt: 2, + expectError: 'ActivityQueryFailed:', expectedCalls: 2, + }); + assert.equal(hydrationFailure.status, 0, `${hydrationFailure.stderr}${hydrationFailure.stdout}`); +}); + +test('getActivity distinguishes absent contributors from an explicitly empty array', () => { + const absent = structuredClone(activityRecord); + delete absent.contributors; + const absentResult = runGetActivity({ + uri: `at://${authorDid}/${ACTIVITY}/activity-without-contributors`, + recordJson: absent, + assertions: `assert(activity.record.contributors == nil and activity.contributors == nil)`, + }); + assert.equal(absentResult.status, 0, `${absentResult.stderr}${absentResult.stdout}`); + + const empty = { ...activityRecord, contributors: [] }; + const emptyResult = runGetActivity({ + uri: `at://${authorDid}/${ACTIVITY}/activity-with-empty-contributors`, + recordJson: empty, + assertions: `assert(type(activity.record.contributors) == 'table' and #activity.record.contributors == 0) +assert(type(activity.contributors) == 'table' and #activity.contributors == 0)`, + }); + assert.equal(emptyResult.status, 0, `${emptyResult.stderr}${emptyResult.stdout}`); +}); diff --git a/hypercerts-api/tooling/build-lua.test.js b/hypercerts-api/tooling/build-lua.test.js index 4f36352e..2c204ca1 100644 --- a/hypercerts-api/tooling/build-lua.test.js +++ b/hypercerts-api/tooling/build-lua.test.js @@ -20,6 +20,11 @@ const sources = [ ['lua/shared/profileList.lua', 'local function profiles_response() return row_view() end\n'], ['lua/shared/organization.lua', 'local function organization_actor_view() return row_view() end\n'], ['lua/shared/organizationList.lua', 'local function organizations_response() return organization_actor_view() end\n'], + ['lua/shared/activity.lua', 'local function activity_view() return "activity" end\n'], + ['lua/shared/activityList.lua', 'local function activity_list_response(search_enabled) return search_enabled end\n'], + ['lua/src/getActivity.lua', 'function handle() return activity_view() end\n'], + ['lua/src/listActivities.lua', 'function handle() return activity_list_response(false) end\n'], + ['lua/src/searchActivities.lua', 'function handle() return activity_list_response(true) end\n'], ['lua/src/getOrganization.lua', 'function handle() return organization_actor_view() end\n'], ['lua/src/listOrganizations.lua', 'function handle() return organizations_response(false) end\n'], ['lua/src/searchOrganizations.lua', 'function handle() return organizations_response(true) end\n'], @@ -107,10 +112,36 @@ test('builds listing and search handlers from their declared organization source }); }); +test('builds get, list, and search activity handlers from their declared sources', async () => { + await withLuaRoot(async (root) => { + await buildLuaBundles(root); + assert.equal( + await readFile(path.join(root, 'lua/endpoints/getActivity.lua'), 'utf8'), + [ + 'local function activity_view() return "activity" end', + 'function handle() return activity_view() end', + ].join('\n\n') + '\n', + ); + for (const [name, searchEnabled] of [['listActivities', false], ['searchActivities', true]]) { + assert.equal( + await readFile(path.join(root, `lua/endpoints/${name}.lua`), 'utf8'), + [ + 'local function activity_view() return "activity" end', + 'local function activity_list_response(search_enabled) return search_enabled end', + `function handle() return activity_list_response(${searchEnabled}) end`, + ].join('\n\n') + '\n', + ); + } + }); +}); + test('checks generated bundles without rewriting stale outputs', async () => { await withLuaRoot(async (root) => { const expectedStale = [ 'lua/endpoints/getLocation.lua', + 'lua/endpoints/getActivity.lua', + 'lua/endpoints/listActivities.lua', + 'lua/endpoints/searchActivities.lua', 'lua/endpoints/listLocations.lua', 'lua/endpoints/getFollow.lua', 'lua/endpoints/listActorFollowers.lua', @@ -138,6 +169,9 @@ test('reports missing generated bundles and accepts fresh bundles', async () => await withLuaRoot(async (root) => { const expected = [ 'lua/endpoints/getLocation.lua', + 'lua/endpoints/getActivity.lua', + 'lua/endpoints/listActivities.lua', + 'lua/endpoints/searchActivities.lua', 'lua/endpoints/listLocations.lua', 'lua/endpoints/getFollow.lua', 'lua/endpoints/listActorFollowers.lua', diff --git a/hypercerts-api/tooling/lexicons.test.js b/hypercerts-api/tooling/lexicons.test.js index 317dfc55..ea3fe939 100644 --- a/hypercerts-api/tooling/lexicons.test.js +++ b/hypercerts-api/tooling/lexicons.test.js @@ -22,6 +22,8 @@ test('the full validation Lexicon closure resolves locally while only selected p 'app.certified.graph.follow', 'app.certified.location', 'app.certified.signature.defs', + 'org.hypercerts.claim.activity', + 'org.hypercerts.claim.contributorInformation', 'org.hypercerts.defs', ]); for (const asset of deployedPackageAssets) { diff --git a/hypercerts-api/tooling/lua-bundles.js b/hypercerts-api/tooling/lua-bundles.js index 751ab1fa..69ea929f 100644 --- a/hypercerts-api/tooling/lua-bundles.js +++ b/hypercerts-api/tooling/lua-bundles.js @@ -3,6 +3,8 @@ import path from 'node:path'; const bundles = [ { shared: ['query', 'recordIdentifier', 'recordView', 'actorView', 'location'], endpoints: ['getLocation'] }, + { shared: ['activity'], endpoints: ['getActivity'] }, + { shared: ['activity', 'activityList'], endpoints: ['listActivities', 'searchActivities'] }, { shared: ['query', 'recordIdentifier', 'listQuery', 'recordView', 'actorView', 'location'], endpoints: ['listLocations'] }, { shared: ['query', 'recordView', 'actorFollow', 'actorFollowLookup'], endpoints: ['getFollow'] }, { shared: ['query', 'recordIdentifier', 'listQuery', 'recordView', 'actorView', 'actorFollow', 'actorFollowList'], endpoints: ['listActorFollowers', 'listActorFollowing'] }, diff --git a/hypercerts-api/tooling/seed.js b/hypercerts-api/tooling/seed.js index 167f8fc7..95517dfd 100644 --- a/hypercerts-api/tooling/seed.js +++ b/hypercerts-api/tooling/seed.js @@ -4,6 +4,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { seedSql, locationRecords, profileRecords, organizationRecords } from '../tests/fixtures/records.js'; import { actorFollowRecords, actorFollowProfileRecords, actorFollowOrganizationRecords } from '../tests/fixtures/actor-follows.js'; +import { activityFixtureRows } from '../tests/fixtures/activities.js'; import { badDateSeedSql } from '../tests/fixtures/bad-dates.js'; import { badDateLocations } from '../tests/fixtures/bad-location-dates.js'; @@ -37,6 +38,7 @@ function sqlInput(statements, types = 'text, text, text, text, jsonb, text, text export function buildSeedInput(env = process.env, rows = [ ...locationRecords, ...profileRecords, ...organizationRecords, ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, + ...activityFixtureRows, ]) { disposableTarget(env); return sqlInput(seedSql(rows, { disposableTestTarget: true })); diff --git a/hypercerts-api/tooling/seed.test.js b/hypercerts-api/tooling/seed.test.js index 1ce5c6fb..152df648 100644 --- a/hypercerts-api/tooling/seed.test.js +++ b/hypercerts-api/tooling/seed.test.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { locationRecords, profileRecords, organizationRecords } from '../tests/fixtures/records.js'; import { actorFollowRecords, actorFollowProfileRecords, actorFollowOrganizationRecords } from '../tests/fixtures/actor-follows.js'; +import { activityFixtureRows } from '../tests/fixtures/activities.js'; import { badDateLocations } from '../tests/fixtures/bad-location-dates.js'; import { buildBadDateSeedInput, buildSeedInput, psqlTargetArgs } from './seed.js'; @@ -19,6 +20,7 @@ async function withCopiedSeedModule(run) { await copyFile(new URL('./seed.js', import.meta.url), path.join(root, 'tooling/seed.js')); await copyFile(new URL('../tests/fixtures/records.js', import.meta.url), path.join(root, 'tests/fixtures/records.js')); await copyFile(new URL('../tests/fixtures/actor-follows.js', import.meta.url), path.join(root, 'tests/fixtures/actor-follows.js')); + await copyFile(new URL('../tests/fixtures/activities.js', import.meta.url), path.join(root, 'tests/fixtures/activities.js')); await copyFile(new URL('../tests/fixtures/bad-dates.js', import.meta.url), path.join(root, 'tests/fixtures/bad-dates.js')); await copyFile(new URL('../tests/fixtures/bad-location-dates.js', import.meta.url), path.join(root, 'tests/fixtures/bad-location-dates.js')); await writeFile(path.join(root, 'package.json'), '{"type":"module"}\n'); @@ -172,6 +174,7 @@ test('bad-date location rows stay isolated from routine fixtures', () => { const normal = [ ...locationRecords, ...profileRecords, ...organizationRecords, ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, + ...activityFixtureRows, ]; const normalDids = new Set(normal.map(({ did }) => did)); const normalUris = new Set(normal.map(({ uri }) => uri)); @@ -218,6 +221,7 @@ test('bad-date CLI is opt-in, passes eight-parameter SQL to selected executable, const expectedRows = [ ...locationRecords, ...profileRecords, ...organizationRecords, ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, + ...activityFixtureRows, ]; assert.equal(executions.length, expectedRows.length); for (const row of expectedRows) {