@jskit-ai/resource-runtime 0.0.0-stage → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/changeHorizon.js +26 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +137 -0
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +233 -0
- package/src/server/handleHidden.js +410 -0
- package/src/server/handleLocks.js +135 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +18 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +92 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +737 -0
- package/src/server/resourceHost.js +687 -0
- package/src/server/resourceRuntime.js +300 -0
- package/src/server/standaloneResources.js +49 -0
- package/src/server/types.js +49 -0
- package/src/test/expectFlatQueries.js +117 -0
- package/src/test/expectScoped.js +111 -0
- package/src/test/index.js +123 -0
- package/test/callSettings.test.js +100 -0
- package/test/changes.postgres.test.js +141 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +244 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +65 -0
- package/test/manyRowWrites.mysql.test.js +154 -0
- package/test/queryFields.test.js +69 -0
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +2098 -0
- package/test/storedIntegers.mysql.test.js +4 -0
- package/test/storedIntegers.postgres.test.js +5 -0
- package/test/support/storedIntegerCases.js +152 -0
- package/test/testHelpers.test.js +290 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Depends on json-rest-api 3.1.0 exactly (from 3.0.1), from the registry with its published integrity: `lockMode: "noKeyUpdate"`, ON CONFLICT on the matching unique index's exact columns, the PostgreSQL merge race on an uncommitted insert, and the `xid8` field type the change horizon reads.
|
|
6
|
+
- Tests pin three paths the guide now documents: a shared row of a `workspace_or_shared` table is written in the owner's root system context, naming `workspaceId: null`, also inside a request's transaction, and never from a request or a derived job context (`forUser` is refused 403); a job's `actorId` never moves its scope, while a row policy may hold a job done for a person to that person's rows; and a hand route's `request.callOptions()` scopes user, workspace and workspace_user tables as the same request's action does, with no user scope when no one is signed in.
|
|
7
|
+
- A table resource reads an xid8 change stamp through an `xid8` field, json-rest-api 3.1.0's type for PostgreSQL's transaction id: decimal text below 2^64, which filters such as `changedSince: { type: "xid8", actualField: "changed", filterOperator: ">=" }` compare as transaction ids in SQL. A malformed cursor is refused as `REST_API_VALIDATION` before any statement, where a string field sent it to PostgreSQL (22P02), and the field is refused at registration on MySQL, MariaDB and SQLite. With `changeHorizon()`, a read of what changed since goes through the handle, where it was a hand-written scoped query; the data-access guide's "What changed since" gives the migration (an xid8 column a trigger stamps on every insert and update), the field, the filters and the read.
|
|
8
|
+
- Every handle has `changeHorizon(options)`: PostgreSQL's snapshot horizon, `pg_snapshot_xmin(pg_current_snapshot())`, as decimal text, read in the caller's transaction when there is one, with the call's context checked as for every call. Every transaction below it had finished when it was read, so a read of what changed starts there: a later read of the rows whose xid8 change stamp is at or above it finds what an open transaction wrote, a row whose id was taken before its transaction committed and a changed older row included, which "past the highest id seen" misses. It reads no row, where it was a hand-written `knex.raw` beside the handle. MySQL, MariaDB and SQLite stamp no row with its transaction: there it throws `UnsupportedDatabaseError` (code `RESOURCE_DATABASE_UNSUPPORTED`, exported from `./server`) before any statement. `changes.postgres.test.js` proves the catch-up with a writer still open at the horizon.
|
|
9
|
+
- **Security.** A table resource with ownership `system` (rows of no workspace or user, such as an agent's runs or a server's leases) answers only a system context. Every call with a request's context, a job context derived with `forWorkspace`, `forUser` or `forWorkspaceUser`, or none throws `SystemContextRequiredError` (code `RESOURCE_SYSTEM_CONTEXT_REQUIRED`, exported from `./server`) before any statement: such a table has no ownership predicate, so that context reached every workspace's rows. The table gets no autofilter preset; the owner's own methods decide what a request may see.
|
|
10
|
+
- `lock`, `lockMany`, `lockWhere`, `claim`, `lockOrCreate` and `patchWhere` take `lockMode: "update"` (the default, `FOR UPDATE`) or `"noKeyUpdate"`, passed on to json-rest-api: on PostgreSQL `FOR NO KEY UPDATE`, which still keeps every other lock and write of the row out but lets another transaction insert a row that references it; MySQL, MariaDB and SQLite lock as for `"update"`. Another value throws a TypeError naming the resource and the method before anything runs (`notes.lock() lockMode is "update" (the default) or "noKeyUpdate". Received: "share"`).
|
|
11
|
+
- `./test` re-exports database-runtime's `createPackageTestKnex(t, { environment })`. The package's own data tests run on it, so a `DB_CLIENT=pg` run proves their paths on PostgreSQL, each test in a schema of its own; `locks.postgres.test.js` adds PostgreSQL's concurrent cases with real transactions: two `patchWhere` calls racing for one row, `deleteWhere` against `patchWhere`, a `patchWhere` that waited writing only rows that still match, two `claim` workers taking different rows, `lockMany` and `lockWhere` waiting for a holder, upserts of one key behind an uncommitted insert, a deadlock victim running again, `retryLostInsertRace`, `findOrCreate` and `lockOrCreate` first uses at once, and `lockMode: "noKeyUpdate"`. CI runs them, and the data tests of this package and crud-core, on PostgreSQL 17.
|
|
12
|
+
- Tests pin how a whole number stored in a BIGINT column that is not an id (a counter, a read position) reads through a handle, on MariaDB/MySQL and PostgreSQL (`storedIntegers.mysql.test.js` and `storedIntegers.postgres.test.js`, the latter in CI's PostgreSQL job). Both drivers read BIGINT as text, and a field of type `integer` answers a number on create, get, query, queryAll, patch, createMany, patchMany, lockMany, claim, aggregate and a hidden-field read, while an id field answers text. Past 2^53 − 1 the digits stay text, never a rounded number, and a `scopedQuery` row is the driver's text. No behaviour changes.
|
|
13
|
+
- `./server` exports `checkForeignKeys({ knex, resources, tables })`, which compares the table resources' declared `foreignKeys` (each field mapped to its column as json-rest-api stores it) with the keys the database holds: `missing` lists a declared key the database lacks, `undeclared` a key of one of `tables` that nothing declares. `jskit test --databases` runs it once per run on the prepared test database; it never runs at boot. A declared `foreignKeys` reaches the json-rest-api resource unchanged; json-rest-api 3.0.1 only keeps it for its table helpers.
|
|
14
|
+
- **Security.** A handle answers `joinSource({ alias, filters }, options)`: `{ query, column }`, the caller's scoped rows under the alias (ownership, the row policy and the filters applied) with the stored columns a read answers, never a hidden one, and `column(field)`, which refuses a hidden field. `withoutHiddenFields` hands another feature no other query builder: a method answering one, at once or inside its answer, is refused outside a system context with a TypeError naming the method and pointing at `joinSource`. A join source an owner built on `scopedQuery()` selected every column, so `source.query.select(source.column("publicToken"))` read another feature's hidden values; such a source becomes the handle's `joinSource({ alias }, options)`.
|
|
15
|
+
- **Security.** `withoutHiddenFields` refuses a call whose `fields`, in any record it takes (a list, names separated by commas or a sparse fieldset), names a hidden field of the tables its owner owns, whatever the method. Only the seven standard reads refused one: a method `decorateRepository` adds that forwards the caller's `fields` answered the field stripped, as undefined, which a caller used as a value (a token in an email link). A system context, or one it derived, counts in whichever record the call takes, where only the last one counted.
|
|
16
|
+
- `withoutHiddenFields` refuses owned tables of which one hides a field and another shows a field of the same name, with a TypeError naming both, when the feature's repository is built: it strips hidden fields by name, so the visible field never reached another feature. Hide it in both, or rename one.
|
|
17
|
+
- Runs on json-rest-api 3.0.1 (from 3.0.0), exactly. A handle's `createMany` with `onConflict: { action: "merge" }` inside a transaction that read before another transaction committed the same key no longer fails as forbidden ("the record these conflict keys match cannot be changed by the caller") on MySQL and MariaDB, so two first registrations of one email at once both land; and a merge whose late matched row it left unchanged answers that row instead of empty data. PostgreSQL and SQLite behave as before.
|
|
18
|
+
- `expectScoped` compares the other owner's stored rows, every column read through the system context's scoped query, where it compared the records a handle answers. A path that changed only a hidden or `defaultExclude` field of another owner's row (a `patchWhere` under a system context with `returning: "none"`), or a column outside the table resource's schema, passed.
|
|
19
|
+
- `@jskit-ai/database-runtime` is a required peer dependency at the same version, where it was a dependency: the application holds one copy of the table runtime, since a nested copy brings its own error classes and transactions.
|
|
20
|
+
- `./server` also exports `isSystemContext(context)` and the runtime's defaults, `DEFAULT_REQUEST_ROW_LIMIT` and `DEFAULT_SERVER_PAGE_SIZE`; `./test` re-exports database-runtime's `TestDatabaseMissingError` beside `requireTestDatabase`; `./server/types` holds the types of `runtime.resources`, its handles, call options and system contexts.
|
|
21
|
+
- `delete`, `deleteWhere` and `createMany` refuse `lockVersion` and `onlyIf`, and `patchMany` and `patchWhere` refuse `lockVersion`, with a TypeError naming the resource, the method and the key and saying what to do instead (`notes.delete() cannot compare lockVersion, so it refuses rather than write without them. …`). They ignored them, so a delete meant to apply only to an unchanged record deleted it whatever it held. A delete compares in a transaction (`lock()`, compare, delete), `deleteWhere` in its filters, and a many-row patch with `onlyIf` on the lock version field.
|
|
22
|
+
- A `patch` with `onlyIf` and no changes compares onlyIf in SQL, on the table as `scopedQuery` gives it, as the UPDATE of a patch with changes does: it compared the record a read answered, with strict equality, so an id given as a number never matched the text the record answers, and a hidden field, which no read answers, never matched at all. It refuses an onlyIf field that is the id or not stored, and a value other than a scalar, a Date or null, as that UPDATE does. A `patch` with `lockVersion` or `onlyIf` locks only the lock version, since onlyIf is compared in SQL.
|
|
23
|
+
- `createdAt` and `updatedAt` a caller sets itself (in a system context or one it derived) are cut to the field's `temporalPrecision`, as the handle's own stamps are: a job's `Date#toISOString()`, with milliseconds, was refused (REST_API_VALIDATION) on a table whose timestamps declare `temporalPrecision: 0`. The fraction is cut, never rounded; a value with no fraction, or that is no date-time, is left for validation to judge.
|
|
24
|
+
- `./server` exports `passedOn(options)`, what a call hands on (its context and transaction), from `@jskit-ai/resource-core/shared/callOptions`. Code that must load without the table runtime and the database (an assistant on file storage, where `@jskit-ai/resource-runtime` is an optional peer and not installed) imports it from resource-core, a dependency it has.
|
|
25
|
+
- **Security.** `withoutHiddenFields` guards every function the repository holds, not only the seven standard reads: whatever a method answers (its records, in arrays, maps and nested plain records, and those of its sub-repositories) leaves without the hidden fields of the tables its owner owns, unless the call's options (its last record argument) carry a system or derived job context. A read an owner added in `decorateRepository`, such as one forwarding the caller's `fields` to its handle's `queryAll`, handed hidden values to any caller. A handle's `scopedQuery` is refused under any name, and `withTransaction` and `lockOrCreate` answer the caller's own work as it is. `withoutHiddenFields(repository, resources)` takes the served resource, or a list of it first and the owner's other tables. A function that answers a Knex value rather than rows (`raw()`, `column()`, or an owner's join source answering a builder) answers it as it is, unawaited, so another feature still builds its scoped query or subquery on it.
|
|
26
|
+
- **Security.** `withoutHiddenFields`, the repository a CRUD feature gives other features and its own actions, refuses `scopedQuery()` on a table with hidden fields unless it runs in a system context or one it derived: its builder selects every column, so `(await repository.scopedQuery({}, options)).query.select(repository.column("tokenHash"))` read another feature's hidden values. The owner's own methods, its join sources among them, build on the handle as before.
|
|
27
|
+
- `expectFlatQueries` fails when the path sent no statement at any size. That is a test counting on a channel the path does not use, such as no `knex` in a test whose features run on one and that booted no server, which measured 0 at every size and passed.
|
|
28
|
+
- A call's settings stay on that call. `withTransaction` hands its work only `{ context, trx }`, and so does a handle to its `afterWrite` hooks, to `lockOrCreate`'s work and to its own nested calls, instead of every option the caller passed: an outer `returning: "none"` made a create inside, or `findOrCreate`, answer nothing, and an outer `missing: "skip"` made a get inside answer null for a missing record. A handle's own write and read still take its `format`. `passedOn(options)`, exported from `./server`, gives the same `{ context, trx }` to code that calls another table.
|
|
29
|
+
- The `createdAt` and `updatedAt` a handle stamps are cut to the field's `temporalPrecision` (whole seconds for 0): they always carried milliseconds, so every create on a table whose timestamps declare `temporalPrecision: 0` failed with TYPE_CAST_FAILED.
|
|
30
|
+
- A row policy (an owner's or a contribution) is told who the call runs for only as `caller`: json-rest-api's own `system` flag is no longer handed on beside it, so a policy has one way to tell a system context, a job, a user and no one apart. No JSKIT policy read the flag.
|
|
31
|
+
- `expectScoped` compares the path's answers: it seeds owner A and runs the path as A, then seeds owner B and runs it as A again, and fails when the second answer differs from the first, as well as when the path changed or removed a row of B's. It used to fail only when the answer held one of B's rows by id with the same fields, so a report that joined, summed or counted B's rows (an answer like "Pat at business B") passed. The path runs twice, so a write answers what repeats. B's rows are read back by id, a thousand per read, and the tables' ids in one statement each, instead of whole tables through `queryAll`, which refused a table past its row limit.
|
|
32
|
+
- A handle's plain read answers the hidden fields (`hidden: true`) its `fields` names: `get`, `query` and `queryAll` in their params, and `lock`, `lockMany`, `lockWhere` and `claim` in their options. json-rest-api never selects a hidden field, so the handle reads them in one more statement per 1,000 records, on the table as `scopedQuery` gives it (the caller's ownership and the row policy applied), in the call's transaction, and answers them as json-rest-api reads their type. A hidden field may be an id, string, integer, number or boolean; naming one of any other type refuses the read before it runs. A read without `fields`, and any JSON:API read (`format: "jsonapi"`), still never answers one. Before, an owner read its own bearer token or storage key with a `scopedQuery` report, and a job that had to learn each row's owner across workspaces had to unhide the ownership field.
|
|
33
|
+
- `withoutHiddenFields(repository, resource)` (`@jskit-ai/resource-runtime/server`) answers the repository an owner gives other features: the same, except that a read whose `fields` names a hidden field of the resource is refused, with a TypeError naming the method and fields, unless it runs in a system context or one it derived. A resource without hidden fields keeps its repository as it is.
|
|
34
|
+
- **Breaking.** A query field's `select` (an `owns` entry's `queryFields`) receives `{ raw, column, filters }` instead of json-rest-api's `{ knex, db, column, context, ... }`. `raw(sql, bindings)` builds one SQL expression from a string and its bindings as an array, and is a value only: it is no promise (its `then` is undefined, so awaiting it answers the expression itself, unrun), and running it as a statement (`catch`, `finally`, `asCallback`, `stream`, `pipe`) throws. `column(field)` names a field's column, and `filters` is the read's filters, frozen. A select could run any statement on the Knex it was handed, with no ownership scope; now it writes the field's value and nothing else: `select({ raw, column }) { return raw("?? - coalesce(??, 0)", [column("receivedWeight"), column("processedWeight")]); }`. A handle's `raw()` refuses the same way bindings that are not an array, or a third argument. jskit/sql-literal holds a destructured `raw(...)` to the rules it holds `handle.raw(...)` to (one literal, bindings for its placeholders, no statement words).
|
|
35
|
+
- `lockWhere` and `claim` take `fields` in their options, as `lock` and `lockMany` do (`{ ...txOptions, fields: ["stage"] }`), and read only those, so a belongs-to field left out costs no check that its related record is visible: a claim of a job that links to two tables was a locking read and two checks, and is the locking read alone. Without `fields` they read what `lockMany` reads, so a collection with `hydrateOnView: false` is no longer loaded unless `fields` names it.
|
|
36
|
+
- A handle write refuses a condition its options name with no value: `patch` with `lockVersion: undefined` or `onlyIf: undefined`, and `patchMany` or `patchWhere` with `onlyIf: undefined`, throw a TypeError naming the resource, the method and the key (`notes.patch() options.lockVersion is undefined`). They used to run without the condition, so a version read under the wrong field name made a version advance only read. A call without the condition leaves the key out.
|
|
37
|
+
- An error a transaction's work throws itself reaches the caller of `withTransaction` (or of a handle call that opened its own transaction) reporting `transactionOutcome: "rolledBack"`, not enumerable, with its status and code unchanged, so `isLostDuplicateInsertRace` and a caller's retry tell a rolled-back duplicate the work turned into its own 409 from one inside a joined transaction. A frozen error is rethrown as it is.
|
|
38
|
+
- A handle's `query`, `queryAll` and `aggregate` whose filters give an empty list to a list filter (the id filter, or a filter with `filterOperator: "in"`) answer without a statement what a read matching no row answers: no records (`hasMore: false`), and for `aggregate` no groups, or one row of totals whose counts are 0 and other measures null. The context, page size and filter guards still run first; a JSON:API (`format: "jsonapi"`) query still reads.
|
|
39
|
+
- **Security.** `createStandaloneResources` refuses, on the Knex client of a JSKIT server, a table one of the server's features owns, saying which: standalone resources (for a command-line script) built a runtime of their own, with its own table owners and any system scope, so code inside a server could own an app table a second time and read it past its owner. A second Knex client on the same database is not recognised; code inside a server uses its features' handles.
|
|
40
|
+
- A job context from `forWorkspace`, `forUser` or `forWorkspaceUser` carries the event scope of its workspace, user or both (`{ kind: "workspace", id }`, `{ kind: "user", id }`, `{ kind: "workspace_user", id, scopeId, userId }`, as a request there does), so an action event it publishes reaches that audience, where it was scoped global.
|
|
41
|
+
- **Security.** A context that names no workspace or no user is refused wherever a table owned by it is read, not only on that table's own handle: an `include`, a link's visibility check and a link write into a workspace- or user-owned table now fail with json-rest-api's `REST_API_AUTOFILTER_CONTEXT`, where they read or linked the rows whose owner is NULL. The workspace and user owner resolvers answer nothing (undefined) when the context names no owner; only `workspace_or_shared` keeps its own resolver, which answers null, so a context with no workspace still reads only the shared rows.
|
|
42
|
+
- `own()` refuses at startup a cross-table filter (a `searchSchema` or field `search` entry whose `actualField` or `oneOf` names "resource.field") whose target field is not declared `indexed: true`, naming the filter, the target and the fix. json-rest-api refuses that filter only at the first query that uses it. A filter whose target table no feature owns yet is checked when its owner owns it.
|
|
43
|
+
- Runs on json-rest-api 3.0.0's release candidate (a17b722). A record written back as it was read keeps a link to a target the writer cannot see: the null it read counts as unchanged, while linking or clearing a visible target applies. A read that includes a link's target no longer checks that link's visibility again (one statement fewer per included type). A system context may group by and count distinct a hidden ownership field, so a job finds its units through its handle; a request is still refused. Stored integer and number values over BIGINT columns read as numbers when exact; ids stay strings, and getter, computed and virtual fields keep their own values.
|
|
44
|
+
- A system context's `forWorkspace`, `forUser` and `forWorkspaceUser` take `{ actorId }` last, for work a job does for one person (a request it carries out later). The derived context carries it as `onBehalfOf`, which audit fields read with the kernel's `resolveAuditActorId(context)`, and a row policy sees it as `caller.actorId` beside `kind: "job"`. It is attribution only: it never changes which rows the job reaches, and it signs no one in, so `resolveActorId`, `requireAuth` and an action's `authenticated` permission still see no one. Any other last argument is refused.
|
|
45
|
+
- `@jskit-ai/resource-runtime/test` exports `startTestFeatures({ knex, features, requires, inputs, createTables, logger })`: it starts features and providers as an application's server does, without HTTP, on a test resource runtime over the test's Knex, with the kernel's events and actions. The kernel orders them by their requires, each owner gets its handles and system context, every boot runs (so `crud.contracts` is sealed), and it answers `{ capabilities, resources, stop }`, where `capabilities` holds what `requires` names.
|
|
46
|
+
- `patch(id, changes, { ...options, onlyIf })` compares a `hidden` field: the UPDATE compares the onlyIf values itself, after the lock that tells a missing record from a changed one, where the handle compared them with the locked record, which no read gives a hidden field, so such a patch never applied unless it also said `missing: "skip"`.
|
|
47
|
+
- A row policy (and each contribution) receives `caller`, who the call runs for: `{ kind: "system", reason }` for a system context, `{ kind: "job", reason }` for a context `forWorkspace`, `forUser` or `forWorkspaceUser` derived from one, `{ kind: "user", userId }` for a signed-in request and `{ kind: "anonymous" }` otherwise. A derived job context names no user, so a policy that keyed on the actor treated it as an anonymous request; it now tells them apart by `caller.kind`. `system` is still passed.
|
|
48
|
+
- A table whose primary key is a `uuid` or a `string` takes its ids as text without a `normalizeId` on its `owns` entry; json-rest-api's default normalizer takes numeric ids only and answered "Resource document id is invalid". An owner's own `normalizeId` still wins.
|
|
49
|
+
- `createMany` with `onConflict: { action: "merge" }` and no `merge` list works on a table that stores `createdAt`: the merge list the handle builds from the rows' fields leaves out the conflict keys, the ownership fields and the lock version, which json-rest-api refused to merge ("onConflict.merge cannot change …"), as well as the id and `createdAt`.
|
|
50
|
+
- `aggregate` counts `id` (`{ count: "id" }` or `{ countDistinct: "id" }`) as the count of rows, on a schema that declares no id field too; json-rest-api refused it as an unknown field.
|
|
51
|
+
- A handle's included records read as a read of their own resource answers them, at every depth and in an included collection: a belongs-to field carries its id under its own key (`pet.contactId`) and the related record stays nested only when the include names it (`include: "pet.contact"`), where an included record kept json-rest-api's shape (`pet.contact: { id }`, no `contactId`). An included record leaves out a collection it does not include itself, which json-rest-api does not read for it and would answer as empty.
|
|
52
|
+
- `lock` and `lockMany` take `fields` in their options (`{ ...txOptions, fields: ["stage"] }`) and read only those, so a belongs-to field left out costs no check that its related record is visible: a lock of a record with two relations was a locking read and two checks, and is the locking read alone. A `patch` with `lockVersion` or `onlyIf` locks only the fields it compares, so its own locking read checks no relation, and `onlyIf` may now compare a field reads hide by default (it never matched before).
|
|
53
|
+
- A sparse `fields` names a belongs-to field by its own key (`customerId`) as well as by its relationship (`customer`): the key used to select no relationship and answer `customerId: null` for every row. A sparse read leaves out a belongs-to field it does not name, where it answered `null`.
|
|
54
|
+
- A collection relation with `hydrateOnList: false` (lists: `query`, `queryAll`) or `hydrateOnView: false` (`get`, `lock`, `lockMany`) no longer loads its rows on that kind of read unless the read includes it or names it in `fields`, as the CRUD runtime before the redesign did.
|
|
55
|
+
- On a table with a lock version, a patch that changes no field still advances the version, and now stamps `updatedAt` as every other update does: `patch(id, {}, { lockVersion })` (which did not stamp before) and an empty `patchMany` row, so many records advance in one UPDATE (`patchMany(ids.map((id) => ({ id })), options)`). A table without a lock version still writes nothing for an empty change.
|
|
56
|
+
- **Breaking.** Every handle owns the `id` filter, which takes one id or a list (`filters: { id }`), and registering a table resource that declares its own refuses: a `searchSchema` `id` entry (shared or the owner's) or `search: true` on the `id` field. A declared scalar `id` entry used to replace the handle's, so `filters: { id: ids }` failed on that table. **Upgrading:** delete the resource's `id: { type: "id", actualField: "id" }` search entry and any `search: true` on its `id` field; filters by one id or a list keep working.
|
|
57
|
+
- **Breaking (security).** A system context comes only from a declared system scope. `runtime.resources` no longer has `createSystemContext`: `declareSystemScope(featureId, systemScope)` records the scope (one per feature; declaring another throws) and answers the feature's `systemContext(reason)` factory, which the kernel hands a feature's setup, and `systemScopes()` lists them. `createResourceRuntime` and the `./server/resourceHost` subpath are no longer exported, so no code outside the package builds a runtime or a host of its own: code outside a JSKIT server owns its tables with `createStandaloneResources({ knex, feature, owns, systemScope? })`, which answers `{ resources, systemContext? }`, and tests with `createTestResourceRuntime` from `./test`, which now takes `hostHooks` for json-rest-api hooks a test installs. `buildJsonRestQueryParams`, `resolveWorkspaceScopeValue` and `resolveUserScopeValue` are exported from `./server`. `resolveUserScopeValue` throws when a context's `scopeValues.userId` and `visibilityContext.userId` name two users, as the workspace resolver does for two workspaces, instead of the scope value winning.
|
|
58
|
+
- A table resource whose schema stores `createdAt` and `updatedAt` as date-times has every handle write stamp them: `create` and `createMany` stamp both, `patch`, `patchMany`, `patchWhere` and an upsert merge stamp `updatedAt` (a merge keeps the stored `createdAt` of a record it updates), and a patch that changes nothing stamps nothing. The stamps replace what the caller sends, so a request can never forge them; only a system context, or a context it derived (an import, a migration, a seed), sets its own, and a timestamp it leaves out is stamped. A call without a context, which server code may make on a public table, is a request's: server code cannot pass a forged body through, and a feature that writes its own timestamps declares a `systemScope` and writes in its system context. Only the CRUD service stamped them before, so server code that wrote through a handle left them unchanged.
|
|
59
|
+
- Handles gain `lockOrCreate(row, options, work)`: on a table with no owners it locks the record with `row.id`, creating it from `row` first when it is missing (an insert that ignores a clash on the id), and runs `work(record, txOptions)` in that transaction, so work that fails leaves no record behind. In the caller's transaction it runs there once; on its own it opens a transaction that runs work once, and when two first uses of one id collide on the insert (a deadlock, or a duplicate key) before work starts, the loser runs again, up to four runs in all.
|
|
60
|
+
- **Breaking: the package is now `@jskit-ai/resource-runtime`** (was `@jskit-ai/json-rest-api-core`), on json-rest-api 3.0.0. Its provider is `ResourceRuntimeProvider`, which provides `runtime.resources`; the host it builds is `createResourceHost` (was `createJsonRestApiHost`), at `./server/resourceHost` (was `./server/jsonRestApiHost`). The `runtime.json-rest-api` capability and the host helpers `addResourceIfMissing`, `createJsonRestResourceScopeOptions`, `queryAllJsonRestCollectionRows`, `queryAllJsonRestCollectionDocument` and `extractJsonRestCollectionRows` are removed: a feature owns its tables with `defineFeature({ owns })` and reads every matching row with its handle's `queryAll`.
|
|
61
|
+
- **`runtime.resources`.** The kernel calls `own(featureId, owns)` for a feature with `owns`: it registers each table resource (or `{ resource, rowPolicy, hooks: { afterWrite }, acceptsContributions, normalizeId, queryFields, searchSchema }`) under its name, throws `DuplicateOwnerError` when another feature owns the table or the name, and answers one handle per resource. `createSystemContext({ feature, systemScope, reason })` answers json-rest-api's system context, with `forWorkspace(id)` and `forUser(id)` for work scoped to one workspace or user.
|
|
62
|
+
- **Handles.** Every method takes `options = { context, trx }` last and refuses an owned resource's call without a context (`MissingScopeError`). Reads: `query(params)` answers `{ records, page: { size, hasMore, next } }` and requires `page.size` (`UnpagedQueryError`) no larger than the resource's page limit (`PageSizeError`), with `page.after` (the previous page's `next`) for scrolling or `page.number` (from 1) for a numbered page, each one statement with no count; `queryAll` reads in 1,000-row pages and, in a request context, throws `RowLimitError` past 10,000 rows (a 400 coded `RESOURCE_ROW_LIMIT`, with the message code `resource.rowLimit` for the user's language) (system, workspace and user job contexts have no limit); `get(id, params, options)` answers a record; `aggregate` answers rows. Writes: `create`, `patch` (answers the record; `lockVersion` throws `StaleRecordError`, 409 `crud.document.lockVersionConflict`; `onlyIf` patches only matching rows and answers null otherwise) and `delete` (answers the id), and the many-row `createMany`, `patchMany`, `patchWhere` and `deleteWhere`, one statement per chunk. One missing-row rule: `get`, `patch`, `delete` and `lock` throw json-rest-api's not-found error (404) for a missing or hidden row, and answer null with `missing: "skip"`, which never fails the caller's transaction. Every filter takes `id`, one id or a list, so `patchWhere({ id: ids }, changes)` and `deleteWhere({ id: ids })` cover a list of ids. An upsert's rows carry no id, and `patchWhere` and `deleteWhere` with empty filters need `all: true`. Locks: `lock`, `lockMany`, `lockWhere`, `claim({ filters, sort, take }, options)`. Escape hatch: `scopedQuery` answers `{ query }`, a read-only builder with ownership and the row policy applied, with `column` and `raw`. Reads take `(params, options)` and writes `(data, options)`; settings ride in options, as `{ ...options, lockVersion }`. `withTransaction(options, work)` runs work in the caller's transaction or one it opens and retries a deadlock victim unless `{ ...options, retry: false }`; `{ ...options, outermost: true }` refuses a call inside a transaction. When work throws, the caller gets its own error; a failed write inside it or a failed commit arrives as json-rest-api's `RestApiWriteError` with the transaction's outcome. `findOrCreate({ id, ...attributes }, options)` reads the record with that id or creates it, and answers the winner's record when a concurrent creation wins outside a transaction. An id another scope's row holds answers a missing record, as `get` of it does, never `null` or that row, so it tells the caller no more than any id it cannot see. `afterWrite` hooks run once per write in its transaction and scope.
|
|
63
|
+
- A system context also derives `forWorkspaceUser(workspaceId, userId)`: one user's context inside one workspace, so a `workspace_user` table reads that user's rows in that workspace, a `workspace` table the workspace's and a `user` table the user's. Both ids are required.
|
|
64
|
+
- **Row policies** receive `ownTable(name)`, a SELECT-only builder over the owner's own tables, and a resource whose owner says `acceptsContributions` takes other features' `contributeRowPolicy(name, policy)`: each contribution is ANDed with the owner's policy inside its own group, so it only narrows.
|
|
65
|
+
- **`workspace_or_shared`** ownership reads a workspace's rows and the rows whose workspace is null, and writes only its own.
|
|
66
|
+
- **`./test`:** `expectScoped`, `expectFlatQueries` (moved from database-runtime; it now allows the later chunks a many-row write reports), `expectEveryTableOwned`, `requireTestDatabase` and `createTestResourceRuntime`.
|
|
67
|
+
- json-rest-api 3.0.0 includes 2.2.0 to 2.4.0. A handle's `queryAll` reads in 1,000-row pages; writes skip the post-write refresh nothing observes (a created child: 9 statements and 5 policy runs instead of 11 and 7); linkage checks run one query per type; reads copy records for `originalRecord` only for hooks that can read it (a query of 100 rows of 38 KB: about a third of the CPU). Also from 2.2.0-2.3.1: `aggregate()`, `email` and `uuid` field types, `retired: true` fields, AnyAPI indexed slots, and relationship targets locked FOR SHARE instead of FOR UPDATE.
|
|
68
|
+
|
|
69
|
+
## 0.2.0 - 2026-10-29
|
|
70
|
+
|
|
71
|
+
- Translation ready: every user-visible text is a message key with its English in `locales/en.json`, so an app translates it by supplying translation files only. With no translations, texts and behaviour are unchanged. See the guide "Translating a JSKIT app".
|
|
72
|
+
|
|
73
|
+
### New message keys (2)
|
|
74
|
+
|
|
75
|
+
All keys in `locales/en.json` (exported as `./locales/en.json`) are new in this version.
|
|
76
|
+
|
|
77
|
+
<details><summary>Key list</summary>
|
|
78
|
+
|
|
79
|
+
- `jskit.errors.REST_API_VERSION_CONFLICT`
|
|
80
|
+
- `jskit.errors.REST_API_PRECONDITION_FAILED`
|
|
81
|
+
|
|
82
|
+
</details>
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jskit": {
|
|
3
|
+
"errors": {
|
|
4
|
+
"REST_API_VERSION_CONFLICT": "Resource version condition failed",
|
|
5
|
+
"REST_API_PRECONDITION_FAILED": "HTTP representation precondition failed"
|
|
6
|
+
},
|
|
7
|
+
"crud": {
|
|
8
|
+
"document": {
|
|
9
|
+
"lockVersionConflict": "This record changed. Reload and try again."
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"resource": {
|
|
13
|
+
"rowLimit": "This request would read more than {rowLimit} rows. Ask for fewer and try again."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/resource-runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.13",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.0.0"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "jskit test"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./server": "./src/server/index.js",
|
|
13
|
+
"./server/types": "./src/server/types.js",
|
|
14
|
+
"./test": "./src/test/index.js",
|
|
15
|
+
"./locales/en.json": "./locales/en.json"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@jskit-ai/resource-core": "0.1.206",
|
|
19
|
+
"@jskit-ai/resource-crud-core": "0.1.206",
|
|
20
|
+
"json-rest-api": "3.1.0"
|
|
21
|
+
},
|
|
22
|
+
"description": "The resource runtime: features own their tables through it and read and write them with scoped handles on json-rest-api.",
|
|
23
|
+
"jskit": {
|
|
24
|
+
"kind": "runtime",
|
|
25
|
+
"capabilities": {
|
|
26
|
+
"provides": [
|
|
27
|
+
"runtime.resources"
|
|
28
|
+
],
|
|
29
|
+
"requires": [
|
|
30
|
+
"runtime.database",
|
|
31
|
+
"runtime.logger"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"runtime": {
|
|
35
|
+
"server": {
|
|
36
|
+
"providers": [
|
|
37
|
+
{
|
|
38
|
+
"entrypoint": "src/server/ResourceRuntimeProvider.js",
|
|
39
|
+
"export": "ResourceRuntimeProvider"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"providers": []
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"metadata": {
|
|
48
|
+
"apiSummary": {
|
|
49
|
+
"surfaces": [
|
|
50
|
+
{
|
|
51
|
+
"subpath": "./server",
|
|
52
|
+
"summary": "Provides runtime.resources (ResourceRuntimeProvider): own(featureId, owns) registers a feature's tables and answers their scoped handles, and declareSystemScope(featureId, systemScope) answers the feature's systemContext(reason) for work across every workspace and user. Exports createStandaloneResources, passedOn, withoutHiddenFields, isSystemContext, buildJsonRestQueryParams, the workspace and user scope resolvers, the default row limit and page size, and the runtime's error classes."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"subpath": "./server/types",
|
|
56
|
+
"summary": "Types of runtime.resources, its handles, call options and system contexts."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"subpath": "./test",
|
|
60
|
+
"summary": "Data test helpers on a real test database: createTestResourceRuntime, startTestFeatures, expectScoped, expectFlatQueries, expectEveryTableOwned, createPackageTestKnex, requireTestDatabase and TestDatabaseMissingError."
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@jskit-ai/database-runtime": "0.1.264",
|
|
68
|
+
"@jskit-ai/kernel": "0.2.15"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
2
|
+
import { createResourceHost } from "./resourceHost.js";
|
|
3
|
+
import { createResourceRuntime } from "./resourceRuntime.js";
|
|
4
|
+
import { registerServerRuntime } from "./standaloneResources.js";
|
|
5
|
+
|
|
6
|
+
const ResourceRuntimeProvider = defineProvider({
|
|
7
|
+
id: "runtime.resources",
|
|
8
|
+
requires: {
|
|
9
|
+
database: "runtime.database",
|
|
10
|
+
logger: "runtime.logger"
|
|
11
|
+
},
|
|
12
|
+
provides: {
|
|
13
|
+
resources: "runtime.resources"
|
|
14
|
+
},
|
|
15
|
+
/**
|
|
16
|
+
* `runtime.resources`, through which features own their tables, on one
|
|
17
|
+
* json-rest-api host.
|
|
18
|
+
* @param {object} dependencies
|
|
19
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeDatabase} dependencies.database
|
|
20
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} dependencies.logger
|
|
21
|
+
*/
|
|
22
|
+
async setup({ database, logger }) {
|
|
23
|
+
const api = await createResourceHost({ knex: database.knex, logger });
|
|
24
|
+
const resources = createResourceRuntime({ api });
|
|
25
|
+
registerServerRuntime(database.knex, resources);
|
|
26
|
+
return { resources };
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export { ResourceRuntimeProvider };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { detectDialectFromClient } from "@jskit-ai/database-runtime/shared";
|
|
2
|
+
import { UnsupportedDatabaseError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PostgreSQL's snapshot horizon, `pg_snapshot_xmin(pg_current_snapshot())`, as decimal text: every transaction below it
|
|
6
|
+
* had finished when it was read. A cursor of what changed starts there: taken before a read, a later read of the rows
|
|
7
|
+
* whose xid8 change stamp is at or above it finds every row a transaction that read could not see wrote, a row whose
|
|
8
|
+
* id was taken before its transaction committed and a changed older row included (see the data-access guide, "What
|
|
9
|
+
* changed since"). It reads no row. MySQL, MariaDB and SQLite have no transaction stamps: it throws
|
|
10
|
+
* UnsupportedDatabaseError there, before anything runs.
|
|
11
|
+
* @param {string} name The resource's name, for the message.
|
|
12
|
+
* @param {import("knex").Knex | import("knex").Knex.Transaction} db The caller's transaction, or the runtime's Knex.
|
|
13
|
+
* @returns {Promise<string>}
|
|
14
|
+
*/
|
|
15
|
+
async function readChangeHorizon(name, db) {
|
|
16
|
+
if (detectDialectFromClient(db) !== "postgres") {
|
|
17
|
+
throw new UnsupportedDatabaseError(
|
|
18
|
+
`${name}.changeHorizon()`,
|
|
19
|
+
"reads PostgreSQL's snapshot horizon (pg_snapshot_xmin), which MySQL, MariaDB and SQLite do not have."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const { rows: [{ horizon }] } = await db.raw("select pg_snapshot_xmin(pg_current_snapshot())::text as horizon");
|
|
23
|
+
return horizon;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { readChangeHorizon };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createSystemContext as createJsonRestSystemContext, isSystemContext } from "json-rest-api";
|
|
2
|
+
import { resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
|
|
3
|
+
import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
|
+
|
|
5
|
+
// Module-private: only jobContext() marks a context as background work's, so a
|
|
6
|
+
// request cannot claim it. Object spread keeps the marker, as json-rest-api's
|
|
7
|
+
// own system marker.
|
|
8
|
+
const JOB_CONTEXT = Symbol("jskit.resources.jobContext");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The context one json-rest-api call works in: a copy of the caller's, with its
|
|
12
|
+
* visibility context and scope values copied too, since json-rest-api writes an
|
|
13
|
+
* operation's working state into the context it is given. A system context
|
|
14
|
+
* stays one, because spread keeps its marker.
|
|
15
|
+
* @param {object | null | undefined} [context]
|
|
16
|
+
*/
|
|
17
|
+
function createJsonRestContext(context = null) {
|
|
18
|
+
if (!context || typeof context !== "object" || Array.isArray(context)) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const nextContext = {
|
|
23
|
+
...context
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (context.visibilityContext && typeof context.visibilityContext === "object" && !Array.isArray(context.visibilityContext)) {
|
|
27
|
+
nextContext.visibilityContext = {
|
|
28
|
+
...context.visibilityContext
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (context.scopeValues && typeof context.scopeValues === "object" && !Array.isArray(context.scopeValues)) {
|
|
33
|
+
nextContext.scopeValues = {
|
|
34
|
+
...context.scopeValues
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return nextContext;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a context belongs to background work: a system context, or a
|
|
43
|
+
* workspace or user context a system context derived. Request row limits
|
|
44
|
+
* apply to every other context.
|
|
45
|
+
* @param {unknown} context
|
|
46
|
+
*/
|
|
47
|
+
function isJobContext(context) {
|
|
48
|
+
return isSystemContext(context) || Boolean(context && typeof context === "object" && context[JOB_CONTEXT]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Who a call runs for, as its row policy sees it: background work across every workspace and user ("system"),
|
|
53
|
+
* background work a system context derived for one workspace or user ("job"), each with its reason; a request by a
|
|
54
|
+
* signed-in user ("user", with the user's id); or a request by no one ("anonymous"). A derived job context names a
|
|
55
|
+
* user only as the person it acts for (`actorId`, when it was given one), never as a signed-in user, so a policy
|
|
56
|
+
* that keys on the actor tells it from an anonymous request by its kind.
|
|
57
|
+
* @param {unknown} context
|
|
58
|
+
* @returns {Readonly<{ kind: "system", reason: string } | { kind: "job", reason: string, actorId?: string } | { kind: "user", userId: string } | { kind: "anonymous" }>}
|
|
59
|
+
*/
|
|
60
|
+
function callerOf(context) {
|
|
61
|
+
if (isSystemContext(context)) {
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
kind: "system",
|
|
64
|
+
reason: context.systemReason
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (context && typeof context === "object" && context[JOB_CONTEXT]) {
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
kind: "job",
|
|
70
|
+
reason: context.jobReason,
|
|
71
|
+
...(context.onBehalfOf ? { actorId: context.onBehalfOf.id } : {})
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const userId = resolveActorId(context);
|
|
75
|
+
return Object.freeze(userId ? { kind: "user", userId } : { kind: "anonymous" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The event scope of work on one workspace, one user or one person in a workspace, as a request there carries it, so
|
|
80
|
+
* an event the work publishes reaches that workspace's or that user's audience, never everyone.
|
|
81
|
+
* @param {{ workspaceId?: string, userId?: string }} scopeValues
|
|
82
|
+
*/
|
|
83
|
+
function eventScopeOf({ workspaceId, userId }) {
|
|
84
|
+
if (workspaceId && userId) {
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
kind: "workspace_user",
|
|
87
|
+
id: workspaceId,
|
|
88
|
+
scopeId: workspaceId,
|
|
89
|
+
userId
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze(workspaceId ? { kind: "workspace", id: workspaceId } : { kind: "user", id: userId });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} reason
|
|
97
|
+
* @param {Record<string, unknown>} scopeValues
|
|
98
|
+
* @param {{ actorId?: unknown }} options The person the work acts for, if any: `onBehalfOf.id`, for audit fields only.
|
|
99
|
+
* @param {string} label The deriving method, such as "forWorkspace".
|
|
100
|
+
*/
|
|
101
|
+
function jobContext(reason, scopeValues, options, label) {
|
|
102
|
+
if (options !== undefined && (!options || typeof options !== "object" || Object.keys(options).some((key) => key !== "actorId"))) {
|
|
103
|
+
throw new TypeError(`${label} takes { actorId } as its last argument: the user the work acts for.`);
|
|
104
|
+
}
|
|
105
|
+
const actorId = options?.actorId === undefined ? null : requireScopeId(options.actorId, label, "user it acts for");
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
scopeValues: Object.freeze(scopeValues),
|
|
108
|
+
scope: eventScopeOf(scopeValues),
|
|
109
|
+
jobReason: reason,
|
|
110
|
+
...(actorId ? { onBehalfOf: Object.freeze({ id: actorId }) } : {}),
|
|
111
|
+
[JOB_CONTEXT]: true
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {unknown} value
|
|
117
|
+
* @param {string} label The deriving method, such as "forWorkspace".
|
|
118
|
+
* @param {"workspace" | "user" | "user it acts for"} subject What the id names.
|
|
119
|
+
*/
|
|
120
|
+
function requireScopeId(value, label, subject) {
|
|
121
|
+
const id = normalizeRecordId(value, { fallback: null });
|
|
122
|
+
if (!id) {
|
|
123
|
+
throw new TypeError(`${label} needs the id of the ${subject} to work on.`);
|
|
124
|
+
}
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The context of work across every workspace and user, for a feature that
|
|
130
|
+
* declares `systemScope`. Ownership predicates are skipped for it, row
|
|
131
|
+
* policies receive `caller.kind` "system" (a derived context's `caller.kind`
|
|
132
|
+
* is "job"), and creates name their ownership values.
|
|
133
|
+
* `forWorkspace(id)`, `forUser(id)` and `forWorkspaceUser(workspaceId, userId)`
|
|
134
|
+
* derive ordinary contexts scoped to one workspace, one user, or one user in
|
|
135
|
+
* one workspace, so the work for each is scoped as usual (and, as background
|
|
136
|
+
* work, free of request row limits). Each takes `{ actorId }` last, for work
|
|
137
|
+
* done for one person (a request a job carries out): the context's
|
|
138
|
+
* `onBehalfOf`, which audit fields read with `resolveAuditActorId(context)`,
|
|
139
|
+
* and the row policy's `caller.actorId`. It names who the work is for, never
|
|
140
|
+
* which rows it reaches, and it signs no one in: `resolveActorId` and every
|
|
141
|
+
* authentication check still see no one.
|
|
142
|
+
* @param {{ feature: string, systemScope: string, reason: string }} declaration
|
|
143
|
+
*/
|
|
144
|
+
function createSystemContext({ feature, systemScope, reason }) {
|
|
145
|
+
const normalizedReason = normalizeText(reason);
|
|
146
|
+
if (!normalizedReason) {
|
|
147
|
+
throw new TypeError(`Feature "${feature}" needs the reason its work reads or writes every workspace's rows.`);
|
|
148
|
+
}
|
|
149
|
+
const description = `${feature}: ${normalizedReason}`;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {unknown} workspaceId
|
|
153
|
+
* @param {{ actorId?: unknown }} [options]
|
|
154
|
+
*/
|
|
155
|
+
function forWorkspace(workspaceId, options) {
|
|
156
|
+
return jobContext(description, { workspaceId: requireScopeId(workspaceId, "forWorkspace", "workspace") }, options, "forWorkspace");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {unknown} userId
|
|
161
|
+
* @param {{ actorId?: unknown }} [options]
|
|
162
|
+
*/
|
|
163
|
+
function forUser(userId, options) {
|
|
164
|
+
return jobContext(description, { userId: requireScopeId(userId, "forUser", "user") }, options, "forUser");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One user's context inside one workspace: a `workspace_user` table reads
|
|
169
|
+
* that user's rows in that workspace, a `workspace` table the workspace's
|
|
170
|
+
* and a `user` table the user's.
|
|
171
|
+
* @param {unknown} workspaceId
|
|
172
|
+
* @param {unknown} userId
|
|
173
|
+
* @param {{ actorId?: unknown }} [options]
|
|
174
|
+
*/
|
|
175
|
+
function forWorkspaceUser(workspaceId, userId, options) {
|
|
176
|
+
return jobContext(description, {
|
|
177
|
+
workspaceId: requireScopeId(workspaceId, "forWorkspaceUser", "workspace"),
|
|
178
|
+
userId: requireScopeId(userId, "forWorkspaceUser", "user")
|
|
179
|
+
}, options, "forWorkspaceUser");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Object.freeze(createJsonRestSystemContext(description, {
|
|
183
|
+
feature,
|
|
184
|
+
systemScope,
|
|
185
|
+
reason: normalizedReason,
|
|
186
|
+
forWorkspace,
|
|
187
|
+
forUser,
|
|
188
|
+
forWorkspaceUser
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { callerOf, createJsonRestContext, createSystemContext, isJobContext, isSystemContext };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
|
|
3
|
+
// The resource runtime's own refusals. json-rest-api's errors (RestApiRecordsNotFoundError,
|
|
4
|
+
// RestApiBulkWriteUnsupportedError, RestApiLockOutsideTransactionError, RestApiScopedQueryReadOnlyError,
|
|
5
|
+
// RestApiWriteError and the others) reach the caller as json-rest-api throws them.
|
|
6
|
+
|
|
7
|
+
/** A handle call on an owned resource without the caller's context: ownership cannot be applied, so nothing runs. */
|
|
8
|
+
class MissingScopeError extends TypeError {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} resourceName
|
|
11
|
+
* @param {"workspace" | "user"} [owner] The owner the table's rows belong to, when the context names none.
|
|
12
|
+
*/
|
|
13
|
+
constructor(resourceName, owner) {
|
|
14
|
+
super(
|
|
15
|
+
(owner
|
|
16
|
+
? `${resourceName} rows each belong to a ${owner}, and this context names no ${owner}, so the ownership scope cannot be applied. `
|
|
17
|
+
: `${resourceName} needs options.context, so the ownership scope can be applied. `) +
|
|
18
|
+
"Pass on the options your function received. Only a background job of a feature with systemScope uses " +
|
|
19
|
+
"systemContext(reason).forWorkspace(id); never use it to make a request work."
|
|
20
|
+
);
|
|
21
|
+
this.name = "MissingScopeError";
|
|
22
|
+
this.code = "RESOURCE_MISSING_SCOPE";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A handle call on a `system` table in a context that is not a system context: its rows belong to no workspace or user,
|
|
28
|
+
* so a request's context, or one derived for a workspace or a user, would reach every workspace's rows. Nothing runs.
|
|
29
|
+
*/
|
|
30
|
+
class SystemContextRequiredError extends TypeError {
|
|
31
|
+
/** @param {string} resourceName */
|
|
32
|
+
constructor(resourceName) {
|
|
33
|
+
super(
|
|
34
|
+
`${resourceName} rows belong to no workspace or user (ownership "system"), so only a system context reaches them: ` +
|
|
35
|
+
"the background work of the feature that owns the table, with systemContext(reason). A request's context, or one " +
|
|
36
|
+
"forWorkspace, forUser or forWorkspaceUser derived, would reach every workspace's rows. Call the owner's own methods, " +
|
|
37
|
+
"which run in its system context."
|
|
38
|
+
);
|
|
39
|
+
this.name = "SystemContextRequiredError";
|
|
40
|
+
this.code = "RESOURCE_SYSTEM_CONTEXT_REQUIRED";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A handle call that reads something only some databases have, on one that lacks it, such as changeHorizon() outside
|
|
46
|
+
* PostgreSQL. Nothing runs.
|
|
47
|
+
*/
|
|
48
|
+
class UnsupportedDatabaseError extends Error {
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} call The call, as `notes.changeHorizon()`.
|
|
51
|
+
* @param {string} reason What the call reads and which databases have it.
|
|
52
|
+
*/
|
|
53
|
+
constructor(call, reason) {
|
|
54
|
+
super(`${call} ${reason}`);
|
|
55
|
+
this.name = "UnsupportedDatabaseError";
|
|
56
|
+
this.code = "RESOURCE_DATABASE_UNSUPPORTED";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** query() without page.size: every list is paged, and every-row reads say so with queryAll(). */
|
|
61
|
+
class UnpagedQueryError extends TypeError {
|
|
62
|
+
/** @param {string} resourceName */
|
|
63
|
+
constructor(resourceName) {
|
|
64
|
+
super(`${resourceName}.query() needs page.size; read every row with queryAll(), count with aggregate().`);
|
|
65
|
+
this.name = "UnpagedQueryError";
|
|
66
|
+
this.code = "RESOURCE_UNPAGED_QUERY";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** query() asking for a page larger than the resource serves. */
|
|
71
|
+
class PageSizeError extends TypeError {
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} resourceName
|
|
74
|
+
* @param {unknown} size
|
|
75
|
+
* @param {number} limit
|
|
76
|
+
*/
|
|
77
|
+
constructor(resourceName, size, limit) {
|
|
78
|
+
super(
|
|
79
|
+
`${resourceName}.query() page.size ${size} is above the resource's page limit (${limit}), so it would return ` +
|
|
80
|
+
`only ${limit} records. Read every row with queryAll(), or page with page.after.`
|
|
81
|
+
);
|
|
82
|
+
this.name = "PageSizeError";
|
|
83
|
+
this.code = "RESOURCE_PAGE_SIZE";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* queryAll() in a request context reading past the request row limit: the request answers a 400 coded
|
|
89
|
+
* RESOURCE_ROW_LIMIT, with a sentence the client can show in the user's language, instead of a 500.
|
|
90
|
+
*/
|
|
91
|
+
class RowLimitError extends AppError {
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} resourceName
|
|
94
|
+
* @param {number} rowLimit
|
|
95
|
+
* @param {unknown} cause
|
|
96
|
+
*/
|
|
97
|
+
constructor(resourceName, rowLimit, cause) {
|
|
98
|
+
super(
|
|
99
|
+
400,
|
|
100
|
+
`${resourceName}.queryAll() read more than ${rowLimit} rows inside a request. Work over that many rows belongs ` +
|
|
101
|
+
"in a background job: a feature with systemScope, working per workspace with systemContext(reason).forWorkspace(id).",
|
|
102
|
+
{ cause, code: "RESOURCE_ROW_LIMIT", messageCode: "resource.rowLimit", messageParams: { rowLimit } }
|
|
103
|
+
);
|
|
104
|
+
this.name = "RowLimitError";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** patch() with lockVersion on a record someone else changed since it was read. */
|
|
109
|
+
class StaleRecordError extends AppError {
|
|
110
|
+
constructor() {
|
|
111
|
+
super(409, "This record changed. Reload and try again.", {
|
|
112
|
+
code: "crud.document.lockVersionConflict"
|
|
113
|
+
});
|
|
114
|
+
this.name = "StaleRecordError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** own() for a table or resource name another feature already owns. */
|
|
119
|
+
class DuplicateOwnerError extends Error {
|
|
120
|
+
/** @param {string} message */
|
|
121
|
+
constructor(message) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.name = "DuplicateOwnerError";
|
|
124
|
+
this.code = "RESOURCE_DUPLICATE_OWNER";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export {
|
|
129
|
+
DuplicateOwnerError,
|
|
130
|
+
MissingScopeError,
|
|
131
|
+
PageSizeError,
|
|
132
|
+
RowLimitError,
|
|
133
|
+
StaleRecordError,
|
|
134
|
+
SystemContextRequiredError,
|
|
135
|
+
UnpagedQueryError,
|
|
136
|
+
UnsupportedDatabaseError
|
|
137
|
+
};
|