@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
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
|
+
});
|
|
@@ -1,18 +1,19 @@
|
|
|
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 { countQueries } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
5
|
import { withoutHiddenFields } from "../src/server/index.js";
|
|
7
|
-
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
6
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
7
|
+
|
|
8
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
9
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
8
10
|
|
|
9
11
|
const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
|
|
10
12
|
|
|
11
13
|
// Cards with a hidden owner, a hidden bearer token, a hidden flag, a hidden count and a hidden time: two in workspace
|
|
12
14
|
// 1 and one in workspace 2.
|
|
13
15
|
async function createCardFixture(t) {
|
|
14
|
-
const knex =
|
|
15
|
-
t.after(() => knex.destroy());
|
|
16
|
+
const knex = await testKnex(t);
|
|
16
17
|
await knex.schema.createTable("cards", (table) => {
|
|
17
18
|
table.increments("id");
|
|
18
19
|
table.integer("workspace_id").notNullable();
|
|
@@ -46,8 +47,8 @@ async function createCardFixture(t) {
|
|
|
46
47
|
// The tables each SELECT reads, in order.
|
|
47
48
|
const selects = async (work) => (await countQueries(knex, work)).queries
|
|
48
49
|
.filter((sql) => /^select/iu.test(sql))
|
|
49
|
-
.map((sql) => [...sql.matchAll(/from `(\w+)
|
|
50
|
-
return { cards, resource, system, selects, options: { context: WORKSPACE_A } };
|
|
50
|
+
.map((sql) => [...sql.matchAll(/from [`"](\w+)[`"]/gu)].map(([, table]) => table)[0]);
|
|
51
|
+
return { knex, cards, resource, system, selects, options: { context: WORKSPACE_A } };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
test("a handle's plain read answers the hidden fields its fields names, in one more statement, and no read without them does", async (t) => {
|
|
@@ -113,43 +114,54 @@ test("withoutHiddenFields refuses another feature's read naming a hidden field,
|
|
|
113
114
|
assert.equal(withoutHiddenFields(cards, plain), cards);
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
test("withoutHiddenFields refuses another feature's scopedQuery,
|
|
117
|
-
const { cards, resource, system, options } = await createCardFixture(t);
|
|
118
|
-
//
|
|
117
|
+
test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column", async (t) => {
|
|
118
|
+
const { knex, cards, resource, system, options } = await createCardFixture(t);
|
|
119
|
+
// Methods an owner's decorateRepository adds with the handle it receives, answering its scoped rows as scopedQuery()
|
|
120
|
+
// gives them, every column with them: wrapped, and at once.
|
|
119
121
|
async function cardSource(alias, callOptions) {
|
|
120
122
|
const { query } = await cards.scopedQuery({ alias }, callOptions);
|
|
121
123
|
return { query, column: (field) => cards.column(field, alias) };
|
|
122
124
|
}
|
|
123
|
-
const
|
|
125
|
+
const everyColumn = () => knex("cards");
|
|
126
|
+
const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource, everyColumn }), resource);
|
|
124
127
|
|
|
125
128
|
// Another feature's own builder would select the token: refused before it reads anything.
|
|
126
129
|
await assert.rejects(
|
|
127
130
|
(async () => (await exposed.scopedQuery({}, options)).query.select(exposed.column("publicToken")))(),
|
|
128
|
-
/cards\.scopedQuery\(\) answers every column, its hidden fields too, which only the table's owner, or a system context, reads/u
|
|
131
|
+
/cards\.scopedQuery\(\) answers every column, its hidden fields too, which only the table's owner, or a system context, reads: read the rows through the repository's reads, or join its joinSource\(\{ alias \}, options\)/u
|
|
129
132
|
);
|
|
130
133
|
await assert.rejects(exposed.withTransaction(options, (tx) => exposed.scopedQuery({ alias: "c" }, tx)), /cards\.scopedQuery\(\)/u);
|
|
134
|
+
await assert.rejects(exposed.cardSource("c", options),
|
|
135
|
+
/cards\.cardSource\(\) answers a query builder, which can select every column, the hidden fields too, which only the table's owner, or a system context, reads: answer the table's joinSource\(\{ alias \}, options\)/u);
|
|
136
|
+
assert.throws(() => exposed.everyColumn(), /cards\.everyColumn\(\) answers a query builder/u);
|
|
131
137
|
|
|
132
|
-
// A system context, and one it derived, still builds on
|
|
138
|
+
// A system context, and one it derived, still builds on them.
|
|
133
139
|
const { query: everyWorkspace } = await exposed.scopedQuery({}, { context: system });
|
|
134
|
-
assert.deepEqual((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards, 3);
|
|
140
|
+
assert.deepEqual(Number((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards), 3);
|
|
135
141
|
const { query: workspaceTwo } = await exposed.scopedQuery({}, { context: system.forWorkspace("2") });
|
|
136
142
|
assert.deepEqual(await workspaceTwo.select({ title: exposed.column("title") }), [{ title: "Kit" }]);
|
|
143
|
+
const everyCard = await exposed.cardSource("c", { context: system });
|
|
144
|
+
assert.deepEqual(await everyCard.query.select({ token: everyCard.column("publicToken") }).orderBy(everyCard.column("id")),
|
|
145
|
+
[{ token: "t1" }, { token: "t2" }, { token: "t3" }]);
|
|
137
146
|
|
|
138
|
-
// The cross-owner report pattern: the
|
|
139
|
-
|
|
147
|
+
// The cross-owner report pattern: the table's joinSource() answers the caller's scoped rows, which the report
|
|
148
|
+
// selects from, with the columns a read answers and no other.
|
|
149
|
+
const source = await exposed.joinSource({ alias: "c" }, options);
|
|
140
150
|
const rows = await source.query.select({ title: source.column("title") }).orderBy(source.column("id"));
|
|
141
151
|
assert.deepEqual(rows, [{ title: "Rex" }, { title: "Bo" }]);
|
|
152
|
+
const { query: rex } = await exposed.joinSource({ alias: "c", filters: { title: "Rex" } }, options);
|
|
153
|
+
assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "title"]]);
|
|
154
|
+
assert.throws(() => source.column("publicToken"), /cards\.joinSource\(\) carries no hidden field, so it has no column for "publicToken"/u);
|
|
155
|
+
const again = await exposed.joinSource({ alias: "c" }, options);
|
|
156
|
+
await assert.rejects(async () => again.query.select(cards.column("publicToken", "c")), /public_token/u);
|
|
157
|
+
await assert.rejects(exposed.joinSource({}, options), /cards\.joinSource\(\) needs the alias the report joins the cards under/u);
|
|
142
158
|
});
|
|
143
159
|
|
|
144
|
-
test("withoutHiddenFields answers raw(), column() and an owner's Knex values as they are, so another feature builds on them in a scoped query and a subquery", async (t) => {
|
|
160
|
+
test("withoutHiddenFields answers raw(), column(), joinSource() and an owner's Knex values as they are, so another feature builds on them in a scoped query and a subquery", async (t) => {
|
|
145
161
|
const { cards, resource, options } = await createCardFixture(t);
|
|
146
|
-
async function cardSource(alias, callOptions) {
|
|
147
|
-
const { query } = await cards.scopedQuery({ alias }, callOptions);
|
|
148
|
-
return { query, column: (field) => cards.column(field, alias) };
|
|
149
|
-
}
|
|
150
162
|
// An owner's method answering a Knex value at once: an expression over its own column.
|
|
151
163
|
const lowerTitle = (alias) => cards.raw("LOWER(??)", [cards.column("title", alias)]);
|
|
152
|
-
const exposed = withoutHiddenFields(Object.freeze({ ...cards,
|
|
164
|
+
const exposed = withoutHiddenFields(Object.freeze({ ...cards, lowerTitle }), resource);
|
|
153
165
|
|
|
154
166
|
assert.equal(exposed.column("title", "c"), cards.column("title", "c"));
|
|
155
167
|
const shout = exposed.raw("UPPER(??)", [exposed.column("title", "c")]);
|
|
@@ -157,24 +169,25 @@ test("withoutHiddenFields answers raw(), column() and an owner's Knex values as
|
|
|
157
169
|
assert.equal(typeof exposed.lowerTitle("i").toSQL, "function");
|
|
158
170
|
|
|
159
171
|
// In the owner's scoped rows, as a select.
|
|
160
|
-
const source = await exposed.
|
|
172
|
+
const source = await exposed.joinSource({ alias: "c" }, options);
|
|
161
173
|
assert.deepEqual(await source.query.select({ shout }).orderBy(source.column("id")), [{ shout: "REX" }, { shout: "BO" }]);
|
|
162
174
|
|
|
163
175
|
// In a subquery: the cards of workspace 1 whose lower-cased title is "bo".
|
|
164
|
-
const outer = await exposed.
|
|
165
|
-
const inner = await exposed.
|
|
176
|
+
const outer = await exposed.joinSource({ alias: "c" }, options);
|
|
177
|
+
const inner = await exposed.joinSource({ alias: "i" }, options);
|
|
166
178
|
const matching = inner.query.select(inner.column("id")).where(exposed.lowerTitle("i"), exposed.raw("?", ["bo"]));
|
|
167
179
|
assert.deepEqual(await outer.query.whereIn(outer.column("id"), matching).select({ title: outer.column("title") }), [{ title: "Bo" }]);
|
|
168
180
|
});
|
|
169
181
|
|
|
170
|
-
test("withoutHiddenFields strips the hidden fields from every answer of a read the owner adds, unless it runs in a system context", async (t) => {
|
|
182
|
+
test("withoutHiddenFields refuses a hidden field named through any method, and strips the hidden fields from every answer of a read the owner adds, unless it runs in a system context", async (t) => {
|
|
171
183
|
const { cards, resource, system, options } = await createCardFixture(t);
|
|
172
184
|
// The owner's decorateRepository reads: one forwarding the caller's params, the handle's own under another name, one
|
|
173
|
-
// answering a map, one in a sub-repository and one answering at once.
|
|
185
|
+
// taking its options first, one answering a map, one in a sub-repository and one answering at once.
|
|
174
186
|
const owner = Object.freeze({
|
|
175
187
|
...cards,
|
|
176
188
|
queryAllIncludingArchived: (params, callOptions) => cards.queryAll(params, callOptions),
|
|
177
189
|
everyCard: cards.queryAll,
|
|
190
|
+
cardsTitled: (callOptions, { title }) => cards.queryAll({ filters: { title }, fields: ["title", "publicToken"] }, callOptions),
|
|
178
191
|
async cardsByTitle(callOptions) {
|
|
179
192
|
const records = await cards.queryAll({ fields: ["title", "publicToken"] }, callOptions);
|
|
180
193
|
return new Map(records.map((record) => [record.title, record]));
|
|
@@ -184,10 +197,15 @@ test("withoutHiddenFields strips the hidden fields from every answer of a read t
|
|
|
184
197
|
});
|
|
185
198
|
const exposed = withoutHiddenFields(owner, resource);
|
|
186
199
|
|
|
187
|
-
// Another feature's calls:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
// Another feature's calls: a hidden field its fields names is refused before the method runs, whatever the method's
|
|
201
|
+
// name, as a list or a sparse fieldset, so a method forwarding the caller's fields never answers it as undefined.
|
|
202
|
+
await assert.rejects(exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title", "publicToken", "workspaceId"] }, options),
|
|
203
|
+
/cards\.queryAllIncludingArchived\(\) fields names the hidden field "publicToken", "workspaceId", which only the table's owner, or a system context, reads/u);
|
|
204
|
+
await assert.rejects(exposed.everyCard({ filters: { title: "Bo" }, fields: { cards: "title, sent" } }, options),
|
|
205
|
+
/cards\.everyCard\(\) fields names the hidden field "sent"/u);
|
|
206
|
+
assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title"] }, options), [{ id: "1", title: "Rex" }]);
|
|
207
|
+
// No hidden field the owner's own reads name leaves, whatever the method's name or depth.
|
|
208
|
+
assert.deepEqual(await exposed.cardsTitled(options, { title: "Rex" }), [{ id: "1", title: "Rex" }]);
|
|
191
209
|
assert.deepEqual([...(await exposed.cardsByTitle(options)).values()], [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
|
|
192
210
|
assert.deepEqual(await exposed.archive.list(options), [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
|
|
193
211
|
// A method that answers at once still does, and withTransaction answers the caller's own work as it is.
|
|
@@ -200,4 +218,27 @@ test("withoutHiddenFields strips the hidden fields from every answer of a read t
|
|
|
200
218
|
assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Kit" }, fields: ["publicToken", "workspaceId"] }, { context: system }),
|
|
201
219
|
[{ id: "3", publicToken: "t3", workspaceId: "2" }]);
|
|
202
220
|
assert.deepEqual(await exposed.archive.list({ context: system.forWorkspace("2") }), [{ id: "3", title: "Kit", views: 1 }]);
|
|
221
|
+
// The system context counts in whichever record carries it, not only the last.
|
|
222
|
+
assert.deepEqual(await exposed.cardsTitled({ context: system.forWorkspace("2") }, { title: "Kit" }), [{ id: "3", title: "Kit", publicToken: "t3" }]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("withoutHiddenFields refuses a field one owned table hides and another shows, since it strips hidden fields by name", () => {
|
|
226
|
+
const cards = defineTableResource({
|
|
227
|
+
name: "cards",
|
|
228
|
+
tableName: "cards",
|
|
229
|
+
ownership: "public",
|
|
230
|
+
schema: { title: { type: "string" }, publicToken: { type: "string", hidden: true } }
|
|
231
|
+
});
|
|
232
|
+
const links = (hidden) => defineTableResource({
|
|
233
|
+
name: "links",
|
|
234
|
+
tableName: "links",
|
|
235
|
+
ownership: "public",
|
|
236
|
+
schema: { publicToken: { type: "string", ...(hidden ? { hidden } : {}) } }
|
|
237
|
+
});
|
|
238
|
+
const repository = Object.freeze({ get: async () => ({ id: "1", publicToken: "t1" }) });
|
|
239
|
+
assert.throws(() => withoutHiddenFields(repository, [cards, links(false)]),
|
|
240
|
+
/links\.publicToken is a visible field, but cards, which the same feature owns, hides a field of that name; its repository strips hidden fields by name from every answer/u);
|
|
241
|
+
assert.throws(() => withoutHiddenFields(repository, [links(false), cards]), /links\.publicToken is a visible field, but cards/u);
|
|
242
|
+
// Hidden in both, it is one hidden field.
|
|
243
|
+
assert.equal(typeof withoutHiddenFields(repository, [cards, links(true)]).get, "function");
|
|
203
244
|
});
|