Add resource changes feed API#817
Conversation
Adds GET /resourceregistry/api/v1/resource/changes - a keyset-paginated feed of resources that have changed, ordered by version_id. Only resources with an active policy (non-deleted resource subjects) are included, and each resource appears at most once, at its latest change. Policy updates are reflected in the feed via a new TouchResourceModified repository method called from StorePolicy. It copies the latest resource version forward entirely inside SQL with an updated modified timestamp, so a concurrent metadata update can never be overwritten by stale in-memory state. Pagination follows the existing resource/updated endpoint pattern: Opaque<long> continuation token over version_id, Paginated<T> response envelope, limit 1-1000 fetching limit+1 rows to detect the next page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new keyset-paginated “resource changes” feed endpoint to the Resource Registry so downstream consumers can poll for changed resources (metadata or policy-driven “touches”) without refetching the full list.
Changes:
- Added
GET /resourceregistry/api/v1/resource/changeswith opaque continuation token + limit validation andPaginated<T>response. - Implemented persistence/service support for querying “latest change per resource” (filtered to resources with active policy) and for “touching” a resource on policy updates.
- Added both mock-based and DB-backed tests covering pagination, filtering, and policy-update bumping behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/Altinn.ResourceRegistry.Tests/ResourceControllerWithDbTests.cs | Adds DB-backed integration tests for the changes feed, including policy bump behavior. |
| test/Altinn.ResourceRegistry.Tests/ResourceControllerTest.cs | Adds mock-based controller tests for pagination and invalid limit handling. |
| test/Altinn.ResourceRegistry.Tests/Mocks/RegisterResourceRepositoryMock.cs | Extends the repository mock with FindChangedResources and TouchResourceModified. |
| src/Altinn.ResourceRegistry/Controllers/ResourceController.cs | Adds the new /resource/changes endpoint, limit validation, and next-link generation. |
| src/Altinn.ResourceRegistry.Persistence/ResourceRegistryRepository.cs | Implements SQL for change-feed reads and the “touch resource modified” insert-select. |
| src/Altinn.ResourceRegistry.Core/Services/ResourceRegistryService.cs | Calls the new “touch” method after storing policy-derived subjects; exposes FindChangedResources. |
| src/Altinn.ResourceRegistry.Core/Services/Interfaces/IResourceRegistry.cs | Adds service interface contract for FindChangedResources. |
| src/Altinn.ResourceRegistry.Core/Models/ResourceChange.cs | Introduces the API model for change feed items (with internal VersionId cursor). |
| src/Altinn.ResourceRegistry.Core/IResourceRegistryRepository.cs | Adds repository interface methods for change-feed querying and “touching” resources. |
| src/Altinn.ResourceRegistry.Core/Errors/ValidationErrors.cs | Adds validation descriptor for invalid limit on the changes endpoint. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await _repository.SetResourceSubjects(resourceSubjects, CancellationToken.None); | ||
| await _repository.TouchResourceModified(serviceResource.Identifier, CancellationToken.None); |
There was a problem hiding this comment.
Intentional, and consistent with the pre-existing pattern: SetResourceSubjects was already called with CancellationToken.None here before this PR. Once the policy blob has been written to storage, cancelling the follow-up DB updates midway would leave the blob and the database inconsistent (policy stored but subjects/feed not updated). Letting the DB updates run to completion keeps the write atomic-ish; a client that cancels simply retries the idempotent upload. TouchResourceModified follows the same rule for the same reason.
Review feedback: filtering version_id > @version_id inside the CTE lets PostgreSQL prune via the primary key index before the window function runs, making incremental polls with a high cursor cheap. Results are unchanged: rows at or below the cursor can never be the per-resource max when that max is above the cursor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the copy-forward touch approach with a change-log table (migration v0.10) written atomically by every mutation path: CreateResource, UpdateResource and DeleteResource log inside their own statements/transaction, and policy updates log via LogPolicyChanged from StorePolicy. The feed pages on the log's seq column instead of resources.version_id. Compared to copying the full resource row forward on policy updates, this avoids duplicating serviceresourcejson and keeps the resource version history free of no-op versions. It also records deletes: the feed excludes resources whose latest change is a delete (previously deleted resources silently disappeared because their version rows were hard-deleted), and exposing delete events later is now just a matter of lifting that filter. The migration backfills one log entry per existing resource at its latest version, in version order, so pre-existing resources are served by the feed from the start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared GetServiceResource row-mapper reads version_id, but DeleteResource's RETURNING clause never included it - a latent bug from the v0.09 table split that no test exercised until the new change-feed delete test hit it in CI. Also collapse the result to the latest version row: deleting a multi-version resource returns one row per version, which would make SingleOrDefaultAsync throw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
| await using (var cmd3 = new NpgsqlCommand( | ||
| @" | ||
| INSERT INTO resourceregistry.resource_change_log(identifier, changed_at, change_source) | ||
| VALUES (@identifier, @changedat, 'metadata');", | ||
| conn, | ||
| tx)) | ||
| { | ||
| cmd3.Parameters.AddWithValue("identifier", NpgsqlDbType.Text, resource.Identifier); | ||
| cmd3.Parameters.AddWithValue("changedat", NpgsqlDbType.TimestampTz, modified); | ||
| await cmd3.ExecuteNonQueryAsync(cancellationToken); | ||
| } |
| await _repository.SetResourceSubjects(resourceSubjects, CancellationToken.None); | ||
| await _repository.LogPolicyChanged(serviceResource.Identifier, CancellationToken.None); |
| ( | ||
| seq BIGSERIAL NOT NULL PRIMARY KEY, | ||
| identifier text NOT NULL, | ||
| changed_at timestamp with time zone NOT NULL DEFAULT now(), | ||
| change_source text NOT NULL | ||
| ); |



Description
Implements the resource change feed designed in #812.
Adds
GET /resourceregistry/api/v1/resource/changes— a keyset-paginated feed of resources that have changed, ordered by change. Consumers (e.g. downstream sync/cache jobs) can poll this endpoint to find out which resources changed since their last poll, without re-fetching or diffing the full resource list.Design (per #812, updated after review discussion)
Changes are tracked in a dedicated
resource_change_logtable (migration v0.10:seq BIGSERIAL,identifier,changed_at,change_source), written atomically by every mutation path:CreateResourceandUpdateResourcelog a'metadata'entry inside their own transaction/statement (data-modifying CTE).'policy'entry via a newLogPolicyChangedrepository method, called fromStorePolicyafterSetResourceSubjects.DeleteResourcelogs a'deleted'entry in the same statement as the delete.The migration backfills one entry per existing resource at its latest version, in version order, so pre-existing resources are served by the feed from the start.
Feed semantics:
resourcesubjectsare filtered out.ROW_NUMBER() OVER (PARTITION BY identifier ORDER BY seq DESC) ... rn = 1, keyset-paged onseqwith the cursor predicate pushed into the CTE for PK-index pruning.'deleted'drops out of the feed. Delete events are recorded in the log, so exposing them to consumers later (e.g. achangeTypefield) is just a matter of lifting this filter — no new bookkeeping needed.resource/updatedendpoint pattern:Opaque<long>continuation token,Paginated<T>envelope,limit1–1000 (default 1000), fetchinglimit + 1rows to detect the next page.API
{ "links": { "next": ".../resource/changes?token=<opaque>&limit=1000" }, "items": [ { "resourceId": "some-resource", "changedAt": "2026-07-08T10:15:30Z" } ] }The
seqcursor is internal ([JsonIgnore]on the model) — clients only see the opaque token.Tests
Closes #812.
🤖 Generated with Claude Code