@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
|
@@ -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
|
-
|
|
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
|
|
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 };
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
+
});
|