Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 92 additions & 11 deletions docs/api.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,18 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@haverstack/adapter-local": "^0.31.0",
"@haverstack/commons": "^0.26.0",
"@haverstack/core": "^0.32.0",
"@haverstack/wire-types": "^0.31.0",
"@haverstack/adapter-local": "^0.36.0",
"@haverstack/commons": "^0.31.0",
"@haverstack/core": "^0.37.0",
"@haverstack/wire-types": "^0.36.0",
"@hono/node-server": "^2.1.1",
"hono": "^4.13.7",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@haverstack/conformance-fixtures": "^0.25.0",
"@haverstack/conformance-fixtures": "^0.30.0",
"@types/node": "^26.2.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
76 changes: 38 additions & 38 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/lib/queryLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@ import type { StackQuery } from '@haverstack/core';

// core's parsers (@haverstack/core/wire) report `limit` as requested and
// never clamp it — a ceiling is deployment policy, not wire contract. This
// server's ceiling, applied at the call site in records.ts.
// server's ceilings, applied at the call sites in records.ts.
export const MAX_QUERY_LIMIT = 1000;

export function clampLimit(query: StackQuery): StackQuery {
if (query.limit === undefined) return query;
return { ...query, limit: Math.min(query.limit, MAX_QUERY_LIMIT) };
}

// The journal is the one read with no ceiling when `limit` is omitted, so
// a page size can't be supplied on the caller's behalf: omitting `limit`
// reads the whole log by contract, and truncating it silently would betray
// exactly the caller reconstructing an association's full history. This
// server bounds a page and says so in `cursor` instead. See
// docs/spec/wire-format.md § Journal.
export const MAX_JOURNAL_LIMIT = 500;

export function clampJournalLimit(limit: number | undefined): number {
return limit === undefined ? MAX_JOURNAL_LIMIT : Math.min(limit, MAX_JOURNAL_LIMIT);
}
103 changes: 84 additions & 19 deletions src/routes/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ import { readJson } from '../lib/json.js';
import {
parseQueryBody,
parseQueryParams,
parseJournalParams,
parsePositiveInt,
parseIfMatch,
createOptionsFromWireRecord,
changesFromWireBody,
} from '@haverstack/core/wire';
import { clampLimit } from '../lib/queryLimit.js';
import { serializeRecord, serializeVersion } from '@haverstack/wire-types';
import type { WireQueryResponse } from '@haverstack/wire-types';
import { clampLimit, clampJournalLimit } from '../lib/queryLimit.js';
import { serializeRecord, serializeVersion, serializeJournalEntry } from '@haverstack/wire-types';
import type { WireQueryResponse, WireJournalResponse } from '@haverstack/wire-types';
import { StackValidationError, StackQueryError, StackNotFoundError } from '@haverstack/core';
import type { Association, TypeId } from '@haverstack/core';
import type { AuthorityAssociation, DataAssociation, TypeId } from '@haverstack/core';

// ---------------------------------------------------------------------------
// Route factory
Expand Down Expand Up @@ -119,19 +120,27 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
return c.json(serializeRecord(updated));
});

// DELETE /records/:id (?hard=true for permanent). A soft delete bumps
// the version like any other mutation, so it answers with the record it
// produced (carrying deletedAt); a hard delete leaves nothing to answer
// with, so that one stays 204.
// DELETE /records/:id (?hard=true for permanent). Both answer 200 with
// a record: a soft delete with the tombstone it produced, a hard delete
// with the record as it stood immediately before destruction. That body
// is the requester's only report of the files the purge stranded; every
// other row naming them is gone by the time it lands. deleteAndReturn()
// captures it inside the same write that destroys or tombstones the
// record, so there is no read-then-delete window for a concurrent write
// to fall into. See docs/spec/wire-format.md § Records and
// docs/spec/attachments.md § A purge strands the bytes it referenced.
app.delete('/:id', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const hard = new URL(c.req.url).searchParams.get('hard') === 'true';
const session = stack.forSession(auth);

await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) });
if (hard) return c.body(null, 204);
return c.json(serializeRecord((await session.get(id))!));
const { record } = await session.deleteAndReturn(id, {
hard,
ifVersion: parseIfMatch(c.req.header('If-Match')),
});
if (!record) throw new StackNotFoundError('Record not found');
return c.json(serializeRecord(record));
});

