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
7 changes: 6 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ Standard HTTP status codes are used throughout:
| **413** | Request entity too large | Attachment upload exceeds the server's size limit |
| **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) |

The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema.
The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /records/:id/migrate`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema.

### Records

Expand All @@ -703,6 +703,7 @@ PATCH /records/:id — update content only (partial merge, null = dele
DELETE /records/:id — soft delete
DELETE /records/:id?hard=true — hard delete
POST /records/:id/undelete — undelete (reverse a soft delete; idempotent)
POST /records/:id/migrate — commit a migration (change typeId + content together)
```

**`GET /records` query params:**
Expand Down Expand Up @@ -734,13 +735,17 @@ POST /records/:id/undelete — undelete (reverse a soft delete; idempotent)

`POST /records` accepts a full record body, including an optional client-supplied `id` — see [Record IDs](#record-ids) for the validation and duplicate-conflict rules the server applies.

`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body: `{ "toTypeId": "...", "content": {...} }` — the full post-migration content, computed client-side by the type's owning app (migration functions are app code, not server code) and validated by the server against `toTypeId`'s schema before writing. This is what `stack.update()` uses to commit a pending lazy migration alongside a content patch (a content-only `PATCH` can't carry a `typeId` change), and what `stack.migrateAll()` uses for each record in a batch pass.

### Permissions

```
GET /records/:id/permissions — get current permissions
PUT /records/:id/permissions — replace all permissions (empty array = private)
```

Both endpoints use the envelope `{ "permissions": [...] }` as the request/response body.

**Response envelope:**

```json
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@haverstack/wire-types": "workspace:*"
},
"devDependencies": {
"@haverstack/conformance-fixtures": "workspace:*",
"@types/node": "^22.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
Expand Down
39 changes: 35 additions & 4 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
StackQuery,
QueryResult,
Association,
Permission,
AdapterCapabilities,
RecordId,
FileId,
Expand Down Expand Up @@ -315,8 +316,24 @@ export class APIAdapter implements StackAdapter {
return raw ? parseRecord(raw) : null;
}

async updateRecord(id: RecordId, changes: Partial<StackRecord>): Promise<StackRecord> {
const raw = await this.request<WireRecord>('PATCH', `/records/${id}`, changes);
async patchContent(id: RecordId, patch: Record<string, unknown | null>): Promise<StackRecord> {
// Content-only RFC 7396 merge patch — no record fields (typeId, version,
// updatedAt) travel in this body. The server merges against its own
// current state and assigns the new version/updatedAt; the response is
// authoritative.
const raw = await this.request<WireRecord>('PATCH', `/records/${id}`, patch);
return parseRecord(raw);
}

async commitMigration(
id: RecordId,
toTypeId: TypeId,
content: Record<string, unknown>,
): Promise<StackRecord> {
const raw = await this.request<WireRecord>('POST', `/records/${id}/migrate`, {
toTypeId,
content,
});
return parseRecord(raw);
}

Expand Down Expand Up @@ -367,6 +384,14 @@ export class APIAdapter implements StackAdapter {
await this.request<void>('DELETE', `/records/${id}/associations`, association);
}

// -------------------------------------------------------
// Permissions
// -------------------------------------------------------

async setPermissions(id: RecordId, permissions: Permission[]): Promise<void> {
await this.request<void>('PUT', `/records/${id}/permissions`, { permissions });
}

// -------------------------------------------------------
// Versions
// -------------------------------------------------------
Expand All @@ -387,8 +412,14 @@ export class APIAdapter implements StackAdapter {
}

async saveVersion(_id: RecordId, _version: RecordVersion): Promise<void> {
// The server snapshots versions automatically as a side effect of updateRecord.
// There is no client-initiated saveVersion endpoint in the wire protocol.
// The server snapshots versions automatically as a side effect of every
// mutating endpoint. There is no client-initiated saveVersion endpoint
// in the wire protocol.
}

async restoreVersion(id: RecordId, version: number): Promise<StackRecord> {
const raw = await this.request<WireRecord>('POST', `/records/${id}/restore/${version}`);
return parseRecord(raw);
}

// -------------------------------------------------------
Expand Down
87 changes: 83 additions & 4 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,31 +264,88 @@ describe('getRecord', () => {
});

// -------------------------------------------------------
// updateRecord
// patchContent
// -------------------------------------------------------

describe('updateRecord', () => {
describe('patchContent', () => {
test('sends PATCH /records/:id', async () => {
const adapter = await openAdapter();
const updated = { ...RECORD_RAW, content: { text: 'Updated' }, version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(updated));
await adapter.updateRecord('rec-abc123', { content: { text: 'Updated' }, version: 2 });
await adapter.patchContent('rec-abc123', { text: 'Updated' });
expect(mockFetch).toHaveBeenLastCalledWith(
`${BASE_URL}/records/rec-abc123`,
expect.objectContaining({ method: 'PATCH' }),
);
});

test('sends only the raw patch — no record fields — as the body', async () => {
const adapter = await openAdapter();
const updated = { ...RECORD_RAW, content: { text: 'Updated' }, version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(updated));
await adapter.patchContent('rec-abc123', { text: 'Updated', removedField: null });
const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({ text: 'Updated', removedField: null });
});

test('returns updated record with parsed dates', async () => {
const adapter = await openAdapter();
const updated = { ...RECORD_RAW, content: { text: 'Updated' }, version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(updated));
const result = await adapter.updateRecord('rec-abc123', { content: { text: 'Updated' } });
const result = await adapter.patchContent('rec-abc123', { text: 'Updated' });
expect(result.content).toEqual({ text: 'Updated' });
expect(result.updatedAt).toBeInstanceOf(Date);
});
});

// -------------------------------------------------------
// commitMigration
// -------------------------------------------------------

describe('commitMigration', () => {
test('sends POST /records/:id/migrate with toTypeId and content', async () => {
const adapter = await openAdapter();
const migrated = { ...RECORD_RAW, typeId: 'com.example/note@2', version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(migrated));
await adapter.commitMigration('rec-abc123', 'com.example/note@2', { text: 'Hello world' });
expect(mockFetch).toHaveBeenLastCalledWith(
`${BASE_URL}/records/rec-abc123/migrate`,
expect.objectContaining({ method: 'POST' }),
);
const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
toTypeId: 'com.example/note@2',
content: { text: 'Hello world' },
});
});

test('returns the migrated record', async () => {
const adapter = await openAdapter();
const migrated = { ...RECORD_RAW, typeId: 'com.example/note@2', version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(migrated));
const result = await adapter.commitMigration('rec-abc123', 'com.example/note@2', {});
expect(result.typeId).toBe('com.example/note@2');
});
});

// -------------------------------------------------------
// setPermissions
// -------------------------------------------------------

describe('setPermissions', () => {
test('sends PUT /records/:id/permissions with a permissions envelope', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(noContent());
await adapter.setPermissions('rec-abc123', [{ access: 'public' }]);
expect(mockFetch).toHaveBeenLastCalledWith(
`${BASE_URL}/records/rec-abc123/permissions`,
expect.objectContaining({ method: 'PUT' }),
);
const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({ permissions: [{ access: 'public' }] });
});
});

// -------------------------------------------------------
// deleteRecord
// -------------------------------------------------------
Expand Down Expand Up @@ -503,6 +560,28 @@ describe('getVersion', () => {
});
});

describe('restoreVersion', () => {
test('sends POST /records/:id/restore/:version', async () => {
const adapter = await openAdapter();
const restored = { ...RECORD_RAW, content: { text: 'original' }, version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(restored));
await adapter.restoreVersion('rec-abc123', 1);
expect(mockFetch).toHaveBeenLastCalledWith(
`${BASE_URL}/records/rec-abc123/restore/1`,
expect.objectContaining({ method: 'POST' }),
);
});

test('returns the restored record with parsed dates', async () => {
const adapter = await openAdapter();
const restored = { ...RECORD_RAW, content: { text: 'original' }, version: 2 };
mockFetch.mockResolvedValueOnce(jsonResponse(restored));
const result = await adapter.restoreVersion('rec-abc123', 1);
expect(result.content).toEqual({ text: 'original' });
expect(result.createdAt).toBeInstanceOf(Date);
});
});

describe('saveVersion', () => {
test('is a no-op — does not make any HTTP requests', async () => {
const adapter = await openAdapter();
Expand Down
Loading
Loading