@jskit-ai/resource-runtime 0.0.0-stage → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/changeHorizon.js +26 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +137 -0
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +233 -0
- package/src/server/handleHidden.js +410 -0
- package/src/server/handleLocks.js +135 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +18 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +92 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +737 -0
- package/src/server/resourceHost.js +687 -0
- package/src/server/resourceRuntime.js +300 -0
- package/src/server/standaloneResources.js +49 -0
- package/src/server/types.js +49 -0
- package/src/test/expectFlatQueries.js +117 -0
- package/src/test/expectScoped.js +111 -0
- package/src/test/index.js +123 -0
- package/test/callSettings.test.js +100 -0
- package/test/changes.postgres.test.js +141 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +244 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +65 -0
- package/test/manyRowWrites.mysql.test.js +154 -0
- package/test/queryFields.test.js +69 -0
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +2098 -0
- package/test/storedIntegers.mysql.test.js +4 -0
- package/test/storedIntegers.postgres.test.js +5 -0
- package/test/support/storedIntegerCases.js +152 -0
- package/test/testHelpers.test.js +290 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,155 @@
|
|
|
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 { emptyInputValidator, resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
|
|
5
|
+
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
6
|
+
import { HttpProvider } from "@jskit-ai/kernel/server/http";
|
|
7
|
+
import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
8
|
+
import { createPackageTestKnex, startTestFeatures } from "../src/test/index.js";
|
|
9
|
+
|
|
10
|
+
// A route that reads its own tables without an action takes request.callOptions(): the call options the same request's
|
|
11
|
+
// action gets, from the same context contributors, so each table is scoped as the action would scope it.
|
|
12
|
+
|
|
13
|
+
const VISIBILITIES = Object.freeze(["user", "workspace", "workspace_user"]);
|
|
14
|
+
const userField = { type: "id", nullable: true, storage: { column: "user_id" } };
|
|
15
|
+
const workspaceField = { type: "id", nullable: true, storage: { column: "workspace_id" } };
|
|
16
|
+
const tables = Object.freeze([
|
|
17
|
+
defineTableResource({ name: "bookmarks", tableName: "bookmarks", ownership: "user", schema: { userId: userField, title: { type: "string" } } }),
|
|
18
|
+
defineTableResource({ name: "notes", tableName: "notes", ownership: "workspace", schema: { workspaceId: workspaceField, title: { type: "string" } } }),
|
|
19
|
+
defineTableResource({
|
|
20
|
+
name: "pins",
|
|
21
|
+
tableName: "pins",
|
|
22
|
+
ownership: "workspace_user",
|
|
23
|
+
schema: { workspaceId: workspaceField, userId: userField, title: { type: "string" } }
|
|
24
|
+
})
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// What auth-core and workspaces-core give every action of a request: the user and workspace the auth plugin resolved,
|
|
28
|
+
// and the route's visibility scope.
|
|
29
|
+
const RequestAuthProvider = defineProvider({
|
|
30
|
+
id: "test.request-auth",
|
|
31
|
+
requires: { actions: "runtime.actions", http: "runtime.http" },
|
|
32
|
+
setup({ actions, http }) {
|
|
33
|
+
actions.registerContextContributor({
|
|
34
|
+
id: "test.request-auth",
|
|
35
|
+
contribute: ({ context }) => {
|
|
36
|
+
const request = context.requestMeta?.request;
|
|
37
|
+
return { ...(request?.user ? { actor: request.user } : {}), ...(request?.workspace ? { workspace: request.workspace } : {}) };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
http.registerVisibilityResolver({
|
|
41
|
+
id: "test.visibility",
|
|
42
|
+
resolve: ({ visibility, context }) => {
|
|
43
|
+
const userId = resolveActorId(context);
|
|
44
|
+
const scopeOwnerId = context.workspace?.id;
|
|
45
|
+
if (visibility === "user") return userId ? { userId, requiresActorScope: true } : {};
|
|
46
|
+
if (visibility === "workspace") return scopeOwnerId ? { scopeKind: "workspace", scopeOwnerId } : {};
|
|
47
|
+
if (visibility === "workspace_user") {
|
|
48
|
+
return { scopeKind: "workspace_user", requiresActorScope: true, ...(scopeOwnerId ? { scopeOwnerId } : {}), ...(userId ? { userId } : {}) };
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Each table's titles the call options reach, or the owner they name none of.
|
|
58
|
+
async function readEveryTable(resources, options) {
|
|
59
|
+
const reads = {};
|
|
60
|
+
for (const { name } of tables) {
|
|
61
|
+
try {
|
|
62
|
+
reads[name] = (await resources[name].queryAll({}, options)).map(({ title }) => title).sort();
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code !== "RESOURCE_MISSING_SCOPE") throw error;
|
|
65
|
+
reads[name] = "refused";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return reads;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ReadsFeature = defineFeature({
|
|
72
|
+
id: "reads",
|
|
73
|
+
domain: "reads",
|
|
74
|
+
owns: tables,
|
|
75
|
+
requires: { http: "runtime.http" },
|
|
76
|
+
provides: { reads: "test.reads" },
|
|
77
|
+
setup({ http, resources }) {
|
|
78
|
+
const reads = (options) => readEveryTable(resources, options);
|
|
79
|
+
for (const visibility of VISIBILITIES) {
|
|
80
|
+
http.router.get(`/action/${visibility}`, { surface: "app", visibility }, async (request, reply) => {
|
|
81
|
+
reply.send(await request.executeAction({ actionId: `reads.${visibility}` }));
|
|
82
|
+
});
|
|
83
|
+
http.router.get(`/hand/${visibility}`, { surface: "app", visibility }, async (request, reply) => {
|
|
84
|
+
const options = await request.callOptions();
|
|
85
|
+
reply.send({ reads: await reads(options), visibilityContext: options.context.visibilityContext });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { reads };
|
|
89
|
+
},
|
|
90
|
+
actions: ({ reads }) => VISIBILITIES.map((visibility) => ({
|
|
91
|
+
id: `reads.${visibility}`,
|
|
92
|
+
kind: "query",
|
|
93
|
+
channels: ["api"],
|
|
94
|
+
surfaces: ["app"],
|
|
95
|
+
permission: { require: "none" },
|
|
96
|
+
input: emptyInputValidator,
|
|
97
|
+
execute: async (_input, context) => ({ reads: await reads({ context }), visibilityContext: context.visibilityContext })
|
|
98
|
+
}))
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function fastifyStub() {
|
|
102
|
+
return {
|
|
103
|
+
routes: [],
|
|
104
|
+
log: { error() {} },
|
|
105
|
+
route(value) { this.routes.push(value); },
|
|
106
|
+
addHook() {},
|
|
107
|
+
setErrorHandler() {},
|
|
108
|
+
hasContentTypeParser: () => true,
|
|
109
|
+
getDefaultJsonParser: () => (_request, body, done) => done(null, body),
|
|
110
|
+
addContentTypeParser() {}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test("a hand route's callOptions() scope user, workspace and workspace_user tables as the same request's action", async (t) => {
|
|
115
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
116
|
+
const knex = await createPackageTestKnex(t, { environment: process.env });
|
|
117
|
+
for (const [table, columns] of [["bookmarks", ["user_id"]], ["notes", ["workspace_id"]], ["pins", ["workspace_id", "user_id"]]]) {
|
|
118
|
+
await knex.schema.createTable(table, (builder) => {
|
|
119
|
+
builder.increments("id");
|
|
120
|
+
for (const column of columns) builder.integer(column);
|
|
121
|
+
builder.string("title");
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
await knex("bookmarks").insert([{ user_id: 7, title: "7's" }, { user_id: 8, title: "8's" }]);
|
|
125
|
+
await knex("notes").insert([{ workspace_id: 1, title: "in 1" }, { workspace_id: 2, title: "in 2" }]);
|
|
126
|
+
await knex("pins").insert([{ workspace_id: 1, user_id: 7, title: "7's in 1" }, { workspace_id: 1, user_id: 8, title: "8's in 1" }, { workspace_id: 2, user_id: 7, title: "7's in 2" }]);
|
|
127
|
+
const fastify = fastifyStub();
|
|
128
|
+
const { stop } = await startTestFeatures({ knex, features: [HttpProvider, RequestAuthProvider, ReadsFeature], inputs: { "runtime.fastify": fastify } });
|
|
129
|
+
t.after(stop);
|
|
130
|
+
|
|
131
|
+
async function call(path, requestValues) {
|
|
132
|
+
const route = fastify.routes.find(({ url }) => url === path);
|
|
133
|
+
const reply = { statusCode: 200, headers: {}, code() { return this; }, header() { return this; }, hasHeader: () => false, send(value) { this.payload = value; return this; } };
|
|
134
|
+
await route.handler({ params: {}, routeOptions: { config: route.config }, ...requestValues }, reply);
|
|
135
|
+
return reply.payload;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const member = { user: { id: "7" }, workspace: { id: "1" } };
|
|
139
|
+
const expected = {
|
|
140
|
+
user: [{ user: member.user }, { bookmarks: ["7's"], notes: "refused", pins: "refused" }],
|
|
141
|
+
workspace: [member, { bookmarks: "refused", notes: ["in 1"], pins: "refused" }],
|
|
142
|
+
workspace_user: [member, { bookmarks: ["7's"], notes: ["in 1"], pins: ["7's in 1"] }]
|
|
143
|
+
};
|
|
144
|
+
for (const visibility of VISIBILITIES) {
|
|
145
|
+
const [request, reads] = expected[visibility];
|
|
146
|
+
const action = await call(`/action/${visibility}`, request);
|
|
147
|
+
assert.deepEqual(await call(`/hand/${visibility}`, request), action, `${visibility} route`);
|
|
148
|
+
assert.deepEqual(action.reads, reads, `${visibility} route`);
|
|
149
|
+
// A request with no one signed in gets no user scope, exactly as its action does.
|
|
150
|
+
const anonymous = await call(`/hand/${visibility}`, {});
|
|
151
|
+
assert.deepEqual(anonymous, await call(`/action/${visibility}`, {}), `${visibility} route, signed out`);
|
|
152
|
+
assert.equal(anonymous.reads.bookmarks, "refused");
|
|
153
|
+
assert.equal(anonymous.visibilityContext.userId ?? null, null);
|
|
154
|
+
}
|
|
155
|
+
});
|