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
5 changes: 5 additions & 0 deletions .changeset/write-expectation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@haverstack/core': minor
---

`Stack` and `ScopedStack` accept an internal write expectation on every write verb — the record's Type family, and on a content write its exact version — checked against the read each write already makes, so a typed write is refused before anything lands and costs no extra round trip. A typed `mutate()` or `patchContent()` now uses it: a record of another family is not found, and one stored at another version of the family is still refused with `StackBadRequestError`. `ScopedStack.amendAssociations()` and `amendAccess()` take their options third and `surface` fourth, as `Stack`'s do.
2 changes: 1 addition & 1 deletion docs/spec/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ A handle names exactly one version. A call that means the whole family takes `ha

A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. A record holding an enum value the handle does not list arrives without its `record`, as it does wherever the emitter cannot supply one; the typed `get()` then says why. The change filter takes every key but `typeId` and `baseId`.

A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write first reads the record and refuses one stored at another `typeId`, since a patch is validated against the record's own stored Type; a missing, unreadable or deleted record is left to the untyped write to refuse.
A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed; a deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it.

The derived types are a convenience over [runtime validation](#types), which stays the guarantee. A Type known only at runtime has no static shape and stays `Record<string, unknown>`.

Expand Down
102 changes: 77 additions & 25 deletions packages/core/src/scoped-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ import {
UNGRANTABLE_SYSTEM_TYPES,
} from './grants.js';
import { bindingFieldsOf } from './identity-bindings.js';
import { checkFamily, storedVersionDiffers, WRITE_EXPECTATION } from './write-expectation.js';
import type { ExpectationOptions } from './write-expectation.js';
import { claimedFamilies, familyStanding, linkedIds, INSTALL_APP_LABEL } from './install.js';
import { assertAttachmentSize } from './limits.js';
import { validateIdTimestampSkew, validateRecordId } from './record-id.js';
Expand Down Expand Up @@ -621,12 +623,13 @@ export class ScopedStack implements StackClient {
*/
private async requireUpdatable(
id: string,
opts: { mutating?: boolean } = {},
opts: { mutating?: boolean; expect?: ExpectationOptions } = {},
): Promise<StackRecord> {
// The `_grant` write fence applies to mutating callers only: reading a
// grant Record's history is not the escalation that fence exists to stop.
return this.requireVerb(id, ['update-own', 'update-any'], {
fenceGrantRecord: opts.mutating ?? true,
expect: opts.expect,
});
}

Expand All @@ -640,10 +643,13 @@ export class ScopedStack implements StackClient {
private async requireVerb(
id: string,
actions: GrantAction[],
opts: { fenceGrantRecord: boolean },
opts: { fenceGrantRecord: boolean; expect?: ExpectationOptions },
): Promise<StackRecord> {
const record = await this.stack.get(id, { includeDeleted: true });
if (!record) throw new StackNotFoundError(`Record not found: "${id}"`);
// Ahead of the gate, so another family's record is not found whatever
// this requester may do to it — the answer a typed get() gives.
checkFamily(record, opts.expect);
if (opts.fenceGrantRecord) await this.requireOwnerForGrantRecord(record);
if (isGroupRecord(record)) {
if (!this.isGroupManager(record)) {
Expand Down Expand Up @@ -675,10 +681,10 @@ export class ScopedStack implements StackClient {
* Fetch a record the subject can reach and the principal holds `delete` on
* (via permissions or a delete grant), or throw.
*/
private requireDeletable(id: string): Promise<StackRecord> {
private requireDeletable(id: string, expect?: ExpectationOptions): Promise<StackRecord> {
// No soft-delete refusal: undelete() shares this gate, and a tombstone
// is exactly what it addresses.
return this.requireVerb(id, ['delete-own', 'delete-any'], { fenceGrantRecord: true });
return this.requireVerb(id, ['delete-own', 'delete-any'], { fenceGrantRecord: true, expect });
}

/**
Expand Down Expand Up @@ -1094,7 +1100,7 @@ export class ScopedStack implements StackClient {
}
const id = first;
const changes = second as RecordChangeSet;
const opts = (third ?? {}) as IfVersionOptions;
const opts = (third ?? {}) as IfVersionOptions & ExpectationOptions;
// Ahead of every gate: a malformed change set is a validation error
// for every requester, rather than one for the owner and a permission
// refusal for everyone else.
Expand Down Expand Up @@ -1129,7 +1135,9 @@ export class ScopedStack implements StackClient {
// reshare keys need the record before their own gate, and a change set
// carrying only those must not be held to the write gate it doesn't
// need. One read either way.
const record = writes ? await this.requireUpdatable(id) : await this.requireReshareable(id);
const record = writes
? await this.requireUpdatable(id, { expect: opts })
: await this.requireReshareable(id, opts);
// Narrowed to what the gate below actually authorized. A key the gate
// read as inert is dropped rather than forwarded: `Stack` recomputes
// the delta against its own read of the record, so a key left standing
Expand All @@ -1147,7 +1155,13 @@ export class ScopedStack implements StackClient {
}
}

if (changes.contentPatch) {
// A patch for another version is refused by `Stack`, which owes the
// tombstone and `ifVersion` refusals first; the content gates would read
// a stored Type it was not written for. Pinned to this read, `Stack`
// cannot find a record the patch lands on.
const mismatched = storedVersionDiffers(record, opts, changes.contentPatch !== undefined);

if (changes.contentPatch && !mismatched) {
const patch = changes.contentPatch;
// Value-wise, not presence-wise: a client that reads a card, edits
// its `name` and sends the whole content object back is not setting
Expand Down Expand Up @@ -1180,7 +1194,8 @@ export class ScopedStack implements StackClient {
);
}

return this.stack.mutate(id, authorized, { ...opts, ...this.actor });
const ifVersion = mismatched ? (opts.ifVersion ?? record.version) : opts.ifVersion;
return this.stack.mutate(id, authorized, { ...opts, ifVersion, ...this.actor });
}

async patchContent<S extends ReadonlyTypeSchema>(
Expand Down Expand Up @@ -1213,9 +1228,10 @@ export class ScopedStack implements StackClient {
* `permissions` and `unlisted` keys carry, which the write bit
* deliberately does not confer.
*/
private async requireReshareable(id: string): Promise<StackRecord> {
private async requireReshareable(id: string, expect?: ExpectationOptions): Promise<StackRecord> {
const record = await this.stack.get(id, { includeDeleted: true });
if (!record) throw new StackNotFoundError(`Record not found: "${id}"`);
checkFamily(record, expect);
await this.requireOwnerForGrantRecord(record);
await this.requireReshareOf(record);
return record;
Expand Down Expand Up @@ -1350,21 +1366,31 @@ export class ScopedStack implements StackClient {
* escalation the partition exists to stop, decided before any record is
* read so it cannot depend on who is asking.
*/
async associate(id: RecordId, associations: DataAssociation[]): Promise<StackRecord> {
async associate(
id: RecordId,
associations: DataAssociation[],
opts: ExpectationOptions = {},
): Promise<StackRecord> {
assertAssociationList(associations, 'associate()', 'associations', 'data');
return this.amendAssociations(
id,
associations.map((association) => ({ op: 'add', association })),
opts,
'associate()',
);
}

/** See associate() — the same write gate, the same kind refusal. */
async dissociate(id: RecordId, associations: DataAssociation[]): Promise<StackRecord> {
async dissociate(
id: RecordId,
associations: DataAssociation[],
opts: ExpectationOptions = {},
): Promise<StackRecord> {
assertAssociationList(associations, 'dissociate()', 'associations', 'data');
return this.amendAssociations(
id,
associations.map((association) => ({ op: 'remove', association })),
opts,
'dissociate()',
);
}
Expand All @@ -1377,15 +1403,16 @@ export class ScopedStack implements StackClient {
async amendAssociations(
id: RecordId,
changes: AssociationEdit[],
opts: ExpectationOptions = {},
surface = 'amendAssociations()',
): Promise<StackRecord> {
assertAssociationEdits(changes, surface, 'data');
const record = await this.requireUpdatable(id);
const record = await this.requireUpdatable(id, { expect: opts });
for (const change of changes) {
if (change.op === 'add')
await this.requireAssociationAccess(record.typeId, change.association);
}
return this.stack.amendAssociations(id, changes, this.actor, surface);
return this.stack.amendAssociations(id, changes, { ...opts, ...this.actor }, surface);
}

/**
Expand All @@ -1396,21 +1423,31 @@ export class ScopedStack implements StackClient {
* scoping access is for.
* See docs/spec/access-control.md § Record-level permissions.
*/
async grantAccess(id: RecordId, permissions: AuthorityAssociation[]): Promise<StackRecord> {
async grantAccess(
id: RecordId,
permissions: AuthorityAssociation[],
opts: ExpectationOptions = {},
): Promise<StackRecord> {
assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
return this.amendAccess(
id,
permissions.map((association) => ({ op: 'add', association })),
opts,
'grantAccess()',
);
}

/** Withdraw elements of who reaches a record — see grantAccess(). */
async revokeAccess(id: RecordId, permissions: AuthorityAssociation[]): Promise<StackRecord> {
async revokeAccess(
id: RecordId,
permissions: AuthorityAssociation[],
opts: ExpectationOptions = {},
): Promise<StackRecord> {
assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
return this.amendAccess(
id,
permissions.map((association) => ({ op: 'remove', association })),
opts,
'revokeAccess()',
);
}
Expand All @@ -1419,11 +1456,12 @@ export class ScopedStack implements StackClient {
async amendAccess(
id: RecordId,
changes: AssociationEdit[],
opts: ExpectationOptions = {},
surface = 'amendAccess()',
): Promise<StackRecord> {
assertAssociationEdits(changes, surface, 'authority');
await this.requireReshareable(id);
return this.stack.amendAccess(id, changes, this.actor, surface);
await this.requireReshareable(id, opts);
return this.stack.amendAccess(id, changes, { ...opts, ...this.actor }, surface);
}

/**
Expand All @@ -1432,7 +1470,10 @@ export class ScopedStack implements StackClient {
* reach it, and delegation doesn't carry it either. Everyone else is
* limited to soft delete.
*/
async delete(id: RecordId, opts: DeleteRecordOptions = {}): Promise<DeleteResult> {
async delete(
id: RecordId,
opts: DeleteRecordOptions & ExpectationOptions = {},
): Promise<DeleteResult> {
const { referencedFileIds } = await this.deleteAndReturn(id, opts);
return { referencedFileIds };
}
Expand All @@ -1446,22 +1487,33 @@ export class ScopedStack implements StackClient {
*/
async deleteAndReturn(
id: RecordId,
opts: DeleteRecordOptions = {},
opts: DeleteRecordOptions & ExpectationOptions = {},
): Promise<DeleteAndReturnResult> {
await this.requireDeletable(id);
const record = await this.requireDeletable(id, opts);
if (opts.purge && !this.ownerActingAlone) {
throw new StackPermissionError('Purge is owner-only');
}
return this.stack.deleteAndReturn(id, { ...opts, ...this.actor });
if (!opts[WRITE_EXPECTATION]) return this.stack.deleteAndReturn(id, { ...opts, ...this.actor });
// Checked against the read above, which the delete is pinned to.
// Forwarded, it would cost a purge the read `Stack` otherwise skips.
const { [WRITE_EXPECTATION]: _checked, ...rest } = opts;
return this.stack.deleteAndReturn(id, {
...rest,
ifVersion: opts.ifVersion ?? record.version,
...this.actor,
});
}

/**
* Reverse a soft delete. Gated the same as delete() — undelete is the
* inverse of soft delete, so granting one direction without the other
* would be backwards. Idempotent, per Stack.undelete().
*/
async undelete(id: RecordId, opts: IfVersionOptions = {}): Promise<StackRecord> {
await this.requireDeletable(id);
async undelete(
id: RecordId,
opts: IfVersionOptions & ExpectationOptions = {},
): Promise<StackRecord> {
await this.requireDeletable(id, opts);
return this.stack.undelete(id, { ...opts, ...this.actor });
}

Expand Down Expand Up @@ -1508,9 +1560,9 @@ export class ScopedStack implements StackClient {
async restoreVersion(
id: RecordId,
version: number,
opts: IfVersionOptions = {},
opts: IfVersionOptions & ExpectationOptions = {},
): Promise<StackRecord> {
const record = await this.requireUpdatable(id);
const record = await this.requireUpdatable(id, { expect: opts });
if (!this.ownerActingAlone) {
const target = await this.stack.getVersion(id, version);
if (target) {
Expand Down
Loading
Loading