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
50 changes: 38 additions & 12 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 @@ -17,18 +17,18 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@haverstack/adapter-local": "^0.26.0",
"@haverstack/commons": "^0.21.0",
"@haverstack/core": "^0.27.0",
"@haverstack/wire-types": "^0.26.0",
"@haverstack/adapter-local": "^0.30.0",
"@haverstack/commons": "^0.25.0",
"@haverstack/core": "^0.31.0",
"@haverstack/wire-types": "^0.30.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.20.0",
"@haverstack/conformance-fixtures": "^0.24.0",
"@types/node": "^26.2.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
78 changes: 39 additions & 39 deletions pnpm-lock.yaml

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

17 changes: 10 additions & 7 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
allowBuilds:
esbuild: true
# pnpm 11 waits `minimumReleaseAge` (default 1440 — a day) before it will
# resolve a newly published version, and appends every package it had to
# hold to this list on the install that hits one. Haverstack releases in
# lockstep, so a sync lands the whole scope on its release day and every
# one of them was being written back here, a version at a time.
#
# The scope pattern says once what those entries said one by one: these are
# this project's own packages, published by its own CI, and the cooldown is
# not the check that vets them. Third-party dependencies keep it.
minimumReleaseAgeExclude:
- '@haverstack/adapter-local@0.20.0 || 0.25.0 || 0.26.0'
- '@haverstack/blob-adapter-disk@0.19.0 || 0.24.0 || 0.25.0'
- '@haverstack/commons@0.15.0 || 0.20.0 || 0.21.0'
- '@haverstack/conformance-fixtures@0.14.0 || 0.19.0 || 0.20.0'
- '@haverstack/core@0.21.0 || 0.26.0 || 0.27.0'
- '@haverstack/record-adapter-sqlite@0.13.0 || 0.18.0 || 0.19.0'
- '@haverstack/wire-types@0.20.0 || 0.25.0 || 0.26.0'
- '@haverstack/*'
2 changes: 1 addition & 1 deletion src/routes/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function entityRoutes(ctx: StackContext): Hono<AppEnv> {
const body = await readJson<Record<string, unknown>>(c);
const updated = await stack
.forSession(auth)
.update(record.id, (body.content ?? {}) as Record<string, unknown>);
.patchContent(record.id, (body.content ?? {}) as Record<string, unknown>);
return c.json(serializeRecord(updated));
});

Expand Down
60 changes: 19 additions & 41 deletions src/routes/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import {
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 { StackValidationError, StackQueryError, StackNotFoundError } from '@haverstack/core';
import type { Association, Permission, TypeId } from '@haverstack/core';
import type { Association, TypeId } from '@haverstack/core';

// ---------------------------------------------------------------------------
// Route factory
Expand Down Expand Up @@ -89,25 +90,32 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
return c.json(serializeRecord(record));
});

// PATCH /records/:id — the body IS the content patch (RFC 7396 merge
// patch), never an envelope: a conforming client sends { "title": "New" }
// directly, not { "content": { "title": "New" } }. Wrapping it here would
// make every field the client sends invisible to Stack.update(), turning
// a real edit into a silent no-op that still bumps version. null field
// values remove the field.
// PATCH /records/:id — the body is a change set: an envelope naming any
// combination of `contentPatch`, `parentId`, `permissions`, `associations`
// and `unlisted`, applied as one atomic write that produces exactly one
// version. A conforming client sends { "contentPatch": { "title": "New" } },
// never the bare patch — content is one aspect among five here, so the
// envelope is what tells a `parentId` naming a container from a content
// field of that name. One If-Match fences the whole multi-aspect edit.
//
// `changesFromWireBody()` owns the entire read of that envelope: which
// keys it may carry, the 400 an unrecognized or wholly absent key earns,
// and the 422 a malformed value earns. Content keys stay Stack's to judge,
// so nothing here inspects the patch. See docs/spec/wire-format.md § Records.
//
// No route-level guard belongs here: ScopedStack owns the _attachment@1
// immutable-field and _grant@1 owner-only rules (docs/spec/attachments.md,
// docs/spec/access-control.md), so a denial round-trips as the core error
// it is rather than a guess made before the permission check ran.
// docs/spec/access-control.md) and resolves each change-set key against its
// own gate, so a denial round-trips as the core error it is rather than a
// guess made before the permission check ran.
app.patch('/:id', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const patch = await readJson<Record<string, unknown>>(c);
const changes = changesFromWireBody(await readJson(c));

const updated = await stack
.forSession(auth)
.update(id, patch, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
.mutate(id, changes, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.json(serializeRecord(updated));
});

Expand Down Expand Up @@ -148,36 +156,6 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
return c.json({ permissions: record.permissions ?? [] });
});

app.put('/:id/permissions', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<{ permissions: Permission[] }>(c);
if (!Array.isArray(body.permissions)) throw new StackQueryError('permissions must be an array');
const updated = await stack.forSession(auth).setPermissions(id, body.permissions, {
ifVersion: parseIfMatch(c.req.header('If-Match')),
});
return c.json(serializeRecord(updated));
});

// ------------------------------------------------------------------
// Unlisted
// ------------------------------------------------------------------

// PUT /records/:id/unlisted — withhold from enumeration, or relist.
// Orthogonal to permissions: decides whether the record is enumerable,
// never who may read it. The owner-or-creator gate and the 409 on a
// soft-deleted record both come from ScopedStack.setUnlisted().
app.put('/:id/unlisted', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<{ unlisted: boolean }>(c);
if (typeof body.unlisted !== 'boolean') throw new StackQueryError('unlisted must be a boolean');
const updated = await stack.forSession(auth).setUnlisted(id, body.unlisted, {
ifVersion: parseIfMatch(c.req.header('If-Match')),
});
return c.json(serializeRecord(updated));
});

// ------------------------------------------------------------------
// Associations
// ------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('request body size limit', () => {

const { status, data } = await req(smallApp, 'PATCH', `/records/${id}`, {
token: TEST_TOKEN,
body: { body: 'x'.repeat(6000) },
body: { contentPatch: { body: 'x'.repeat(6000) } },
});
expect(status).toBe(413);
expect((data as { error: { code: string } }).error.code).toBe('payload_too_large');
Expand Down
Loading
Loading