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
37 changes: 29 additions & 8 deletions apps/core/src/operation-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,25 @@ export interface ChannelCapabilities {
readonly resource?: Capability;
/** Brands an expected error the channel raised as the host's. */
readonly trust: (error: Error) => void;
/** The refusal for each capability it hasn't. */
/**
* Told of every use the channel refused or that failed, wherever it
* failed (its arguments refused at the boundary included), with the
* host's own copy of the error.
*/
readonly refused?: (
name: "read" | "write" | "call" | "resource",
error: CodedError
) => void;
/**
* The refusal for each capability it hasn't (`operation.not_found` for
* one without its own).
*/
readonly refusals: Readonly<
Record<
"read" | "write" | "call" | "resource",
Parameters<typeof operationErrors.create>[0]
Partial<
Record<
"read" | "write" | "call" | "resource",
Parameters<typeof operationErrors.create>[0]
>
>
>;
}
Expand Down Expand Up @@ -147,7 +161,7 @@ class Channel extends ChannelTarget implements OperationChannel {
}

async #use(
name: keyof ChannelCapabilities["refusals"],
name: "read" | "write" | "call" | "resource",
args: unknown[]
): Promise<unknown> {
const capability = this.#capabilities[name];
Expand All @@ -156,12 +170,14 @@ class Channel extends ChannelTarget implements OperationChannel {
throw operationErrors.create("operation.ended");
}
if (capability === undefined) {
throw operationErrors.create(this.#capabilities.refusals[name]);
throw operationErrors.create(
this.#capabilities.refusals[name] ?? "operation.not_found"
);
}
return await capability(...plainArgs(args));
} catch (error) {
if (isExpectedError(error)) {
throw this.#raise(error);
throw this.#raise(name, error);
}
// A domain failure of a function this one called, as the host
// rebuilt it: App code may handle it, or throw it on.
Expand All @@ -176,6 +192,7 @@ class Channel extends ChannelTarget implements OperationChannel {
...errorFields(error),
});
throw this.#raise(
name,
internalErrors.create("internal.unexpected", { requestId })
);
}
Expand All @@ -187,10 +204,14 @@ class Channel extends ChannelTarget implements OperationChannel {
* so whatever App code does to the error it caught (its message, code or
* details, now or after a later await) changes nothing the host answers.
*/
#raise(error: CodedError): Error {
#raise(
name: "read" | "write" | "call" | "resource",
error: CodedError
): Error {
const kept = copyOf(error);
this.#capabilities.trust(kept);
this.#state.raised.set(kept.code, kept);
this.#capabilities.refused?.(name, kept);
return copyOf(error);
}
}
Expand Down
88 changes: 72 additions & 16 deletions apps/core/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@grasp-os/sdk/host";
import type { AppManifest, OperationManifest } from "@grasp-os/sdk/host";
import type { OperationChannel } from "@grasp-os/sdk/runner";
import { appCallLimits } from "@grasp-os/shared/apps";
import { sha256Hex } from "@grasp-os/shared/encoding";
import { internalErrors, isExpectedError } from "@grasp-os/shared/errors";
import { canonicalJson } from "@grasp-os/shared/json";
Expand Down Expand Up @@ -95,8 +96,9 @@ import {
// manifest, hashed here; no channel method takes any of them.
// - A query writes, a mutation reaches outside, an action holds the
// database: each kind's channel has only its capabilities, whatever the
// App code sends (operation-channel.ts), and actions don't run in this
// release's host at all yet.
// App code sends (operation-channel.ts): an action reaches data only
// through the App's queries and mutations it lists, each an invocation
// of its own.
// - A function's access is widened past its declaration: every read and
// staged write is checked against the verbs inspection gave the
// function (its kind and `operationAccess`, narrowing only), and the
Expand Down Expand Up @@ -714,6 +716,17 @@ interface Staging {
/** The bytes of the writes staged so far, as UTF-8 JSON. */
readonly bytes: () => number;
readonly stage: (request: unknown) => Promise<unknown>;
/**
* The first write the host refused to stage, if any: App code may not
* have waited for it, or may have caught it, but its change set lost a
* write, so the mutation can't commit as though it hadn't.
*/
readonly refused: () => Error | undefined;
/**
* Remembers a refused write: told by the channel, wherever the write
* was refused (at its boundary, before staging, included).
*/
readonly remember: (error: Error) => void;
readonly preview: () => StagedWrites | undefined;
readonly writes: () => StagedWrites["writes"];
}
Expand Down Expand Up @@ -779,6 +792,7 @@ const stagingFor = (
staged += bytes;
writes.push(write);
};
let refused: Error | undefined;
const stage = async (request: unknown): Promise<unknown> => {
const write = operationErrors.parse(
"contract.invalid_write",
Expand Down Expand Up @@ -876,6 +890,10 @@ const stagingFor = (
stagedAt,
bytes: () => staged,
stage,
refused: () => refused,
remember: (error: Error) => {
refused ??= error;
},
preview: () =>
writes.length === 0 ? undefined : { stagedAt, writes: [...writes] },
writes: () => [...writes],
Expand Down Expand Up @@ -1198,7 +1216,7 @@ const scopeOf = async (
* own function and three nested in it, as calls between Apps
* (`appCallLimits.depth`).
*/
export const callMaxDepth = 3;
export const callMaxDepth = appCallLimits.depth;

/** One function's run, and where it sits in the call it belongs to. */
interface Run {
Expand Down Expand Up @@ -1229,9 +1247,28 @@ export const callMaxResourceRequests = 32;
interface CallBudget {
nested: number;
requests: number;
readonly reads: ReadBudget;
/**
* The current run's reads: each run of the call's own function (a
* query run again for stale reads, a mutation's next attempt) starts a
* fresh one, which everything nested in that run shares. The limits are
* per run, as their names say; the bounded number of reruns bounds the
* whole call.
*/
reads: ReadBudget;
}

/**
* The read budget of a run of `run`'s function: a fresh one, shared by
* what it calls, when it is the call's own function; the run it is nested
* in's otherwise, so nested calls never multiply what one run reads.
*/
const runReads = (run: Run): ReadBudget => {
if (run.depth === 0) {
run.budget.reads = readBudget();
}
return run.budget.reads;
};

/** A new call's budget. */
const callBudget = (): CallBudget => ({
nested: 0,
Expand Down Expand Up @@ -1313,8 +1350,15 @@ const trackedCalls = (
if (left <= 0) {
throw hostError(operationErrors.create("operation.unfinished_call"));
}
// The timer is cleared once the calls settle first, so no wait is
// left pending past the run.
const timer = new AbortController();
// oxlint-disable-next-line no-await-in-loop -- until none is left
await Promise.race([Promise.allSettled(running), scheduler.wait(left)]);
await Promise.race([
Promise.allSettled(running),
scheduler.wait(left, { signal: timer.signal }).catch(() => null),
]);
timer.abort();
}
return unfinished?.error;
},
Expand All @@ -1326,6 +1370,11 @@ const staleRun = new Error("A run's reads went stale");

/** The capabilities one handler's channel gets, besides its refusals. */
interface Capabilities {
/** Told of every refused or failed use, as the channel's `refused`. */
readonly onRefused?: (
name: "read" | "write" | "call" | "resource",
error: Error
) => void;
readonly read?: (request: unknown) => Promise<unknown>;
readonly write?: (request: unknown) => Promise<unknown>;
readonly call?: (
Expand All @@ -1343,22 +1392,14 @@ interface Capabilities {
/** Why each kind's channel refuses what it hasn't. */
const refusalsOf = {
query: {
read: "operation.no_database",
write: "operation.read_only",
call: "operation.read_only",
resource: "operation.no_resources",
},
mutation: {
read: "operation.no_database",
write: "operation.read_only",
call: "operation.not_found",
resource: "operation.no_resources",
},
mutation: { resource: "operation.no_resources" },
action: {
read: "operation.no_database",
write: "operation.no_database",
call: "operation.not_found",
resource: "operation.no_resources",
},
} as const;

Expand All @@ -1382,6 +1423,9 @@ const runHandler = async (
path: operation.path,
...calls.capabilities,
trust,
...(capabilities.onRefused === undefined
? {}
: { refused: capabilities.onRefused }),
refusals: refusalsOf[operation.kind],
});
try {
Expand Down Expand Up @@ -1587,7 +1631,7 @@ const runQuery = async (
return await trusted(
async () =>
await readConsistently(invocation.store, scope, async (reader) => {
const budget = run.budget.reads;
const budget = runReads(run);
return await runHandler(run, operation, args, {
read: async (request) => {
spendRead(budget, 0);
Expand Down Expand Up @@ -1640,7 +1684,7 @@ const runMutation = async (
const scope = await scopeOf(compiled, invocation, roles);
const staging = stagingFor(compiled, operation, Date.now());
const reader = new StoreReader(store, scope, staging.preview);
const budget = run.budget.reads;
const budget = runReads(run);
const spend = () => {
spendRead(budget, staging.bytes());
};
Expand All @@ -1659,6 +1703,14 @@ const runMutation = async (
return await readFor(operation, reader, request);
},
write: staging.stage,
// Any write refused, at the channel's boundary or in staging,
// fails the mutation (`refused`); calls and resources stay
// catchable.
onRefused: (name, error) => {
if (name === "write") {
staging.remember(error);
}
},
call: async (kind, path, callArgs) =>
await nestedCall(
run,
Expand All @@ -1676,6 +1728,10 @@ const runMutation = async (
}
throw error;
}
const refusedWrite = staging.refused();
if (refusedWrite !== undefined) {
throw sealed(hostError(refusedWrite));
}
// oxlint-disable-next-line no-await-in-loop -- see above
const current = await trusted(invocation.authorize);
// Its reads were scoped for roles the caller no longer has: run again
Expand Down
20 changes: 17 additions & 3 deletions apps/core/src/store-reader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { ReadScope } from "@grasp-os/shared/store-queries";
import type { ReadRequest } from "@grasp-os/shared/store-reads";
import { queryErrors } from "@grasp-os/shared/stores";
import {
commitMaxGuards,
dataErrors,
queryErrors,
} from "@grasp-os/shared/stores";
import type { StagedWrites } from "@grasp-os/shared/stores";

import type { OpenStore } from "./data-stores.ts";
Expand Down Expand Up @@ -42,8 +46,8 @@ export interface ReadGuard {

/**
* The generations a failed read's error carries (`details.generations`,
* StoreHost's `#withGenerations`), taken off it, so App code never sees
* them; none for an error raised before anything was read.
* StoreHost's `#readIn`), taken off it, so App code never sees them; none
* for an error raised before the read looked at a record.
*/
const takeGenerations = (error: unknown): Record<string, number> => {
if (typeof error !== "object" || error === null) {
Expand Down Expand Up @@ -129,6 +133,16 @@ export class StoreReader {
({ table, id }) => table === request.table && id === request.id
)
) {
// One more than a commit can check would fail only at the commit,
// after the whole mutation ran: refused here, at the read. Only a
// mutation's reader (it stages writes) has guards a commit checks; a
// plain query never commits, so its reads are not capped here.
if (
this.#staged !== undefined &&
this.#guards.length >= commitMaxGuards
) {
throw dataErrors.create("data.guard_limit");
}
this.#guards.push({
table: request.table,
id: request.id,
Expand Down
Loading
Loading