@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,5 @@
|
|
|
1
|
+
import { storedIntegerCases } from "./support/storedIntegerCases.js";
|
|
2
|
+
|
|
3
|
+
// Non-id BIGINT columns read through a handle on PostgreSQL: run with TEST_DB_NAME (and the usual DB_HOST, DB_PORT,
|
|
4
|
+
// DB_USER and DB_PASSWORD) naming a PostgreSQL database, and DB_CLIENT unset or pg.
|
|
5
|
+
storedIntegerCases("postgres");
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knexLib from "knex";
|
|
4
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
|
+
import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
|
+
import { createTestResourceRuntime } from "../../src/test/index.js";
|
|
7
|
+
|
|
8
|
+
// Whole numbers stored in BIGINT columns that are not ids (a counter, a byte count, a read position), read through a
|
|
9
|
+
// handle on MariaDB/MySQL and on PostgreSQL alike. Both drivers answer BIGINT as text (mysql2 with the runtime's
|
|
10
|
+
// bigNumberStrings, pg for every int8); a field of type "integer" reads back as a number on every handle read, and an
|
|
11
|
+
// id field as text.
|
|
12
|
+
|
|
13
|
+
const WORKSPACE = Object.freeze({ workspace: { id: "1" } });
|
|
14
|
+
const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
|
|
15
|
+
// The largest whole number a JavaScript number holds exactly (2^53 - 1), and the next odd one past it.
|
|
16
|
+
const LARGEST_SAFE = "9007199254740991";
|
|
17
|
+
const PAST_SAFE = "9007199254740993";
|
|
18
|
+
|
|
19
|
+
// A PostgreSQL test on a run for another database, such as CI's MariaDB run, is left to the PostgreSQL run.
|
|
20
|
+
const POSTGRES_ELSEWHERE = process.env.DB_CLIENT && process.env.DB_CLIENT !== "pg"
|
|
21
|
+
? `a PostgreSQL test, on a ${process.env.DB_CLIENT} run`
|
|
22
|
+
: false;
|
|
23
|
+
|
|
24
|
+
const counterResource = defineTableResource({
|
|
25
|
+
name: "counters",
|
|
26
|
+
tableName: "counters",
|
|
27
|
+
ownership: "workspace",
|
|
28
|
+
defaultSort: ["id"],
|
|
29
|
+
schema: {
|
|
30
|
+
id: { type: "id", primary: true },
|
|
31
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
32
|
+
total: { type: "integer", search: true },
|
|
33
|
+
readUpTo: { type: "integer", nullable: true, storage: { column: "read_up_to" } },
|
|
34
|
+
hiddenCount: { type: "integer", nullable: true, hidden: true, storage: { column: "hidden_count" } },
|
|
35
|
+
lastMessageId: { type: "id", nullable: true, storage: { column: "last_message_id" } }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const FIELDS = Object.freeze(["workspaceId", "total", "readUpTo", "hiddenCount", "lastMessageId"]);
|
|
39
|
+
|
|
40
|
+
/** A MariaDB/MySQL database of the test's own beside TEST_DB_NAME, dropped after it. */
|
|
41
|
+
async function mysqlKnex(context, suffix) {
|
|
42
|
+
const name = `${requireTestDatabase(process.env)}_${suffix}`;
|
|
43
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
44
|
+
const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
45
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
46
|
+
await admin.raw("CREATE DATABASE ??", [name]);
|
|
47
|
+
const knex = knexLib({ client: "mysql2", connection: { ...server, database: name, ...RUNTIME_MYSQL }, pool: { min: 0, max: 2 } });
|
|
48
|
+
context.after(async () => {
|
|
49
|
+
await knex.destroy();
|
|
50
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
51
|
+
await admin.destroy();
|
|
52
|
+
});
|
|
53
|
+
return knex;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A PostgreSQL schema of the test's own in TEST_DB_NAME, dropped after it. */
|
|
57
|
+
async function postgresKnex(context, suffix) {
|
|
58
|
+
const database = requireTestDatabase(process.env);
|
|
59
|
+
const schema = `${database}_${suffix}_${process.pid}`;
|
|
60
|
+
const connection = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 5432, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database };
|
|
61
|
+
const admin = knexLib({ client: "pg", connection, pool: { min: 0, max: 1 } });
|
|
62
|
+
await admin.raw("DROP SCHEMA IF EXISTS ?? CASCADE", [schema]);
|
|
63
|
+
await admin.raw("CREATE SCHEMA ??", [schema]);
|
|
64
|
+
const knex = knexLib({ client: "pg", connection, searchPath: [schema], pool: { min: 0, max: 2 } });
|
|
65
|
+
context.after(async () => {
|
|
66
|
+
await knex.destroy();
|
|
67
|
+
await admin.raw("DROP SCHEMA IF EXISTS ?? CASCADE", [schema]);
|
|
68
|
+
await admin.destroy();
|
|
69
|
+
});
|
|
70
|
+
return knex;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The record fields a test compares: their values with their types.
|
|
74
|
+
const typed = (record) => Object.fromEntries(
|
|
75
|
+
["id", ...FIELDS].filter((field) => Object.hasOwn(record, field)).map((field) => [field, record[field]])
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Registers the stored-integer tests on one database.
|
|
80
|
+
* @param {"mysql" | "postgres"} dialect
|
|
81
|
+
*/
|
|
82
|
+
function storedIntegerCases(dialect) {
|
|
83
|
+
const skip = dialect === "postgres" ? POSTGRES_ELSEWHERE : false;
|
|
84
|
+
const openKnex = dialect === "postgres" ? postgresKnex : mysqlKnex;
|
|
85
|
+
|
|
86
|
+
async function counters(context) {
|
|
87
|
+
const knex = await openKnex(context, "stored_ints");
|
|
88
|
+
await knex.schema.createTable("counters", (table) => {
|
|
89
|
+
table.bigIncrements("id");
|
|
90
|
+
table.bigInteger("workspace_id").nullable();
|
|
91
|
+
table.bigInteger("total").notNullable();
|
|
92
|
+
table.bigInteger("read_up_to").nullable();
|
|
93
|
+
table.bigInteger("hidden_count").nullable();
|
|
94
|
+
table.bigInteger("last_message_id").nullable();
|
|
95
|
+
});
|
|
96
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
97
|
+
const { counters: handle } = await runtime.own("counters.feature", [counterResource]);
|
|
98
|
+
return { knex, counters: handle };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test(`${dialect}: a non-id BIGINT field of type integer reads back as a number on every handle read; an id as text`, { skip }, async (context) => {
|
|
102
|
+
const { counters: handle } = await counters(context);
|
|
103
|
+
const options = { context: WORKSPACE };
|
|
104
|
+
const expected = { id: "1", workspaceId: "1", total: 5, readUpTo: Number(LARGEST_SAFE), hiddenCount: 3, lastMessageId: "11" };
|
|
105
|
+
|
|
106
|
+
const created = await handle.create({ workspaceId: "1", total: 5, readUpTo: Number(LARGEST_SAFE), hiddenCount: 3, lastMessageId: "11" }, options);
|
|
107
|
+
const { hiddenCount, ...visible } = expected;
|
|
108
|
+
assert.equal(hiddenCount, 3);
|
|
109
|
+
assert.deepEqual(typed(created), visible, "create answers the stored record without its hidden field");
|
|
110
|
+
assert.deepEqual(typed(await handle.get(created.id, { fields: FIELDS }, options)), expected);
|
|
111
|
+
assert.deepEqual((await handle.query({ fields: FIELDS, page: { size: 10 } }, options)).records.map(typed), [expected]);
|
|
112
|
+
assert.deepEqual((await handle.queryAll({ fields: FIELDS }, options)).map(typed), [expected]);
|
|
113
|
+
assert.deepEqual(typed(await handle.patch(created.id, { total: 6 }, options)), { ...visible, total: 6 });
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
(await handle.patchMany([{ id: created.id, total: 7 }], { ...options, returning: "records" })).map(typed),
|
|
116
|
+
[{ ...visible, total: 7 }]
|
|
117
|
+
);
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
(await handle.createMany([{ workspaceId: "1", total: 8 }], { ...options, returning: "records" })).map(typed),
|
|
120
|
+
[{ id: "2", workspaceId: "1", total: 8, readUpTo: null, lastMessageId: null }]
|
|
121
|
+
);
|
|
122
|
+
const locked = await handle.withTransaction(options, async (txOptions) => ({
|
|
123
|
+
lockMany: await handle.lockMany([created.id], { ...txOptions, fields: FIELDS }),
|
|
124
|
+
claim: await handle.claim({ filters: { total: 7 }, take: 1 }, { ...txOptions, fields: FIELDS })
|
|
125
|
+
}));
|
|
126
|
+
assert.deepEqual(locked.lockMany.map(typed), [{ ...expected, total: 7 }]);
|
|
127
|
+
assert.deepEqual(locked.claim.map(typed), [{ ...expected, total: 7 }]);
|
|
128
|
+
const totals = await handle.aggregate({
|
|
129
|
+
measures: { total: { sum: "total" }, highest: { max: "readUpTo" }, lowest: { min: "total" }, rows: { count: "*" } }
|
|
130
|
+
}, options);
|
|
131
|
+
assert.deepEqual(totals.map((row) => ({ ...row })), [{ total: 15, highest: Number(LARGEST_SAFE), lowest: 7, rows: 2 }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test(`${dialect}: past 2^53 a stored integer keeps its digits as text, never a rounded number; scopedQuery rows are the driver's text`, { skip }, async (context) => {
|
|
135
|
+
const { knex, counters: handle } = await counters(context);
|
|
136
|
+
const options = { context: WORKSPACE };
|
|
137
|
+
await knex("counters").insert({ workspace_id: 1, total: PAST_SAFE, read_up_to: LARGEST_SAFE, hidden_count: PAST_SAFE });
|
|
138
|
+
|
|
139
|
+
const [record] = await handle.queryAll({ fields: FIELDS }, options);
|
|
140
|
+
assert.deepEqual(typed(record), { id: "1", workspaceId: "1", total: PAST_SAFE, readUpTo: Number(LARGEST_SAFE), hiddenCount: PAST_SAFE, lastMessageId: null });
|
|
141
|
+
|
|
142
|
+
// A report's own builder answers what the driver read: BIGINT as text on both drivers, ids and counters alike,
|
|
143
|
+
// and a COUNT too, which both databases answer as a BIGINT.
|
|
144
|
+
const { query } = await handle.scopedQuery({ filters: {} }, options);
|
|
145
|
+
const rows = await query.select("id", "total", "read_up_to");
|
|
146
|
+
assert.deepEqual(rows.map((row) => ({ ...row })), [{ id: "1", total: PAST_SAFE, read_up_to: LARGEST_SAFE }]);
|
|
147
|
+
const { query: counting } = await handle.scopedQuery({ filters: {} }, options);
|
|
148
|
+
assert.deepEqual((await counting.count({ rows: "*" })).map((row) => ({ ...row })), [{ rows: "1" }]);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { storedIntegerCases };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knexFactory from "knex";
|
|
4
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
|
+
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
6
|
+
import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
7
|
+
import { createPackageTestKnex, createTestResourceRuntime, expectEveryTableOwned, expectFlatQueries, expectScoped, startTestFeatures, TestDatabaseMissingError } from "../src/test/index.js";
|
|
8
|
+
|
|
9
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
10
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
11
|
+
|
|
12
|
+
const environment = Object.freeze({ TEST_DB_NAME: "catalogue_test" });
|
|
13
|
+
|
|
14
|
+
test("expectFlatQueries() passes a path that reads its rows at once and fails one that reads them row by row", async () => {
|
|
15
|
+
const knex = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
16
|
+
try {
|
|
17
|
+
await knex.schema.createTable("books", (table) => { table.increments("id"); table.integer("shelf"); table.string("title"); });
|
|
18
|
+
let shelf = 0;
|
|
19
|
+
// Each size gets a shelf of its own, so the sizes stay apart.
|
|
20
|
+
const seed = async (size) => {
|
|
21
|
+
shelf += 1;
|
|
22
|
+
await knex("books").insert(Array.from({ length: size }, (_, index) => ({ shelf, title: `Book ${index + 1}` })));
|
|
23
|
+
return shelf;
|
|
24
|
+
};
|
|
25
|
+
const atOnce = (seeded) => knex("books").where({ shelf: seeded }).orderBy("id").pluck("title");
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
await expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: atOnce }),
|
|
28
|
+
[{ size: 3, count: 1, chunkStatements: 0 }, { size: 30, count: 1, chunkStatements: 0 }]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const rowByRow = async (seeded) => {
|
|
32
|
+
const ids = await knex("books").where({ shelf: seeded }).pluck("id");
|
|
33
|
+
for (const id of ids) await knex("books").where({ id }).first("title");
|
|
34
|
+
};
|
|
35
|
+
await assert.rejects(expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: rowByRow }), (error) => {
|
|
36
|
+
assert.equal(error.name, "AssertionError");
|
|
37
|
+
assert.equal(error.message, [
|
|
38
|
+
"The statements grow with the rows: 4 at 3 rows, 31 at 30 rows.",
|
|
39
|
+
"Repeated most at 30 rows:",
|
|
40
|
+
" 30 x select `title` from `books` where `id` = ? limit ?",
|
|
41
|
+
" 1 x select `id` from `books` where `shelf` = ?"
|
|
42
|
+
].join("\n"));
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let seeded = false;
|
|
47
|
+
await assert.rejects(
|
|
48
|
+
expectFlatQueries({ knex, environment: {}, seed: () => { seeded = true; }, run: atOnce }),
|
|
49
|
+
TestDatabaseMissingError
|
|
50
|
+
);
|
|
51
|
+
assert.equal(seeded, false);
|
|
52
|
+
for (const sizes of [[10], [200, 10], [10, 10], [0, 10], [10, 20.5]]) {
|
|
53
|
+
await assert.rejects(expectFlatQueries({ knex, environment, sizes, seed, run: atOnce }), /at least two growing whole numbers/u);
|
|
54
|
+
}
|
|
55
|
+
await assert.rejects(expectFlatQueries({ knex, environment, seed }), /requires seed\(size\) and run\(seeded, size\)/u);
|
|
56
|
+
// Counted on a channel the path does not use (another Knex client than its own), it measures nothing.
|
|
57
|
+
const elsewhere = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
58
|
+
try {
|
|
59
|
+
await assert.rejects(expectFlatQueries({ knex: elsewhere, environment, sizes: [3, 30], seed, run: atOnce }), /sent no statement at any size/u);
|
|
60
|
+
} finally {
|
|
61
|
+
await elsewhere.destroy();
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
await knex.destroy();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function createShelfFixture(t) {
|
|
69
|
+
const knex = await testKnex(t);
|
|
70
|
+
await knex.schema.createTable("books", (table) => {
|
|
71
|
+
table.increments("id");
|
|
72
|
+
table.integer("workspace_id");
|
|
73
|
+
table.string("title");
|
|
74
|
+
});
|
|
75
|
+
const books = defineTableResource({
|
|
76
|
+
name: "books",
|
|
77
|
+
tableName: "books",
|
|
78
|
+
ownership: "workspace",
|
|
79
|
+
schema: {
|
|
80
|
+
id: { type: "id", primary: true },
|
|
81
|
+
workspaceId: { type: "id", search: true, storage: { column: "workspace_id" } },
|
|
82
|
+
title: { type: "string", search: true }
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
86
|
+
const { books: handle } = await runtime.own("books.feature", [books]);
|
|
87
|
+
const systemContext = runtime.declareSystemScope("books.feature", "tests")("check scope");
|
|
88
|
+
return { knex, runtime, books: handle, systemContext };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test("expectFlatQueries() allows the later chunks a many-row write reports, and nothing else", async (t) => {
|
|
92
|
+
const { knex, books } = await createShelfFixture(t);
|
|
93
|
+
let workspace = 0;
|
|
94
|
+
const seed = () => {
|
|
95
|
+
workspace += 1;
|
|
96
|
+
return { context: { workspace: { id: String(workspace) } } };
|
|
97
|
+
};
|
|
98
|
+
const createInChunks = (options, size) => books.createMany(
|
|
99
|
+
Array.from({ length: size }, (_, index) => ({ title: `Book ${index}` })),
|
|
100
|
+
{ ...options, chunkSize: 5 }
|
|
101
|
+
);
|
|
102
|
+
const counts = await expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: createInChunks });
|
|
103
|
+
assert.deepEqual(counts.map(({ size, chunkStatements }) => [size, chunkStatements]), [[3, 0], [30, 5]]);
|
|
104
|
+
|
|
105
|
+
const oneByOne = async (options, size) => {
|
|
106
|
+
for (let index = 0; index < size; index += 1) {
|
|
107
|
+
await books.create({ title: `Book ${index}` }, { ...options, returning: "none" });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
await assert.rejects(expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: oneByOne }), /The statements grow with the rows/u);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("expectScoped() passes a path that keeps to its workspace and fails one that reaches another", async (t) => {
|
|
114
|
+
const { books, systemContext } = await createShelfFixture(t);
|
|
115
|
+
const owners = [{ context: { workspace: { id: "1" } } }, { context: { workspace: { id: "2" } } }];
|
|
116
|
+
const seed = (owner) => books.createMany([{ title: "a" }, { title: "b" }], owner);
|
|
117
|
+
const handles = [books];
|
|
118
|
+
|
|
119
|
+
const result = await expectScoped({
|
|
120
|
+
environment,
|
|
121
|
+
handles,
|
|
122
|
+
systemContext,
|
|
123
|
+
owners,
|
|
124
|
+
seed,
|
|
125
|
+
run: (owner) => books.queryAll({}, owner)
|
|
126
|
+
});
|
|
127
|
+
assert.equal(result.length, 2);
|
|
128
|
+
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
expectScoped({ environment, handles, systemContext, owners, seed, run: () => books.queryAll({}, { context: systemContext }) }),
|
|
131
|
+
/answered differently once the other owner's rows were seeded/u
|
|
132
|
+
);
|
|
133
|
+
await assert.rejects(
|
|
134
|
+
expectScoped({ environment, handles, systemContext, owners, seed, run: () => books.patchWhere({}, { title: "every" }, { context: systemContext, all: true }) }),
|
|
135
|
+
/changed books:\d+, a row of the other owner/u
|
|
136
|
+
);
|
|
137
|
+
await assert.rejects(
|
|
138
|
+
expectScoped({ environment, handles, systemContext, owners, seed, run: () => books.deleteWhere({ title: "b" }, { context: systemContext }) }),
|
|
139
|
+
/removed books:\d+, a row of the other owner/u
|
|
140
|
+
);
|
|
141
|
+
await assert.rejects(expectScoped({ environment: {}, handles, systemContext, owners, seed, run: seed }), TestDatabaseMissingError);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("expectScoped() fails a report that joins another owner's table by name, though no row of it is in the answer", async (t) => {
|
|
145
|
+
const knex = await testKnex(t);
|
|
146
|
+
await knex.schema.createTable("visits", (table) => { table.increments("id"); table.integer("workspace_id"); table.string("person"); });
|
|
147
|
+
await knex.schema.createTable("people", (table) => { table.increments("id"); table.integer("workspace_id"); table.string("name"); table.string("business"); });
|
|
148
|
+
const owned = (name, schema) => defineTableResource({
|
|
149
|
+
name,
|
|
150
|
+
tableName: name,
|
|
151
|
+
ownership: "workspace",
|
|
152
|
+
schema: { id: { type: "id", primary: true }, workspaceId: { type: "id", search: true, storage: { column: "workspace_id" } }, ...schema }
|
|
153
|
+
});
|
|
154
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
155
|
+
const { visits } = await runtime.own("visits.feature", [owned("visits", { person: { type: "string" } })]);
|
|
156
|
+
const { people } = await runtime.own("people.feature", [owned("people", { name: { type: "string" }, business: { type: "string" } })]);
|
|
157
|
+
const systemContext = runtime.declareSystemScope("visits.feature", "tests")("check scope");
|
|
158
|
+
const owners = [{ context: { workspace: { id: "1" } }, business: "business A" }, { context: { workspace: { id: "2" } }, business: "business B" }];
|
|
159
|
+
const seed = async ({ context, business }) => {
|
|
160
|
+
await visits.create({ person: "Pat" }, { context, returning: "none" });
|
|
161
|
+
await people.create({ name: "Pat", business }, { context, returning: "none" });
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Knex: report - each visit with where its person works, joining the people table by name: it reads every owner's people
|
|
165
|
+
async function visitLabels(options) {
|
|
166
|
+
const { query } = await visits.scopedQuery({ alias: "v" }, options);
|
|
167
|
+
const rows = await query.join("people", "people.name", visits.column("person", "v")).select("people.name", "people.business").orderBy("people.id");
|
|
168
|
+
return rows.map((row) => `${row.name} at ${row.business}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Each answer is text, so no row of the other owner's is in it by id; the second answer gained one.
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
expectScoped({ environment, handles: [visits, people], systemContext, owners, seed, run: (owner) => visitLabels(owner) }),
|
|
174
|
+
(error) => {
|
|
175
|
+
assert.equal(error.name, "AssertionError");
|
|
176
|
+
assert.match(error.message, /answered differently once the other owner's rows were seeded/u);
|
|
177
|
+
assert.deepEqual(error.expected, ["Pat at business A"]);
|
|
178
|
+
assert.deepEqual(error.actual, ["Pat at business A", "Pat at business B"]);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("expectScoped() compares the other owner's stored rows, so a changed hidden column or one outside the schema fails", async (t) => {
|
|
185
|
+
const knex = await testKnex(t);
|
|
186
|
+
await knex.schema.createTable("keys", (table) => {
|
|
187
|
+
table.increments("id");
|
|
188
|
+
table.integer("workspace_id");
|
|
189
|
+
table.string("label");
|
|
190
|
+
table.string("secret");
|
|
191
|
+
table.string("note");
|
|
192
|
+
});
|
|
193
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
194
|
+
const { keys } = await runtime.own("keys.feature", [defineTableResource({
|
|
195
|
+
name: "keys",
|
|
196
|
+
tableName: "keys",
|
|
197
|
+
ownership: "workspace",
|
|
198
|
+
schema: {
|
|
199
|
+
id: { type: "id", primary: true },
|
|
200
|
+
workspaceId: { type: "id", search: true, hidden: true, storage: { column: "workspace_id" } },
|
|
201
|
+
label: { type: "string", search: true },
|
|
202
|
+
secret: { type: "string", hidden: true }
|
|
203
|
+
}
|
|
204
|
+
})]);
|
|
205
|
+
const systemContext = runtime.declareSystemScope("keys.feature", "tests")("check scope");
|
|
206
|
+
const owners = [{ context: { workspace: { id: "1" } } }, { context: { workspace: { id: "2" } } }];
|
|
207
|
+
const seed = (owner) => keys.createMany([{ label: "a", secret: "s" }, { label: "b", secret: "s" }], { ...owner, returning: "none" });
|
|
208
|
+
const scoped = (run) => expectScoped({ environment, handles: [keys], systemContext, owners, seed, run });
|
|
209
|
+
|
|
210
|
+
const result = await scoped((owner) => keys.queryAll({}, owner));
|
|
211
|
+
assert.deepEqual(result.map(({ label }) => label).sort(), ["a", "b"]);
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
scoped(() => keys.patchWhere({ label: "b" }, { secret: "rewritten" }, { context: systemContext, returning: "none" })),
|
|
214
|
+
/changed keys:\d+, a row of the other owner/u
|
|
215
|
+
);
|
|
216
|
+
// Knex: write - a column outside the table resource's schema, changed for every workspace
|
|
217
|
+
await assert.rejects(scoped(() => knex("keys").update({ note: "everyone's" })), /changed keys:\d+, a row of the other owner/u);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("expectScoped() reads back only the other owner's rows, so a table past queryAll's row limit is checked too", async (t) => {
|
|
221
|
+
const { knex, books, systemContext } = await createShelfFixture(t);
|
|
222
|
+
await knex.batchInsert("books", Array.from({ length: 10_001 }, (_, index) => ({ workspace_id: 3, title: `Old ${index}` })), 500);
|
|
223
|
+
const owners = [{ context: { workspace: { id: "1" } } }, { context: { workspace: { id: "2" } } }];
|
|
224
|
+
const result = await expectScoped({
|
|
225
|
+
environment,
|
|
226
|
+
handles: [books],
|
|
227
|
+
systemContext,
|
|
228
|
+
owners,
|
|
229
|
+
seed: (owner) => books.createMany([{ title: "a" }, { title: "b" }], owner),
|
|
230
|
+
run: (owner) => books.queryAll({}, owner)
|
|
231
|
+
});
|
|
232
|
+
assert.deepEqual(result.map(({ title }) => title).sort(), ["a", "b"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("expectEveryTableOwned() names each table no feature owns", async (t) => {
|
|
236
|
+
const { knex, runtime } = await createShelfFixture(t);
|
|
237
|
+
await expectEveryTableOwned({ knex, runtime, environment });
|
|
238
|
+
await knex.schema.createTable("loose_notes", (table) => table.increments("id"));
|
|
239
|
+
await assert.rejects(expectEveryTableOwned({ knex, runtime, environment }), /Tables no feature owns: loose_notes\./u);
|
|
240
|
+
await expectEveryTableOwned({ knex, runtime, infrastructureTables: ["loose_notes"], environment });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("startTestFeatures() starts features in the order their requires give, on tables it creates, and boots them", async (t) => {
|
|
244
|
+
const knex = await testKnex(t);
|
|
245
|
+
const shelves = defineTableResource({
|
|
246
|
+
name: "shelves",
|
|
247
|
+
tableName: "shelves",
|
|
248
|
+
ownership: "workspace",
|
|
249
|
+
schema: { workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } }, label: { type: "string", required: true } }
|
|
250
|
+
});
|
|
251
|
+
const order = [];
|
|
252
|
+
const ShelvesFeature = defineFeature({
|
|
253
|
+
id: "shelves",
|
|
254
|
+
owns: [shelves],
|
|
255
|
+
systemScope: "count every workspace's shelves",
|
|
256
|
+
provides: { shelfRecords: "shelves.records" },
|
|
257
|
+
setup: ({ resources, systemContext }) => {
|
|
258
|
+
order.push("shelves");
|
|
259
|
+
return {
|
|
260
|
+
shelfRecords: Object.freeze({
|
|
261
|
+
add: (label, options) => resources.shelves.create({ label }, options),
|
|
262
|
+
countAll: () => resources.shelves.aggregate({ measures: { shelves: { count: "id" } } }, { context: systemContext("count every shelf") })
|
|
263
|
+
})
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
// Listed first, it still starts after the feature whose records it requires, and its boot runs after every setup.
|
|
268
|
+
const ReportsFeature = defineFeature({
|
|
269
|
+
id: "reports",
|
|
270
|
+
requires: { shelfRecords: "shelves.records" },
|
|
271
|
+
provides: { reports: "reports" },
|
|
272
|
+
setup: ({ shelfRecords }) => {
|
|
273
|
+
order.push("reports");
|
|
274
|
+
return { reports: Object.freeze({ shelfCount: async () => (await shelfRecords.countAll())[0].shelves }) };
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
const Sealer = defineProvider({ id: "test.sealer", setup: () => ({}), boot: () => { order.push("boot"); } });
|
|
278
|
+
const { capabilities, resources, stop } = await startTestFeatures({
|
|
279
|
+
knex,
|
|
280
|
+
features: [ReportsFeature, Sealer, ShelvesFeature],
|
|
281
|
+
requires: { reports: "reports", shelfRecords: "shelves.records" },
|
|
282
|
+
createTables: true
|
|
283
|
+
});
|
|
284
|
+
t.after(stop);
|
|
285
|
+
await capabilities.shelfRecords.add("fiction", { context: { workspace: { id: "1" } } });
|
|
286
|
+
await capabilities.shelfRecords.add("poetry", { context: { workspace: { id: "2" } } });
|
|
287
|
+
assert.equal(await capabilities.reports.shelfCount(), 2);
|
|
288
|
+
assert.deepEqual(order, ["shelves", "reports", "boot"]);
|
|
289
|
+
assert.deepEqual(resources.ownedTables(), [{ name: "shelves", tableName: "shelves", featureId: "shelves" }]);
|
|
290
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolveWorkspaceScopeValue } from "../src/server/resourceHost.js";
|
|
4
|
+
|
|
5
|
+
test("resolveWorkspaceScopeValue reads one workspace id from any of its sources", () => {
|
|
6
|
+
const all = {
|
|
7
|
+
workspace: { id: "1" },
|
|
8
|
+
requestMeta: {
|
|
9
|
+
resolvedWorkspaceContext: { workspace: { id: "1" } },
|
|
10
|
+
request: { workspace: { id: 1 } }
|
|
11
|
+
},
|
|
12
|
+
visibilityContext: { scopeOwnerId: "1" },
|
|
13
|
+
scopeValues: { workspaceId: 1 }
|
|
14
|
+
};
|
|
15
|
+
assert.equal(resolveWorkspaceScopeValue(all), "1");
|
|
16
|
+
assert.equal(resolveWorkspaceScopeValue({ workspace: { id: "2" } }), "2");
|
|
17
|
+
assert.equal(resolveWorkspaceScopeValue({ requestMeta: { resolvedWorkspaceContext: { workspace: { id: "3" } } } }), "3");
|
|
18
|
+
assert.equal(resolveWorkspaceScopeValue({ requestMeta: { request: { workspace: { id: "4" } } } }), "4");
|
|
19
|
+
assert.equal(resolveWorkspaceScopeValue({ scopeValues: { workspaceId: 5 } }), "5");
|
|
20
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "workspace_user", scopeKind: "workspace_user", scopeOwnerId: "6" } }), "6");
|
|
21
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } }), "7");
|
|
22
|
+
assert.equal(resolveWorkspaceScopeValue({}), null);
|
|
23
|
+
assert.equal(resolveWorkspaceScopeValue(), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("resolveWorkspaceScopeValue ignores the owner of a visibility scope that is not a workspace's", () => {
|
|
27
|
+
const scopeValues = { workspaceId: "4" };
|
|
28
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "user", scopeOwnerId: "9" }, scopeValues }), "4");
|
|
29
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "public", scopeOwnerId: "9" } }), null);
|
|
30
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "workspace", scopeKind: "team", scopeOwnerId: "9" } }), null);
|
|
31
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: null, scopeValues }), "4");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("resolveWorkspaceScopeValue refuses a context that names two workspaces", () => {
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => resolveWorkspaceScopeValue({
|
|
37
|
+
workspace: { id: "1" },
|
|
38
|
+
requestMeta: { resolvedWorkspaceContext: { workspace: { id: "2" } } }
|
|
39
|
+
}),
|
|
40
|
+
{ message: "safety invariant: a context names two workspaces (1, 2); one execution acts on one workspace." }
|
|
41
|
+
);
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => resolveWorkspaceScopeValue({
|
|
44
|
+
visibilityContext: { visibility: "workspace", scopeOwnerId: "3" },
|
|
45
|
+
scopeValues: { workspaceId: "4" }
|
|
46
|
+
}),
|
|
47
|
+
{ message: "safety invariant: a context names two workspaces (3, 4); one execution acts on one workspace." }
|
|
48
|
+
);
|
|
49
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knexLib from "knex";
|
|
4
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
|
+
import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
|
+
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
7
|
+
|
|
8
|
+
// Values a handle writes and compares on MySQL or MariaDB: timestamps a job sets itself, with milliseconds, on a
|
|
9
|
+
// table that stores whole seconds, and a patch's onlyIf with no changes, compared in SQL.
|
|
10
|
+
|
|
11
|
+
const WORKSPACE = Object.freeze({ workspace: { id: "1" } });
|
|
12
|
+
const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
|
|
13
|
+
|
|
14
|
+
const eventResource = defineTableResource({
|
|
15
|
+
name: "events",
|
|
16
|
+
tableName: "events",
|
|
17
|
+
ownership: "workspace",
|
|
18
|
+
defaultSort: ["id"],
|
|
19
|
+
schema: {
|
|
20
|
+
id: { type: "id", primary: true },
|
|
21
|
+
workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
|
|
22
|
+
ownerUserId: { type: "id", nullable: true, storage: { column: "owner_user_id" } },
|
|
23
|
+
title: { type: "string", required: true },
|
|
24
|
+
secret: { type: "string", nullable: true, hidden: true },
|
|
25
|
+
createdAt: { type: "dateTime", temporalPrecision: 0, readOnly: true, storage: { column: "created_at" } },
|
|
26
|
+
updatedAt: { type: "dateTime", temporalPrecision: 0, readOnly: true, storage: { column: "updated_at" } }
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** A database of the test's own beside TEST_DB_NAME, with one events table storing whole seconds, dropped after it. */
|
|
31
|
+
async function ownDatabase(context, suffix) {
|
|
32
|
+
const name = `${requireTestDatabase(process.env)}_${suffix}`;
|
|
33
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
34
|
+
const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
35
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
36
|
+
await admin.raw("CREATE DATABASE ??", [name]);
|
|
37
|
+
const knex = knexLib({ client: "mysql2", connection: { ...server, database: name, ...RUNTIME_MYSQL }, pool: { min: 0, max: 2 } });
|
|
38
|
+
context.after(async () => {
|
|
39
|
+
await knex.destroy();
|
|
40
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
41
|
+
await admin.destroy();
|
|
42
|
+
});
|
|
43
|
+
await knex.schema.createTable("events", (table) => {
|
|
44
|
+
table.increments("id");
|
|
45
|
+
table.integer("workspace_id").nullable();
|
|
46
|
+
table.integer("owner_user_id").nullable();
|
|
47
|
+
table.string("title").notNullable();
|
|
48
|
+
table.string("secret").nullable();
|
|
49
|
+
table.datetime("created_at", { precision: 0 }).notNullable();
|
|
50
|
+
table.datetime("updated_at", { precision: 0 }).notNullable();
|
|
51
|
+
});
|
|
52
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
53
|
+
const { events } = await runtime.own("events.feature", [eventResource]);
|
|
54
|
+
const job = runtime.declareSystemScope("events.feature", "import past events")("import the events");
|
|
55
|
+
return { knex, events, job };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The stored timestamps of a row, as UTC text.
|
|
59
|
+
async function storedTimes(knex, id) {
|
|
60
|
+
const row = await knex("events").where({ id }).first("created_at", "updated_at");
|
|
61
|
+
return [row.created_at.toISOString(), row.updated_at.toISOString()];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("a job's own timestamps with milliseconds are stored to the whole second on a temporalPrecision 0 table", async (t) => {
|
|
65
|
+
const { knex, events, job } = await ownDatabase(t, "job_stamps");
|
|
66
|
+
const options = { context: job };
|
|
67
|
+
// A job's Date#toISOString(): its milliseconds are cut, as the handle cuts its own stamps, never rounded up.
|
|
68
|
+
const created = await events.create(
|
|
69
|
+
{ workspaceId: "1", title: "imported", createdAt: "2026-01-02T03:04:05.956Z", updatedAt: "2026-01-02T03:04:06.250Z" },
|
|
70
|
+
options
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(await storedTimes(knex, created.id), ["2026-01-02T03:04:05.000Z", "2026-01-02T03:04:06.000Z"]);
|
|
73
|
+
await events.patch(created.id, { title: "renamed", updatedAt: "2026-02-03T04:05:06.999Z" }, options);
|
|
74
|
+
assert.deepEqual(await storedTimes(knex, created.id), ["2026-01-02T03:04:05.000Z", "2026-02-03T04:05:06.000Z"]);
|
|
75
|
+
|
|
76
|
+
const ids = await events.createMany([
|
|
77
|
+
{ workspaceId: "1", title: "a", createdAt: "2026-03-04T05:06:07.123Z", updatedAt: "2026-03-04T05:06:07.123Z" },
|
|
78
|
+
{ workspaceId: "1", title: "b", createdAt: "2026-03-04T05:06:08.987Z", updatedAt: "2026-03-04T05:06:08.987Z" }
|
|
79
|
+
], { ...options, returning: "ids" });
|
|
80
|
+
assert.deepEqual(await storedTimes(knex, ids[1]), ["2026-03-04T05:06:08.000Z", "2026-03-04T05:06:08.000Z"]);
|
|
81
|
+
await events.patchMany([{ id: ids[0], updatedAt: "2026-04-05T06:07:08.765Z" }], options);
|
|
82
|
+
assert.deepEqual(await storedTimes(knex, ids[0]), ["2026-03-04T05:06:07.000Z", "2026-04-05T06:07:08.000Z"]);
|
|
83
|
+
await events.patchWhere({ id: ids[1] }, { updatedAt: "2026-05-06T07:08:09.5Z" }, options);
|
|
84
|
+
assert.deepEqual(await storedTimes(knex, ids[1]), ["2026-03-04T05:06:08.000Z", "2026-05-06T07:08:09.000Z"]);
|
|
85
|
+
|
|
86
|
+
// A request's timestamps are the handle's, whole seconds too.
|
|
87
|
+
const requested = await events.create({ workspaceId: "1", title: "asked" }, { context: WORKSPACE });
|
|
88
|
+
const [requestCreated] = await storedTimes(knex, requested.id);
|
|
89
|
+
assert.match(requestCreated, /\.000Z$/u);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a patch with onlyIf and no changes compares in SQL: an id given as a number or as text, and a hidden field, match", async (t) => {
|
|
93
|
+
const { events } = await ownDatabase(t, "only_if");
|
|
94
|
+
const options = { context: WORKSPACE };
|
|
95
|
+
const event = await events.create({ title: "talk", ownerUserId: "7", secret: "s1" }, options);
|
|
96
|
+
// A record never answers the hidden secret, so only SQL can compare it.
|
|
97
|
+
assert.equal(Object.hasOwn(event, "secret"), false);
|
|
98
|
+
for (const missing of ["throw", "skip"]) {
|
|
99
|
+
const call = { ...options, missing };
|
|
100
|
+
// The id compares whether the caller gives it as a number or as text, however the record answers it.
|
|
101
|
+
assert.equal((await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: 7 } }))?.id, event.id, missing);
|
|
102
|
+
assert.equal((await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: "7" } }))?.id, event.id, missing);
|
|
103
|
+
assert.equal((await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: "7", secret: "s1" } }))?.id, event.id, missing);
|
|
104
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: 8 } }), null, missing);
|
|
105
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { secret: "other" } }), null, missing);
|
|
106
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { secret: null } }), null, missing);
|
|
107
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { title: "talk" }, returning: "id" }), event.id, missing);
|
|
108
|
+
}
|
|
109
|
+
// Another workspace's caller does not see the record, whatever onlyIf holds.
|
|
110
|
+
assert.equal(await events.patch(event.id, {}, { context: { workspace: { id: "2" } }, missing: "skip", onlyIf: { ownerUserId: 7 } }), null);
|
|
111
|
+
});
|
package/README.md
DELETED