@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.
@@ -1,6 +1,7 @@
1
1
  export { ResourceRuntimeProvider } from "./ResourceRuntimeProvider.js";
2
2
  export { DEFAULT_REQUEST_ROW_LIMIT, DEFAULT_SERVER_PAGE_SIZE } from "./resourceRuntime.js";
3
3
  export { buildJsonRestQueryParams, resolveUserScopeValue, resolveWorkspaceScopeValue } from "./resourceHost.js";
4
+ export { checkForeignKeys } from "./foreignKeyCheck.js";
4
5
  export { createStandaloneResources } from "./standaloneResources.js";
5
6
  export { isSystemContext } from "./contexts.js";
6
7
  export { withoutHiddenFields } from "./handleHidden.js";
@@ -11,5 +12,7 @@ export {
11
12
  PageSizeError,
12
13
  RowLimitError,
13
14
  StaleRecordError,
14
- UnpagedQueryError
15
+ SystemContextRequiredError,
16
+ UnpagedQueryError,
17
+ UnsupportedDatabaseError
15
18
  } from "./errors.js";
@@ -1,6 +1,7 @@
1
1
  import { isDeadlockError, isDuplicateEntryError, isLostDuplicateInsertRace } from "@jskit-ai/database-runtime/shared";
2
2
  import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { passedOn } from "./handleGuards.js";
4
+ import { lockModeOf } from "./handleLocks.js";
4
5
 
5
6
  // First uses of one id at once can collide on its insert before any work runs (on MySQL and MariaDB, first uses of
6
7
  // neighbouring ids too, through their gap locks): each collision runs the loser again after a short random wait.
@@ -19,7 +20,8 @@ const CREATE_IF_MISSING = Object.freeze({ keys: ["id"], action: "ignore" });
19
20
  * @template T
20
21
  * @param {Pick<import("./types.js").ResourceHandle, "name" | "withTransaction" | "lock" | "createMany">} handle
21
22
  * @param {{ id: unknown } & Record<string, unknown>} row
22
- * @param {{ context?: object | null, trx?: object | null }} options
23
+ * @param {{ context?: object | null, trx?: object | null, lockMode?: "update" | "noKeyUpdate" }} options `lockMode` is the
24
+ * record's lock, as lock() takes it.
23
25
  * @param {(record: Record<string, any>, txOptions: Readonly<Record<string, any>>) => Promise<T> | T} work
24
26
  * @returns {Promise<T>}
25
27
  */
@@ -27,13 +29,15 @@ async function lockOrCreate(handle, row, options, work) {
27
29
  if (!isRecord(row) || row.id == null) {
28
30
  throw new TypeError(`${handle.name}.lockOrCreate() takes (row, options, work), the row to create holding its id.`);
29
31
  }
32
+ // The lock mode the caller names, for both locks of the record, refused before any transaction opens.
33
+ const mode = lockModeOf(handle.name, "lockOrCreate", options.lockMode);
30
34
  const locked = async (txOptions) => {
31
- const existing = await handle.lock(row.id, { ...txOptions, missing: "skip" });
35
+ const existing = await handle.lock(row.id, { ...txOptions, ...mode, missing: "skip" });
32
36
  if (existing) {
33
37
  return existing;
34
38
  }
35
39
  await handle.createMany([row], { ...txOptions, onConflict: CREATE_IF_MISSING, returning: "none" });
36
- return handle.lock(row.id, txOptions);
40
+ return handle.lock(row.id, { ...txOptions, ...mode });
37
41
  };
38
42
  if (options.trx) {
39
43
  const txOptions = passedOn(options);
@@ -2,12 +2,13 @@ import diagnosticsChannel from "node:diagnostics_channel";
2
2
  import { RestApiRowLimitError } from "json-rest-api";
3
3
  import { retryDeadlocks } from "@jskit-ai/database-runtime/shared";
4
4
  import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { readChangeHorizon } from "./changeHorizon.js";
5
6
  import { createJsonRestContext, isJobContext } from "./contexts.js";
6
- import { createHiddenFieldReads, markHandleFunctions } from "./handleHidden.js";
7
- import { createLockReads } from "./handleLocks.js";
7
+ import { createHiddenFieldReads, createJoinSource, markHandleFunctions } from "./handleHidden.js";
8
+ import { createLockReads, lockModeOf } from "./handleLocks.js";
8
9
  import { findOrCreateRecord, lockOrCreate } from "./lockOrCreate.js";
9
10
  import { valueOnlyRaw } from "./rawExpression.js";
10
- import { MissingScopeError, PageSizeError, RowLimitError, StaleRecordError, UnpagedQueryError } from "./errors.js";
11
+ import { PageSizeError, RowLimitError, StaleRecordError, UnpagedQueryError } from "./errors.js";
11
12
  import {
12
13
  formatOf, matchesNoRow, missingOf, passedOn, refuseConditions, requireConditionValues, requireDefinedFilters, requireOwners, requirePatchConditions,
13
14
  requireReadParams, requireUpsertRows, whereSelection
@@ -154,9 +155,6 @@ function createResourceHandle({ api, resource, hooks, settings, resourceNamed })
154
155
  /** @param {{ context?: object | null }} options */
155
156
  function contextOf(options) {
156
157
  requireHandleTransaction(name, options);
157
- if (!options.context && resource.ownership !== "public") {
158
- throw new MissingScopeError(name);
159
- }
160
158
  requireOwners(name, resource.ownership, options.context);
161
159
  return createJsonRestContext(options.context);
162
160
  }
@@ -165,6 +163,8 @@ function createResourceHandle({ api, resource, hooks, settings, resourceNamed })
165
163
  const readFields = (params, kind) => requestedFields(params, quiet[kind], () => jsonRest().vars.schemaInfo);
166
164
  // The hidden fields a plain read names, which json-rest-api leaves out (see handleHidden.js).
167
165
  const hiddenFields = createHiddenFieldReads({ resource, scopedQuery, column });
166
+ // The table as a join source, with its visible columns only (see handleHidden.js).
167
+ const joinSource = createJoinSource({ api, resource, jsonRest, scopedQuery, column });
168
168
  // lock, lockMany, lockWhere and claim (see handleLocks.js).
169
169
  const { lock, lockMany, lockWhere, claim } = createLockReads({ name, resource, jsonRest, readFields, contextOf, hiddenFields });
170
170
 
@@ -590,17 +590,19 @@ function createResourceHandle({ api, resource, hooks, settings, resourceNamed })
590
590
  * The same changes to every record the filters select (the caller's own rows only), in one statement
591
591
  * that carries the scope and filters, so a row gone meanwhile is skipped. Empty filters change every
592
592
  * row the caller owns, so they need `all: true`. `returning: "count"` answers how many changed. lockVersion is
593
- * refused (see ONE_VERSION).
593
+ * refused (see ONE_VERSION). `lockMode` is the row lock its selection takes before the write, as the locks take it
594
+ * (see handleLocks.js).
594
595
  * @param {object} filters
595
596
  * @param {Record<string, unknown>} attributes
596
- * @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "records" | "count", onlyIf?: object, all?: boolean, chunkSize?: number }} [options]
597
+ * @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "records" | "count", onlyIf?: object, all?: boolean, chunkSize?: number, lockMode?: "update" | "noKeyUpdate" }} [options]
597
598
  */
