@tablecraft-app/plugin-api 1.1.0 → 1.3.0
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/dist/errors.d.ts +10 -1
- package/dist/errors.js +18 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/keyspace.d.ts +192 -0
- package/dist/keyspace.js +2 -0
- package/dist/manifest.d.ts +14 -0
- package/dist/manifest.js +65 -3
- package/dist/plugin.d.ts +117 -1
- package/dist/plugin.js +9 -0
- package/dist/testing/index.d.ts +113 -3
- package/dist/testing/index.js +357 -18
- package/dist/url.d.ts +3 -1
- package/dist/url.js +11 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z, type DatasourcePlugin, type PluginManifest, type Routine, type RoutineArgument, type Row, type ServerMessage } from "@tablecraft-app/plugin-api";
|
|
1
|
+
import { z, type DatasourcePlugin, type KeyChange, type KeyContents, type KeySource, type KeyType, type PluginManifest, type Routine, type RoutineArgument, type Row, type ServerMessage } from "@tablecraft-app/plugin-api";
|
|
2
2
|
export interface ContractFixture {
|
|
3
3
|
/** The plugin's parsed `table-craft-plugin.json`, whose connection form the suite checks. */
|
|
4
4
|
readonly manifest: PluginManifest;
|
|
@@ -32,7 +32,8 @@ export interface ContractFixture {
|
|
|
32
32
|
readonly offset: number;
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
|
-
|
|
35
|
+
/** Required when the plugin declares `sql`. */
|
|
36
|
+
readonly typeProbe?: {
|
|
36
37
|
/** The rows of `SELECT * FROM type_probe ORDER BY id`, as wire values. */
|
|
37
38
|
readonly expected: readonly Row[];
|
|
38
39
|
/**
|
|
@@ -68,10 +69,19 @@ export interface ContractFixture {
|
|
|
68
69
|
/** A switch to a namespace that does not exist, where the dialect rejects one. */
|
|
69
70
|
readonly missing?: string;
|
|
70
71
|
};
|
|
71
|
-
/** Required when the plugin declares `
|
|
72
|
+
/** Required when the plugin declares `commands` (decision 112). */
|
|
73
|
+
readonly commands?: CommandsFixture;
|
|
74
|
+
/** Required when the plugin declares `keyValue` (decision 113). */
|
|
75
|
+
readonly keyspace?: KeyspaceFixture;
|
|
76
|
+
/**
|
|
77
|
+
* Required when the plugin declares `sessions`, which also needs `statements.sleep`, and
|
|
78
|
+
* `statements.write` where it declares `transactions`.
|
|
79
|
+
*/
|
|
72
80
|
readonly sessions?: {
|
|
73
81
|
/** Connects as an account that sees every session and may stop any, such as a superuser. */
|
|
74
82
|
readonly monitorConfig: unknown;
|
|
83
|
+
/** What a listing shows as the sleeping statement's query; the statement itself by default. */
|
|
84
|
+
readonly sleepQuery?: string;
|
|
75
85
|
/** An account that sees only its own sessions, and what the listing's note must say about it. */
|
|
76
86
|
readonly limited?: {
|
|
77
87
|
readonly config: unknown;
|
|
@@ -119,6 +129,106 @@ export interface ContractSql {
|
|
|
119
129
|
/** Opens a transaction explicitly in auto-commit mode. */
|
|
120
130
|
readonly begin?: string;
|
|
121
131
|
}
|
|
132
|
+
/** A command and the result tab it opens: column names and rows as wire values. */
|
|
133
|
+
export interface CommandRows {
|
|
134
|
+
readonly command: string;
|
|
135
|
+
readonly columns: readonly string[];
|
|
136
|
+
readonly rows: readonly Row[];
|
|
137
|
+
}
|
|
138
|
+
/** What a `commands` plugin's console runs against the fixture database (decision 112). */
|
|
139
|
+
export interface CommandsFixture {
|
|
140
|
+
/** Scripts the language splits, and the commands it must find in each. */
|
|
141
|
+
readonly scripts: readonly SplitCase[];
|
|
142
|
+
/** A command answered with one scalar, and the text Output shows for the reply. */
|
|
143
|
+
readonly scalar: {
|
|
144
|
+
readonly command: string;
|
|
145
|
+
readonly text: string;
|
|
146
|
+
};
|
|
147
|
+
/** A command answered with an array. */
|
|
148
|
+
readonly array: CommandRows;
|
|
149
|
+
/** A command answered with a map. */
|
|
150
|
+
readonly map: CommandRows;
|
|
151
|
+
/** A command answering bytes that are not UTF-8, and those bytes in hex. */
|
|
152
|
+
readonly binary: {
|
|
153
|
+
readonly command: string;
|
|
154
|
+
readonly hex: string;
|
|
155
|
+
};
|
|
156
|
+
/** A write a read-only session refuses, and the command name the refusal carries. */
|
|
157
|
+
readonly write: {
|
|
158
|
+
readonly command: string;
|
|
159
|
+
readonly name: string;
|
|
160
|
+
};
|
|
161
|
+
/** A command classified costly, and the text of the first quick fix its problem offers. */
|
|
162
|
+
readonly costly: {
|
|
163
|
+
readonly command: string;
|
|
164
|
+
readonly fixed: string;
|
|
165
|
+
};
|
|
166
|
+
/** A command classified destructive and confirmed by the datasource's name. */
|
|
167
|
+
readonly confirmByName: string;
|
|
168
|
+
}
|
|
169
|
+
/** The keys a `keyValue` plugin's fixture database carries (decision 113). */
|
|
170
|
+
export interface KeyspaceFixture {
|
|
171
|
+
/** The database the fixture keys live in. */
|
|
172
|
+
readonly database: number;
|
|
173
|
+
/** A glob, and how many keys it matches: enough for many scan batches. */
|
|
174
|
+
readonly scan: {
|
|
175
|
+
readonly match: string;
|
|
176
|
+
readonly count: number;
|
|
177
|
+
readonly batch: number;
|
|
178
|
+
};
|
|
179
|
+
/** A glob and a type, and the names of the keys of that type it matches. */
|
|
180
|
+
readonly typed: {
|
|
181
|
+
readonly match: string;
|
|
182
|
+
readonly type: KeyType;
|
|
183
|
+
readonly keys: readonly string[];
|
|
184
|
+
};
|
|
185
|
+
/** A key with an expiry. */
|
|
186
|
+
readonly expiring: string;
|
|
187
|
+
/** A key whose name is not valid UTF-8, as lower-case hex, a glob that finds it, and its value. */
|
|
188
|
+
readonly binary: {
|
|
189
|
+
readonly hex: string;
|
|
190
|
+
readonly match: string;
|
|
191
|
+
readonly type: KeyType;
|
|
192
|
+
readonly value: string;
|
|
193
|
+
};
|
|
194
|
+
/** A hash key and its first two columns' rows, in any order. */
|
|
195
|
+
readonly hash: KeyRowsFixture;
|
|
196
|
+
/** A sorted-set key and its member and score cells, in rank order. */
|
|
197
|
+
readonly zset: KeyRowsFixture;
|
|
198
|
+
/** A stream key, its columns and its rows, in id order. */
|
|
199
|
+
readonly stream: KeyRowsFixture & {
|
|
200
|
+
readonly columns: readonly string[];
|
|
201
|
+
};
|
|
202
|
+
/** A scratch key the suite creates, changes and removes. */
|
|
203
|
+
readonly edit: KeyEditFixture;
|
|
204
|
+
/**
|
|
205
|
+
* Commands run in a console and the key each reply names as its source, null for none
|
|
206
|
+
* (decision 119): one that reads a whole key, one that reads part of one, and one that reads
|
|
207
|
+
* several. Without it the reply-source tests are skipped.
|
|
208
|
+
*/
|
|
209
|
+
readonly sources?: readonly KeySourceFixture[];
|
|
210
|
+
}
|
|
211
|
+
/** A command and the source its reply names (decision 119). */
|
|
212
|
+
export interface KeySourceFixture {
|
|
213
|
+
readonly command: string;
|
|
214
|
+
readonly source: KeySource | null;
|
|
215
|
+
/** The reply is a value read, which the console opens as a one-row result. */
|
|
216
|
+
readonly read?: boolean;
|
|
217
|
+
}
|
|
218
|
+
/** How the suite changes one element of a scratch key and reads it back (decision 115). */
|
|
219
|
+
export interface KeyEditFixture {
|
|
220
|
+
readonly key: string;
|
|
221
|
+
readonly type: KeyType;
|
|
222
|
+
/** A change setting the element to `value`, read as `original` (null: it did not exist). */
|
|
223
|
+
readonly change: (value: string, original: string | null) => KeyChange;
|
|
224
|
+
/** The element's value in what `read` returned; null when it is missing. */
|
|
225
|
+
readonly valueOf: (contents: KeyContents) => Promise<string | null>;
|
|
226
|
+
}
|
|
227
|
+
/** A key and the rows reading it gives. */
|
|
228
|
+
export interface KeyRowsFixture {
|
|
229
|
+
readonly key: string;
|
|
230
|
+
readonly rows: readonly Row[];
|
|
231
|
+
}
|
|
122
232
|
/** One routine call: which routine (from `routines.expected`), its arguments, and the rows it returns. */
|
|
123
233
|
export interface RoutineCase {
|
|
124
234
|
readonly name: string;
|
package/dist/testing/index.js
CHANGED
|
@@ -122,6 +122,10 @@ export function contractSql(fixture) {
|
|
|
122
122
|
const ROWS_READ_BEFORE_RETURN = 3;
|
|
123
123
|
/** Longer than `BYTES_PREVIEW_LENGTH` and within every fixture's `payload` column (MySQL's is `VARBINARY(64)`). */
|
|
124
124
|
const LONG_BYTES_LENGTH = 48;
|
|
125
|
+
/** Stops a scan pass that never ends, so a broken cursor fails rather than hangs. */
|
|
126
|
+
const MAX_SCAN_BATCHES = 10_000;
|
|
127
|
+
const READ_WINDOW = 100;
|
|
128
|
+
const SLOW_LOG_LIMIT = 5;
|
|
125
129
|
const LONG_BYTES_HEX = "c3";
|
|
126
130
|
const HEX_DIGITS_PER_BYTE = 2;
|
|
127
131
|
/** Created and dropped by the import-type case. */
|
|
@@ -182,6 +186,16 @@ async function collect(rows) {
|
|
|
182
186
|
collected.push(row);
|
|
183
187
|
return collected;
|
|
184
188
|
}
|
|
189
|
+
function typeProbeOf(fixture) {
|
|
190
|
+
if (fixture.typeProbe === undefined)
|
|
191
|
+
throw new Error("A plugin that declares sql needs fixture.typeProbe.");
|
|
192
|
+
return fixture.typeProbe;
|
|
193
|
+
}
|
|
194
|
+
function commandsOf(fixture) {
|
|
195
|
+
if (fixture.commands === undefined)
|
|
196
|
+
throw new Error("A plugin that declares commands needs fixture.commands.");
|
|
197
|
+
return fixture.commands;
|
|
198
|
+
}
|
|
185
199
|
function catalogPathOf(fixture) {
|
|
186
200
|
if (fixture.catalog === undefined)
|
|
187
201
|
throw new Error("A plugin that declares schemaTree needs fixture.catalog.");
|
|
@@ -316,6 +330,15 @@ export function runPluginContract(plugin, fixture) {
|
|
|
316
330
|
const fold = (name) => (name === LINE_ITEMS_TABLE ? name : (fixture.foldName?.(name) ?? name));
|
|
317
331
|
const selectOne = dialectSql.select("1 AS one");
|
|
318
332
|
const dropIfExists = dialectSql.dropTableIfExists;
|
|
333
|
+
/** Shows `session` still answers: a one-row SELECT, or the fixture's scalar command. */
|
|
334
|
+
async function expectAlive(session) {
|
|
335
|
+
if (plugin.capabilities.includes("commands")) {
|
|
336
|
+
const { command, text } = commandsOf(fixture).scalar;
|
|
337
|
+
expect((await session.execute(command)).reply?.text).toBe(text);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
341
|
+
}
|
|
319
342
|
describe(`plugin contract: ${plugin.id}`, () => {
|
|
320
343
|
it("givenPlugin_whenInspected_thenDeclaresOnlyKnownCapabilities", () => {
|
|
321
344
|
for (const capability of plugin.capabilities)
|
|
@@ -346,6 +369,28 @@ export function runPluginContract(plugin, fixture) {
|
|
|
346
369
|
it("givenPlugin_whenInspected_thenHasLanguageExactlyWhenSqlDeclared", () => {
|
|
347
370
|
expect(plugin.language !== undefined).toBe(plugin.capabilities.includes("sql"));
|
|
348
371
|
});
|
|
372
|
+
it("givenPlugin_whenInspected_thenHasCommandLanguageExactlyWhenCommandsDeclared", () => {
|
|
373
|
+
const commands = plugin.capabilities.includes("commands");
|
|
374
|
+
if (commands)
|
|
375
|
+
expect(plugin.capabilities).not.toContain("sql");
|
|
376
|
+
expect(plugin.commandLanguage !== undefined).toBe(commands);
|
|
377
|
+
});
|
|
378
|
+
it("givenSession_whenInspected_thenHasKeyspaceExactlyWhenKeyValueDeclared", async () => {
|
|
379
|
+
const keyValue = plugin.capabilities.includes("keyValue");
|
|
380
|
+
if (keyValue)
|
|
381
|
+
expect(plugin.capabilities).toContain("commands");
|
|
382
|
+
await withSession(plugin, fixture, (session) => {
|
|
383
|
+
expect(session.keyspace !== undefined).toBe(keyValue);
|
|
384
|
+
return Promise.resolve();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
it("givenSession_whenInspected_thenHasNodesAndServerInfoExactlyWhenDeclared", async () => {
|
|
388
|
+
await withSession(plugin, fixture, (session) => {
|
|
389
|
+
expect(session.nodes !== undefined).toBe(plugin.capabilities.includes("nodes"));
|
|
390
|
+
expect(session.serverInfo !== undefined).toBe(plugin.capabilities.includes("serverInfo"));
|
|
391
|
+
return Promise.resolve();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
349
394
|
it("givenSession_whenInspected_thenHasTransactionsExactlyWhenDeclared", async () => {
|
|
350
395
|
await withSession(plugin, fixture, (session) => {
|
|
351
396
|
expect(session.transactions !== undefined).toBe(plugin.capabilities.includes("transactions"));
|
|
@@ -412,8 +457,10 @@ export function runPluginContract(plugin, fixture) {
|
|
|
412
457
|
});
|
|
413
458
|
it("givenSession_whenInspected_thenHasSessionMonitorExactlyWhenSessionsDeclared", async () => {
|
|
414
459
|
const declared = plugin.capabilities.includes("sessions");
|
|
415
|
-
if (declared)
|
|
416
|
-
expect(plugin.capabilities
|
|
460
|
+
if (declared) {
|
|
461
|
+
expect(plugin.capabilities.includes("sql") || plugin.capabilities.includes("commands")).toBe(true);
|
|
462
|
+
expect(plugin.capabilities.includes("schemaTree") || plugin.capabilities.includes("keyValue")).toBe(true);
|
|
463
|
+
}
|
|
417
464
|
await withSession(plugin, fixture, (session) => {
|
|
418
465
|
expect(session.sessions !== undefined).toBe(declared);
|
|
419
466
|
expect(typeof session.serverSessionId === "string").toBe(declared);
|
|
@@ -492,7 +539,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
492
539
|
await withSession(plugin, fixture, async (session) => {
|
|
493
540
|
const result = await session.execute(`SELECT * FROM ${TYPE_PROBE_TABLE} ORDER BY id`);
|
|
494
541
|
expect(result.columns.map((column) => column.name)).toEqual(TYPE_PROBE_COLUMNS.map(fold));
|
|
495
|
-
expect(await collect(result.rows)).toEqual(fixture.
|
|
542
|
+
expect(await collect(result.rows)).toEqual(typeProbeOf(fixture).expected);
|
|
496
543
|
});
|
|
497
544
|
});
|
|
498
545
|
describe("bound parameters", () => {
|
|
@@ -575,7 +622,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
575
622
|
it("givenTimestamp_whenSelected_thenTextIsUnshiftedByClientTimeZone", async () => {
|
|
576
623
|
const momentColumn = TYPE_PROBE_COLUMNS.indexOf("moment");
|
|
577
624
|
const dayColumn = TYPE_PROBE_COLUMNS.indexOf("day");
|
|
578
|
-
const expected = fixture.
|
|
625
|
+
const expected = typeProbeOf(fixture).expected.find((row) => row[0] === PROBE_ROW_ID);
|
|
579
626
|
await withSession(plugin, fixture, async (session) => {
|
|
580
627
|
const result = await session.execute(`SELECT day, moment FROM ${TYPE_PROBE_TABLE} WHERE id = ${PROBE_ROW_ID}`);
|
|
581
628
|
expect(await collect(result.rows)).toEqual([[expected?.[dayColumn], expected?.[momentColumn]]]);
|
|
@@ -644,13 +691,13 @@ export function runPluginContract(plugin, fixture) {
|
|
|
644
691
|
const rows = await collect(source.rows);
|
|
645
692
|
const names = source.columns.map((column) => dialect.quoteIdentifier(column.name)).join(", ");
|
|
646
693
|
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
647
|
-
await run(session, fixture.
|
|
694
|
+
await run(session, typeProbeOf(fixture).copyTable);
|
|
648
695
|
try {
|
|
649
696
|
for (const row of rows) {
|
|
650
697
|
const values = row.map((value, index) => dialect.literal(value, source.columns[index]?.type ?? null));
|
|
651
698
|
await run(session, `INSERT INTO ${dialect.quoteIdentifier(fold(TYPE_PROBE_COPY_TABLE))} (${names}) VALUES (${values.join(", ")})`);
|
|
652
699
|
}
|
|
653
|
-
expect(await run(session, `SELECT * FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(fixture.
|
|
700
|
+
expect(await run(session, `SELECT * FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(typeProbeOf(fixture).expected);
|
|
654
701
|
}
|
|
655
702
|
finally {
|
|
656
703
|
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
@@ -666,7 +713,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
666
713
|
const payloadType = probe.columns[1]?.type ?? null;
|
|
667
714
|
await collect(probe.rows);
|
|
668
715
|
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
669
|
-
await run(session, fixture.
|
|
716
|
+
await run(session, typeProbeOf(fixture).copyTable);
|
|
670
717
|
try {
|
|
671
718
|
const payload = dialect.literal({ kind: "bytes", preview: whole, length: LONG_BYTES_LENGTH }, payloadType);
|
|
672
719
|
await run(session, `INSERT INTO ${dialect.quoteIdentifier(fold(TYPE_PROBE_COPY_TABLE))} (id, payload) VALUES (${PROBE_ROW_ID}, ${payload})`);
|
|
@@ -759,7 +806,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
759
806
|
/** The copy table as the engine targets it: named and typed by the introspector. */
|
|
760
807
|
async function copyTarget(session) {
|
|
761
808
|
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
762
|
-
await run(session, fixture.
|
|
809
|
+
await run(session, typeProbeOf(fixture).copyTable);
|
|
763
810
|
const introspector = session.introspector;
|
|
764
811
|
if (introspector === undefined) {
|
|
765
812
|
const probe = await session.execute(`SELECT * FROM ${TYPE_PROBE_COPY_TABLE}`);
|
|
@@ -788,9 +835,9 @@ export function runPluginContract(plugin, fixture) {
|
|
|
788
835
|
}
|
|
789
836
|
it("givenBulkInsert_whenTypeProbeTextInserted_thenReadBackEqual", async () => {
|
|
790
837
|
await withCopy(async (session, target) => {
|
|
791
|
-
const rows = fixture.
|
|
838
|
+
const rows = typeProbeOf(fixture).expected.map((row) => row.map(exportedText));
|
|
792
839
|
await insertRowsOf(session)(target, rows);
|
|
793
|
-
expect(await run(session, `SELECT * FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(fixture.
|
|
840
|
+
expect(await run(session, `SELECT * FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(typeProbeOf(fixture).expected);
|
|
794
841
|
});
|
|
795
842
|
});
|
|
796
843
|
it("givenBulkInsert_whenBatchRefused_thenNothingOfItAndTransactionUsable", async () => {
|
|
@@ -1770,6 +1817,276 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1770
1817
|
});
|
|
1771
1818
|
});
|
|
1772
1819
|
});
|
|
1820
|
+
describe.runIf(plugin.capabilities.includes("commands"))("commands", () => {
|
|
1821
|
+
function language() {
|
|
1822
|
+
if (plugin.commandLanguage === undefined)
|
|
1823
|
+
throw new Error("A plugin that declares commands has no commandLanguage.");
|
|
1824
|
+
return plugin.commandLanguage;
|
|
1825
|
+
}
|
|
1826
|
+
async function rowsOf(command, session) {
|
|
1827
|
+
const result = await session.execute(command);
|
|
1828
|
+
return { columns: result.columns.map((column) => column.name), rows: await collect(result.rows) };
|
|
1829
|
+
}
|
|
1830
|
+
it("givenCommands_whenSplit_thenOneSpanPerLineSkippingComments", () => {
|
|
1831
|
+
for (const script of commandsOf(fixture).scripts) {
|
|
1832
|
+
const spans = language().split(script.text);
|
|
1833
|
+
expect(spans.map((span) => span.sql)).toEqual(script.statements);
|
|
1834
|
+
for (const span of spans)
|
|
1835
|
+
expect(script.text.slice(span.from, span.to)).toBe(span.sql);
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
it("givenScalarCommand_whenExecute_thenReplyAndNoColumns", async () => {
|
|
1839
|
+
const { command, text } = commandsOf(fixture).scalar;
|
|
1840
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1841
|
+
const result = await session.execute(command);
|
|
1842
|
+
expect(result.reply?.text).toBe(text);
|
|
1843
|
+
expect(result.columns).toEqual([]);
|
|
1844
|
+
expect(await collect(result.rows)).toEqual([]);
|
|
1845
|
+
});
|
|
1846
|
+
});
|
|
1847
|
+
it("givenArrayCommand_whenExecute_thenIndexValueRows", async () => {
|
|
1848
|
+
const { command, columns, rows } = commandsOf(fixture).array;
|
|
1849
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1850
|
+
expect(await rowsOf(command, session)).toEqual({ columns, rows });
|
|
1851
|
+
});
|
|
1852
|
+
});
|
|
1853
|
+
it("givenMapReply_whenExecute_thenKeyValueRows", async () => {
|
|
1854
|
+
const { command, columns, rows } = commandsOf(fixture).map;
|
|
1855
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1856
|
+
expect(await rowsOf(command, session)).toEqual({ columns, rows });
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1859
|
+
it("givenBinaryValue_whenExecute_thenBytesValue", async () => {
|
|
1860
|
+
const { command, hex } = commandsOf(fixture).binary;
|
|
1861
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1862
|
+
const result = await session.execute(command, [], { fullBytes: true });
|
|
1863
|
+
expect(result.reply?.value).toEqual({
|
|
1864
|
+
kind: "bytes",
|
|
1865
|
+
preview: hex,
|
|
1866
|
+
length: hex.length / HEX_DIGITS_PER_BYTE,
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
1870
|
+
it("givenReadOnly_whenWriteCommand_thenStatementFailedNamingIt", async () => {
|
|
1871
|
+
const { command, name } = commandsOf(fixture).write;
|
|
1872
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1873
|
+
const failure = await session.execute(command).then(() => undefined, (error) => error);
|
|
1874
|
+
expect(isPluginError(failure) ? failure.code : failure).toBe("statementFailed");
|
|
1875
|
+
expect(failure instanceof Error ? failure.message : "").toContain(name);
|
|
1876
|
+
}, READ_ONLY);
|
|
1877
|
+
});
|
|
1878
|
+
it("givenFlushdb_whenClassify_thenDestructiveConfirmByName", () => {
|
|
1879
|
+
const classified = language().classify(commandsOf(fixture).confirmByName);
|
|
1880
|
+
expect(classified.destructive).not.toBeNull();
|
|
1881
|
+
expect(classified.confirmByName).toBe(true);
|
|
1882
|
+
});
|
|
1883
|
+
it("givenKeysPattern_whenClassify_thenCostlyWithScanFix", async () => {
|
|
1884
|
+
const { command, fixed } = commandsOf(fixture).costly;
|
|
1885
|
+
expect(language().classify(command).costly).toMatch(/\S/);
|
|
1886
|
+
const problems = (await language().inspect?.({ text: command })) ?? [];
|
|
1887
|
+
const fix = problems[0]?.fixes[0];
|
|
1888
|
+
expect(fix).toBeDefined();
|
|
1889
|
+
let text = command;
|
|
1890
|
+
for (const edit of [...(fix?.edits ?? [])].sort((a, b) => b.from - a.from)) {
|
|
1891
|
+
text = text.slice(0, edit.from) + edit.insert + text.slice(edit.to);
|
|
1892
|
+
}
|
|
1893
|
+
expect(text).toBe(fixed);
|
|
1894
|
+
});
|
|
1895
|
+
it("givenCommand_whenComplete_thenNeverRejects", async () => {
|
|
1896
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1897
|
+
for (const text of ["", "h", "HGET ", '"unterminated', "# only a comment"]) {
|
|
1898
|
+
const result = await language().complete({ text, offset: text.length }, session);
|
|
1899
|
+
expect(result.from).toBeLessThanOrEqual(text.length);
|
|
1900
|
+
}
|
|
1901
|
+
const offline = await language().complete({ text: "h", offset: 1 }, null);
|
|
1902
|
+
expect(offline.to).toBe(1);
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
describe.runIf(plugin.capabilities.includes("keyValue"))("keyValue", () => {
|
|
1907
|
+
function keyspaceFixture() {
|
|
1908
|
+
if (fixture.keyspace === undefined)
|
|
1909
|
+
throw new Error("keyValue needs fixture.keyspace");
|
|
1910
|
+
return fixture.keyspace;
|
|
1911
|
+
}
|
|
1912
|
+
function keyspaceOf(session) {
|
|
1913
|
+
if (session.keyspace === undefined)
|
|
1914
|
+
throw new Error("keyValue needs session.keyspace");
|
|
1915
|
+
return session.keyspace;
|
|
1916
|
+
}
|
|
1917
|
+
/** Every key of one pass, and how many batches it took. */
|
|
1918
|
+
async function scanAll(keyspace, request) {
|
|
1919
|
+
const keys = [];
|
|
1920
|
+
let cursor = null;
|
|
1921
|
+
let batches = 0;
|
|
1922
|
+
do {
|
|
1923
|
+
const scan = await keyspace.scan({
|
|
1924
|
+
database: keyspaceFixture().database,
|
|
1925
|
+
cursor,
|
|
1926
|
+
match: request.match,
|
|
1927
|
+
type: request.type ?? null,
|
|
1928
|
+
...(request.count === undefined ? {} : { count: request.count }),
|
|
1929
|
+
});
|
|
1930
|
+
keys.push(...scan.keys);
|
|
1931
|
+
cursor = scan.cursor;
|
|
1932
|
+
batches += 1;
|
|
1933
|
+
} while (cursor !== null && batches < MAX_SCAN_BATCHES);
|
|
1934
|
+
expect(cursor).toBeNull();
|
|
1935
|
+
return { keys, batches };
|
|
1936
|
+
}
|
|
1937
|
+
it.runIf(fixture.keyspace?.sources !== undefined)("givenSingleKeyRead_whenExecute_thenReplyNamesItsSourceKey", async () => {
|
|
1938
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1939
|
+
for (const expected of keyspaceFixture().sources ?? []) {
|
|
1940
|
+
const result = await session.execute(expected.command);
|
|
1941
|
+
await collect(result.rows);
|
|
1942
|
+
expect(result.source ?? null, expected.command).toEqual(expected.source);
|
|
1943
|
+
if (expected.read !== undefined)
|
|
1944
|
+
expect(result.reply?.read ?? false, expected.command).toBe(expected.read);
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
});
|
|
1948
|
+
it("givenKeyspace_whenDatabases_thenFixtureDatabaseCountedAndOneCurrent", async () => {
|
|
1949
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1950
|
+
const databases = await keyspaceOf(session).databases();
|
|
1951
|
+
const own = databases.find((database) => database.index === keyspaceFixture().database);
|
|
1952
|
+
expect(own?.keys).toBeGreaterThanOrEqual(keyspaceFixture().scan.count);
|
|
1953
|
+
expect(databases.filter((database) => database.current)).toHaveLength(1);
|
|
1954
|
+
expect(keyspaceOf(session).delimiter).not.toBe("");
|
|
1955
|
+
});
|
|
1956
|
+
});
|
|
1957
|
+
it("givenKeyspace_whenScanToEnd_thenEveryFixtureKeyOnceAndCursorNull", async () => {
|
|
1958
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1959
|
+
const { match, count, batch } = keyspaceFixture().scan;
|
|
1960
|
+
const { keys, batches } = await scanAll(keyspaceOf(session), { match, count: batch });
|
|
1961
|
+
const names = keys.map((key) => key.text);
|
|
1962
|
+
expect(new Set(names).size).toBe(count);
|
|
1963
|
+
expect(names).toHaveLength(count);
|
|
1964
|
+
expect(batches).toBeGreaterThan(1);
|
|
1965
|
+
});
|
|
1966
|
+
});
|
|
1967
|
+
it("givenMatchAndType_whenScan_thenOnlyMatchingKeys", async () => {
|
|
1968
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1969
|
+
const { match, type, keys } = keyspaceFixture().typed;
|
|
1970
|
+
const found = await scanAll(keyspaceOf(session), { match, type });
|
|
1971
|
+
expect(found.keys.map((key) => key.text).sort()).toEqual([...keys].sort());
|
|
1972
|
+
});
|
|
1973
|
+
});
|
|
1974
|
+
it("givenKeys_whenDescribe_thenTypeExpiryAndMissingInOrder", async () => {
|
|
1975
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1976
|
+
const { typed, expiring, database } = keyspaceFixture();
|
|
1977
|
+
const [first = ""] = typed.keys;
|
|
1978
|
+
const names = [first, expiring, "table-craft:contract:missing"];
|
|
1979
|
+
const infos = await keyspaceOf(session).describe(database, names.map((text) => ({ text, hex: null })));
|
|
1980
|
+
expect(infos.map((info) => info.key.text)).toEqual(names);
|
|
1981
|
+
expect(infos[0]).toMatchObject({ type: typed.type, ttlMs: null });
|
|
1982
|
+
expect(infos[1]?.ttlMs).toBeGreaterThan(0);
|
|
1983
|
+
expect(infos[2]).toMatchObject({ type: null, typeName: null, ttlMs: null });
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1986
|
+
it("givenBinaryKeyName_whenScanAndRead_thenHexRoundTrips", async () => {
|
|
1987
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1988
|
+
const { binary, database } = keyspaceFixture();
|
|
1989
|
+
const found = await scanAll(keyspaceOf(session), { match: binary.match });
|
|
1990
|
+
const key = found.keys.find((candidate) => candidate.hex === binary.hex);
|
|
1991
|
+
expect(key?.text).toMatch(/\\x[0-9a-f]{2}/);
|
|
1992
|
+
if (key === undefined)
|
|
1993
|
+
return;
|
|
1994
|
+
const [info] = await keyspaceOf(session).describe(database, [key]);
|
|
1995
|
+
expect(info?.type).toBe(binary.type);
|
|
1996
|
+
const contents = await keyspaceOf(session).read(database, key, { window: READ_WINDOW });
|
|
1997
|
+
expect(contents.details.key).toEqual(key);
|
|
1998
|
+
expect(contents.value).toBe(binary.value);
|
|
1999
|
+
});
|
|
2000
|
+
});
|
|
2001
|
+
/** The key's rows, each cut to its first `width` cells. */
|
|
2002
|
+
async function readRows(session, key, width) {
|
|
2003
|
+
const contents = await keyspaceOf(session).read(keyspaceFixture().database, { text: key, hex: null }, { window: READ_WINDOW });
|
|
2004
|
+
if (contents.result === null)
|
|
2005
|
+
throw new Error(`${key} read no rows`);
|
|
2006
|
+
const rows = await collect(contents.result.rows);
|
|
2007
|
+
return {
|
|
2008
|
+
contents,
|
|
2009
|
+
columns: contents.result.columns.map((column) => column.name),
|
|
2010
|
+
rows: rows.map((row) => row.slice(0, width)),
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
it("givenHash_whenRead_thenFieldValueRows", async () => {
|
|
2014
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2015
|
+
const { hash } = keyspaceFixture();
|
|
2016
|
+
const read = await readRows(session, hash.key, 2);
|
|
2017
|
+
expect(read.columns.slice(0, 2)).toEqual(["field", "value"]);
|
|
2018
|
+
const sorted = (rows) => [...rows].sort((a, b) => JSON.stringify(a[0]).localeCompare(JSON.stringify(b[0])));
|
|
2019
|
+
expect(sorted(read.rows)).toEqual(sorted(hash.rows));
|
|
2020
|
+
expect(read.contents.details).toMatchObject({ type: "hash", length: hash.rows.length });
|
|
2021
|
+
});
|
|
2022
|
+
});
|
|
2023
|
+
it("givenZset_whenRead_thenMemberScoreRowsInRankOrder", async () => {
|
|
2024
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2025
|
+
const { zset } = keyspaceFixture();
|
|
2026
|
+
const read = await readRows(session, zset.key, 3);
|
|
2027
|
+
expect(read.columns).toEqual(["#", "member", "score"]);
|
|
2028
|
+
expect(read.rows.map((row) => row.slice(1))).toEqual(zset.rows);
|
|
2029
|
+
expect(read.rows.map((row) => row[0])).toEqual(zset.rows.map((_, index) => index));
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
it("givenStream_whenRead_thenIdAndFieldColumns", async () => {
|
|
2033
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2034
|
+
const { stream } = keyspaceFixture();
|
|
2035
|
+
const read = await readRows(session, stream.key, stream.columns.length);
|
|
2036
|
+
expect(read.columns).toEqual(stream.columns);
|
|
2037
|
+
expect(read.rows).toEqual(stream.rows);
|
|
2038
|
+
});
|
|
2039
|
+
});
|
|
2040
|
+
it("givenMissingKey_whenRead_thenNoTypeAndNoContents", async () => {
|
|
2041
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2042
|
+
const contents = await keyspaceOf(session).read(keyspaceFixture().database, { text: "table-craft:contract:missing", hex: null }, { window: READ_WINDOW });
|
|
2043
|
+
expect(contents).toMatchObject({ details: { type: null }, result: null, value: null });
|
|
2044
|
+
});
|
|
2045
|
+
});
|
|
2046
|
+
/** Runs `use` with the scratch key removed before and after. */
|
|
2047
|
+
async function withScratchKey(use) {
|
|
2048
|
+
const { database, edit } = keyspaceFixture();
|
|
2049
|
+
const key = { text: edit.key, hex: null };
|
|
2050
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2051
|
+
const keyspace = keyspaceOf(session);
|
|
2052
|
+
await keyspace.remove(database, [key]);
|
|
2053
|
+
try {
|
|
2054
|
+
await use(keyspace, {
|
|
2055
|
+
set: (value, original, guard) => keyspace.apply(database, key, [edit.change(value, original)], { type: edit.type, guard }),
|
|
2056
|
+
value: async () => edit.valueOf(await keyspace.read(database, key, { window: READ_WINDOW })),
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
finally {
|
|
2060
|
+
await keyspace.remove(database, [key]);
|
|
2061
|
+
}
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
it("givenChange_whenApply_thenValueWritten", async () => {
|
|
2065
|
+
await withScratchKey(async (keyspace, scratch) => {
|
|
2066
|
+
const { edit } = keyspaceFixture();
|
|
2067
|
+
const applied = await scratch.set("one", null, true);
|
|
2068
|
+
expect(await scratch.value()).toBe("one");
|
|
2069
|
+
const options = { type: edit.type, guard: true };
|
|
2070
|
+
expect(applied.commands).toEqual(keyspace.preview({ text: edit.key, hex: null }, [edit.change("one", null)], options));
|
|
2071
|
+
});
|
|
2072
|
+
});
|
|
2073
|
+
it("givenChangedSinceRead_whenApplyGuarded_thenConflictAndNothingWritten", async () => {
|
|
2074
|
+
await withScratchKey(async (_keyspace, scratch) => {
|
|
2075
|
+
await scratch.set("one", null, true);
|
|
2076
|
+
await scratch.set("two", "one", false);
|
|
2077
|
+
expect(await rejectionCode(scratch.set("three", "one", true))).toBe("conflict");
|
|
2078
|
+
expect(await scratch.value()).toBe("two");
|
|
2079
|
+
});
|
|
2080
|
+
});
|
|
2081
|
+
it("givenChangedSinceRead_whenApplyUnguarded_thenWritten", async () => {
|
|
2082
|
+
await withScratchKey(async (_keyspace, scratch) => {
|
|
2083
|
+
await scratch.set("one", null, true);
|
|
2084
|
+
await scratch.set("two", "one", false);
|
|
2085
|
+
await scratch.set("three", "one", false);
|
|
2086
|
+
expect(await scratch.value()).toBe("three");
|
|
2087
|
+
});
|
|
2088
|
+
});
|
|
2089
|
+
});
|
|
1773
2090
|
describe.runIf(plugin.capabilities.includes("cancel"))("cancel", () => {
|
|
1774
2091
|
function sleepSql() {
|
|
1775
2092
|
const sleep = fixture.statements?.sleep;
|
|
@@ -1795,7 +2112,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1795
2112
|
it("givenIdleSession_whenCancelled_thenResolvesAndSessionWorks", async () => {
|
|
1796
2113
|
await withSession(plugin, fixture, async (session) => {
|
|
1797
2114
|
await session.cancel?.();
|
|
1798
|
-
|
|
2115
|
+
await expectAlive(session);
|
|
1799
2116
|
});
|
|
1800
2117
|
});
|
|
1801
2118
|
it("givenSleepingStatement_whenCancelled_thenExecuteRejectsWithCancelled", async () => {
|
|
@@ -1806,7 +2123,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1806
2123
|
it("givenCancelledStatement_whenNextStatementRun_thenSessionStillWorks", async () => {
|
|
1807
2124
|
await withSession(plugin, fixture, async (session) => {
|
|
1808
2125
|
await runAndCancel(session);
|
|
1809
|
-
|
|
2126
|
+
await expectAlive(session);
|
|
1810
2127
|
});
|
|
1811
2128
|
});
|
|
1812
2129
|
});
|
|
@@ -1815,9 +2132,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1815
2132
|
await withSession(plugin, fixture, async (session) => {
|
|
1816
2133
|
await session.ping?.();
|
|
1817
2134
|
await session.ping?.();
|
|
1818
|
-
|
|
1819
|
-
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
1820
|
-
}
|
|
2135
|
+
await expectAlive(session);
|
|
1821
2136
|
}, { readOnly: false, keepAliveInitialDelayMs: KEEPALIVE_DELAY_MS });
|
|
1822
2137
|
});
|
|
1823
2138
|
it("givenClosedSession_whenPinged_thenRejectsWithConnectionLost", async () => {
|
|
@@ -1826,6 +2141,30 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1826
2141
|
expect(await rejectionCode(session.ping?.() ?? Promise.resolve())).toBe("connectionLost");
|
|
1827
2142
|
});
|
|
1828
2143
|
});
|
|
2144
|
+
describe.runIf(plugin.capabilities.includes("serverInfo"))("serverInfo", () => {
|
|
2145
|
+
it("givenEveryNode_whenServerInfo_thenTitledSectionsWithEntries", async () => {
|
|
2146
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2147
|
+
const nodes = (await session.nodes?.()) ?? [];
|
|
2148
|
+
for (const node of [null, ...nodes.map((listed) => listed.id)]) {
|
|
2149
|
+
const info = await session.serverInfo?.(node);
|
|
2150
|
+
expect(info?.sections.length).toBeGreaterThan(0);
|
|
2151
|
+
for (const section of info?.sections ?? [])
|
|
2152
|
+
expect(section.title).not.toBe("");
|
|
2153
|
+
expect(info?.sections.some((section) => section.entries.length > 0)).toBe(true);
|
|
2154
|
+
if (node !== null)
|
|
2155
|
+
expect(info?.node).toBe(node);
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
2158
|
+
});
|
|
2159
|
+
it("givenSlowLog_whenRead_thenAtMostLimitNewestFirst", async () => {
|
|
2160
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2161
|
+
const entries = (await session.slowLog?.(null, SLOW_LOG_LIMIT)) ?? [];
|
|
2162
|
+
expect(entries.length).toBeLessThanOrEqual(SLOW_LOG_LIMIT);
|
|
2163
|
+
const times = entries.map((entry) => entry.at);
|
|
2164
|
+
expect(times).toEqual([...times].sort().reverse());
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
});
|
|
1829
2168
|
describe.runIf(plugin.capabilities.includes("sessions"))("sessions", () => {
|
|
1830
2169
|
function sessionsFixture() {
|
|
1831
2170
|
if (fixture.sessions === undefined)
|
|
@@ -1896,7 +2235,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1896
2235
|
await withMonitor(async (monitor) => {
|
|
1897
2236
|
await withSleeper(async (sleeper) => {
|
|
1898
2237
|
const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
|
|
1899
|
-
expect(row.query).toContain(sleepSql());
|
|
2238
|
+
expect(row.query).toContain(sessionsFixture().sleepQuery ?? sleepSql());
|
|
1900
2239
|
expect(row.queryMs).not.toBeNull();
|
|
1901
2240
|
});
|
|
1902
2241
|
});
|
|
@@ -1911,7 +2250,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1911
2250
|
// MySQL's SLEEP answers 1 when killed rather than failing; either way it stops early.
|
|
1912
2251
|
expect(["cancelled", "resolved"]).toContain(await running);
|
|
1913
2252
|
expect(Date.now() - started).toBeLessThan(CANCEL_DEADLINE_MS);
|
|
1914
|
-
|
|
2253
|
+
await expectAlive(sleeper);
|
|
1915
2254
|
});
|
|
1916
2255
|
});
|
|
1917
2256
|
});
|
|
@@ -1937,7 +2276,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1937
2276
|
expect(await rejectionCode(monitor.terminateSession({ id, instance: null }))).toBe("statementFailed");
|
|
1938
2277
|
});
|
|
1939
2278
|
});
|
|
1940
|
-
it("givenLockWait_whenList_thenBlockedByFilled", async () => {
|
|
2279
|
+
it.runIf(plugin.capabilities.includes("transactions"))("givenLockWait_whenList_thenBlockedByFilled", async () => {
|
|
1941
2280
|
const write = fixture.statements?.write;
|
|
1942
2281
|
if (write === undefined)
|
|
1943
2282
|
throw new Error("A plugin that declares sessions needs fixture.statements.write.");
|