@jskit-ai/resource-runtime 0.0.0-stage → 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 +82 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/changeHorizon.js +26 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +137 -0
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +233 -0
- package/src/server/handleHidden.js +410 -0
- package/src/server/handleLocks.js +135 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +18 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +92 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +737 -0
- package/src/server/resourceHost.js +687 -0
- package/src/server/resourceRuntime.js +300 -0
- package/src/server/standaloneResources.js +49 -0
- package/src/server/types.js +49 -0
- package/src/test/expectFlatQueries.js +117 -0
- package/src/test/expectScoped.js +111 -0
- package/src/test/index.js +123 -0
- package/test/callSettings.test.js +100 -0
- package/test/changes.postgres.test.js +141 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +244 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +65 -0
- package/test/manyRowWrites.mysql.test.js +154 -0
- package/test/queryFields.test.js +69 -0
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +2098 -0
- 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 +290 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AssertionError } from "node:assert";
|
|
2
|
+
import { ActionProvider } from "@jskit-ai/kernel/server/actions";
|
|
3
|
+
import { EventProvider } from "@jskit-ai/kernel/server/runtime";
|
|
4
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
5
|
+
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
6
|
+
import { createPackageTestKnex, requireTestDatabase, tableNames, TestDatabaseMissingError } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
7
|
+
import { createResourceHost } from "../server/resourceHost.js";
|
|
8
|
+
import { createResourceRuntime } from "../server/resourceRuntime.js";
|
|
9
|
+
import { expectFlatQueries } from "./expectFlatQueries.js";
|
|
10
|
+
import { expectScoped } from "./expectScoped.js";
|
|
11
|
+
|
|
12
|
+
// Tables every migrated database has that no feature owns: the migration ledger.
|
|
13
|
+
const MIGRATION_TABLES = Object.freeze(["knex_migrations", "knex_migrations_lock"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A resource runtime on the test's own Knex, for a package test that owns
|
|
17
|
+
* tables without booting a server: `runtime.own(featureId, owns)` answers handles.
|
|
18
|
+
* With `createTables`, own() also creates each owned table from its resource's
|
|
19
|
+
* schema, for a SQLite test whose shipped migrations are written for another database.
|
|
20
|
+
* `hostHooks` are json-rest-api hooks the test installs on the runtime's host,
|
|
21
|
+
* such as an afterCommit that fails on request.
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {import("knex").Knex} options.knex
|
|
24
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} [options.logger]
|
|
25
|
+
* @param {number} [options.requestRowLimit]
|
|
26
|
+
* @param {number} [options.serverPageSize]
|
|
27
|
+
* @param {boolean} [options.createTables]
|
|
28
|
+
* @param {Record<string, Function>} [options.hostHooks]
|
|
29
|
+
*/
|
|
30
|
+
async function createTestResourceRuntime({ knex, logger = silentLogger, requestRowLimit, serverPageSize, createTables = false, hostHooks }) {
|
|
31
|
+
const api = await createResourceHost({ knex, logger });
|
|
32
|
+
if (hostHooks) {
|
|
33
|
+
await api.customize({ hooks: hostHooks });
|
|
34
|
+
}
|
|
35
|
+
const runtime = createResourceRuntime({ api, requestRowLimit, serverPageSize });
|
|
36
|
+
if (!createTables) {
|
|
37
|
+
return runtime;
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
...runtime,
|
|
41
|
+
async own(featureId, owns) {
|
|
42
|
+
const handles = await runtime.own(featureId, owns);
|
|
43
|
+
for (const name of Object.keys(handles)) {
|
|
44
|
+
await api.resources[name].createKnexTable();
|
|
45
|
+
}
|
|
46
|
+
return handles;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts features as an application's server does, on the test's own Knex and
|
|
53
|
+
* without HTTP: the kernel orders them by what they require, the resource
|
|
54
|
+
* runtime gives each owner its handles and system context (with `createTables`,
|
|
55
|
+
* it also creates each owned table from its resource), and every boot runs, so
|
|
56
|
+
* a registry that seals at boot, such as `crud.contracts`, is sealed. The
|
|
57
|
+
* runtime's events and actions are started with them; `inputs` give anything
|
|
58
|
+
* else the features require, by capability id, and `requires` names what the
|
|
59
|
+
* test reads, as a feature's own requires do. `stop()` shuts them down.
|
|
60
|
+
* @template {Record<string, string>} R
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {import("knex").Knex} options.knex
|
|
63
|
+
* @param {ReadonlyArray<ReturnType<typeof defineProvider>>} options.features The features and providers under test.
|
|
64
|
+
* @param {R} [options.requires] What the test reads: local name to capability id.
|
|
65
|
+
* @param {Record<string, unknown>} [options.inputs] Capability values given as they are, by capability id.
|
|
66
|
+
* @param {boolean} [options.createTables]
|
|
67
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} [options.logger]
|
|
68
|
+
* @returns {Promise<{ capabilities: { [K in keyof R]: any }, resources: ReturnType<typeof createResourceRuntime>, stop: () => Promise<unknown> }>}
|
|
69
|
+
*/
|
|
70
|
+
async function startTestFeatures({ knex, features, requires = {}, inputs = {}, createTables = false, logger = silentLogger }) {
|
|
71
|
+
const resources = await createTestResourceRuntime({ knex, logger, createTables });
|
|
72
|
+
let capabilities = {};
|
|
73
|
+
const reader = defineProvider({
|
|
74
|
+
id: "test.features.reader",
|
|
75
|
+
requires,
|
|
76
|
+
/** @param {Record<string, unknown>} values The capabilities the test reads. */
|
|
77
|
+
setup(values) {
|
|
78
|
+
capabilities = Object.freeze({ ...values });
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const runtime = createCapabilityRuntime({
|
|
83
|
+
inputs: { "runtime.logger": logger, ...inputs, "runtime.resources": resources },
|
|
84
|
+
providers: [EventProvider, ActionProvider, ...features, reader]
|
|
85
|
+
});
|
|
86
|
+
await runtime.start();
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
capabilities,
|
|
89
|
+
resources,
|
|
90
|
+
stop: () => runtime.shutdown()
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fails when the migrated test database holds a table no feature owns and no
|
|
96
|
+
* package declares as infrastructure (the migration ledger always is).
|
|
97
|
+
* @param {object} options
|
|
98
|
+
* @param {import("knex").Knex} options.knex The test database.
|
|
99
|
+
* @param {{ ownedTables(): ReadonlyArray<{ tableName: string }> }} options.runtime The resource runtime every feature owned its tables through.
|
|
100
|
+
* @param {ReadonlyArray<string>} [options.infrastructureTables] Tables a package keeps outside resources, such as sessions of a sign-in library.
|
|
101
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.environment The test's environment, such as `process.env`.
|
|
102
|
+
*/
|
|
103
|
+
async function expectEveryTableOwned({ knex, runtime, infrastructureTables = [], environment }) {
|
|
104
|
+
requireTestDatabase(environment);
|
|
105
|
+
const accounted = new Set([...MIGRATION_TABLES, ...infrastructureTables, ...runtime.ownedTables().map(({ tableName }) => tableName)]);
|
|
106
|
+
const unowned = (await tableNames(knex)).filter((table) => !accounted.has(table));
|
|
107
|
+
if (unowned.length > 0) {
|
|
108
|
+
throw new AssertionError({
|
|
109
|
+
message: `Tables no feature owns: ${unowned.join(", ")}. Each table is a table resource one feature owns, or a declared infrastructure table.`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export {
|
|
115
|
+
TestDatabaseMissingError,
|
|
116
|
+
createPackageTestKnex,
|
|
117
|
+
createTestResourceRuntime,
|
|
118
|
+
expectEveryTableOwned,
|
|
119
|
+
expectFlatQueries,
|
|
120
|
+
expectScoped,
|
|
121
|
+
requireTestDatabase,
|
|
122
|
+
startTestFeatures
|
|
123
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { passedOn } from "../src/server/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 });
|
|
9
|
+
|
|
10
|
+
// A call's settings (returning, missing, format, lockVersion...) are for that one call. What it hands on, to the work
|
|
11
|
+
// of a transaction, to an afterWrite hook or to the handle's own nested calls, is its context and transaction only.
|
|
12
|
+
|
|
13
|
+
const WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: { id: "1" } }) });
|
|
14
|
+
// Every setting a caller may have spread into the options it hands on.
|
|
15
|
+
const SETTINGS = Object.freeze({ returning: "none", missing: "skip", format: "jsonapi", fields: ["title"], lockVersion: 3 });
|
|
16
|
+
const notFound = (error) => error.code === "REST_API_RESOURCE" && error.subtype === "not_found";
|
|
17
|
+
|
|
18
|
+
async function createNotes(t, hooks) {
|
|
19
|
+
const knex = await testKnex(t);
|
|
20
|
+
await knex.schema.createTable("notes", (table) => {
|
|
21
|
+
table.increments("id");
|
|
22
|
+
table.integer("workspace_id").nullable();
|
|
23
|
+
table.string("title").notNullable();
|
|
24
|
+
table.integer("lock_version").notNullable().defaultTo(0);
|
|
25
|
+
});
|
|
26
|
+
await knex.schema.createTable("counters", (table) => {
|
|
27
|
+
table.string("id").primary();
|
|
28
|
+
table.integer("value").notNullable();
|
|
29
|
+
});
|
|
30
|
+
// lockOrCreate creates on the id alone, so it runs on a table without owners.
|
|
31
|
+
const counters = defineTableResource({
|
|
32
|
+
name: "counters",
|
|
33
|
+
tableName: "counters",
|
|
34
|
+
ownership: "public",
|
|
35
|
+
schema: { id: { type: "string", primary: true }, value: { type: "integer", required: true } }
|
|
36
|
+
});
|
|
37
|
+
const resource = defineTableResource({
|
|
38
|
+
name: "notes",
|
|
39
|
+
tableName: "notes",
|
|
40
|
+
ownership: "workspace",
|
|
41
|
+
lockVersionField: "lockVersion",
|
|
42
|
+
schema: {
|
|
43
|
+
id: { type: "id", primary: true },
|
|
44
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
45
|
+
title: { type: "string", required: true, search: true },
|
|
46
|
+
lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } }
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
50
|
+
const owned = await runtime.own("notes.feature", [hooks ? { resource, hooks } : resource, { normalizeId: String, resource: counters }]);
|
|
51
|
+
return { notes: owned.notes, counters: owned.counters };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("withTransaction's work receives the context and the transaction, never the caller's settings", async (t) => {
|
|
55
|
+
const { notes } = await createNotes(t);
|
|
56
|
+
const keys = await notes.withTransaction({ ...WORKSPACE, ...SETTINGS }, async (txOptions) => {
|
|
57
|
+
// A create inside answers its record: an outer returning: "none" never reached it.
|
|
58
|
+
const created = await notes.create({ title: "inside" }, txOptions);
|
|
59
|
+
assert.equal(created.title, "inside");
|
|
60
|
+
// A get of a missing record throws: an outer missing: "skip" never reached it.
|
|
61
|
+
await assert.rejects(notes.get("999", {}, txOptions), notFound);
|
|
62
|
+
// Inside the caller's transaction too.
|
|
63
|
+
const nested = await notes.withTransaction({ ...txOptions, ...SETTINGS }, async (inner) => Object.keys(inner).sort());
|
|
64
|
+
assert.deepEqual(nested, ["context", "trx"]);
|
|
65
|
+
return Object.keys(txOptions).sort();
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(keys, ["context", "trx"]);
|
|
68
|
+
assert.deepEqual(Object.keys(passedOn({ ...WORKSPACE, ...SETTINGS })), ["context"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("an afterWrite hook and lockOrCreate's work receive only the context and the transaction", async (t) => {
|
|
72
|
+
const seen = [];
|
|
73
|
+
const { notes, counters } = await createNotes(t, { afterWrite: ({ options }) => { seen.push(Object.keys(options).sort()); } });
|
|
74
|
+
await notes.create({ title: "hooked" }, { ...WORKSPACE, returning: "id" });
|
|
75
|
+
await notes.withTransaction(WORKSPACE, (txOptions) => notes.create({ title: "inside" }, { ...txOptions, returning: "none" }));
|
|
76
|
+
assert.deepEqual(seen, [["context", "trx"], ["context", "trx"]]);
|
|
77
|
+
|
|
78
|
+
const outside = await counters.lockOrCreate({ id: "visits", value: 0 }, { ...WORKSPACE, returning: "none" }, (row, txOptions) => Object.keys(txOptions).sort());
|
|
79
|
+
assert.deepEqual(outside, ["context", "trx"]);
|
|
80
|
+
const inside = await counters.withTransaction(WORKSPACE, (txOptions) => counters.lockOrCreate(
|
|
81
|
+
{ id: "visits", value: 5 },
|
|
82
|
+
{ ...txOptions, returning: "none" },
|
|
83
|
+
(row, work) => [row.value, Object.keys(work).sort()]
|
|
84
|
+
));
|
|
85
|
+
assert.deepEqual(inside, [0, ["context", "trx"]]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("findOrCreate answers the record under an outer returning: none, and a patch keeps its own format", async (t) => {
|
|
89
|
+
const { notes } = await createNotes(t);
|
|
90
|
+
const found = await notes.findOrCreate({ id: 7, title: "first" }, { ...WORKSPACE, returning: "none" });
|
|
91
|
+
assert.equal(found.title, "first");
|
|
92
|
+
assert.equal((await notes.findOrCreate({ id: 7, title: "again" }, { ...WORKSPACE, returning: "none" })).title, "first");
|
|
93
|
+
|
|
94
|
+
// The patch's own settings still reach its own write and read inside the transaction it opens for its lockVersion.
|
|
95
|
+
const document = await notes.patch(7, { title: "second" }, { ...WORKSPACE, lockVersion: 0, format: "jsonapi" });
|
|
96
|
+
assert.equal(document.data.attributes.title, "second");
|
|
97
|
+
const advanced = await notes.patch(7, {}, { ...WORKSPACE, lockVersion: 1, format: "jsonapi" });
|
|
98
|
+
assert.equal(advanced.data.attributes.lockVersion, 2);
|
|
99
|
+
assert.equal((await notes.patch(7, {}, { ...WORKSPACE, format: "jsonapi" })).data.id, "7");
|
|
100
|
+
});
|
|
@@ -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
|
+
});
|