@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.
- package/CHANGELOG.md +12 -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 +137 -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 +71 -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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knexLib from "knex";
|
|
4
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
|
+
import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
|
+
import { createTestResourceRuntime } from "../../src/test/index.js";
|
|
7
|
+
|
|
8
|
+
// Whole numbers stored in BIGINT columns that are not ids (a counter, a byte count, a read position), read through a
|
|
9
|
+
// handle on MariaDB/MySQL and on PostgreSQL alike. Both drivers answer BIGINT as text (mysql2 with the runtime's
|
|
10
|
+
// bigNumberStrings, pg for every int8); a field of type "integer" reads back as a number on every handle read, and an
|
|
11
|
+
// id field as text.
|
|
12
|
+
|
|
13
|
+
const WORKSPACE = Object.freeze({ workspace: { id: "1" } });
|
|
14
|
+
const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
|
|
15
|
+
// The largest whole number a JavaScript number holds exactly (2^53 - 1), and the next odd one past it.
|
|
16
|
+
const LARGEST_SAFE = "9007199254740991";
|
|
17
|
+
const PAST_SAFE = "9007199254740993";
|
|
18
|
+
|
|
19
|
+
// A PostgreSQL test on a run for another database, such as CI's MariaDB run, is left to the PostgreSQL run.
|
|
20
|
+
const POSTGRES_ELSEWHERE = process.env.DB_CLIENT && process.env.DB_CLIENT !== "pg"
|
|
21
|
+
? `a PostgreSQL test, on a ${process.env.DB_CLIENT} run`
|
|
22
|
+
: false;
|
|
23
|
+
|
|
24
|
+
const counterResource = defineTableResource({
|
|
25
|
+
name: "counters",
|
|
26
|
+
tableName: "counters",
|
|
27
|
+
ownership: "workspace",
|
|
28
|
+
defaultSort: ["id"],
|
|
29
|
+
schema: {
|
|
30
|
+
id: { type: "id", primary: true },
|
|
31
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
32
|
+
total: { type: "integer", search: true },
|
|
33
|
+
readUpTo: { type: "integer", nullable: true, storage: { column: "read_up_to" } },
|
|
34
|
+
hiddenCount: { type: "integer", nullable: true, hidden: true, storage: { column: "hidden_count" } },
|
|
35
|
+
lastMessageId: { type: "id", nullable: true, storage: { column: "last_message_id" } }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const FIELDS = Object.freeze(["workspaceId", "total", "readUpTo", "hiddenCount", "lastMessageId"]);
|
|
39
|
+
|
|
40
|
+
/** A MariaDB/MySQL database of the test's own beside TEST_DB_NAME, dropped after it. */
|
|
41
|
+
async function mysqlKnex(context, suffix) {
|
|
42
|
+
const name = `${requireTestDatabase(process.env)}_${suffix}`;
|
|
43
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
44
|
+
const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
45
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
46
|
+
await admin.raw("CREATE DATABASE ??", [name]);
|
|
47
|
+
const knex = knexLib({ client: "mysql2", connection: { ...server, database: name, ...RUNTIME_MYSQL }, pool: { min: 0, max: 2 } });
|
|
48
|
+
context.after(async () => {
|
|
49
|
+
await knex.destroy();
|
|
50
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
51
|
+
await admin.destroy();
|
|
52
|
+
});
|
|
53
|
+
return knex;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A PostgreSQL schema of the test's own in TEST_DB_NAME, dropped after it. */
|
|
57
|
+
async function postgresKnex(context, suffix) {
|
|
58
|
+
const database = requireTestDatabase(process.env);
|
|
59
|
+
const schema = `${database}_${suffix}_${process.pid}`;
|
|
60
|
+
const connection = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 5432, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database };
|
|
61
|
+
const admin = knexLib({ client: "pg", connection, pool: { min: 0, max: 1 } });
|
|
62
|
+
await admin.raw("DROP SCHEMA IF EXISTS ?? CASCADE", [schema]);
|
|
63
|
+
await admin.raw("CREATE SCHEMA ??", [schema]);
|
|
64
|
+
const knex = knexLib({ client: "pg", connection, searchPath: [schema], pool: { min: 0, max: 2 } });
|
|
65
|
+
context.after(async () => {
|
|
66
|
+
await knex.destroy();
|
|
67
|
+
await admin.raw("DROP SCHEMA IF EXISTS ?? CASCADE", [schema]);
|
|
68
|
+
await admin.destroy();
|
|
69
|
+
});
|
|
70
|
+
return knex;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The record fields a test compares: their values with their types.
|
|
74
|
+
const typed = (record) => Object.fromEntries(
|
|
75
|
+
["id", ...FIELDS].filter((field) => Object.hasOwn(record, field)).map((field) => [field, record[field]])
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Registers the stored-integer tests on one database.
|
|
80
|
+
* @param {"mysql" | "postgres"} dialect
|
|
81
|
+
*/
|
|
82
|
+
function storedIntegerCases(dialect) {
|
|
83
|
+
const skip = dialect === "postgres" ? POSTGRES_ELSEWHERE : false;
|
|
84
|
+
const openKnex = dialect === "postgres" ? postgresKnex : mysqlKnex;
|
|
85
|
+
|
|
86
|
+
async function counters(context) {
|
|
87
|
+
const knex = await openKnex(context, "stored_ints");
|
|
88
|
+
await knex.schema.createTable("counters", (table) => {
|
|
89
|
+
table.bigIncrements("id");
|
|
90
|
+
table.bigInteger("workspace_id").nullable();
|
|
91
|
+
table.bigInteger("total").notNullable();
|
|
92
|
+
table.bigInteger("read_up_to").nullable();
|
|
93
|
+
table.bigInteger("hidden_count").nullable();
|
|
94
|
+
table.bigInteger("last_message_id").nullable();
|
|
95
|
+
});
|
|
96
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
97
|
+
const { counters: handle } = await runtime.own("counters.feature", [counterResource]);
|
|
98
|
+
return { knex, counters: handle };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test(`${dialect}: a non-id BIGINT field of type integer reads back as a number on every handle read; an id as text`, { skip }, async (context) => {
|
|
102
|
+
const { counters: handle } = await counters(context);
|
|
103
|
+
const options = { context: WORKSPACE };
|
|
104
|
+
const expected = { id: "1", workspaceId: "1", total: 5, readUpTo: Number(LARGEST_SAFE), hiddenCount: 3, lastMessageId: "11" };
|
|
105
|
+
|
|
106
|
+
const created = await handle.create({ workspaceId: "1", total: 5, readUpTo: Number(LARGEST_SAFE), hiddenCount: 3, lastMessageId: "11" }, options);
|
|
107
|
+
const { hiddenCount, ...visible } = expected;
|
|
108
|
+
assert.equal(hiddenCount, 3);
|
|
109
|
+
assert.deepEqual(typed(created), visible, "create answers the stored record without its hidden field");
|
|
110
|
+
assert.deepEqual(typed(await handle.get(created.id, { fields: FIELDS }, options)), expected);
|
|
111
|
+
assert.deepEqual((await handle.query({ fields: FIELDS, page: { size: 10 } }, options)).records.map(typed), [expected]);
|
|
112
|
+
assert.deepEqual((await handle.queryAll({ fields: FIELDS }, options)).map(typed), [expected]);
|
|
113
|
+
assert.deepEqual(typed(await handle.patch(created.id, { total: 6 }, options)), { ...visible, total: 6 });
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
(await handle.patchMany([{ id: created.id, total: 7 }], { ...options, returning: "records" })).map(typed),
|
|
116
|
+
[{ ...visible, total: 7 }]
|
|
117
|
+
);
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
(await handle.createMany([{ workspaceId: "1", total: 8 }], { ...options, returning: "records" })).map(typed),
|
|
120
|
+
[{ id: "2", workspaceId: "1", total: 8, readUpTo: null, lastMessageId: null }]
|
|
121
|
+
);
|
|
122
|
+
const locked = await handle.withTransaction(options, async (txOptions) => ({
|
|
123
|
+
lockMany: await handle.lockMany([created.id], { ...txOptions, fields: FIELDS }),
|
|
124
|
+
claim: await handle.claim({ filters: { total: 7 }, take: 1 }, { ...txOptions, fields: FIELDS })
|
|
125
|
+
}));
|
|
126
|
+
assert.deepEqual(locked.lockMany.map(typed), [{ ...expected, total: 7 }]);
|
|
127
|
+
assert.deepEqual(locked.claim.map(typed), [{ ...expected, total: 7 }]);
|
|
128
|
+
const totals = await handle.aggregate({
|
|
129
|
+
measures: { total: { sum: "total" }, highest: { max: "readUpTo" }, lowest: { min: "total" }, rows: { count: "*" } }
|
|
130
|
+
}, options);
|
|
131
|
+
assert.deepEqual(totals.map((row) => ({ ...row })), [{ total: 15, highest: Number(LARGEST_SAFE), lowest: 7, rows: 2 }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test(`${dialect}: past 2^53 a stored integer keeps its digits as text, never a rounded number; scopedQuery rows are the driver's text`, { skip }, async (context) => {
|
|
135
|
+
const { knex, counters: handle } = await counters(context);
|
|
136
|
+
const options = { context: WORKSPACE };
|
|
137
|
+
await knex("counters").insert({ workspace_id: 1, total: PAST_SAFE, read_up_to: LARGEST_SAFE, hidden_count: PAST_SAFE });
|
|
138
|
+
|
|
139
|
+
const [record] = await handle.queryAll({ fields: FIELDS }, options);
|
|
140
|
+
assert.deepEqual(typed(record), { id: "1", workspaceId: "1", total: PAST_SAFE, readUpTo: Number(LARGEST_SAFE), hiddenCount: PAST_SAFE, lastMessageId: null });
|
|
141
|
+
|
|
142
|
+
// A report's own builder answers what the driver read: BIGINT as text on both drivers, ids and counters alike,
|
|
143
|
+
// and a COUNT too, which both databases answer as a BIGINT.
|
|
144
|
+
const { query } = await handle.scopedQuery({ filters: {} }, options);
|
|
145
|
+
const rows = await query.select("id", "total", "read_up_to");
|
|
146
|
+
assert.deepEqual(rows.map((row) => ({ ...row })), [{ id: "1", total: PAST_SAFE, read_up_to: LARGEST_SAFE }]);
|
|
147
|
+
const { query: counting } = await handle.scopedQuery({ filters: {} }, options);
|
|
148
|
+
assert.deepEqual((await counting.count({ rows: "*" })).map((row) => ({ ...row })), [{ rows: "1" }]);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { storedIntegerCases };
|
package/test/testHelpers.test.js
CHANGED
|
@@ -4,14 +4,10 @@ import knexFactory from "knex";
|
|
|
4
4
|
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
5
|
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
6
6
|
import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
expectFlatQueries,
|
|
12
|
-
expectScoped,
|
|
13
|
-
startTestFeatures
|
|
14
|
-
} from "../src/test/index.js";
|
|
7
|
+
import { createPackageTestKnex, createTestResourceRuntime, expectEveryTableOwned, expectFlatQueries, expectScoped, startTestFeatures, TestDatabaseMissingError } from "../src/test/index.js";
|
|
8
|
+
|
|
9
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
10
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
15
11
|
|
|
16
12
|
const environment = Object.freeze({ TEST_DB_NAME: "catalogue_test" });
|
|
17
13
|
|
|
@@ -70,8 +66,7 @@ test("expectFlatQueries() passes a path that reads its rows at once and fails on
|
|
|
70
66
|
});
|
|
71
67
|
|
|
72
68
|
async function createShelfFixture(t) {
|
|
73
|
-
const knex =
|
|
74
|
-
t.after(() => knex.destroy());
|
|
69
|
+
const knex = await testKnex(t);
|
|
75
70
|
await knex.schema.createTable("books", (table) => {
|
|
76
71
|
table.increments("id");
|
|
77
72
|
table.integer("workspace_id");
|
|
@@ -147,8 +142,7 @@ test("expectScoped() passes a path that keeps to its workspace and fails one tha
|
|
|
147
142
|
});
|
|
148
143
|
|
|
149
144
|
test("expectScoped() fails a report that joins another owner's table by name, though no row of it is in the answer", async (t) => {
|
|
150
|
-
const knex =
|
|
151
|
-
t.after(() => knex.destroy());
|
|
145
|
+
const knex = await testKnex(t);
|
|
152
146
|
await knex.schema.createTable("visits", (table) => { table.increments("id"); table.integer("workspace_id"); table.string("person"); });
|
|
153
147
|
await knex.schema.createTable("people", (table) => { table.increments("id"); table.integer("workspace_id"); table.string("name"); table.string("business"); });
|
|
154
148
|
const owned = (name, schema) => defineTableResource({
|
|
@@ -188,8 +182,7 @@ test("expectScoped() fails a report that joins another owner's table by name, th
|
|
|
188
182
|
});
|
|
189
183
|
|
|
190
184
|
test("expectScoped() compares the other owner's stored rows, so a changed hidden column or one outside the schema fails", async (t) => {
|
|
191
|
-
const knex =
|
|
192
|
-
t.after(() => knex.destroy());
|
|
185
|
+
const knex = await testKnex(t);
|
|
193
186
|
await knex.schema.createTable("keys", (table) => {
|
|
194
187
|
table.increments("id");
|
|
195
188
|
table.integer("workspace_id");
|
|
@@ -248,8 +241,7 @@ test("expectEveryTableOwned() names each table no feature owns", async (t) => {
|
|
|
248
241
|
});
|
|
249
242
|
|
|
250
243
|
test("startTestFeatures() starts features in the order their requires give, on tables it creates, and boots them", async (t) => {
|
|
251
|
-
const knex =
|
|
252
|
-
t.after(() => knex.destroy());
|
|
244
|
+
const knex = await testKnex(t);
|
|
253
245
|
const shelves = defineTableResource({
|
|
254
246
|
name: "shelves",
|
|
255
247
|
tableName: "shelves",
|