@jskit-ai/resource-runtime 0.2.12 → 0.2.14

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,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A handle's `joinSource()` carries the table's ownership fields (`workspaceId`, `userId`) as join keys, hidden or not, and `column()` names them: a report joining another owner's scoped rows on the workspace, such as a system job reading each workspace's settings beside its bookings, was refused (`contactRoles.joinSource() carries no hidden field, so it has no column for "workspaceId"`). They name the caller's own scope, so nothing secret leaves the owner; every other hidden column stays out, and `column()` still refuses it.
6
+ - 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.
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - **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.
11
+ - `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"`).
12
+ - `./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.
13
+ - 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.
14
+ - `./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.
15
+ - **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)`.
16
+ - **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.
17
+ - `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
18
  - 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
19
  - `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
20
  - `@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.14",
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 and ownership fields only: the
138
+ // one query builder 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,78 @@ 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 and the ownership fields, hidden or not,
155
+ * since a report joins on them and they name the caller's own scope; never another hidden column. `column(field)`
156
+ * names each under that alias. A CRUD feature's repository answers it to any feature, where it
157
+ * refuses scopedQuery(). The builder comes wrapped, since a builder is thenable.
158
+ * @param {object} dependencies
159
+ * @param {{ knex: { instance: any } }} dependencies.api
160
+ * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
161
+ * @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any>, ownershipFields?: string[] } } }} dependencies.jsonRest
162
+ * @param {(params: { alias: string, filters?: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
163
+ * @param {(field: string, alias?: string) => string} dependencies.column
164
+ */
165
+ function createJoinSource({ api, resource, jsonRest, scopedQuery, column }) {
166
+ const { name } = resource;
167
+ /**
168
+ * @param {{ alias: string, filters?: object }} params
169
+ * @param {{ context?: object | null, trx?: object | null }} [options]
170
+ * @returns {Promise<Readonly<{ query: any, column: (field: string) => string }>>}
171
+ */
172
+ return async function joinSource({ alias, filters } = {}, options = {}) {
173
+ if (typeof alias !== "string" || !alias) {
174
+ throw new TypeError(`${name}.joinSource() needs the alias the report joins the ${name} under.`);
175
+ }
176
+ const { query } = await scopedQuery({ alias, ...(filters === undefined ? {} : { filters }) }, options);
177
+ const { schemaStructure, ownershipFields = [] } = jsonRest().vars.schemaInfo;
178
+ const joinKeys = new Set(ownershipFields);
179
+ const carried = Object.keys(schemaStructure).filter((field) => {
180
+ const definition = schemaStructure[field];
181
+ return field !== "id" && (definition.hidden !== true || joinKeys.has(field)) &&
182
+ definition.virtual !== true && !definition.belongsToPolymorphic;
183
+ });
184
+ // The scoped rows' visible columns and ownership fields, as a derived table under the alias: nothing a caller adds
185
+ // reaches the others.
186
+ const source = readOnlyQueryBuilder(
187
+ (options.trx || api.knex.instance).from(query.select(["id", ...carried].map((field) => column(field, alias))).as(alias)),
188
+ { resourceType: name }
189
+ );
190
+ JOIN_SOURCES.add(source);
191
+ return Object.freeze({
192
+ query: source,
193
+ column(field) {
194
+ if (resource.schema[field]?.hidden === true && !joinKeys.has(field)) {
195
+ throw new TypeError(`${name}.joinSource() carries no hidden field but its ownership fields, so it has no column for "${field}".`);
196
+ }
197
+ return column(field, alias);
198
+ }
199
+ });
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The field names a call's `fields` names: a list of names, names separated by commas, or a sparse fieldset (each
205
+ * resource type's names, separated by commas).
206
+ * @param {unknown} fields
207
+ * @returns {string[]}
208
+ */
209
+ function fieldNames(fields) {
210
+ if (typeof fields === "string") {
211
+ return fields.split(",").map((field) => field.trim());
212
+ }
213
+ if (Array.isArray(fields)) {
214
+ return fields.filter((field) => typeof field === "string");
215
+ }
216
+ return isRecord(fields)
217
+ ? Object.values(fields).flatMap((names) => (typeof names === "string" || Array.isArray(names) ? fieldNames(names) : []))
218
+ : [];
219
+ }
220
+
221
+ /**
222
+ * Whether a function answered a Knex value (a raw expression, a column's builder, a joinSource()'s query builder)
223
+ * rather than rows: a value for the caller's own query, which it builds on and never awaits.
161
224
  * @param {unknown} value
162
225
  */
163
226
  function isKnexValue(value) {
@@ -165,6 +228,25 @@ function isKnexValue(value) {
165
228
  return isRecord(value) && typeof value.toSQL === "function";
166
229
  }
167
230
 
231
+ /**
232
+ * A Knex value another feature may build on, as it is: a raw expression or a column's reference, or a joinSource()'s
233
+ * builder. Any other query builder (a scopedQuery()'s, or one the owner built) can select every column of its table,
234
+ * the hidden ones too, so it is refused.
235
+ * @param {any} value A Knex value (see isKnexValue).
236
+ * @param {string} label
237
+ */
238
+ function withoutFullBuilder(value, label) {
239
+ // readable-check: allow method-probe - data: a Knex query builder, unlike a raw expression, takes where()
240
+ if (typeof value.where === "function" && !JOIN_SOURCES.has(value)) {
241
+ throw new TypeError(
242
+ `${label} answers a query builder, which can select every column, the hidden fields too, which only the table's ` +
243
+ "owner, or a system context, reads: answer the table's joinSource({ alias }, options), which carries its visible " +
244
+ "columns only."
245
+ );
246
+ }
247
+ return value;
248
+ }
249
+
168
250
  /** @param {unknown} value */
169
251
  function isPlainRecord(value) {
170
252
  if (!isRecord(value)) {
@@ -176,23 +258,27 @@ function isPlainRecord(value) {
176
258
 
177
259
  /**
178
260
  * 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.
261
+ * of what changed (frozen when the original was), the value itself when nothing did. A query builder in it that is no
262
+ * joinSource()'s is refused (see withoutFullBuilder); raw expressions, dates and other instances are left as they are.
181
263
  * @param {unknown} value
182
264
  * @param {Set<string>} hidden
265
+ * @param {string} label
183
266
  * @param {WeakMap<object, unknown>} [seen]
184
267
  */
185
- function withoutHiddenValues(value, hidden, seen = new WeakMap()) {
268
+ function withoutHiddenValues(value, hidden, label, seen = new WeakMap()) {
186
269
  if (!value || typeof value !== "object") {
187
270
  return value;
188
271
  }
272
+ if (isKnexValue(value)) {
273
+ return withoutFullBuilder(value, label);
274
+ }
189
275
  if (seen.has(value)) {
190
276
  return seen.get(value);
191
277
  }
192
278
  seen.set(value, value);
193
279
  let changed = false;
194
280
  const strip = (entry) => {
195
- const next = withoutHiddenValues(entry, hidden, seen);
281
+ const next = withoutHiddenValues(entry, hidden, label, seen);
196
282
  changed ||= next !== entry;
197
283
  return next;
198
284
  };
@@ -220,13 +306,15 @@ function withoutHiddenValues(value, hidden, seen = new WeakMap()) {
220
306
 
221
307
  /**
222
308
  * 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.
309
+ * its sub-repositories' included, answers without the hidden fields of the tables the owner owns. A call whose
310
+ * `fields` (in any record it takes) names one of those hidden fields is refused before it runs, whatever the method,
311
+ * so a read the owner adds in decorateRepository that forwards the caller's `fields` fails loudly rather than answer
312
+ * the field as undefined; so is any scopedQuery(), and an answer holding a query builder other than a joinSource()'s
313
+ * (either can select every column, hidden ones too). A call in a system context or one it derived, in any record it
314
+ * takes, runs as it is. The owner's own code reads with the repository itself. withTransaction and lockOrCreate answer
315
+ * the caller's own work as it is, and a function answering a Knex value (raw(), a joinSource()'s builder) answers it
316
+ * as it is, unawaited. A repository whose tables have no hidden fields stays as it is. Hidden fields are told by
317
+ * name, so a field one owned table hides and another shows is refused here, when the feature's repository is built.
230
318
  * @template {object} T
231
319
  * @param {T} repository
232
320
  * @param {{ name: string, schema: Record<string, any> } | ReadonlyArray<{ name: string, schema: Record<string, any> }>} resources
@@ -239,12 +327,24 @@ function withoutHiddenFields(repository, resources) {
239
327
  if (!isRecord(repository) || hidden.size < 1) {
240
328
  return repository;
241
329
  }
330
+ for (const { name, schema } of owned) {
331
+ const shown = Object.keys(schema).find((field) => hidden.has(field) && schema[field]?.hidden !== true);
332
+ if (shown) {
333
+ const hiders = owned.filter((other) => other.schema[shown]?.hidden === true).map((other) => other.name);
334
+ throw new TypeError(
335
+ `${name}.${shown} is a visible field, but ${hiders.join(", ")}, which the same feature owns, hides a field of that ` +
336
+ `name; its repository strips hidden fields by name from every answer, so ${name}.${shown} would never reach ` +
337
+ "another feature: hide it in both tables, or rename one of them."
338
+ );
339
+ }
340
+ }
242
341
  return guardedRepository(repository, owned[0], hidden, new WeakSet());
243
342
  }
244
343
 
245
344
  /**
246
345
  * @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.
346
+ * @param {{ name: string, schema: Record<string, any> } | null} resource The resource it serves, which names its methods
347
+ * in errors and whose scopedQuery it refuses under that name too; none for a sub-repository.
248
348
  * @param {Set<string>} hidden
249
349
  * @param {WeakSet<object>} guarded
250
350
  */
@@ -272,21 +372,22 @@ function guardedRepository(repository, resource, hidden, guarded) {
272
372
  */
273
373
  function guardedFunction(repository, key, call, resource, hidden) {
274
374
  const label = `${resource?.name ?? "repository"}.${key}()`;
275
- const fieldRead = resource && Object.hasOwn(FIELD_READS, key) ? FIELD_READS[key] : null;
276
375
  const builder = HANDLE_SCOPED_QUERIES.has(call) || (resource !== null && key === "scopedQuery");
277
376
  const work = HANDLE_WORK_RUNNERS.has(call) || WORK_RUNNER_NAMES.has(key);
278
377
  return function callWithoutHiddenFields(...args) {
279
- const options = fieldRead ? args[fieldRead.optionsAt] : args.findLast(isRecord);
280
- if (isJobContext(options?.context)) {
378
+ // A system context, or one it derived, in whichever record the method takes its options.
379
+ if (args.some((arg) => isRecord(arg) && isJobContext(arg.context))) {
281
380
  return call.apply(repository, args);
282
381
  }
283
382
  if (builder) {
284
383
  return Promise.reject(new TypeError(
285
384
  `${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."
385
+ "reads: read the rows through the repository's reads, or join its joinSource({ alias }, options), which " +
386
+ "carries the visible columns only."
287
387
  ));
288
388
  }
289
- const named = fieldRead ? namedHiddenFields(resource, args[fieldRead.fieldsAt]?.fields) : [];
389
+ const named = [...new Set(args.flatMap((arg) => (isRecord(arg) ? fieldNames(arg.fields) : [])))]
390
+ .filter((field) => hidden.has(field));
290
391
  if (named.length > 0) {
291
392
  return Promise.reject(new TypeError(
292
393
  `${label} fields names the hidden field ${named.map((field) => `"${field}"`).join(", ")}, ` +
@@ -294,16 +395,20 @@ function guardedFunction(repository, key, call, resource, hidden) {
294
395
  ));
295
396
  }
296
397
  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)) {
398
+ // A runner's answer is the caller's own work: it passes as it is.
399
+ if (work) {
300
400
  return answer;
301
401
  }
402
+ // A Knex value (raw(), a joinSource()'s builder) carries no rows: it passes as it is, unawaited, so a builder
403
+ // never runs here.
404
+ if (isKnexValue(answer)) {
405
+ return withoutFullBuilder(answer, label);
406
+ }
302
407
  // readable-check: allow method-probe - data: a repository's method may answer at once or with a promise
303
408
  return typeof answer?.then === "function"
304
- ? answer.then((value) => withoutHiddenValues(value, hidden))
305
- : withoutHiddenValues(answer, hidden);
409
+ ? answer.then((value) => withoutHiddenValues(value, hidden, label))
410
+ : withoutHiddenValues(answer, hidden, label);
306
411
  };
307
412
  }
308
413
 
309
- export { createHiddenFieldReads, markHandleFunctions, withoutHiddenFields };
414
+ export { createHiddenFieldReads, createJoinSource, markHandleFunctions, withoutHiddenFields };