@jskit-ai/resource-runtime 0.2.12 → 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 +12 -0
- package/package.json +7 -7
- package/src/server/changeHorizon.js +26 -0
- package/src/server/errors.js +44 -1
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +9 -2
- package/src/server/handleHidden.js +137 -36
- package/src/server/handleLocks.js +47 -15
- package/src/server/index.js +4 -1
- package/src/server/lockOrCreate.js +7 -3
- package/src/server/resourceHandle.js +22 -9
- package/src/server/resourceHost.js +3 -2
- package/src/test/index.js +2 -1
- package/test/callSettings.test.js +5 -4
- package/test/changes.postgres.test.js +141 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +71 -30
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +5 -4
- package/test/manyRowWrites.mysql.test.js +9 -0
- package/test/queryFields.test.js +5 -4
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +259 -53
- 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 +8 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
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.
|
|
5
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.
|
|
6
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.
|
|
7
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/resource-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.0.0"
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"./locales/en.json": "./locales/en.json"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@jskit-ai/resource-core": "0.1.
|
|
19
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
20
|
-
"json-rest-api": "3.0
|
|
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
21
|
},
|
|
22
22
|
"description": "The resource runtime: features own their tables through it and read and write them with scoped handles on json-rest-api.",
|
|
23
23
|
"jskit": {
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"subpath": "./test",
|
|
60
|
-
"summary": "Data test helpers on a real test database: createTestResourceRuntime, startTestFeatures, expectScoped, expectFlatQueries, expectEveryTableOwned, requireTestDatabase and TestDatabaseMissingError."
|
|
60
|
+
"summary": "Data test helpers on a real test database: createTestResourceRuntime, startTestFeatures, expectScoped, expectFlatQueries, expectEveryTableOwned, createPackageTestKnex, requireTestDatabase and TestDatabaseMissingError."
|
|
61
61
|
}
|
|
62
62
|
]
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
68
|
-
"@jskit-ai/kernel": "0.2.
|
|
67
|
+
"@jskit-ai/database-runtime": "0.1.264",
|
|
68
|
+
"@jskit-ai/kernel": "0.2.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -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 };
|
package/src/server/errors.js
CHANGED
|
@@ -23,6 +23,40 @@ class MissingScopeError extends TypeError {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
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
|
+
|
|
26
60
|
/** query() without page.size: every list is paged, and every-row reads say so with queryAll(). */
|
|
27
61
|
class UnpagedQueryError extends TypeError {
|
|
28
62
|
/** @param {string} resourceName */
|
|
@@ -91,4 +125,13 @@ class DuplicateOwnerError extends Error {
|
|
|
91
125
|
}
|
|
92
126
|
}
|
|
93
127
|
|
|
94
|
-
export {
|
|
128
|
+
export {
|
|
129
|
+
DuplicateOwnerError,
|
|
130
|
+
MissingScopeError,
|
|
131
|
+
PageSizeError,
|
|
132
|
+
RowLimitError,
|
|
133
|
+
StaleRecordError,
|
|
134
|
+
SystemContextRequiredError,
|
|
135
|
+
UnpagedQueryError,
|
|
136
|
+
UnsupportedDatabaseError
|
|
137
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { listForeignKeys } from "@jskit-ai/database-runtime/server/foreignKeyMigrations";
|
|
2
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
3
|
+
import { createResourceHost } from "./resourceHost.js";
|
|
4
|
+
import { createResourceRuntime } from "./resourceRuntime.js";
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ tableName: string, columns: string[], referencedTableName: string, referencedColumns: string[] }} ForeignKeyColumns */
|
|
7
|
+
|
|
8
|
+
const sameNames = (left, right) => left.length === right.length && left.every((name, index) => name === right[index]);
|
|
9
|
+
const sameKey = (left, right) => left.tableName === right.tableName && left.referencedTableName === right.referencedTableName &&
|
|
10
|
+
sameNames(left.columns, right.columns) && sameNames(left.referencedColumns, right.referencedColumns);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Each resource's declared foreignKeys in database columns, its fields mapped as json-rest-api stores them.
|
|
14
|
+
* @param {import("knex").Knex} knex
|
|
15
|
+
* @param {ReadonlyArray<Record<string, any>>} resources
|
|
16
|
+
*/
|
|
17
|
+
async function declaredForeignKeys(knex, resources) {
|
|
18
|
+
const declaring = resources.filter((resource) => resource.foreignKeys);
|
|
19
|
+
if (declaring.length < 1) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const api = await createResourceHost({ knex, logger: silentLogger });
|
|
23
|
+
await createResourceRuntime({ api }).own("jskit.foreign-key-check", declaring);
|
|
24
|
+
return declaring.flatMap((resource) => {
|
|
25
|
+
const adapter = api.knex.helpers.getStorageAdapter(resource.name);
|
|
26
|
+
const column = (field) => (field === "id" ? adapter.getIdColumn() : adapter.translateColumn(field));
|
|
27
|
+
return resource.foreignKeys.map((declared) => Object.freeze({
|
|
28
|
+
resource: resource.name,
|
|
29
|
+
declared,
|
|
30
|
+
tableName: resource.tableName,
|
|
31
|
+
columns: declared.columns.map(column),
|
|
32
|
+
referencedTableName: declared.referencedTableName,
|
|
33
|
+
referencedColumns: declared.referencedColumns ?? ["id"]
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compares the table resources' declared foreignKeys with the keys the connected database holds. json-rest-api relies
|
|
40
|
+
* on a declared key to skip work, so a declared key the database lacks changes results: it is `missing`, and the
|
|
41
|
+
* check fails on it. A database key on one of `tables` that nothing declares is `undeclared`: only work json-rest-api
|
|
42
|
+
* could have skipped. It reads the database, so it runs where one already exists, as `jskit test --databases` does,
|
|
43
|
+
* never at boot.
|
|
44
|
+
* @param {object} options
|
|
45
|
+
* @param {import("knex").Knex} options.knex
|
|
46
|
+
* @param {ReadonlyArray<Record<string, any>>} options.resources Table resources; those declaring foreignKeys are checked.
|
|
47
|
+
* @param {ReadonlyArray<string>} [options.tables] The tables whose undeclared keys are reported.
|
|
48
|
+
*/
|
|
49
|
+
async function checkForeignKeys({ knex, resources, tables = [] }) {
|
|
50
|
+
const [declared, databaseKeys] = await Promise.all([declaredForeignKeys(knex, resources), listForeignKeys(knex)]);
|
|
51
|
+
const reported = new Set(tables);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
missing: declared.filter((key) => !databaseKeys.some((real) => sameKey(key, real))),
|
|
54
|
+
undeclared: databaseKeys.filter((real) => reported.has(real.tableName) && !declared.some((key) => sameKey(key, real)))
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { checkForeignKeys };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import { passedOn } from "@jskit-ai/resource-core/shared/callOptions";
|
|
3
3
|
import { isSystemContext } from "./contexts.js";
|
|
4
|
-
import { MissingScopeError } from "./errors.js";
|
|
4
|
+
import { MissingScopeError, SystemContextRequiredError } from "./errors.js";
|
|
5
5
|
import { resolveUserScopeValue, resolveWorkspaceScopeValue } from "./resourceHost.js";
|
|
6
6
|
|
|
7
7
|
// The checks that keep a handle's calls inside their scope: a call never reaches rows beyond
|
|
@@ -19,7 +19,8 @@ const REQUIRED_OWNERS = Object.freeze({
|
|
|
19
19
|
const OWNER_RESOLVERS = Object.freeze({ workspace: resolveWorkspaceScopeValue, user: resolveUserScopeValue });
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Refuses a context, other than a system context, that names no owner the table's ownership needs
|
|
22
|
+
* Refuses a context, other than a system context, that names no owner the table's ownership needs: none at all on an
|
|
23
|
+
* owned table, and any on a `system` table, which only a system context reaches.
|
|
23
24
|
* @param {string} name The resource's name.
|
|
24
25
|
* @param {string} ownership
|
|
25
26
|
* @param {object | null | undefined} context
|
|
@@ -28,6 +29,12 @@ function requireOwners(name, ownership, context) {
|
|
|
28
29
|
if (isSystemContext(context)) {
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
32
|
+
if (ownership === "system") {
|
|
33
|
+
throw new SystemContextRequiredError(name);
|
|
34
|
+
}
|
|
35
|
+
if (!context && ownership !== "public") {
|
|
36
|
+
throw new MissingScopeError(name);
|
|
37
|
+
}
|
|
31
38
|
const missing = REQUIRED_OWNERS[ownership].find((owner) => !OWNER_RESOLVERS[owner](context));
|
|
32
39
|
if (missing) {
|
|
33
40
|
throw new MissingScopeError(name, missing);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RestApiResourceError } from "json-rest-api";
|
|
1
|
+
import { RestApiResourceError, readOnlyQueryBuilder } from "json-rest-api";
|
|
2
2
|
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
import { isJobContext } from "./contexts.js";
|
|
4
4
|
import { namedFields } from "./handleRecords.js";
|
|
@@ -11,16 +11,6 @@ import { namedFields } from "./handleRecords.js";
|
|
|
11
11
|
const READABLE_TYPES = Object.freeze(new Set(["id", "string", "integer", "number", "boolean"]));
|
|
12
12
|
// The records each statement of the hidden fields' read covers.
|
|
13
13
|
const RECORDS_PER_READ = 1000;
|
|
14
|
-
// Where each read takes `fields` and where its options, by argument position.
|
|
15
|
-
const FIELD_READS = Object.freeze({
|
|
16
|
-
get: Object.freeze({ fieldsAt: 1, optionsAt: 2 }),
|
|
17
|
-
query: Object.freeze({ fieldsAt: 0, optionsAt: 1 }),
|
|
18
|
-
queryAll: Object.freeze({ fieldsAt: 0, optionsAt: 1 }),
|
|
19
|
-
lock: Object.freeze({ fieldsAt: 1, optionsAt: 1 }),
|
|
20
|
-
lockMany: Object.freeze({ fieldsAt: 1, optionsAt: 1 }),
|
|
21
|
-
lockWhere: Object.freeze({ fieldsAt: 1, optionsAt: 1 }),
|
|
22
|
-
claim: Object.freeze({ fieldsAt: 1, optionsAt: 1 })
|
|
23
|
-
});
|
|
24
14
|
|
|
25
15
|
/**
|
|
26
16
|
* The resource's hidden fields a read's `fields` names.
|
|
@@ -144,6 +134,9 @@ const HANDLE_SCOPED_QUERIES = new WeakSet();
|
|
|
144
134
|
const HANDLE_WORK_RUNNERS = new WeakSet();
|
|
145
135
|
// The names an owner's own wrappers of those runners keep.
|
|
146
136
|
const WORK_RUNNER_NAMES = Object.freeze(new Set(["withTransaction", "lockOrCreate"]));
|
|
137
|
+
// The builders a handle's joinSource() answers, which carry the table's visible columns only: the one query builder
|
|
138
|
+
// withoutHiddenFields hands another feature.
|
|
139
|
+
const JOIN_SOURCES = new WeakSet();
|
|
147
140
|
|
|
148
141
|
/**
|
|
149
142
|
* Records a new handle's scopedQuery, withTransaction and lockOrCreate for withoutHiddenFields.
|
|
@@ -156,8 +149,74 @@ function markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate }) {
|
|
|
156
149
|
}
|
|
157
150
|
|
|
158
151
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
152
|
+
* A handle's joinSource(): the table as a join source for another owner's report, which joins it in its own
|
|
153
|
+
* statement. It answers `{ query, column }`: the rows scopedQuery() gives under `alias` (the caller's ownership, the
|
|
154
|
+
* row policy and `filters` applied) with the stored columns a read answers only, never a hidden one, and
|
|
155
|
+
* `column(field)` naming each under that alias. A CRUD feature's repository answers it to any feature, where it
|
|
156
|
+
* refuses scopedQuery(). The builder comes wrapped, since a builder is thenable.
|
|
157
|
+
* @param {object} dependencies
|
|
158
|
+
* @param {{ knex: { instance: any } }} dependencies.api
|
|
159
|
+
* @param {{ name: string, schema: Record<string, any> }} dependencies.resource
|
|
160
|
+
* @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any> } } }} dependencies.jsonRest
|
|
161
|
+
* @param {(params: { alias: string, filters?: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
|
|
162
|
+
* @param {(field: string, alias?: string) => string} dependencies.column
|
|
163
|
+
*/
|
|
164
|
+
function createJoinSource({ api, resource, jsonRest, scopedQuery, column }) {
|
|
165
|
+
const { name } = resource;
|
|
166
|
+
/**
|
|
167
|
+
* @param {{ alias: string, filters?: object }} params
|
|
168
|
+
* @param {{ context?: object | null, trx?: object | null }} [options]
|
|
169
|
+
* @returns {Promise<Readonly<{ query: any, column: (field: string) => string }>>}
|
|
170
|
+
*/
|
|
171
|
+
return async function joinSource({ alias, filters } = {}, options = {}) {
|
|
172
|
+
if (typeof alias !== "string" || !alias) {
|
|
173
|
+
throw new TypeError(`${name}.joinSource() needs the alias the report joins the ${name} under.`);
|
|
174
|
+
}
|
|
175
|
+
const { query } = await scopedQuery({ alias, ...(filters === undefined ? {} : { filters }) }, options);
|
|
176
|
+
const { schemaStructure } = jsonRest().vars.schemaInfo;
|
|
177
|
+
const visible = Object.keys(schemaStructure).filter((field) => {
|
|
178
|
+
const definition = schemaStructure[field];
|
|
179
|
+
return field !== "id" && definition.hidden !== true && definition.virtual !== true && !definition.belongsToPolymorphic;
|
|
180
|
+
});
|
|
181
|
+
// The scoped rows' visible columns, as a derived table under the alias: nothing a caller adds reaches the others.
|
|
182
|
+
const source = readOnlyQueryBuilder(
|
|
183
|
+
(options.trx || api.knex.instance).from(query.select(["id", ...visible].map((field) => column(field, alias))).as(alias)),
|
|
184
|
+
{ resourceType: name }
|
|
185
|
+
);
|
|
186
|
+
JOIN_SOURCES.add(source);
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
query: source,
|
|
189
|
+
column(field) {
|
|
190
|
+
if (resource.schema[field]?.hidden === true) {
|
|
191
|
+
throw new TypeError(`${name}.joinSource() carries no hidden field, so it has no column for "${field}".`);
|
|
192
|
+
}
|
|
193
|
+
return column(field, alias);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The field names a call's `fields` names: a list of names, names separated by commas, or a sparse fieldset (each
|
|
201
|
+
* resource type's names, separated by commas).
|
|
202
|
+
* @param {unknown} fields
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
function fieldNames(fields) {
|
|
206
|
+
if (typeof fields === "string") {
|
|
207
|
+
return fields.split(",").map((field) => field.trim());
|
|
208
|
+
}
|
|
209
|
+
if (Array.isArray(fields)) {
|
|
210
|
+
return fields.filter((field) => typeof field === "string");
|
|
211
|
+
}
|
|
212
|
+
return isRecord(fields)
|
|
213
|
+
? Object.values(fields).flatMap((names) => (typeof names === "string" || Array.isArray(names) ? fieldNames(names) : []))
|
|
214
|
+
: [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether a function answered a Knex value (a raw expression, a column's builder, a joinSource()'s query builder)
|
|
219
|
+
* rather than rows: a value for the caller's own query, which it builds on and never awaits.
|
|
161
220
|
* @param {unknown} value
|
|
162
221
|
*/
|
|
163
222
|
function isKnexValue(value) {
|
|
@@ -165,6 +224,25 @@ function isKnexValue(value) {
|
|
|
165
224
|
return isRecord(value) && typeof value.toSQL === "function";
|
|
166
225
|
}
|
|
167
226
|
|
|
227
|
+
/**
|
|
228
|
+
* A Knex value another feature may build on, as it is: a raw expression or a column's reference, or a joinSource()'s
|
|
229
|
+
* builder. Any other query builder (a scopedQuery()'s, or one the owner built) can select every column of its table,
|
|
230
|
+
* the hidden ones too, so it is refused.
|
|
231
|
+
* @param {any} value A Knex value (see isKnexValue).
|
|
232
|
+
* @param {string} label
|
|
233
|
+
*/
|
|
234
|
+
function withoutFullBuilder(value, label) {
|
|
235
|
+
// readable-check: allow method-probe - data: a Knex query builder, unlike a raw expression, takes where()
|
|
236
|
+
if (typeof value.where === "function" && !JOIN_SOURCES.has(value)) {
|
|
237
|
+
throw new TypeError(
|
|
238
|
+
`${label} answers a query builder, which can select every column, the hidden fields too, which only the table's ` +
|
|
239
|
+
"owner, or a system context, reads: answer the table's joinSource({ alias }, options), which carries its visible " +
|
|
240
|
+
"columns only."
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
|
|
168
246
|
/** @param {unknown} value */
|
|
169
247
|
function isPlainRecord(value) {
|
|
170
248
|
if (!isRecord(value)) {
|
|
@@ -176,23 +254,27 @@ function isPlainRecord(value) {
|
|
|
176
254
|
|
|
177
255
|
/**
|
|
178
256
|
* The value without the hidden fields, wherever a plain record in it, or in its arrays and maps, carries one: a copy
|
|
179
|
-
* of what changed (frozen when the original was), the value itself when nothing did.
|
|
180
|
-
* instances are left as they are.
|
|
257
|
+
* of what changed (frozen when the original was), the value itself when nothing did. A query builder in it that is no
|
|
258
|
+
* joinSource()'s is refused (see withoutFullBuilder); raw expressions, dates and other instances are left as they are.
|
|
181
259
|
* @param {unknown} value
|
|
182
260
|
* @param {Set<string>} hidden
|
|
261
|
+
* @param {string} label
|
|
183
262
|
* @param {WeakMap<object, unknown>} [seen]
|
|
184
263
|
*/
|
|
185
|
-
function withoutHiddenValues(value, hidden, seen = new WeakMap()) {
|
|
264
|
+
function withoutHiddenValues(value, hidden, label, seen = new WeakMap()) {
|
|
186
265
|
if (!value || typeof value !== "object") {
|
|
187
266
|
return value;
|
|
188
267
|
}
|
|
268
|
+
if (isKnexValue(value)) {
|
|
269
|
+
return withoutFullBuilder(value, label);
|
|
270
|
+
}
|
|
189
271
|
if (seen.has(value)) {
|
|
190
272
|
return seen.get(value);
|
|
191
273
|
}
|
|
192
274
|
seen.set(value, value);
|
|
193
275
|
let changed = false;
|
|
194
276
|
const strip = (entry) => {
|
|
195
|
-
const next = withoutHiddenValues(entry, hidden, seen);
|
|
277
|
+
const next = withoutHiddenValues(entry, hidden, label, seen);
|
|
196
278
|
changed ||= next !== entry;
|
|
197
279
|
return next;
|
|
198
280
|
};
|
|
@@ -220,13 +302,15 @@ function withoutHiddenValues(value, hidden, seen = new WeakMap()) {
|
|
|
220
302
|
|
|
221
303
|
/**
|
|
222
304
|
* The repository an owner gives other features (a CRUD feature's `resourceApi.repository`): every function it holds,
|
|
223
|
-
* its sub-repositories' included, answers without the hidden fields of the tables the owner owns
|
|
224
|
-
* `fields`
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* it is,
|
|
305
|
+
* its sub-repositories' included, answers without the hidden fields of the tables the owner owns. A call whose
|
|
306
|
+
* `fields` (in any record it takes) names one of those hidden fields is refused before it runs, whatever the method,
|
|
307
|
+
* so a read the owner adds in decorateRepository that forwards the caller's `fields` fails loudly rather than answer
|
|
308
|
+
* the field as undefined; so is any scopedQuery(), and an answer holding a query builder other than a joinSource()'s
|
|
309
|
+
* (either can select every column, hidden ones too). A call in a system context or one it derived, in any record it
|
|
310
|
+
* takes, runs as it is. The owner's own code reads with the repository itself. withTransaction and lockOrCreate answer
|
|
311
|
+
* the caller's own work as it is, and a function answering a Knex value (raw(), a joinSource()'s builder) answers it
|
|
312
|
+
* as it is, unawaited. A repository whose tables have no hidden fields stays as it is. Hidden fields are told by
|
|
313
|
+
* name, so a field one owned table hides and another shows is refused here, when the feature's repository is built.
|
|
230
314
|
* @template {object} T
|
|
231
315
|
* @param {T} repository
|
|
232
316
|
* @param {{ name: string, schema: Record<string, any> } | ReadonlyArray<{ name: string, schema: Record<string, any> }>} resources
|
|
@@ -239,12 +323,24 @@ function withoutHiddenFields(repository, resources) {
|
|
|
239
323
|
if (!isRecord(repository) || hidden.size < 1) {
|
|
240
324
|
return repository;
|
|
241
325
|
}
|
|
326
|
+
for (const { name, schema } of owned) {
|
|
327
|
+
const shown = Object.keys(schema).find((field) => hidden.has(field) && schema[field]?.hidden !== true);
|
|
328
|
+
if (shown) {
|
|
329
|
+
const hiders = owned.filter((other) => other.schema[shown]?.hidden === true).map((other) => other.name);
|
|
330
|
+
throw new TypeError(
|
|
331
|
+
`${name}.${shown} is a visible field, but ${hiders.join(", ")}, which the same feature owns, hides a field of that ` +
|
|
332
|
+
`name; its repository strips hidden fields by name from every answer, so ${name}.${shown} would never reach ` +
|
|
333
|
+
"another feature: hide it in both tables, or rename one of them."
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
242
337
|
return guardedRepository(repository, owned[0], hidden, new WeakSet());
|
|
243
338
|
}
|
|
244
339
|
|
|
245
340
|
/**
|
|
246
341
|
* @param {object} repository
|
|
247
|
-
* @param {{ name: string, schema: Record<string, any> } | null} resource The resource
|
|
342
|
+
* @param {{ name: string, schema: Record<string, any> } | null} resource The resource it serves, which names its methods
|
|
343
|
+
* in errors and whose scopedQuery it refuses under that name too; none for a sub-repository.
|
|
248
344
|
* @param {Set<string>} hidden
|
|
249
345
|
* @param {WeakSet<object>} guarded
|
|
250
346
|
*/
|
|
@@ -272,21 +368,22 @@ function guardedRepository(repository, resource, hidden, guarded) {
|
|
|
272
368
|
*/
|
|
273
369
|
function guardedFunction(repository, key, call, resource, hidden) {
|
|
274
370
|
const label = `${resource?.name ?? "repository"}.${key}()`;
|
|
275
|
-
const fieldRead = resource && Object.hasOwn(FIELD_READS, key) ? FIELD_READS[key] : null;
|
|
276
371
|
const builder = HANDLE_SCOPED_QUERIES.has(call) || (resource !== null && key === "scopedQuery");
|
|
277
372
|
const work = HANDLE_WORK_RUNNERS.has(call) || WORK_RUNNER_NAMES.has(key);
|
|
278
373
|
return function callWithoutHiddenFields(...args) {
|
|
279
|
-
|
|
280
|
-
if (isJobContext(
|
|
374
|
+
// A system context, or one it derived, in whichever record the method takes its options.
|
|
375
|
+
if (args.some((arg) => isRecord(arg) && isJobContext(arg.context))) {
|
|
281
376
|
return call.apply(repository, args);
|
|
282
377
|
}
|
|
283
378
|
if (builder) {
|
|
284
379
|
return Promise.reject(new TypeError(
|
|
285
380
|
`${label} answers every column, its hidden fields too, which only the table's owner, or a system context, ` +
|
|
286
|
-
"reads:
|
|
381
|
+
"reads: read the rows through the repository's reads, or join its joinSource({ alias }, options), which " +
|
|
382
|
+
"carries the visible columns only."
|
|
287
383
|
));
|
|
288
384
|
}
|
|
289
|
-
const named =
|
|
385
|
+
const named = [...new Set(args.flatMap((arg) => (isRecord(arg) ? fieldNames(arg.fields) : [])))]
|
|
386
|
+
.filter((field) => hidden.has(field));
|
|
290
387
|
if (named.length > 0) {
|
|
291
388
|
return Promise.reject(new TypeError(
|
|
292
389
|
`${label} fields names the hidden field ${named.map((field) => `"${field}"`).join(", ")}, ` +
|
|
@@ -294,16 +391,20 @@ function guardedFunction(repository, key, call, resource, hidden) {
|
|
|
294
391
|
));
|
|
295
392
|
}
|
|
296
393
|
const answer = call.apply(repository, args);
|
|
297
|
-
// A runner's answer is the caller's own work
|
|
298
|
-
|
|
299
|
-
if (work || isKnexValue(answer)) {
|
|
394
|
+
// A runner's answer is the caller's own work: it passes as it is.
|
|
395
|
+
if (work) {
|
|
300
396
|
return answer;
|
|
301
397
|
}
|
|
398
|
+
// A Knex value (raw(), a joinSource()'s builder) carries no rows: it passes as it is, unawaited, so a builder
|
|
399
|
+
// never runs here.
|
|
400
|
+
if (isKnexValue(answer)) {
|
|
401
|
+
return withoutFullBuilder(answer, label);
|
|
402
|
+
}
|
|
302
403
|
// readable-check: allow method-probe - data: a repository's method may answer at once or with a promise
|
|
303
404
|
return typeof answer?.then === "function"
|
|
304
|
-
? answer.then((value) => withoutHiddenValues(value, hidden))
|
|
305
|
-
: withoutHiddenValues(answer, hidden);
|
|
405
|
+
? answer.then((value) => withoutHiddenValues(value, hidden, label))
|
|
406
|
+
: withoutHiddenValues(answer, hidden, label);
|
|
306
407
|
};
|
|
307
408
|
}
|
|
308
409
|
|
|
309
|
-
export { createHiddenFieldReads, markHandleFunctions, withoutHiddenFields };
|
|
410
|
+
export { createHiddenFieldReads, createJoinSource, markHandleFunctions, withoutHiddenFields };
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { missingOf, requireDefinedFilters } from "./handleGuards.js";
|
|
2
2
|
import { handleRecords, sparseFieldset, textList } from "./handleRecords.js";
|
|
3
3
|
|
|
4
|
+
// The row lock a locking read takes. "update" (the default) is FOR UPDATE. "noKeyUpdate" is PostgreSQL's FOR NO KEY
|
|
5
|
+
// UPDATE, which still keeps every other lock and write of the row out but lets other transactions insert rows that
|
|
6
|
+
// reference it (a foreign key's check takes FOR KEY SHARE); MySQL, MariaDB and SQLite have no weaker exclusive lock and
|
|
7
|
+
// lock as for "update".
|
|
8
|
+
const LOCK_MODES = Object.freeze(["update", "noKeyUpdate"]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* json-rest-api's lock mode for a locking read: none when the call names none, so json-rest-api takes its default.
|
|
12
|
+
* Another value is a TypeError naming the handle method, before anything runs.
|
|
13
|
+
* @param {string} name The resource's name.
|
|
14
|
+
* @param {string} caller The handle method, for the message.
|
|
15
|
+
* @param {unknown} lockMode
|
|
16
|
+
*/
|
|
17
|
+
function lockModeOf(name, caller, lockMode) {
|
|
18
|
+
if (lockMode === undefined) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
if (!LOCK_MODES.includes(/** @type {string} */ (lockMode))) {
|
|
22
|
+
throw new TypeError(`${name}.${caller}() lockMode is "update" (the default) or "noKeyUpdate". Received: ${JSON.stringify(lockMode)}`);
|
|
23
|
+
}
|
|
24
|
+
return { lockMode };
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
/**
|
|
5
28
|
* A handle's locking reads: lock, lockMany, lockWhere and claim. Each needs the
|
|
6
29
|
* caller's transaction (`options.trx`), locks only rows the caller may see, and
|
|
@@ -18,12 +41,15 @@ import { handleRecords, sparseFieldset, textList } from "./handleRecords.js";
|
|
|
18
41
|
* fields `options.fields` names, read after the lock.
|
|
19
42
|
*/
|
|
20
43
|
function createLockReads({ name, resource, jsonRest, readFields, contextOf, hiddenFields }) {
|
|
21
|
-
// A locking read (json-rest-api's lockByIds, lockWhere or claim) of the fields options.fields names, as records
|
|
22
|
-
|
|
44
|
+
// A locking read (json-rest-api's lockByIds, lockWhere or claim) of the fields options.fields names, as records, in the
|
|
45
|
+
// lock mode options.lockMode names. `caller` is the handle method, for the messages.
|
|
46
|
+
async function lockRead(caller, method, read, options) {
|
|
47
|
+
const mode = lockModeOf(name, caller, options.lockMode);
|
|
23
48
|
const hidden = hiddenFields.named(options.fields, "plain");
|
|
24
49
|
const fields = readFields({ fields: options.fields }, "view");
|
|
25
50
|
const { data } = await jsonRest()[method]({
|
|
26
51
|
...read,
|
|
52
|
+
...mode,
|
|
27
53
|
transaction: options.trx,
|
|
28
54
|
format: "plain",
|
|
29
55
|
...(fields ? { queryParams: { fields: sparseFieldset(resource, fields) } } : {})
|
|
@@ -35,25 +61,31 @@ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hidd
|
|
|
35
61
|
* Locks the visible rows among the ids, in id order, and answers them; hidden or missing ids throw, or are
|
|
36
62
|
* left out with `missing: "skip"`. MySQL locks every listed id, a hidden one too: take ids from scoped reads.
|
|
37
63
|
* `fields` names the fields the lock reads, so a belongs-to field left out costs no check that its related
|
|
38
|
-
* record is visible; without it every field is read.
|
|
64
|
+
* record is visible; without it every field is read. `lockMode: "noKeyUpdate"` takes PostgreSQL's FOR NO KEY
|
|
65
|
+
* UPDATE, so other transactions may still insert rows that reference the locked ones (see LOCK_MODES).
|
|
39
66
|
* @param {unknown[]} ids
|
|
40
|
-
* @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[] }} options
|
|
67
|
+
* @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
|
|
41
68
|
*/
|
|
42
69
|
async function lockMany(ids, options) {
|
|
43
|
-
return lockRead("lockByIds", { ids, ...(options.missing ? { missing: options.missing } : {}) }, options);
|
|
70
|
+
return lockRead("lockMany", "lockByIds", { ids, ...(options.missing ? { missing: options.missing } : {}) }, options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// lockMany for lock(): its messages name lock().
|
|
74
|
+
function lockOne(id, options) {
|
|
75
|
+
return lockRead("lock", "lockByIds", { ids: [id], ...(options.missing ? { missing: options.missing } : {}) }, options);
|
|
44
76
|
}
|
|
45
77
|
|
|
46
78
|
/**
|
|
47
79
|
* Locks the record's row until the transaction ends and answers it as the
|
|
48
80
|
* lock saw it. Needs options.trx. A missing or hidden record throws
|
|
49
81
|
* json-rest-api's not-found error, or answers null with `missing: "skip"`.
|
|
50
|
-
* `fields` reads only the fields the work needs (see lockMany).
|
|
82
|
+
* `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
|
|
51
83
|
* @param {unknown} id
|
|
52
|
-
* @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[] }} options
|
|
84
|
+
* @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
|
|
53
85
|
*/
|
|
54
86
|
async function lock(id, options) {
|
|
55
87
|
missingOf(name, "lock", options.missing);
|
|
56
|
-
const [record] = await
|
|
88
|
+
const [record] = await lockOne(id, options);
|
|
57
89
|
return record ?? null;
|
|
58
90
|
}
|
|
59
91
|
|
|
@@ -64,28 +96,28 @@ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hidd
|
|
|
64
96
|
* past that). It does not lock rows inserted later: unique numbers take a unique
|
|
65
97
|
* index, or a counter row taken with lock(). On MySQL it needs an index starting
|
|
66
98
|
* with the ownership columns (no owners: with a filtered column), or it refuses.
|
|
67
|
-
* `fields` reads only the fields the work needs (see lockMany).
|
|
99
|
+
* `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
|
|
68
100
|
* @param {object} filters
|
|
69
|
-
* @param {{ context?: object | null, trx: object, fields?: string[] }} options
|
|
101
|
+
* @param {{ context?: object | null, trx: object, fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
|
|
70
102
|
*/
|
|
71
103
|
async function lockWhere(filters, options) {
|
|
72
104
|
requireDefinedFilters(name, "lockWhere", filters);
|
|
73
|
-
return lockRead("lockWhere", { filters }, options);
|
|
105
|
+
return lockRead("lockWhere", "lockWhere", { filters }, options);
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
/**
|
|
77
109
|
* Takes up to `take` (default 1) of the rows the filters select that no
|
|
78
110
|
* other transaction holds, in `sort` order, locked until the transaction ends.
|
|
79
|
-
* `fields` reads only the fields the work needs (see lockMany).
|
|
111
|
+
* `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
|
|
80
112
|
* @param {{ filters: object, sort?: string | string[], take?: number }} params
|
|
81
|
-
* @param {{ context?: object | null, trx: object, fields?: string[] }} options
|
|
113
|
+
* @param {{ context?: object | null, trx: object, fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
|
|
82
114
|
*/
|
|
83
115
|
async function claim({ filters, sort, take } = {}, options, ...rest) {
|
|
84
116
|
if (rest.length > 0) {
|
|
85
117
|
throw new TypeError(`${name}.claim() takes ({ filters, sort, take }, options).`);
|
|
86
118
|
}
|
|
87
119
|
requireDefinedFilters(name, "claim", filters);
|
|
88
|
-
return lockRead("claim", {
|
|
120
|
+
return lockRead("claim", "claim", {
|
|
89
121
|
filters,
|
|
90
122
|
...(take === undefined ? {} : { limit: take }),
|
|
91
123
|
...(sort === undefined ? {} : { sort: textList(sort) })
|
|
@@ -100,4 +132,4 @@ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hidd
|
|
|
100
132
|
});
|
|
101
133
|
}
|
|
102
134
|
|
|
103
|
-
export { createLockReads };
|
|
135
|
+
export { createLockReads, lockModeOf };
|