@jskit-ai/resource-runtime 0.0.0-stage → 0.2.12
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 +70 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +94 -0
- package/src/server/handleGuards.js +226 -0
- package/src/server/handleHidden.js +309 -0
- package/src/server/handleLocks.js +103 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +15 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +88 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +724 -0
- package/src/server/resourceHost.js +686 -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 +122 -0
- package/test/callSettings.test.js +99 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/hiddenFields.test.js +203 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/lostInsertRace.test.js +64 -0
- package/test/manyRowWrites.mysql.test.js +145 -0
- package/test/queryFields.test.js +68 -0
- package/test/resourceRuntime.test.js +1892 -0
- package/test/testHelpers.test.js +298 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,122 @@
|
|
|
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 { 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
|
+
createTestResourceRuntime,
|
|
117
|
+
expectEveryTableOwned,
|
|
118
|
+
expectFlatQueries,
|
|
119
|
+
expectScoped,
|
|
120
|
+
requireTestDatabase,
|
|
121
|
+
startTestFeatures
|
|
122
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { passedOn } from "../src/server/index.js";
|
|
6
|
+
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
7
|
+
|
|
8
|
+
// A call's settings (returning, missing, format, lockVersion...) are for that one call. What it hands on, to the work
|
|
9
|
+
// of a transaction, to an afterWrite hook or to the handle's own nested calls, is its context and transaction only.
|
|
10
|
+
|
|
11
|
+
const WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: { id: "1" } }) });
|
|
12
|
+
// Every setting a caller may have spread into the options it hands on.
|
|
13
|
+
const SETTINGS = Object.freeze({ returning: "none", missing: "skip", format: "jsonapi", fields: ["title"], lockVersion: 3 });
|
|
14
|
+
const notFound = (error) => error.code === "REST_API_RESOURCE" && error.subtype === "not_found";
|
|
15
|
+
|
|
16
|
+
async function createNotes(t, hooks) {
|
|
17
|
+
const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
18
|
+
t.after(() => knex.destroy());
|
|
19
|
+
await knex.schema.createTable("notes", (table) => {
|
|
20
|
+
table.increments("id");
|
|
21
|
+
table.integer("workspace_id").nullable();
|
|
22
|
+
table.string("title").notNullable();
|
|
23
|
+
table.integer("lock_version").notNullable().defaultTo(0);
|
|
24
|
+
});
|
|
25
|
+
await knex.schema.createTable("counters", (table) => {
|
|
26
|
+
table.string("id").primary();
|
|
27
|
+
table.integer("value").notNullable();
|
|
28
|
+
});
|
|
29
|
+
// lockOrCreate creates on the id alone, so it runs on a table without owners.
|
|
30
|
+
const counters = defineTableResource({
|
|
31
|
+
name: "counters",
|
|
32
|
+
tableName: "counters",
|
|
33
|
+
ownership: "public",
|
|
34
|
+
schema: { id: { type: "string", primary: true }, value: { type: "integer", required: true } }
|
|
35
|
+
});
|
|
36
|
+
const resource = defineTableResource({
|
|
37
|
+
name: "notes",
|
|
38
|
+
tableName: "notes",
|
|
39
|
+
ownership: "workspace",
|
|
40
|
+
lockVersionField: "lockVersion",
|
|
41
|
+
schema: {
|
|
42
|
+
id: { type: "id", primary: true },
|
|
43
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
44
|
+
title: { type: "string", required: true, search: true },
|
|
45
|
+
lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } }
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
49
|
+
const owned = await runtime.own("notes.feature", [hooks ? { resource, hooks } : resource, { normalizeId: String, resource: counters }]);
|
|
50
|
+
return { notes: owned.notes, counters: owned.counters };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("withTransaction's work receives the context and the transaction, never the caller's settings", async (t) => {
|
|
54
|
+
const { notes } = await createNotes(t);
|
|
55
|
+
const keys = await notes.withTransaction({ ...WORKSPACE, ...SETTINGS }, async (txOptions) => {
|
|
56
|
+
// A create inside answers its record: an outer returning: "none" never reached it.
|
|
57
|
+
const created = await notes.create({ title: "inside" }, txOptions);
|
|
58
|
+
assert.equal(created.title, "inside");
|
|
59
|
+
// A get of a missing record throws: an outer missing: "skip" never reached it.
|
|
60
|
+
await assert.rejects(notes.get("999", {}, txOptions), notFound);
|
|
61
|
+
// Inside the caller's transaction too.
|
|
62
|
+
const nested = await notes.withTransaction({ ...txOptions, ...SETTINGS }, async (inner) => Object.keys(inner).sort());
|
|
63
|
+
assert.deepEqual(nested, ["context", "trx"]);
|
|
64
|
+
return Object.keys(txOptions).sort();
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(keys, ["context", "trx"]);
|
|
67
|
+
assert.deepEqual(Object.keys(passedOn({ ...WORKSPACE, ...SETTINGS })), ["context"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an afterWrite hook and lockOrCreate's work receive only the context and the transaction", async (t) => {
|
|
71
|
+
const seen = [];
|
|
72
|
+
const { notes, counters } = await createNotes(t, { afterWrite: ({ options }) => { seen.push(Object.keys(options).sort()); } });
|
|
73
|
+
await notes.create({ title: "hooked" }, { ...WORKSPACE, returning: "id" });
|
|
74
|
+
await notes.withTransaction(WORKSPACE, (txOptions) => notes.create({ title: "inside" }, { ...txOptions, returning: "none" }));
|
|
75
|
+
assert.deepEqual(seen, [["context", "trx"], ["context", "trx"]]);
|
|
76
|
+
|
|
77
|
+
const outside = await counters.lockOrCreate({ id: "visits", value: 0 }, { ...WORKSPACE, returning: "none" }, (row, txOptions) => Object.keys(txOptions).sort());
|
|
78
|
+
assert.deepEqual(outside, ["context", "trx"]);
|
|
79
|
+
const inside = await counters.withTransaction(WORKSPACE, (txOptions) => counters.lockOrCreate(
|
|
80
|
+
{ id: "visits", value: 5 },
|
|
81
|
+
{ ...txOptions, returning: "none" },
|
|
82
|
+
(row, work) => [row.value, Object.keys(work).sort()]
|
|
83
|
+
));
|
|
84
|
+
assert.deepEqual(inside, [0, ["context", "trx"]]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("findOrCreate answers the record under an outer returning: none, and a patch keeps its own format", async (t) => {
|
|
88
|
+
const { notes } = await createNotes(t);
|
|
89
|
+
const found = await notes.findOrCreate({ id: 7, title: "first" }, { ...WORKSPACE, returning: "none" });
|
|
90
|
+
assert.equal(found.title, "first");
|
|
91
|
+
assert.equal((await notes.findOrCreate({ id: 7, title: "again" }, { ...WORKSPACE, returning: "none" })).title, "first");
|
|
92
|
+
|
|
93
|
+
// The patch's own settings still reach its own write and read inside the transaction it opens for its lockVersion.
|
|
94
|
+
const document = await notes.patch(7, { title: "second" }, { ...WORKSPACE, lockVersion: 0, format: "jsonapi" });
|
|
95
|
+
assert.equal(document.data.attributes.title, "second");
|
|
96
|
+
const advanced = await notes.patch(7, {}, { ...WORKSPACE, lockVersion: 1, format: "jsonapi" });
|
|
97
|
+
assert.equal(advanced.data.attributes.lockVersion, 2);
|
|
98
|
+
assert.equal((await notes.patch(7, {}, { ...WORKSPACE, format: "jsonapi" })).data.id, "7");
|
|
99
|
+
});
|