598
599
  async function patchWhere(filters, attributes, options = {}) {
599
600
  refuseConditions(name, "patchWhere", options, ["lockVersion"], ONE_VERSION);
600
601
  requireConditionValues(name, "patchWhere", options, ["onlyIf"]);
601
602
  const selection = whereSelection(name, "patchWhere", filters, options);
603
+ const mode = lockModeOf(name, "patchWhere", options.lockMode);
602
604
  return manyRowWrite("patchWhere", 0, options, (params, context) => jsonRest().patchWhere(
603
- { ...params, ...selection, data: inputData(resource, attributes, "update", options.context),
605
+ { ...params, ...selection, ...mode, data: inputData(resource, attributes, "update", options.context),
604
606
  ...(options.onlyIf ? { onlyIf: options.onlyIf } : {}) },
605
607
  context
606
608
  ));
@@ -693,6 +695,15 @@ function createResourceHandle({ api, resource, hooks, settings, resourceNamed })
693
695
  return valueOnlyRaw(`${name}.raw()`, api.knex.instance, [sql, bindings, ...rest]);
694
696
  }
695
697
 
698
+ /**
699
+ * PostgreSQL's snapshot horizon, as decimal text, in the caller's transaction when there is one (see readChangeHorizon).
700
+ * @param {{ context?: object | null, trx?: object | null }} [options]
701
+ */
702
+ async function changeHorizon(options = {}) {
703
+ contextOf(options);
704
+ return readChangeHorizon(name, options.trx || api.knex.instance);
705
+ }
706
+
696
707
  markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate: lockOrCreateRecord });
697
708
  return Object.freeze({
698
709
  name,
@@ -715,7 +726,9 @@ function createResourceHandle({ api, resource, hooks, settings, resourceNamed })
715
726
  lockWhere,
716
727
  lockOrCreate: lockOrCreateRecord,
717
728
  claim,
729
+ changeHorizon,
718
730
  scopedQuery,
731
+ joinSource,
719
732
  column,
720
733
  raw
721
734
  });
