@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,895 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import knexLib from "knex";
|
|
6
|
+
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
7
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
8
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
9
|
+
import { JsonRestApi, RestApiFieldsetError, RestApiWriteError } from "json-rest-api";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildJsonRestQueryParams,
|
|
13
|
+
createJsonRestContext,
|
|
14
|
+
createResourceHost,
|
|
15
|
+
resourceScopeOptions,
|
|
16
|
+
returnNullWhenJsonRestResourceMissing,
|
|
17
|
+
returnBadRequestWhenJsonRestFieldsetInvalid,
|
|
18
|
+
resolveWorkspaceScopeValue,
|
|
19
|
+
resolveUserScopeValue
|
|
20
|
+
} from "../src/server/resourceHost.js";
|
|
21
|
+
import { ResourceRuntimeProvider } from "../src/server/ResourceRuntimeProvider.js";
|
|
22
|
+
|
|
23
|
+
test("package exports its provider and helpers, its types and its test helpers, never the host or the runtime", async () => {
|
|
24
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
25
|
+
const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
|
|
26
|
+
assert.equal(exportsMap["./server"], "./src/server/index.js");
|
|
27
|
+
assert.equal(Object.hasOwn(exportsMap, "./server/resourceHost"), false);
|
|
28
|
+
assert.equal(exportsMap["./server/types"], "./src/server/types.js");
|
|
29
|
+
assert.equal(exportsMap["./test"], "./src/test/index.js");
|
|
30
|
+
// Tables are owned through a feature (or createStandaloneResources outside a server), and system contexts come only
|
|
31
|
+
// from a declared system scope, so the host and the runtime themselves stay inside the package.
|
|
32
|
+
const server = await import("../src/server/index.js");
|
|
33
|
+
for (const name of ["createResourceRuntime", "createResourceHost", "createSystemContext", "createJsonRestContext"]) {
|
|
34
|
+
assert.equal(Object.hasOwn(server, name), false, name);
|
|
35
|
+
}
|
|
36
|
+
assert.equal(typeof server.createStandaloneResources, "function");
|
|
37
|
+
assert.equal(typeof server.buildJsonRestQueryParams, "function");
|
|
38
|
+
assert.equal(typeof server.passedOn, "function");
|
|
39
|
+
// passedOn is resource-core's, which code that runs without the table runtime imports itself.
|
|
40
|
+
assert.equal(server.passedOn, (await import("@jskit-ai/resource-core/shared/callOptions")).passedOn);
|
|
41
|
+
assert.equal(typeof server.resolveWorkspaceScopeValue, "function");
|
|
42
|
+
assert.equal(typeof server.withoutHiddenFields, "function");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("standalone resources on a server's Knex client never own a table one of the server's features owns", async (t) => {
|
|
46
|
+
const { createStandaloneResources } = await import("../src/server/index.js");
|
|
47
|
+
const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
48
|
+
const other = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
49
|
+
t.after(() => Promise.all([knex.destroy(), other.destroy()]));
|
|
50
|
+
const table = (name) => defineTableResource({ name, tableName: name, ownership: "public", schema: { title: { type: "string" } } });
|
|
51
|
+
const { resources: runtime } = await ResourceRuntimeProvider.setup({ database: { knex }, logger: silentLogger });
|
|
52
|
+
await runtime.own("notes.feature", [table("notes")]);
|
|
53
|
+
await assert.rejects(
|
|
54
|
+
createStandaloneResources({ knex, feature: "notes.script", owns: [table("notes")], systemScope: "every note" }),
|
|
55
|
+
/Standalone resources "notes\.script" own table "notes", which feature "notes\.feature" of this server already owns/u
|
|
56
|
+
);
|
|
57
|
+
// A table no feature owns, or the same table on a Knex client no server uses, is the script's own.
|
|
58
|
+
assert.ok((await createStandaloneResources({ knex, feature: "memos.script", owns: [table("memos")] })).resources.memos);
|
|
59
|
+
assert.ok((await createStandaloneResources({ knex: other, feature: "notes.script", owns: [table("notes")] })).resources.notes);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("server resourceHost entrypoint exposes only the focused host API", async () => {
|
|
63
|
+
const hostModule = await import("../src/server/resourceHost.js");
|
|
64
|
+
assert.equal(Object.hasOwn(hostModule, "simplifyJsonApiDocument"), false);
|
|
65
|
+
assert.equal(Object.hasOwn(hostModule, "createJsonApiInputRecord"), false);
|
|
66
|
+
assert.equal(Object.hasOwn(hostModule, "createJsonApiRelationship"), false);
|
|
67
|
+
assert.equal(Object.hasOwn(hostModule, "addResourceIfMissing"), false);
|
|
68
|
+
assert.equal(Object.hasOwn(hostModule, "queryAllJsonRestCollectionRows"), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("server entrypoint exports shared host helpers", () => {
|
|
72
|
+
assert.equal(typeof buildJsonRestQueryParams, "function");
|
|
73
|
+
assert.equal(typeof resourceScopeOptions, "function");
|
|
74
|
+
assert.equal(typeof createJsonRestContext, "function");
|
|
75
|
+
assert.equal(typeof createResourceHost, "function");
|
|
76
|
+
assert.equal(typeof returnNullWhenJsonRestResourceMissing, "function");
|
|
77
|
+
assert.equal(typeof returnBadRequestWhenJsonRestFieldsetInvalid, "function");
|
|
78
|
+
assert.equal(typeof resolveWorkspaceScopeValue, "function");
|
|
79
|
+
assert.equal(typeof resolveUserScopeValue, "function");
|
|
80
|
+
assert.equal(ResourceRuntimeProvider.id, "runtime.resources");
|
|
81
|
+
assert.deepEqual(ResourceRuntimeProvider.requires, { database: "runtime.database", logger: "runtime.logger" });
|
|
82
|
+
assert.deepEqual(ResourceRuntimeProvider.provides, { resources: "runtime.resources" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("createJsonRestContext returns a mutable clone for frozen JSKIT execution context", () => {
|
|
86
|
+
const source = Object.freeze({
|
|
87
|
+
visibilityContext: Object.freeze({
|
|
88
|
+
visibility: "workspace",
|
|
89
|
+
scopeOwnerId: "workspace-7",
|
|
90
|
+
userId: "user-2"
|
|
91
|
+
}),
|
|
92
|
+
scopeValues: Object.freeze({
|
|
93
|
+
workspaceId: "workspace-explicit"
|
|
94
|
+
}),
|
|
95
|
+
requestMeta: Object.freeze({
|
|
96
|
+
traceId: "trace-1"
|
|
97
|
+
})
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = createJsonRestContext(source);
|
|
101
|
+
|
|
102
|
+
assert.notEqual(result, source);
|
|
103
|
+
assert.notEqual(result.visibilityContext, source.visibilityContext);
|
|
104
|
+
assert.notEqual(result.scopeValues, source.scopeValues);
|
|
105
|
+
assert.equal(result.requestMeta, source.requestMeta);
|
|
106
|
+
|
|
107
|
+
result.method = "query";
|
|
108
|
+
result.scopeValues.userId = "user-2";
|
|
109
|
+
|
|
110
|
+
assert.equal(result.method, "query");
|
|
111
|
+
assert.equal(result.scopeValues.userId, "user-2");
|
|
112
|
+
assert.equal(source.scopeValues.userId, undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("createJsonRestContext returns an empty mutable object when source context is absent", () => {
|
|
116
|
+
const result = createJsonRestContext(null);
|
|
117
|
+
|
|
118
|
+
assert.deepEqual(result, {});
|
|
119
|
+
result.method = "query";
|
|
120
|
+
assert.equal(result.method, "query");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("createResourceHost installs normalizeRecordId as the default resource id normalizer", async () => {
|
|
124
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
125
|
+
client: {
|
|
126
|
+
config: {
|
|
127
|
+
client: "sqlite3"
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
async raw() {
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
version: "3.35.5"
|
|
134
|
+
}
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
transaction() {}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const api = await createResourceHost({
|
|
141
|
+
knex: fakeKnex,
|
|
142
|
+
logger: silentLogger
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
assert.equal(api.vars.normalizeId, normalizeRecordId);
|
|
146
|
+
assert.equal(api.vars.normalizeId(7), "7");
|
|
147
|
+
assert.equal(api.vars.normalizeId(0), null);
|
|
148
|
+
assert.equal(api.vars.normalizeId(7.5), null);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("createResourceHost uses the v2 runtime and the logger it is given", async () => {
|
|
152
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
153
|
+
client: {
|
|
154
|
+
config: {
|
|
155
|
+
client: "sqlite3"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async raw() {
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
version: "3.35.5"
|
|
162
|
+
}
|
|
163
|
+
];
|
|
164
|
+
},
|
|
165
|
+
transaction() {}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const messages = [];
|
|
169
|
+
const api = await createResourceHost({
|
|
170
|
+
knex: fakeKnex,
|
|
171
|
+
logger: { ...silentLogger, error: (...args) => messages.push(args) }
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.ok(api instanceof JsonRestApi);
|
|
175
|
+
await assert.rejects(
|
|
176
|
+
() => createResourceHost({ knex: fakeKnex }),
|
|
177
|
+
/createResourceHost requires a logger with debug, info, warn and error\./
|
|
178
|
+
);
|
|
179
|
+
await assert.rejects(
|
|
180
|
+
() => createResourceHost({ knex: fakeKnex, logger: { error() {} } }),
|
|
181
|
+
/createResourceHost requires a logger/
|
|
182
|
+
);
|
|
183
|
+
assert.equal(api.vars.format, "plain");
|
|
184
|
+
assert.equal(api.vars.returning, "full");
|
|
185
|
+
assert.equal(api.options.logging, undefined);
|
|
186
|
+
api.log.error("failure", { code: "example" });
|
|
187
|
+
assert.deepEqual(messages, [["failure", { code: "example" }]]);
|
|
188
|
+
|
|
189
|
+
const provided = await ResourceRuntimeProvider.setup({ database: { knex: fakeKnex }, logger: silentLogger });
|
|
190
|
+
assert.deepEqual(Object.keys(provided), ["resources"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("createResourceHost installs row policies before resources are added", async () => {
|
|
194
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
195
|
+
client: {
|
|
196
|
+
config: {
|
|
197
|
+
client: "sqlite3"
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
async raw() {
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
version: "3.35.5"
|
|
204
|
+
}
|
|
205
|
+
];
|
|
206
|
+
},
|
|
207
|
+
transaction() {}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const api = await createResourceHost({ knex: fakeKnex, logger: silentLogger });
|
|
211
|
+
const rowPolicy = () => false;
|
|
212
|
+
const scopeOptions = resourceScopeOptions(defineTableResource({
|
|
213
|
+
name: "privateContacts",
|
|
214
|
+
tableName: "private_contacts",
|
|
215
|
+
ownership: "public",
|
|
216
|
+
schema: {
|
|
217
|
+
id: { type: "id", primary: true }
|
|
218
|
+
}
|
|
219
|
+
}), {
|
|
220
|
+
rowPolicy
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await api.addResource("privateContacts", scopeOptions);
|
|
224
|
+
|
|
225
|
+
assert.deepEqual(api.rowPolicies.getConfig(), {
|
|
226
|
+
policies: []
|
|
227
|
+
});
|
|
228
|
+
assert.deepEqual(api.rowPolicies.getScopeConfig("privateContacts"), {
|
|
229
|
+
policy: "<inline>",
|
|
230
|
+
source: "inline"
|
|
231
|
+
});
|
|
232
|
+
assert.equal(scopeOptions.rowPolicy, rowPolicy);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("shared query helper builds json-rest-api request shapes", () => {
|
|
236
|
+
assert.deepEqual(
|
|
237
|
+
buildJsonRestQueryParams("contacts", {
|
|
238
|
+
q: "Merc",
|
|
239
|
+
cursor: "cursor_2",
|
|
240
|
+
limit: 10,
|
|
241
|
+
include: "workspace,user",
|
|
242
|
+
sort: ["-createdAt", "name"],
|
|
243
|
+
fields: {
|
|
244
|
+
contacts: ["name", "dob"],
|
|
245
|
+
workspaces: ["slug", "id"],
|
|
246
|
+
"contact-notes": ["body", "id"]
|
|
247
|
+
}
|
|
248
|
+
}),
|
|
249
|
+
{
|
|
250
|
+
filters: {
|
|
251
|
+
q: "Merc"
|
|
252
|
+
},
|
|
253
|
+
include: ["workspace", "user"],
|
|
254
|
+
sort: ["-createdAt", "name"],
|
|
255
|
+
page: {
|
|
256
|
+
after: "cursor_2",
|
|
257
|
+
size: "10"
|
|
258
|
+
},
|
|
259
|
+
fields: {
|
|
260
|
+
contactNotes: "body,id",
|
|
261
|
+
contacts: "dob,name",
|
|
262
|
+
workspaces: "id,slug"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("buildJsonRestQueryParams preserves structured filter values", () => {
|
|
270
|
+
assert.deepEqual(
|
|
271
|
+
buildJsonRestQueryParams("receivals", {
|
|
272
|
+
status: ["pending", "open", ""],
|
|
273
|
+
supplierId: [7, " 8 "],
|
|
274
|
+
availability: true,
|
|
275
|
+
weight: {
|
|
276
|
+
min: 12.5,
|
|
277
|
+
max: " 18 "
|
|
278
|
+
},
|
|
279
|
+
emptyList: [],
|
|
280
|
+
emptyObject: {},
|
|
281
|
+
emptyText: " ",
|
|
282
|
+
cursor: "cursor_1"
|
|
283
|
+
}),
|
|
284
|
+
{
|
|
285
|
+
filters: {
|
|
286
|
+
status: ["pending", "open"],
|
|
287
|
+
supplierId: [7, "8"],
|
|
288
|
+
availability: true,
|
|
289
|
+
weight: {
|
|
290
|
+
min: 12.5,
|
|
291
|
+
max: "18"
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
page: {
|
|
295
|
+
after: "cursor_1"
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("resourceScopeOptions clones canonical resource metadata and resolves symbolic write serializers", () => {
|
|
302
|
+
const serializer = (value) => value;
|
|
303
|
+
const normalizeId = (value) => String(value || "").trim() || null;
|
|
304
|
+
const rowPolicy = () => false;
|
|
305
|
+
const source = defineTableResource({
|
|
306
|
+
name: "contacts",
|
|
307
|
+
tableName: "contacts",
|
|
308
|
+
ownership: "workspace",
|
|
309
|
+
defaultSort: Object.freeze(["-createdAt"]),
|
|
310
|
+
schema: Object.freeze({
|
|
311
|
+
name: Object.freeze({
|
|
312
|
+
type: "string",
|
|
313
|
+
maxLength: 190,
|
|
314
|
+
required: true,
|
|
315
|
+
search: true
|
|
316
|
+
}),
|
|
317
|
+
createdAt: Object.freeze({
|
|
318
|
+
type: "dateTime",
|
|
319
|
+
storage: Object.freeze({
|
|
320
|
+
column: "created_at",
|
|
321
|
+
writeSerializer: "datetime-utc"
|
|
322
|
+
})
|
|
323
|
+
}),
|
|
324
|
+
publishedOn: Object.freeze({
|
|
325
|
+
type: "date"
|
|
326
|
+
}),
|
|
327
|
+
bookingSteps: Object.freeze({
|
|
328
|
+
type: "array",
|
|
329
|
+
storage: Object.freeze({
|
|
330
|
+
virtual: true
|
|
331
|
+
})
|
|
332
|
+
}),
|
|
333
|
+
pets: Object.freeze({
|
|
334
|
+
type: "array",
|
|
335
|
+
relation: Object.freeze({
|
|
336
|
+
kind: "collection",
|
|
337
|
+
namespace: "pets",
|
|
338
|
+
foreignKey: "contactId"
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
}),
|
|
342
|
+
relationships: Object.freeze({
|
|
343
|
+
auditEvents: Object.freeze({
|
|
344
|
+
type: "hasMany",
|
|
345
|
+
target: "auditEvents",
|
|
346
|
+
foreignKey: "contactId"
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const result = resourceScopeOptions(source, {
|
|
352
|
+
normalizeId,
|
|
353
|
+
rowPolicy,
|
|
354
|
+
writeSerializers: {
|
|
355
|
+
"datetime-utc": serializer
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
assert.notEqual(result, source);
|
|
360
|
+
assert.notEqual(result.schema, source.schema);
|
|
361
|
+
assert.notEqual(result.schema.createdAt, source.schema.createdAt);
|
|
362
|
+
assert.equal(result.schema.createdAt.storage.column, "created_at");
|
|
363
|
+
assert.equal(result.schema.createdAt.storage.serialize, serializer);
|
|
364
|
+
assert.equal(result.schema.createdAt.storage.serialize(null), null);
|
|
365
|
+
assert.equal(result.schema.createdAt.storage.writeSerializer, undefined);
|
|
366
|
+
assert.equal(result.schema.publishedOn.storage, undefined);
|
|
367
|
+
assert.equal(result.schema.bookingSteps.virtual, true);
|
|
368
|
+
assert.equal(result.schema.pets.virtual, true);
|
|
369
|
+
assert.equal(result.normalizeId, normalizeId);
|
|
370
|
+
assert.equal(result.rowPolicy, rowPolicy);
|
|
371
|
+
assert.equal(result.schema.name.maxLength, 190);
|
|
372
|
+
assert.equal(result.autofilter, "workspace");
|
|
373
|
+
assert.equal(Object.hasOwn(result, "ownership"), false);
|
|
374
|
+
assert.equal(Object.hasOwn(result, "name"), false);
|
|
375
|
+
assert.deepEqual(result.relationships, {
|
|
376
|
+
pets: {
|
|
377
|
+
type: "hasMany",
|
|
378
|
+
target: "pets",
|
|
379
|
+
foreignKey: "contactId"
|
|
380
|
+
},
|
|
381
|
+
auditEvents: {
|
|
382
|
+
type: "hasMany",
|
|
383
|
+
target: "auditEvents",
|
|
384
|
+
foreignKey: "contactId"
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
result.schema.createdAt.indexed = true;
|
|
389
|
+
assert.equal(source.schema.createdAt.indexed, undefined);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("resourceScopeOptions maps server query projections into json-rest-api queryFields", () => {
|
|
393
|
+
const select = ({ raw }) => raw("1");
|
|
394
|
+
const resource = defineTableResource({
|
|
395
|
+
name: "receivals",
|
|
396
|
+
tableName: "receivals",
|
|
397
|
+
ownership: "public",
|
|
398
|
+
schema: {
|
|
399
|
+
id: { type: "id", primary: true },
|
|
400
|
+
remainingProcessableWeight: {
|
|
401
|
+
type: "number",
|
|
402
|
+
storage: {
|
|
403
|
+
virtual: true,
|
|
404
|
+
queryProjection: {
|
|
405
|
+
sortable: true,
|
|
406
|
+
select
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
availableCapacityWeight: {
|
|
411
|
+
type: "number",
|
|
412
|
+
storage: {
|
|
413
|
+
virtual: true
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const result = resourceScopeOptions(resource, {
|
|
420
|
+
queryFields: {
|
|
421
|
+
availableCapacityWeight: {
|
|
422
|
+
type: "number",
|
|
423
|
+
select
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
searchSchema: {
|
|
427
|
+
status: { type: "string", filterOperator: "=" }
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
assert.deepEqual(Object.keys(result.schema), ["id"]);
|
|
432
|
+
assert.equal(result.queryFields.remainingProcessableWeight.type, "number");
|
|
433
|
+
assert.equal(result.queryFields.remainingProcessableWeight.sortable, true);
|
|
434
|
+
// Each select is called with a value-only raw, column() and the read's filters, never json-rest-api's Knex.
|
|
435
|
+
const db = { raw: (sql, bindings) => ({ sql, bindings, toSQL: () => ({ sql, bindings }) }) };
|
|
436
|
+
for (const field of ["remainingProcessableWeight", "availableCapacityWeight"]) {
|
|
437
|
+
assert.equal(result.queryFields[field].select({ db, column: (name) => name, context: {} }).sql, "1");
|
|
438
|
+
}
|
|
439
|
+
// Every resource's filters also take `id`, one id or a list of them.
|
|
440
|
+
assert.deepEqual(result.searchSchema, {
|
|
441
|
+
status: { type: "string", filterOperator: "=" },
|
|
442
|
+
id: { type: "array", actualField: "id", filterOperator: "in" }
|
|
443
|
+
});
|
|
444
|
+
assert.equal(resource.schema.remainingProcessableWeight.storage.virtual, true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("resourceScopeOptions refuses a resource's own id filter, since every handle filters by one id or a list", () => {
|
|
448
|
+
const declared = defineTableResource({
|
|
449
|
+
name: "tickets",
|
|
450
|
+
tableName: "tickets",
|
|
451
|
+
ownership: "public",
|
|
452
|
+
searchSchema: { id: { type: "id", actualField: "id" } },
|
|
453
|
+
schema: { title: { type: "string", required: true } }
|
|
454
|
+
});
|
|
455
|
+
const searchedId = defineTableResource({
|
|
456
|
+
name: "ticketSettings",
|
|
457
|
+
tableName: "ticket_settings",
|
|
458
|
+
ownership: "public",
|
|
459
|
+
idProperty: "ticket_id",
|
|
460
|
+
schema: { id: { type: "id", primary: true, required: true, search: true, storage: { column: "ticket_id" } } }
|
|
461
|
+
});
|
|
462
|
+
const plain = defineTableResource({
|
|
463
|
+
name: "notes",
|
|
464
|
+
tableName: "notes",
|
|
465
|
+
ownership: "public",
|
|
466
|
+
schema: { title: { type: "string", required: true } }
|
|
467
|
+
});
|
|
468
|
+
assert.throws(() => resourceScopeOptions(declared), /"tickets" declares its own id filter: every handle filters by id/u);
|
|
469
|
+
assert.throws(() => resourceScopeOptions(searchedId), /"ticketSettings" declares its own id filter/u);
|
|
470
|
+
assert.throws(
|
|
471
|
+
() => resourceScopeOptions(plain, { searchSchema: { id: { type: "id", actualField: "id" } } }),
|
|
472
|
+
/"notes" declares its own id filter/u
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("resourceScopeOptions applies resource-owned default response exclusions", () => {
|
|
477
|
+
const source = defineTableResource({
|
|
478
|
+
name: "contacts",
|
|
479
|
+
tableName: "contacts",
|
|
480
|
+
ownership: "public",
|
|
481
|
+
defaultExclude: [
|
|
482
|
+
"privateNotes",
|
|
483
|
+
"searchRank"
|
|
484
|
+
],
|
|
485
|
+
schema: Object.freeze({
|
|
486
|
+
id: Object.freeze({
|
|
487
|
+
type: "id",
|
|
488
|
+
primary: true
|
|
489
|
+
}),
|
|
490
|
+
name: Object.freeze({
|
|
491
|
+
type: "string"
|
|
492
|
+
}),
|
|
493
|
+
privateNotes: Object.freeze({
|
|
494
|
+
type: "string"
|
|
495
|
+
}),
|
|
496
|
+
searchRank: Object.freeze({
|
|
497
|
+
type: "number",
|
|
498
|
+
storage: Object.freeze({
|
|
499
|
+
virtual: true,
|
|
500
|
+
queryProjection: Object.freeze({
|
|
501
|
+
select() {}
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const result = resourceScopeOptions(source);
|
|
509
|
+
|
|
510
|
+
assert.equal(Object.hasOwn(result, "defaultExclude"), false);
|
|
511
|
+
assert.equal(Object.hasOwn(result, "autofilter"), false);
|
|
512
|
+
assert.equal(result.schema.name.normallyHidden, undefined);
|
|
513
|
+
assert.equal(result.schema.privateNotes.normallyHidden, true);
|
|
514
|
+
assert.equal(result.queryFields.searchRank.normallyHidden, true);
|
|
515
|
+
assert.equal(source.schema.privateNotes.normallyHidden, undefined);
|
|
516
|
+
assert.equal(source.schema.searchRank.normallyHidden, undefined);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("resourceScopeOptions rejects invalid default response exclusions", () => {
|
|
520
|
+
const createResource = (defaultExclude) => defineTableResource({
|
|
521
|
+
name: "contacts",
|
|
522
|
+
tableName: "contacts",
|
|
523
|
+
ownership: "public",
|
|
524
|
+
defaultExclude,
|
|
525
|
+
schema: {
|
|
526
|
+
id: {
|
|
527
|
+
type: "id",
|
|
528
|
+
primary: true
|
|
529
|
+
},
|
|
530
|
+
name: {
|
|
531
|
+
type: "string"
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
assert.throws(
|
|
537
|
+
() => resourceScopeOptions(createResource("name")),
|
|
538
|
+
/resource defaultExclude must be an array/
|
|
539
|
+
);
|
|
540
|
+
assert.throws(
|
|
541
|
+
() => resourceScopeOptions(createResource(["unknown"])),
|
|
542
|
+
/defaultExclude references unknown field "unknown"/
|
|
543
|
+
);
|
|
544
|
+
assert.throws(
|
|
545
|
+
() => resourceScopeOptions(createResource(["id"])),
|
|
546
|
+
/defaultExclude cannot exclude identifier field "id"/
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("resourceScopeOptions rejects query field names for column-backed schema fields", () => {
|
|
551
|
+
assert.throws(
|
|
552
|
+
() => resourceScopeOptions(defineTableResource({
|
|
553
|
+
name: "receivals",
|
|
554
|
+
tableName: "receivals",
|
|
555
|
+
ownership: "public",
|
|
556
|
+
schema: {
|
|
557
|
+
id: { type: "id", primary: true },
|
|
558
|
+
status: { type: "string" }
|
|
559
|
+
}
|
|
560
|
+
}), {
|
|
561
|
+
queryFields: {
|
|
562
|
+
status: {
|
|
563
|
+
type: "string",
|
|
564
|
+
select() {}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}),
|
|
568
|
+
/query field "status" conflicts with a column-backed schema field/
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("createResourceHost installs json-rest-api query projections", async (t) => {
|
|
573
|
+
const knex = knexLib({
|
|
574
|
+
client: "better-sqlite3",
|
|
575
|
+
connection: { filename: ":memory:" },
|
|
576
|
+
useNullAsDefault: true
|
|
577
|
+
});
|
|
578
|
+
t.after(() => knex.destroy());
|
|
579
|
+
await knex.schema.createTable("projection_contacts", (table) => {
|
|
580
|
+
table.increments("id");
|
|
581
|
+
});
|
|
582
|
+
await knex("projection_contacts").insert({ id: 1 });
|
|
583
|
+
const api = await createResourceHost({ knex, logger: silentLogger });
|
|
584
|
+
|
|
585
|
+
await api.addResource("projectionContacts", {
|
|
586
|
+
tableName: "projection_contacts",
|
|
587
|
+
schema: {
|
|
588
|
+
id: { type: "id", primary: true }
|
|
589
|
+
},
|
|
590
|
+
queryFields: {
|
|
591
|
+
displayName: {
|
|
592
|
+
type: "string",
|
|
593
|
+
select({ knex }) {
|
|
594
|
+
return knex.raw("'Display'");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const result = await api.resources.projectionContacts.query({ format: "plain" });
|
|
601
|
+
assert.deepEqual(result.data, [{ id: "1", displayName: "Display" }]);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
async function createTemporalFixture() {
|
|
605
|
+
const knex = knexLib({
|
|
606
|
+
client: "better-sqlite3",
|
|
607
|
+
connection: { filename: ":memory:" },
|
|
608
|
+
useNullAsDefault: true
|
|
609
|
+
});
|
|
610
|
+
try {
|
|
611
|
+
await knex.schema.createTable("holidays", (table) => {
|
|
612
|
+
table.increments("id");
|
|
613
|
+
table.date("observed_on");
|
|
614
|
+
});
|
|
615
|
+
await knex.schema.createTable("books", (table) => {
|
|
616
|
+
table.increments("id");
|
|
617
|
+
table.date("published_on");
|
|
618
|
+
table.dateTime("scheduled_at");
|
|
619
|
+
table.time("opens_at");
|
|
620
|
+
table.integer("holiday_id").references("id").inTable("holidays");
|
|
621
|
+
});
|
|
622
|
+
const api = await createResourceHost({ knex, logger: silentLogger });
|
|
623
|
+
await api.addResource("holidays", resourceScopeOptions(defineTableResource({
|
|
624
|
+
name: "holidays",
|
|
625
|
+
tableName: "holidays",
|
|
626
|
+
ownership: "public",
|
|
627
|
+
schema: {
|
|
628
|
+
id: { type: "id", primary: true },
|
|
629
|
+
observedOn: { type: "date" }
|
|
630
|
+
}
|
|
631
|
+
})));
|
|
632
|
+
await api.addResource("books", resourceScopeOptions(defineTableResource({
|
|
633
|
+
name: "books",
|
|
634
|
+
tableName: "books",
|
|
635
|
+
ownership: "public",
|
|
636
|
+
schema: {
|
|
637
|
+
id: { type: "id", primary: true },
|
|
638
|
+
publishedOn: { type: "date" },
|
|
639
|
+
scheduledAt: { type: "dateTime" },
|
|
640
|
+
opensAt: { type: "time" },
|
|
641
|
+
holidayId: { type: "id", belongsTo: "holidays", as: "holiday", nullable: true }
|
|
642
|
+
}
|
|
643
|
+
})));
|
|
644
|
+
return { api, knex };
|
|
645
|
+
} catch (error) {
|
|
646
|
+
await knex.destroy();
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
test("createResourceHost uses core temporal normalization for writes, reads and includes", async (t) => {
|
|
652
|
+
const { api, knex } = await createTemporalFixture();
|
|
653
|
+
t.after(() => knex.destroy());
|
|
654
|
+
const holiday = await api.resources.holidays.post({ data: { observedOn: "2024-03-01" } });
|
|
655
|
+
const book = await api.resources.books.post({
|
|
656
|
+
data: {
|
|
657
|
+
publishedOn: "2024-02-29",
|
|
658
|
+
scheduledAt: "2024-02-29T20:34:56+08:00",
|
|
659
|
+
opensAt: "09:45",
|
|
660
|
+
holiday: holiday.id
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
assert.equal(book.publishedOn, "2024-02-29");
|
|
665
|
+
assert.equal(book.scheduledAt, "2024-02-29T12:34:56.000Z");
|
|
666
|
+
assert.equal(book.opensAt, "09:45:00");
|
|
667
|
+
const document = await api.resources.books.get({
|
|
668
|
+
id: book.id,
|
|
669
|
+
format: "jsonapi",
|
|
670
|
+
queryParams: { include: ["holiday"] }
|
|
671
|
+
});
|
|
672
|
+
assert.equal(document.data.attributes.publishedOn, "2024-02-29");
|
|
673
|
+
assert.equal(document.data.attributes.scheduledAt, "2024-02-29T12:34:56.000Z");
|
|
674
|
+
assert.equal(document.included[0].attributes.observedOn, "2024-03-01");
|
|
675
|
+
await assert.rejects(
|
|
676
|
+
() => api.resources.books.patch({ id: book.id, data: { publishedOn: "2023-02-29" } }),
|
|
677
|
+
(error) => error.code === "REST_API_VALIDATION"
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("calendar date writes and responses keep leap day across server time zones", () => {
|
|
682
|
+
const hostModuleUrl = new URL("../src/server/resourceHost.js", import.meta.url).href;
|
|
683
|
+
const script = `
|
|
684
|
+
import knexLib from "knex";
|
|
685
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
686
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
687
|
+
import { createResourceHost, resourceScopeOptions } from ${JSON.stringify(hostModuleUrl)};
|
|
688
|
+
${createTemporalFixture.toString()}
|
|
689
|
+
const { api, knex } = await createTemporalFixture();
|
|
690
|
+
try {
|
|
691
|
+
const book = await api.resources.books.post({
|
|
692
|
+
data: {
|
|
693
|
+
publishedOn: "2024-02-29",
|
|
694
|
+
scheduledAt: "2024-02-29T12:34:56.000Z",
|
|
695
|
+
opensAt: "09:45:00"
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
const row = await knex("books").where({ id: book.id }).first();
|
|
699
|
+
process.stdout.write(JSON.stringify({
|
|
700
|
+
write: row.published_on,
|
|
701
|
+
publishedOn: book.publishedOn,
|
|
702
|
+
scheduledAt: book.scheduledAt,
|
|
703
|
+
opensAt: book.opensAt
|
|
704
|
+
}));
|
|
705
|
+
} finally {
|
|
706
|
+
await knex.destroy();
|
|
707
|
+
}
|
|
708
|
+
`;
|
|
709
|
+
|
|
710
|
+
for (const timezone of ["UTC", "Australia/Perth", "America/Los_Angeles", "Pacific/Kiritimati"]) {
|
|
711
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
|
|
712
|
+
encoding: "utf8",
|
|
713
|
+
env: { ...process.env, TZ: timezone }
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
assert.equal(result.status, 0, result.stderr);
|
|
717
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
718
|
+
write: Date.parse("2024-02-29T00:00:00.000Z"),
|
|
719
|
+
publishedOn: "2024-02-29",
|
|
720
|
+
scheduledAt: "2024-02-29T12:34:56.000Z",
|
|
721
|
+
opensAt: "09:45:00"
|
|
722
|
+
}, timezone);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("createResourceHost maps native database datetime strings through core reads", async (t) => {
|
|
727
|
+
const { api, knex } = await createTemporalFixture();
|
|
728
|
+
t.after(() => knex.destroy());
|
|
729
|
+
await knex("books").insert({
|
|
730
|
+
id: 1,
|
|
731
|
+
published_on: "2024-02-29",
|
|
732
|
+
scheduled_at: "2024-02-29 12:34:56.123",
|
|
733
|
+
opens_at: "09:45:00"
|
|
734
|
+
});
|
|
735
|
+
const book = await api.resources.books.get({ id: "1" });
|
|
736
|
+
assert.equal(book.scheduledAt, "2024-02-29T12:34:56.123Z");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
|
|
740
|
+
await assert.doesNotReject(async () => {
|
|
741
|
+
const result = await returnNullWhenJsonRestResourceMissing(async () => {
|
|
742
|
+
return "ok";
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
assert.equal(result, "ok");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const missing = Object.freeze({
|
|
749
|
+
code: "REST_API_RESOURCE",
|
|
750
|
+
subtype: "not_found"
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
assert.equal(await returnNullWhenJsonRestResourceMissing(async () => {
|
|
754
|
+
throw missing;
|
|
755
|
+
}), null);
|
|
756
|
+
|
|
757
|
+
const otherError = new Error("boom");
|
|
758
|
+
await assert.rejects(
|
|
759
|
+
async () => returnNullWhenJsonRestResourceMissing(async () => {
|
|
760
|
+
throw otherError;
|
|
761
|
+
}),
|
|
762
|
+
(error) => error === otherError
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("typed invalid sparse fields become a stable 400 without message matching", async () => {
|
|
767
|
+
const sparseFieldError = new RestApiFieldsetError({
|
|
768
|
+
field: "passwordHash",
|
|
769
|
+
resourceType: "contacts"
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
await assert.rejects(
|
|
773
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(async () => {
|
|
774
|
+
throw sparseFieldError;
|
|
775
|
+
}),
|
|
776
|
+
(error) => {
|
|
777
|
+
assert.equal(error.status, 400);
|
|
778
|
+
assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
|
|
779
|
+
assert.equal(error.message, sparseFieldError.message);
|
|
780
|
+
assert.equal(error.cause, sparseFieldError);
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
const messageOnlyError = new Error(sparseFieldError.message);
|
|
786
|
+
await assert.rejects(
|
|
787
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(async () => {
|
|
788
|
+
throw messageOnlyError;
|
|
789
|
+
}),
|
|
790
|
+
(error) => error === messageOnlyError
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const unrelatedError = new Error("database unavailable");
|
|
794
|
+
await assert.rejects(
|
|
795
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(async () => {
|
|
796
|
+
throw unrelatedError;
|
|
797
|
+
}),
|
|
798
|
+
(error) => error === unrelatedError
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("missing-resource mapping preserves failures from unresolved or committed writes", async () => {
|
|
803
|
+
const cause = Object.freeze({ code: "REST_API_RESOURCE", subtype: "not_found" });
|
|
804
|
+
for (const transactionOutcome of ["pending", "committed", "unknown"]) {
|
|
805
|
+
const failure = new RestApiWriteError("Missing resource", { cause, transactionOutcome });
|
|
806
|
+
await assert.rejects(
|
|
807
|
+
() => returnNullWhenJsonRestResourceMissing(() => Promise.reject(failure)),
|
|
808
|
+
(error) => error === failure
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
for (const transactionOutcome of ["none", "rolledBack"]) {
|
|
812
|
+
const failure = new RestApiWriteError("Missing resource", { cause, transactionOutcome });
|
|
813
|
+
assert.equal(await returnNullWhenJsonRestResourceMissing(() => Promise.reject(failure)), null);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("fieldset mapping retains write outcome and the original error chain", async () => {
|
|
818
|
+
const cause = Object.freeze(new RestApiFieldsetError({ field: "privateNotes", resourceType: "books" }));
|
|
819
|
+
const failure = new RestApiWriteError(cause.message, { cause, transactionOutcome: "committed" });
|
|
820
|
+
await assert.rejects(
|
|
821
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(() => Promise.reject(failure)),
|
|
822
|
+
(error) => {
|
|
823
|
+
assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
|
|
824
|
+
assert.equal(error.transactionOutcome, "committed");
|
|
825
|
+
assert.equal(error.cause, failure);
|
|
826
|
+
assert.equal(error.cause.cause, cause);
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("unrelated primitive failures remain unchanged through host error mapping", async () => {
|
|
833
|
+
for (const failure of [null, undefined, "failure", 7]) {
|
|
834
|
+
for (const mapError of [returnNullWhenJsonRestResourceMissing, returnBadRequestWhenJsonRestFieldsetInvalid]) {
|
|
835
|
+
await assert.rejects(
|
|
836
|
+
() => mapError(() => Promise.reject(failure)),
|
|
837
|
+
(error) => error === failure
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("scope resolvers understand explicit scopeValues and JSKIT visibilityContext", () => {
|
|
844
|
+
assert.equal(resolveWorkspaceScopeValue({
|
|
845
|
+
scopeValues: {
|
|
846
|
+
workspaceId: "workspace-explicit"
|
|
847
|
+
},
|
|
848
|
+
visibilityContext: {
|
|
849
|
+
scopeOwnerId: "workspace-explicit"
|
|
850
|
+
}
|
|
851
|
+
}), "workspace-explicit");
|
|
852
|
+
|
|
853
|
+
assert.equal(resolveWorkspaceScopeValue({
|
|
854
|
+
visibilityContext: {
|
|
855
|
+
scopeOwnerId: 42
|
|
856
|
+
}
|
|
857
|
+
}), "42");
|
|
858
|
+
|
|
859
|
+
assert.equal(resolveUserScopeValue({
|
|
860
|
+
scopeValues: {
|
|
861
|
+
userId: "user-explicit"
|
|
862
|
+
},
|
|
863
|
+
visibilityContext: {
|
|
864
|
+
userId: "user-explicit"
|
|
865
|
+
}
|
|
866
|
+
}), "user-explicit");
|
|
867
|
+
assert.equal(resolveUserScopeValue({ scopeValues: { userId: "user-explicit" } }), "user-explicit");
|
|
868
|
+
// Like two workspaces, two users in one context is refused instead of one of them winning.
|
|
869
|
+
assert.throws(() => resolveUserScopeValue({
|
|
870
|
+
scopeValues: { userId: "user-explicit" },
|
|
871
|
+
visibilityContext: { userId: "user-visibility" }
|
|
872
|
+
}), /safety invariant: a context names two users \(user-explicit, user-visibility\)/u);
|
|
873
|
+
|
|
874
|
+
assert.equal(resolveUserScopeValue({
|
|
875
|
+
visibilityContext: {
|
|
876
|
+
userId: 7
|
|
877
|
+
}
|
|
878
|
+
}), "7");
|
|
879
|
+
|
|
880
|
+
assert.equal(resolveWorkspaceScopeValue({
|
|
881
|
+
visibilityContext: {
|
|
882
|
+
scopeOwnerId: " "
|
|
883
|
+
}
|
|
884
|
+
}), null);
|
|
885
|
+
assert.equal(resolveUserScopeValue(null), null);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("json-rest-api's fixed-English errors render from the resource-runtime catalog", async () => {
|
|
889
|
+
const { RestApiPreconditionFailedError, RestApiVersionConflictError } = await import("json-rest-api");
|
|
890
|
+
const { catalogMessage, formatMessage, messageKeyForCode } = await import("@jskit-ai/kernel/shared/i18n");
|
|
891
|
+
const english = JSON.parse(await readFile(new URL("../locales/en.json", import.meta.url), "utf8"));
|
|
892
|
+
for (const error of [new RestApiVersionConflictError(), new RestApiPreconditionFailedError()]) {
|
|
893
|
+
assert.equal(formatMessage(catalogMessage(english, messageKeyForCode(error.code)), {}), error.message, error.code);
|
|
894
|
+
}
|
|
895
|
+
});
|