@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.
@@ -1,6 +1,29 @@
1
1
  import { missingOf, requireDefinedFilters } from "./handleGuards.js";
2
2
  import { handleRecords, sparseFieldset, textList } from "./handleRecords.js";
3
3
 
4
+ // The row lock a locking read takes. "update" (the default) is FOR UPDATE. "noKeyUpdate" is PostgreSQL's FOR NO KEY
5
+ // UPDATE, which still keeps every other lock and write of the row out but lets other transactions insert rows that
6
+ // reference it (a foreign key's check takes FOR KEY SHARE); MySQL, MariaDB and SQLite have no weaker exclusive lock and
7
+ // lock as for "update".
8
+ const LOCK_MODES = Object.freeze(["update", "noKeyUpdate"]);
9
+
10
+ /**
11
+ * json-rest-api's lock mode for a locking read: none when the call names none, so json-rest-api takes its default.
12
+ * Another value is a TypeError naming the handle method, before anything runs.
13
+ * @param {string} name The resource's name.
14
+ * @param {string} caller The handle method, for the message.
15
+ * @param {unknown} lockMode
16
+ */
17
+ function lockModeOf(name, caller, lockMode) {
18
+ if (lockMode === undefined) {
19
+ return {};
20
+ }
21
+ if (!LOCK_MODES.includes(/** @type {string} */ (lockMode))) {
22
+ throw new TypeError(`${name}.${caller}() lockMode is "update" (the default) or "noKeyUpdate". Received: ${JSON.stringify(lockMode)}`);
23
+ }
24
+ return { lockMode };
25
+ }
26
+
4
27
  /**
5
28
  * A handle's locking reads: lock, lockMany, lockWhere and claim. Each needs the
6
29
  * caller's transaction (`options.trx`), locks only rows the caller may see, and
@@ -18,12 +41,15 @@ import { handleRecords, sparseFieldset, textList } from "./handleRecords.js";
18
41
  * fields `options.fields` names, read after the lock.
19
42
  */
20
43
  function createLockReads({ name, resource, jsonRest, readFields, contextOf, hiddenFields }) {
21
- // A locking read (json-rest-api's lockByIds, lockWhere or claim) of the fields options.fields names, as records.
22
- async function lockRead(method, read, options) {
44
+ // A locking read (json-rest-api's lockByIds, lockWhere or claim) of the fields options.fields names, as records, in the
45
+ // lock mode options.lockMode names. `caller` is the handle method, for the messages.
46
+ async function lockRead(caller, method, read, options) {
47
+ const mode = lockModeOf(name, caller, options.lockMode);
23
48
  const hidden = hiddenFields.named(options.fields, "plain");
24
49
  const fields = readFields({ fields: options.fields }, "view");
25
50
  const { data } = await jsonRest()[method]({
26
51
  ...read,
52
+ ...mode,
27
53
  transaction: options.trx,
28
54
  format: "plain",
29
55
  ...(fields ? { queryParams: { fields: sparseFieldset(resource, fields) } } : {})
@@ -35,25 +61,31 @@ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hidd
35
61
  * Locks the visible rows among the ids, in id order, and answers them; hidden or missing ids throw, or are
36
62
  * left out with `missing: "skip"`. MySQL locks every listed id, a hidden one too: take ids from scoped reads.
37
63
  * `fields` names the fields the lock reads, so a belongs-to field left out costs no check that its related
38
- * record is visible; without it every field is read.
64
+ * record is visible; without it every field is read. `lockMode: "noKeyUpdate"` takes PostgreSQL's FOR NO KEY
65
+ * UPDATE, so other transactions may still insert rows that reference the locked ones (see LOCK_MODES).
39
66
  * @param {unknown[]} ids
40
- * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[] }} options
67
+ * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
41
68
  */
42
69
  async function lockMany(ids, options) {
43
- return lockRead("lockByIds", { ids, ...(options.missing ? { missing: options.missing } : {}) }, options);
70
+ return lockRead("lockMany", "lockByIds", { ids, ...(options.missing ? { missing: options.missing } : {}) }, options);
71
+ }
72
+
73
+ // lockMany for lock(): its messages name lock().
74
+ function lockOne(id, options) {
75
+ return lockRead("lock", "lockByIds", { ids: [id], ...(options.missing ? { missing: options.missing } : {}) }, options);
44
76
  }
45
77
 
46
78
  /**
47
79
  * Locks the record's row until the transaction ends and answers it as the
48
80
  * lock saw it. Needs options.trx. A missing or hidden record throws
49
81
  * json-rest-api's not-found error, or answers null with `missing: "skip"`.
50
- * `fields` reads only the fields the work needs (see lockMany).
82
+ * `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
51
83
  * @param {unknown} id
52
- * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[] }} options
84
+ * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
53
85
  */
54
86
  async function lock(id, options) {
55
87
  missingOf(name, "lock", options.missing);
56
- const [record] = await lockMany([id], options);
88
+ const [record] = await lockOne(id, options);
57
89
  return record ?? null;
58
90
  }
59
91
 
@@ -64,28 +96,28 @@ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hidd
64
96
  * past that). It does not lock rows inserted later: unique numbers take a unique
65
97
  * index, or a counter row taken with lock(). On MySQL it needs an index starting
66
98
  * with the ownership columns (no owners: with a filtered column), or it refuses.
67
- * `fields` reads only the fields the work needs (see lockMany).
99
+ * `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
68
100
  * @param {object} filters
69
- * @param {{ context?: object | null, trx: object, fields?: string[] }} options
101
+ * @param {{ context?: object | null, trx: object, fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
70
102
  */
71
103
  async function lockWhere(filters, options) {
72
104
  requireDefinedFilters(name, "lockWhere", filters);
73
- return lockRead("lockWhere", { filters }, options);
105
+ return lockRead("lockWhere", "lockWhere", { filters }, options);
74
106
  }
75
107
 
76
108
  /**
77
109
  * Takes up to `take` (default 1) of the rows the filters select that no
78
110
  * other transaction holds, in `sort` order, locked until the transaction ends.
79
- * `fields` reads only the fields the work needs (see lockMany).
111
+ * `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
80
112
  * @param {{ filters: object, sort?: string | string[], take?: number }} params
81
- * @param {{ context?: object | null, trx: object, fields?: string[] }} options
113
+ * @param {{ context?: object | null, trx: object, fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
82
114
  */
83
115
  async function claim({ filters, sort, take } = {}, options, ...rest) {
84
116
  if (rest.length > 0) {
85
117
  throw new TypeError(`${name}.claim() takes ({ filters, sort, take }, options).`);
86
118
  }
87
119
  requireDefinedFilters(name, "claim", filters);
88
- return lockRead("claim", {
120
+ return lockRead("claim", "claim", {
89
121
  filters,
90
122
  ...(take === undefined ? {} : { limit: take }),
91
123
  ...(sort === undefined ? {} : { sort: textList(sort) })
@@ -100,4 +132,4 @@ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hidd
100
132
  });
101
133
  }
102
134
 
103
- export { createLockReads };
135
+ export { createLockReads, lockModeOf };
@@ -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
+ });