// POST /records/:id/undelete — reverses a soft delete; idempotent
Expand All @@ -156,6 +165,31 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
return c.json({ permissions: record.permissions ?? [] });
});

// The amending spelling, where the change set's `permissions` key
// replaces the whole set — which is what survives two admins sharing one
// record at once. Both carry the reshare gate rather than the write bit,
// and both are ScopedStack's to apply: it refuses a data kind sent to
// this surface and holds the set to `write` implying `read`, so nothing
// here inspects the body. See docs/spec/access-control.md
// § Record-level permissions.
app.post('/:id/permissions', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<AuthorityAssociation>(c);
const updated = await stack.forSession(auth).grantAccess(id, body);
return c.json(serializeRecord(updated));
});

// POST to a /delete sub-path for the reason the association endpoints
// use one, below.
app.post('/:id/permissions/delete', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<AuthorityAssociation>(c);
const updated = await stack.forSession(auth).revokeAccess(id, body);
return c.json(serializeRecord(updated));
});

// ------------------------------------------------------------------
// Associations
// ------------------------------------------------------------------
Expand All @@ -173,14 +207,17 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
return c.json({ associations: assocs });
});

// Neither mutating endpoint reads If-Match, and one sent to either is
// ignored rather than refused: a set add/remove composes whatever the
// write order, so there is no race for a precondition to fence. Both
// answer with a record carrying whatever version it already had. See
// docs/spec/wire-format.md § Associations.
app.post('/:id/associations', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<Association>(c);
const body = await readJson<DataAssociation>(c);
if (!body.kind || !body.label) throw new StackQueryError('kind and label are required');
const updated = await stack
.forSession(auth)
.associate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
const updated = await stack.forSession(auth).associate(id, body);
return c.json(serializeRecord(updated));
});

Expand All @@ -190,13 +227,41 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
app.post('/:id/associations/delete', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<Association>(c);
const updated = await stack
.forSession(auth)
.dissociate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
const body = await readJson<DataAssociation>(c);
const updated = await stack.forSession(auth).dissociate(id, body);
return c.json(serializeRecord(updated));
});

// ------------------------------------------------------------------
// Journal
// ------------------------------------------------------------------

// GET /records/:id/journal — the change journal, oldest first. Not
// optional and never answered empty for a record this server doesn't
// hold: an empty log means "nothing changed" unconditionally, so a
// missing or purged record is the 404 ScopedStack raises.
//
// The mutate-surface gate and the authority-element projection a
// non-resharer gets are both ScopedStack.getJournal()'s, so this route
// decodes the window, bounds the page and serializes. `cursor` is the
// only end-of-log signal — a page filled to the ceiling carries the seq
// to resume from, whether or not the log ends there. See
// docs/spec/journal.md and docs/spec/wire-format.md § Journal.
app.get('/:id/journal', async (c) => {
const id = c.req.param('id');
const auth = c.get('auth');
const query = parseJournalParams(new URL(c.req.url));
const limit = clampJournalLimit(query.limit);

const entries = await scopeFor(auth).getJournal(id, { ...query, limit });
const body: WireJournalResponse = {
entries: entries.map(serializeJournalEntry),
cursor:
entries.length > 0 && entries.length === limit ? entries[entries.length - 1]!.seq : null,
};
return c.json(body);
});

// ------------------------------------------------------------------
// Versions
// ------------------------------------------------------------------
Expand Down
Loading
Loading