@@ -432,7 +432,8 @@ function primaryField(scopeOptions) {
432
432
 
433
433
  /**
434
434
  * json-rest-api's options for a table resource: its own keys, with `ownership`
435
- * as json-rest-api's autofilter preset (none for a public table),
435
+ * as json-rest-api's autofilter preset (none for a public or a system table,
436
+ * which have no owner columns),
436
437
  * `defaultExclude` as normally hidden fields, symbolic write serializers
437
438
  * resolved and collection relations derived. The owner's server-only extras
438
439
  * (row policy, id normalizer, query fields) are added here, never in the
@@ -463,7 +464,7 @@ function resourceScopeOptions(
463
464
  const scopeOptions = cloneJsonRestResourceValue(tableOptions, {
464
465
  writeSerializers: normalizeObject(writeSerializers)
465
466
  });
466
- if (ownership !== "public") {
467
+ if (ownership !== "public" && ownership !== "system") {
467
468
  scopeOptions.autofilter = ownership;
468
469
  }
469
470
  if (isPlainJsonRestObject(searchSchema)) {
package/src/test/index.js CHANGED
@@ -3,7 +3,7 @@ import { ActionProvider } from "@jskit-ai/kernel/server/actions";
3
3
  import { EventProvider } from "@jskit-ai/kernel/server/runtime";
4
4
  import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
5
5
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
6
- import { requireTestDatabase, tableNames, TestDatabaseMissingError } from "@jskit-ai/database-runtime/server/testDatabases";
6
+ import { createPackageTestKnex, requireTestDatabase, tableNames, TestDatabaseMissingError } from "@jskit-ai/database-runtime/server/testDatabases";
7
7
  import { createResourceHost } from "../server/resourceHost.js";
8
8
  import { createResourceRuntime } from "../server/resourceRuntime.js";
9
9
  import { expectFlatQueries } from "./expectFlatQueries.js";
@@ -113,6 +113,7 @@ async function expectEveryTableOwned({ knex, runtime, infrastructureTables = [],
113
113
 
114
114
  export {
115
115
  TestDatabaseMissingError,
116
+ createPackageTestKnex,
116
117
  createTestResourceRuntime,
117
118
  expectEveryTableOwned,
118
119
  expectFlatQueries,
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import knexLib from "knex";
4
3
  import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
4
  import { passedOn } from "../src/server/index.js";
6
- import { createTestResourceRuntime } from "../src/test/index.js";
5
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
6
+
7
+ // SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
8
+ const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
7
9
 
8
10
  // A call's settings (returning, missing, format, lockVersion...) are for that one call. What it hands on, to the work
9
11
  // of a transaction, to an afterWrite hook or to the handle's own nested calls, is its context and transaction only.
@@ -14,8 +16,7 @@ const SETTINGS = Object.freeze({ returning: "none", missing: "skip", format: "js
14
16
  const notFound = (error) => error.code === "REST_API_RESOURCE" && error.subtype === "not_found";
15
17
 
16
18
  async function createNotes(t, hooks) {
17
- const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
18
- t.after(() => knex.destroy());
19
+ const knex = await testKnex(t);
19
20
  await knex.schema.createTable("notes", (table) => {
20
21
  table.increments("id");
21
22
  table.integer("workspace_id").nullable();
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
4
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
5
+
6
+ // What changed since a horizon, on PostgreSQL, with real concurrent transactions. Each row carries the transaction
7
+ // that last wrote it, an xid8 change stamp that a trigger sets on every insert and update, whatever the write sent, read
8
+ // through an xid8 field with changedSince (>=) and changedBefore (<) filters. A reader takes
9
+ // changeHorizon() before it reads; every transaction below the horizon had finished, so a later read of the rows
10
+ // stamped at or above it finds everything the first read could not see. Each test works in a schema of its own in
11
+ // TEST_DB_NAME (createPackageTestKnex); other sessions on the server may hold the horizon lower, never higher, so the
12
+ // tests check what a read must include and what it must leave out.
13
+
14
+ const skip = process.env.DB_CLIENT === "pg" ? false : "a PostgreSQL test: it runs on a DB_CLIENT=pg run, with TEST_DB_NAME";
15
+ const WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "1" }) }) });
16
+
17
+ const messageResource = defineTableResource({
18
+ name: "messages",
19
+ tableName: "messages",
20
+ ownership: "workspace",
21
+ defaultSort: ["id"],
22
+ schema: {
23
+ id: { type: "id", primary: true },
24
+ workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
25
+ body: { type: "string", required: true, search: true },
26
+ // The database stamps it; nothing writes it.
27
+ changed: { type: "xid8", nullable: true }
28
+ },
29
+ searchSchema: {
30
+ changedSince: { type: "xid8", actualField: "changed", filterOperator: ">=" },
31
+ changedBefore: { type: "xid8", actualField: "changed", filterOperator: "<" }
32
+ }
33
+ });
34
+
35
+ async function database(t) {
36
+ const knex = await createPackageTestKnex(t, { environment: process.env });
37
+ await knex.schema.createTable("messages", (table) => {
38
+ table.increments("id");
39
+ table.integer("workspace_id").nullable();
40
+ table.string("body").notNullable();
41
+ table.specificType("changed", "xid8").notNullable().defaultTo(knex.raw("pg_current_xact_id()"));
42
+ });
43
+ await knex.raw(
44
+ "create function messages_changed() returns trigger language plpgsql as $$ " +
45
+ "begin new.changed := pg_current_xact_id(); return new; end $$"
46
+ );
47
+ await knex.raw("create trigger messages_changed before insert or update on messages for each row execute function messages_changed()");
48
+ const runtime = await createTestResourceRuntime({ knex });
49
+ const { messages } = await runtime.own("messages.feature", [messageResource]);
50
+ return { knex, messages };
51
+ }
52
+
53
+ const bodies = (records) => new Map(records.map(({ id, body }) => [String(id), body]));
54
+
55
+ test("a read from the change horizon finds what a transaction still open at the horizon wrote, which the highest id seen misses", { skip }, async (t) => {
56
+ const { knex, messages } = await database(t);
57
+ const [first] = (await messages.createMany([{ body: "first" }], { ...WORKSPACE, returning: "ids" })).map(String);
58
+
59
+ // A writer still open when the reader reads: it changes the first message and adds one, taking its id now.
60
+ const writer = await knex.transaction();
61
+ t.after(() => (writer.isCompleted() ? undefined : writer.rollback()));
62
+ await writer("messages").where({ id: first }).update({ body: "first, edited" });
63
+ const [{ id: added }] = await writer("messages").insert({ workspace_id: 1, body: "added by the open writer" }, ["id"]);
64
+ const [{ id: theirs }] = await writer("messages").insert({ workspace_id: 2, body: "another workspace's" }, ["id"]);
65
+ const { rows: [{ xid: writerXid }] } = await writer.raw("select pg_current_xact_id()::text as xid");
66
+ // A later writer adds one and commits at once, so its id is above the open writer's.
67
+ const [later] = (await messages.createMany([{ body: "added later" }], { ...WORKSPACE, returning: "ids" })).map(String);
68
+
69
+ const horizon = await messages.changeHorizon(WORKSPACE);
70
+ const page = await messages.queryAll({}, WORKSPACE);
71
+ assert.deepEqual([...bodies(page)], [[first, "first"], [later, "added later"]]);
72
+ assert.match(horizon, /^\d{1,20}$/u);
73
+ assert.ok(BigInt(horizon) <= BigInt(writerXid), `the open writer's transaction ${writerXid} is at or above the horizon ${horizon}`);
74
+
75
+ await writer.commit();
76
+ const caughtUp = bodies(await messages.queryAll({ filters: { changedSince: horizon } }, WORKSPACE));
77
+ assert.equal(caughtUp.get(first), "first, edited", "the change of an older message");
78
+ assert.equal(caughtUp.get(String(added)), "added by the open writer", "the message whose id was taken before it committed");
79
+ assert.equal(caughtUp.has(String(theirs)), false, "another workspace's message stays out");
80
+ // "Past the highest id seen" misses both: the edit kept its id, and the added message's id is below the later one's.
81
+ const highestSeen = Math.max(...page.map(({ id }) => Number(id)));
82
+ assert.deepEqual([Number(first) < highestSeen, Number(added) < highestSeen], [true, true]);
83
+ });
84
+
85
+ test("changedSince and changedBefore read the window between two horizons; what the window leaves out, the next read from its end finds", { skip }, async (t) => {
86
+ const { messages } = await database(t);
87
+ const start = await messages.changeHorizon(WORKSPACE);
88
+ const [a] = (await messages.createMany([{ body: "a" }], { ...WORKSPACE, returning: "ids" })).map(String);
89
+ const end = await messages.changeHorizon(WORKSPACE);
90
+ const [b] = (await messages.createMany([{ body: "b" }], { ...WORKSPACE, returning: "ids" })).map(String);
91
+
92
+ const window = bodies(await messages.queryAll({ filters: { changedSince: start, changedBefore: end } }, WORKSPACE));
93
+ const next = bodies(await messages.queryAll({ filters: { changedSince: end } }, WORKSPACE));
94
+ // b was written after the end horizon was read, so its transaction is at or above it: never in the window.
95
+ assert.equal(window.has(b), false);
96
+ assert.equal(next.get(b), "b");
97
+ // a is in the window or, while another session held the end horizon below a's transaction, in the next read.
98
+ assert.equal(window.get(a) ?? next.get(a), "a");
99
+ assert.ok(BigInt(start) <= BigInt(end), "the horizon never moves back");
100
+ });
101
+
102
+ test("a stamp compares as a transaction id: from a row's own stamp it is found, from the next one not, a malformed cursor is refused, and a patch moves the stamp on", { skip }, async (t) => {
103
+ const { knex, messages } = await database(t);
104
+ // The trigger stamps the writing transaction whatever the write sends.
105
+ const [created] = await messages.createMany([{ body: "one", changed: "1" }], { ...WORKSPACE, returning: "records" });
106
+ const stamp = BigInt(created.changed);
107
+ assert.ok(stamp > 1n, `stamped ${created.changed}`);
108
+ const found = async (filters) => (await messages.queryAll({ filters }, WORKSPACE)).map(({ body }) => body);
109
+ const next = String(stamp + 1n);
110
+ assert.deepEqual(
111
+ [await found({ changedSince: created.changed }), await found({ changedSince: next }), await found({ changedBefore: created.changed }), await found({ changedBefore: next })],
112
+ [["one"], [], [], ["one"]]
113
+ );
114
+ // They compare as numbers, not as text, where "9" would sort above a stamp starting with a lower digit.
115
+ assert.deepEqual([await found({ changedSince: "9" }), await found({ changedBefore: "9" })], [["one"], []]);
116
+ assert.deepEqual(await found({ changedSince: "18446744073709551615" }), []);
117
+ // A cursor is decimal text below 2^64, leading zeros allowed; anything else is refused before any statement.
118
+ assert.deepEqual(await found({ changedSince: `000${created.changed}` }), ["one"]);
119
+ const statements = [];
120
+ const counted = ({ sql }) => statements.push(sql);
121
+ knex.on("query", counted);
122
+ for (const cursor of ["not a position", "-1", "18446744073709551616", "1.5"]) {
123
+ await assert.rejects(found({ changedSince: cursor }), { code: "REST_API_VALIDATION" }, cursor);
124
+ }
125
+ knex.off("query", counted);
126
+ assert.deepEqual(statements, []);
127
+ await messages.patch(created.id, { body: "one, edited" }, { ...WORKSPACE, returning: "none" });
128
+ const [{ changed: moved }] = await knex("messages").where({ id: created.id }).select(knex.raw("changed::text as changed"));
129
+ assert.ok(BigInt(moved) > stamp, "a patch stamps its own transaction");
130
+ assert.deepEqual(await found({ changedSince: next }), ["one, edited"]);
131
+ });
132
+
133
+ test("changeHorizon() reads in the caller's transaction and checks the call's context, as every handle call does", { skip }, async (t) => {
134
+ const { messages } = await database(t);
135
+ const inside = await messages.withTransaction(WORKSPACE, async (txOptions) => {
136
+ await messages.create({ body: "x" }, { ...txOptions, returning: "none" });
137
+ return messages.changeHorizon(txOptions);
138
+ });
139
+ assert.match(inside, /^\d{1,20}$/u);
140
+ await assert.rejects(messages.changeHorizon({}), { code: "RESOURCE_MISSING_SCOPE" });
141
+ });
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
5
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
6
+ import { checkForeignKeys } from "../src/server/index.js";
7
+ import { createResourceHost } from "../src/server/resourceHost.js";
8
+ import { createResourceRuntime } from "../src/server/resourceRuntime.js";
9
+
10
+ const owners = defineTableResource({
11
+ name: "owners",
12
+ tableName: "owners",
13
+ ownership: "public",
14
+ schema: { name: { type: "string", required: true } }
15
+ });
16
+
17
+ function petsResource(foreignKeys) {
18
+ return defineTableResource({
19
+ name: "pets",
20
+ tableName: "pets",
21
+ ownership: "public",
22
+ schema: {
23
+ name: { type: "string", required: true },
24
+ ownerId: { type: "id", nullable: true, belongsTo: "owners", as: "owner" },
25
+ vetId: { type: "id", nullable: true, storage: { column: "clinic_vet" } }
26
+ },
27
+ ...(foreignKeys ? { foreignKeys } : {})
28
+ });
29
+ }
30
+
31
+ async function petsDatabase(t) {
32
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
33
+ t.after(() => knex.destroy());
34
+ await knex.schema.createTable("owners", (table) => {
35
+ table.increments("id");
36
+ table.string("name").notNullable();
37
+ });
38
+ await knex.schema.createTable("vets", (table) => table.increments("id"));
39
+ await knex.schema.createTable("pets", (table) => {
40
+ table.increments("id");
41
+ table.string("name").notNullable();
42
+ table.integer("owner_id").references("id").inTable("owners");
43
+ table.integer("clinic_vet");
44
+ });
45
+ return knex;
46
+ }
47
+
48
+ test("a declared foreignKeys reaches the json-rest-api resource, and a table without one declares none", async (t) => {
49
+ const knex = await petsDatabase(t);
50
+ const api = await createResourceHost({ knex, logger: silentLogger });
51
+ const declared = [{ columns: ["ownerId"], referencedTableName: "owners", deleteRule: "SET NULL" }];
52
+ const runtime = createResourceRuntime({ api });
53
+ const { pets } = await runtime.own("app.pets", [owners, petsResource(declared)]);
54
+ const context = runtime.declareSystemScope("app.pets", "The test seeds every owner's pets.")("seed a pet");
55
+
56
+ assert.deepEqual(api.resources.pets.vars.schemaInfo.foreignKeys, declared);
57
+ assert.deepEqual(api.resources.owners.vars.schemaInfo.foreignKeys, []);
58
+ const [ownerId] = await knex("owners").insert({ name: "Ada" });
59
+ const created = await pets.create({ name: "Rex", ownerId: String(ownerId) }, { context });
60
+ assert.equal(created.ownerId, String(ownerId));
61
+ assert.equal((await pets.get(created.id, {}, { context })).ownerId, String(ownerId));
62
+ });
63
+
64
+ test("checkForeignKeys finds a declared key the database lacks, and reports a real key nothing declares", async (t) => {
65
+ const knex = await petsDatabase(t);
66
+
67
+ const exact = await checkForeignKeys({
68
+ knex,
69
+ resources: [owners, petsResource([{ columns: ["ownerId"], referencedTableName: "owners" }])],
70
+ tables: ["owners", "pets"]
71
+ });
72
+ assert.deepEqual(exact, { missing: [], undeclared: [] });
73
+
74
+ const wrong = await checkForeignKeys({
75
+ knex,
76
+ resources: [petsResource([
77
+ { columns: ["ownerId"], referencedTableName: "owners", referencedColumns: ["id"] },
78
+ { name: "pets_vet", columns: ["vetId"], referencedTableName: "vets" }
79
+ ])],
80
+ tables: ["pets"]
81
+ });
82
+ assert.deepEqual(wrong.missing.map(({ resource, tableName, columns, referencedTableName, referencedColumns, declared }) =>
83
+ ({ resource, tableName, columns, referencedTableName, referencedColumns, name: declared.name })), [
84
+ { resource: "pets", tableName: "pets", columns: ["clinic_vet"], referencedTableName: "vets", referencedColumns: ["id"], name: "pets_vet" }
85
+ ]);
86
+ assert.deepEqual(wrong.undeclared, []);
87
+
88
+ const undeclared = await checkForeignKeys({ knex, resources: [owners, petsResource()], tables: ["pets"] });
89
+ assert.deepEqual(undeclared, {
90
+ missing: [],
91
+ undeclared: [{ name: "", tableName: "pets", columns: ["owner_id"], referencedTableName: "owners", referencedColumns: ["id"] }]
92
+ });
93
+ assert.deepEqual((await checkForeignKeys({ knex, resources: [petsResource()], tables: [] })).undeclared, []);
94
+ });
@@ -1,18 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import knexLib from "knex";
4
3
  import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
4
  import { countQueries } from "@jskit-ai/database-runtime/server/testDatabases";
6
5
  import { withoutHiddenFields } from "../src/server/index.js";
7
- import { createTestResourceRuntime } from "../src/test/index.js";
6
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
7
+
8
+ // SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
9
+ const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
8
10
 
9
11
  const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
10
12
 
11
13
  // Cards with a hidden owner, a hidden bearer token, a hidden flag, a hidden count and a hidden time: two in workspace
12
14
  // 1 and one in workspace 2.
13
15
  async function createCardFixture(t) {
14
- const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
15
- t.after(() => knex.destroy());
16
+ const knex = await testKnex(t);
16
17
  await knex.schema.createTable("cards", (table) => {
17
18
  table.increments("id");
18
19
  table.integer("workspace_id").notNullable();
@@ -46,8 +47,8 @@ async function createCardFixture(t) {
46
47
  // The tables each SELECT reads, in order.
47
48
  const selects = async (work) => (await countQueries(knex, work)).queries
48
49
  .filter((sql) => /^select/iu.test(sql))
49
- .map((sql) => [...sql.matchAll(/from `(\w+)`/gu)].map(([, table]) => table)[0]);
50
- return { cards, resource, system, selects, options: { context: WORKSPACE_A } };
50
+ .map((sql) => [...sql.matchAll(/from [`"](\w+)[`"]/gu)].map(([, table]) => table)[0]);
51
+ return { knex, cards, resource, system, selects, options: { context: WORKSPACE_A } };
51
52
  }
52
53
 
53
54
  test("a handle's plain read answers the hidden fields its fields names, in one more statement, and no read without them does", async (t) => {
@@ -113,43 +114,54 @@ test("withoutHiddenFields refuses another feature's read naming a hidden field,
113
114
  assert.equal(withoutHiddenFields(cards, plain), cards);
114
115
  });
115
116
 
116
- test("withoutHiddenFields refuses another feature's scopedQuery, whose builder carries the hidden columns, and the owner's join source still joins", async (t) => {
117
- const { cards, resource, system, options } = await createCardFixture(t);
118
- // The owner's join source, a method its decorateRepository adds with the handle it receives.
117
+ test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column", async (t) => {
118
+ const { knex, cards, resource, system, options } = await createCardFixture(t);
119
+ // Methods an owner's decorateRepository adds with the handle it receives, answering its scoped rows as scopedQuery()
120
+ // gives them, every column with them: wrapped, and at once.
119
121
  async function cardSource(alias, callOptions) {
120
122
  const { query } = await cards.scopedQuery({ alias }, callOptions);
121
123
  return { query, column: (field) => cards.column(field, alias) };
122
124
  }
123
- const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource }), resource);
125
+ const everyColumn = () => knex("cards");
126
+ const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource, everyColumn }), resource);
124
127
 
125
128
  // Another feature's own builder would select the token: refused before it reads anything.
126
129
  await assert.rejects(
127
130
  (async () => (await exposed.scopedQuery({}, options)).query.select(exposed.column("publicToken")))(),
128
- /cards\.scopedQuery\(\) answers every column, its hidden fields too, which only the table's owner, or a system context, reads/u
131
+ /cards\.scopedQuery\(\) answers every column, its hidden fields too, which only the table's owner, or a system context, reads: read the rows through the repository's reads, or join its joinSource\(\{ alias \}, options\)/u
129
132
  );
130
133
  await assert.rejects(exposed.withTransaction(options, (tx) => exposed.scopedQuery({ alias: "c" }, tx)), /cards\.scopedQuery\(\)/u);
134
+ await assert.rejects(exposed.cardSource("c", options),
135
+ /cards\.cardSource\(\) answers a query builder, which can select every column, the hidden fields too, which only the table's owner, or a system context, reads: answer the table's joinSource\(\{ alias \}, options\)/u);
136
+ assert.throws(() => exposed.everyColumn(), /cards\.everyColumn\(\) answers a query builder/u);
131
137
 
132
- // A system context, and one it derived, still builds on it.
138
+ // A system context, and one it derived, still builds on them.
133
139
  const { query: everyWorkspace } = await exposed.scopedQuery({}, { context: system });
134
- assert.deepEqual((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards, 3);
140
+ assert.deepEqual(Number((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards), 3);
135
141
  const { query: workspaceTwo } = await exposed.scopedQuery({}, { context: system.forWorkspace("2") });
136
142
  assert.deepEqual(await workspaceTwo.select({ title: exposed.column("title") }), [{ title: "Kit" }]);
143
+ const everyCard = await exposed.cardSource("c", { context: system });
144
+ assert.deepEqual(await everyCard.query.select({ token: everyCard.column("publicToken") }).orderBy(everyCard.column("id")),
145
+ [{ token: "t1" }, { token: "t2" }, { token: "t3" }]);
137
146
 
138
- // The cross-owner report pattern: the owner's method answers its scoped rows, which the report selects from.
139
- const source = await exposed.cardSource("c", options);
147
+ // The cross-owner report pattern: the table's joinSource() answers the caller's scoped rows, which the report
148
+ // selects from, with the columns a read answers and no other.
149
+ const source = await exposed.joinSource({ alias: "c" }, options);
140
150
  const rows = await source.query.select({ title: source.column("title") }).orderBy(source.column("id"));
141
151
  assert.deepEqual(rows, [{ title: "Rex" }, { title: "Bo" }]);
152
+ const { query: rex } = await exposed.joinSource({ alias: "c", filters: { title: "Rex" } }, options);
153
+ assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "title"]]);
154
+ assert.throws(() => source.column("publicToken"), /cards\.joinSource\(\) carries no hidden field, so it has no column for "publicToken"/u);
155
+ const again = await exposed.joinSource({ alias: "c" }, options);
156
+ await assert.rejects(async () => again.query.select(cards.column("publicToken", "c")), /public_token/u);
157
+ await assert.rejects(exposed.joinSource({}, options), /cards\.joinSource\(\) needs the alias the report joins the cards under/u);
142
158
  });
143
159
 
144
- test("withoutHiddenFields answers raw(), column() and an owner's Knex values as they are, so another feature builds on them in a scoped query and a subquery", async (t) => {
160
+ test("withoutHiddenFields answers raw(), column(), joinSource() and an owner's Knex values as they are, so another feature builds on them in a scoped query and a subquery", async (t) => {
145
161
  const { cards, resource, options } = await createCardFixture(t);
146
- async function cardSource(alias, callOptions) {
147
- const { query } = await cards.scopedQuery({ alias }, callOptions);
148
- return { query, column: (field) => cards.column(field, alias) };
149
- }
150
162
  // An owner's method answering a Knex value at once: an expression over its own column.
151
163
  const lowerTitle = (alias) => cards.raw("LOWER(??)", [cards.column("title", alias)]);
152
- const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource, lowerTitle }), resource);
164
+ const exposed = withoutHiddenFields(Object.freeze({ ...cards, lowerTitle }), resource);
153
165
 
154
166
  assert.equal(exposed.column("title", "c"), cards.column("title", "c"));
155
167
  const shout = exposed.raw("UPPER(??)", [exposed.column("title", "c")]);
@@ -157,24 +169,25 @@ test("withoutHiddenFields answers raw(), column() and an owner's Knex values as
157
169
  assert.equal(typeof exposed.lowerTitle("i").toSQL, "function");
158
170
 
159
171
  // In the owner's scoped rows, as a select.
160
- const source = await exposed.cardSource("c", options);
172
+ const source = await exposed.joinSource({ alias: "c" }, options);
161
173
  assert.deepEqual(await source.query.select({ shout }).orderBy(source.column("id")), [{ shout: "REX" }, { shout: "BO" }]);
162
174
 
163
175
  // In a subquery: the cards of workspace 1 whose lower-cased title is "bo".
164
- const outer = await exposed.cardSource("c", options);
165
- const inner = await exposed.cardSource("i", options);
176
+ const outer = await exposed.joinSource({ alias: "c" }, options);
177
+ const inner = await exposed.joinSource({ alias: "i" }, options);
166
178
  const matching = inner.query.select(inner.column("id")).where(exposed.lowerTitle("i"), exposed.raw("?", ["bo"]));
167
179
  assert.deepEqual(await outer.query.whereIn(outer.column("id"), matching).select({ title: outer.column("title") }), [{ title: "Bo" }]);
168
180
  });
169
181
 
170
- test("withoutHiddenFields strips the hidden fields from every answer of a read the owner adds, unless it runs in a system context", async (t) => {
182
+ test("withoutHiddenFields refuses a hidden field named through any method, and strips the hidden fields from every answer of a read the owner adds, unless it runs in a system context", async (t) => {
171
183
  const { cards, resource, system, options } = await createCardFixture(t);
172
184
  // The owner's decorateRepository reads: one forwarding the caller's params, the handle's own under another name, one
173
- // answering a map, one in a sub-repository and one answering at once.
185
+ // taking its options first, one answering a map, one in a sub-repository and one answering at once.
174
186
  const owner = Object.freeze({
175
187
  ...cards,
176
188
  queryAllIncludingArchived: (params, callOptions) => cards.queryAll(params, callOptions),
177
189
  everyCard: cards.queryAll,
190
+ cardsTitled: (callOptions, { title }) => cards.queryAll({ filters: { title }, fields: ["title", "publicToken"] }, callOptions),
178
191
  async cardsByTitle(callOptions) {
179
192
  const records = await cards.queryAll({ fields: ["title", "publicToken"] }, callOptions);
180
193
  return new Map(records.map((record) => [record.title, record]));
@@ -184,10 +197,15 @@ test("withoutHiddenFields strips the hidden fields from every answer of a read t
184
197
  });
185
198
  const exposed = withoutHiddenFields(owner, resource);
186
199
 
187
- // Another feature's calls: no hidden field leaves, whatever the method's name or depth.
188
- assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title", "publicToken", "workspaceId"] }, options),
189
- [{ id: "1", title: "Rex" }]);
190
- assert.deepEqual(await exposed.everyCard({ filters: { title: "Bo" }, fields: ["publicToken"] }, options), [{ id: "2" }]);
200
+ // Another feature's calls: a hidden field its fields names is refused before the method runs, whatever the method's
201
+ // name, as a list or a sparse fieldset, so a method forwarding the caller's fields never answers it as undefined.
202
+ await assert.rejects(exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title", "publicToken", "workspaceId"] }, options),
203
+ /cards\.queryAllIncludingArchived\(\) fields names the hidden field "publicToken", "workspaceId", which only the table's owner, or a system context, reads/u);
204
+ await assert.rejects(exposed.everyCard({ filters: { title: "Bo" }, fields: { cards: "title, sent" } }, options),
205
+ /cards\.everyCard\(\) fields names the hidden field "sent"/u);
206
+ assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title"] }, options), [{ id: "1", title: "Rex" }]);
207
+ // No hidden field the owner's own reads name leaves, whatever the method's name or depth.
208
+ assert.deepEqual(await exposed.cardsTitled(options, { title: "Rex" }), [{ id: "1", title: "Rex" }]);
191
209
  assert.deepEqual([...(await exposed.cardsByTitle(options)).values()], [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
192
210
  assert.deepEqual(await exposed.archive.list(options), [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
193
211
  // A method that answers at once still does, and withTransaction answers the caller's own work as it is.
@@ -200,4 +218,27 @@ test("withoutHiddenFields strips the hidden fields from every answer of a read t
200
218
  assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Kit" }, fields: ["publicToken", "workspaceId"] }, { context: system }),
201
219
  [{ id: "3", publicToken: "t3", workspaceId: "2" }]);
202
220
  assert.deepEqual(await exposed.archive.list({ context: system.forWorkspace("2") }), [{ id: "3", title: "Kit", views: 1 }]);
221
+ // The system context counts in whichever record carries it, not only the last.
222
+ assert.deepEqual(await exposed.cardsTitled({ context: system.forWorkspace("2") }, { title: "Kit" }), [{ id: "3", title: "Kit", publicToken: "t3" }]);
223
+ });
224
+
225
+ test("withoutHiddenFields refuses a field one owned table hides and another shows, since it strips hidden fields by name", () => {
226
+ const cards = defineTableResource({
227
+ name: "cards",
228
+ tableName: "cards",
229
+ ownership: "public",
230
+ schema: { title: { type: "string" }, publicToken: { type: "string", hidden: true } }
231
+ });
232
+ const links = (hidden) => defineTableResource({
233
+ name: "links",
234
+ tableName: "links",
235
+ ownership: "public",
236
+ schema: { publicToken: { type: "string", ...(hidden ? { hidden } : {}) } }
237
+ });
238
+ const repository = Object.freeze({ get: async () => ({ id: "1", publicToken: "t1" }) });
239
+ assert.throws(() => withoutHiddenFields(repository, [cards, links(false)]),
240
+ /links\.publicToken is a visible field, but cards, which the same feature owns, hides a field of that name; its repository strips hidden fields by name from every answer/u);
241
+ assert.throws(() => withoutHiddenFields(repository, [links(false), cards]), /links\.publicToken is a visible field, but cards/u);
242
+ // Hidden in both, it is one hidden field.
243
+ assert.equal(typeof withoutHiddenFields(repository, [cards, links(true)]).get, "function");
203
244
  });