@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 +13 -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 +141 -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 +78 -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,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.
|
|
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.
|
|
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 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
|
-
*
|
|
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 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.
|
|
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
|
|
224
|
-
* `fields`
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* 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
|
|
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
|
-
|
|
280
|
-
if (isJobContext(
|
|
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:
|
|
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 =
|
|
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
|
|
298
|
-
|
|
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 };
|