@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 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.12",
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.205",
19
- "@jskit-ai/resource-crud-core": "0.1.205",
20
- "json-rest-api": "3.0.1"
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.263",
68
- "@jskit-ai/kernel": "0.2.14"
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 };
@@ -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 { DuplicateOwnerError, MissingScopeError, PageSizeError, RowLimitError, StaleRecordError, UnpagedQueryError };
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
- * Whether a function answered a Knex value (a raw expression, a column's builder, a query builder an owner's join
160
- * source answers) rather than rows: a value for the caller's own query, which it builds on and never awaits.
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. Builders, dates and other
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, and a read whose
224
- * `fields` names a hidden field of the resource, or any scopedQuery() (its builder carries every column, hidden ones
225
- * too), is refused, unless the call runs in a system context or one it derived (its options, the last record it
226
- * takes). So a read the owner adds in decorateRepository cannot hand a hidden field out either; the owner's own
227
- * code, its join sources included, reads with the repository itself. withTransaction and lockOrCreate answer the
228
- * caller's own work as it is, and a function answering a Knex value (raw(), a join source's builder) answers it as
229
- * it is, unawaited. A repository whose tables have no hidden fields stays as 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 whose reads it refuses by name; none for a sub-repository.
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
- const options = fieldRead ? args[fieldRead.optionsAt] : args.findLast(isRecord);
280
- if (isJobContext(options?.context)) {
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: ask the owner for a method answering the rows or a join source."
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 = fieldRead ? namedHiddenFields(resource, args[fieldRead.fieldsAt]?.fields) : [];
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, and a Knex value (raw(), or an owner's join source answering a
298
- // builder) carries no rows: both pass as they are, unawaited, so a builder never runs here.
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
- async function lockRead(method, read, options) {
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 lockMany([id], options);
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 };