@tablecraft-app/plugin-api 1.1.0 → 1.2.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 +180 -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 +106 -1
- package/dist/plugin.js +9 -0
- package/dist/testing/index.d.ts +100 -3
- package/dist/testing/index.js +346 -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 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,93 @@ 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
|
+
/** How the suite changes one element of a scratch key and reads it back (decision 115). */
|
|
206
|
+
export interface KeyEditFixture {
|
|
207
|
+
readonly key: string;
|
|
208
|
+
readonly type: KeyType;
|
|
209
|
+
/** A change setting the element to `value`, read as `original` (null: it did not exist). */
|
|
210
|
+
readonly change: (value: string, original: string | null) => KeyChange;
|
|
211
|
+
/** The element's value in what `read` returned; null when it is missing. */
|
|
212
|
+
readonly valueOf: (contents: KeyContents) => Promise<string | null>;
|
|
213
|
+
}
|
|
214
|
+
/** A key and the rows reading it gives. */
|
|
215
|
+
export interface KeyRowsFixture {
|
|
216
|
+
readonly key: string;
|
|
217
|
+
readonly rows: readonly Row[];
|
|
218
|
+
}
|
|
122
219
|
/** One routine call: which routine (from `routines.expected`), its arguments, and the rows it returns. */
|
|
123
220
|
export interface RoutineCase {
|
|
124
221
|
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,265 @@ 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("givenKeyspace_whenDatabases_thenFixtureDatabaseCountedAndOneCurrent", async () => {
|
|
1938
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1939
|
+
const databases = await keyspaceOf(session).databases();
|
|
1940
|
+
const own = databases.find((database) => database.index === keyspaceFixture().database);
|
|
1941
|
+
expect(own?.keys).toBeGreaterThanOrEqual(keyspaceFixture().scan.count);
|
|
1942
|
+
expect(databases.filter((database) => database.current)).toHaveLength(1);
|
|
1943
|
+
expect(keyspaceOf(session).delimiter).not.toBe("");
|
|
1944
|
+
});
|
|
1945
|
+
});
|
|
1946
|
+
it("givenKeyspace_whenScanToEnd_thenEveryFixtureKeyOnceAndCursorNull", async () => {
|
|
1947
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1948
|
+
const { match, count, batch } = keyspaceFixture().scan;
|
|
1949
|
+
const { keys, batches } = await scanAll(keyspaceOf(session), { match, count: batch });
|
|
1950
|
+
const names = keys.map((key) => key.text);
|
|
1951
|
+
expect(new Set(names).size).toBe(count);
|
|
1952
|
+
expect(names).toHaveLength(count);
|
|
1953
|
+
expect(batches).toBeGreaterThan(1);
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
it("givenMatchAndType_whenScan_thenOnlyMatchingKeys", async () => {
|
|
1957
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1958
|
+
const { match, type, keys } = keyspaceFixture().typed;
|
|
1959
|
+
const found = await scanAll(keyspaceOf(session), { match, type });
|
|
1960
|
+
expect(found.keys.map((key) => key.text).sort()).toEqual([...keys].sort());
|
|
1961
|
+
});
|
|
1962
|
+
});
|
|
1963
|
+
it("givenKeys_whenDescribe_thenTypeExpiryAndMissingInOrder", async () => {
|
|
1964
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1965
|
+
const { typed, expiring, database } = keyspaceFixture();
|
|
1966
|
+
const [first = ""] = typed.keys;
|
|
1967
|
+
const names = [first, expiring, "table-craft:contract:missing"];
|
|
1968
|
+
const infos = await keyspaceOf(session).describe(database, names.map((text) => ({ text, hex: null })));
|
|
1969
|
+
expect(infos.map((info) => info.key.text)).toEqual(names);
|
|
1970
|
+
expect(infos[0]).toMatchObject({ type: typed.type, ttlMs: null });
|
|
1971
|
+
expect(infos[1]?.ttlMs).toBeGreaterThan(0);
|
|
1972
|
+
expect(infos[2]).toMatchObject({ type: null, typeName: null, ttlMs: null });
|
|
1973
|
+
});
|
|
1974
|
+
});
|
|
1975
|
+
it("givenBinaryKeyName_whenScanAndRead_thenHexRoundTrips", async () => {
|
|
1976
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1977
|
+
const { binary, database } = keyspaceFixture();
|
|
1978
|
+
const found = await scanAll(keyspaceOf(session), { match: binary.match });
|
|
1979
|
+
const key = found.keys.find((candidate) => candidate.hex === binary.hex);
|
|
1980
|
+
expect(key?.text).toMatch(/\\x[0-9a-f]{2}/);
|
|
1981
|
+
if (key === undefined)
|
|
1982
|
+
return;
|
|
1983
|
+
const [info] = await keyspaceOf(session).describe(database, [key]);
|
|
1984
|
+
expect(info?.type).toBe(binary.type);
|
|
1985
|
+
const contents = await keyspaceOf(session).read(database, key, { window: READ_WINDOW });
|
|
1986
|
+
expect(contents.details.key).toEqual(key);
|
|
1987
|
+
expect(contents.value).toBe(binary.value);
|
|
1988
|
+
});
|
|
1989
|
+
});
|
|
1990
|
+
/** The key's rows, each cut to its first `width` cells. */
|
|
1991
|
+
async function readRows(session, key, width) {
|
|
1992
|
+
const contents = await keyspaceOf(session).read(keyspaceFixture().database, { text: key, hex: null }, { window: READ_WINDOW });
|
|
1993
|
+
if (contents.result === null)
|
|
1994
|
+
throw new Error(`${key} read no rows`);
|
|
1995
|
+
const rows = await collect(contents.result.rows);
|
|
1996
|
+
return {
|
|
1997
|
+
contents,
|
|
1998
|
+
columns: contents.result.columns.map((column) => column.name),
|
|
1999
|
+
rows: rows.map((row) => row.slice(0, width)),
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
it("givenHash_whenRead_thenFieldValueRows", async () => {
|
|
2003
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2004
|
+
const { hash } = keyspaceFixture();
|
|
2005
|
+
const read = await readRows(session, hash.key, 2);
|
|
2006
|
+
expect(read.columns.slice(0, 2)).toEqual(["field", "value"]);
|
|
2007
|
+
const sorted = (rows) => [...rows].sort((a, b) => JSON.stringify(a[0]).localeCompare(JSON.stringify(b[0])));
|
|
2008
|
+
expect(sorted(read.rows)).toEqual(sorted(hash.rows));
|
|
2009
|
+
expect(read.contents.details).toMatchObject({ type: "hash", length: hash.rows.length });
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
it("givenZset_whenRead_thenMemberScoreRowsInRankOrder", async () => {
|
|
2013
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2014
|
+
const { zset } = keyspaceFixture();
|
|
2015
|
+
const read = await readRows(session, zset.key, 3);
|
|
2016
|
+
expect(read.columns).toEqual(["#", "member", "score"]);
|
|
2017
|
+
expect(read.rows.map((row) => row.slice(1))).toEqual(zset.rows);
|
|
2018
|
+
expect(read.rows.map((row) => row[0])).toEqual(zset.rows.map((_, index) => index));
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
it("givenStream_whenRead_thenIdAndFieldColumns", async () => {
|
|
2022
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2023
|
+
const { stream } = keyspaceFixture();
|
|
2024
|
+
const read = await readRows(session, stream.key, stream.columns.length);
|
|
2025
|
+
expect(read.columns).toEqual(stream.columns);
|
|
2026
|
+
expect(read.rows).toEqual(stream.rows);
|
|
2027
|
+
});
|
|
2028
|
+
});
|
|
2029
|
+
it("givenMissingKey_whenRead_thenNoTypeAndNoContents", async () => {
|
|
2030
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2031
|
+
const contents = await keyspaceOf(session).read(keyspaceFixture().database, { text: "table-craft:contract:missing", hex: null }, { window: READ_WINDOW });
|
|
2032
|
+
expect(contents).toMatchObject({ details: { type: null }, result: null, value: null });
|
|
2033
|
+
});
|
|
2034
|
+
});
|
|
2035
|
+
/** Runs `use` with the scratch key removed before and after. */
|
|
2036
|
+
async function withScratchKey(use) {
|
|
2037
|
+
const { database, edit } = keyspaceFixture();
|
|
2038
|
+
const key = { text: edit.key, hex: null };
|
|
2039
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2040
|
+
const keyspace = keyspaceOf(session);
|
|
2041
|
+
await keyspace.remove(database, [key]);
|
|
2042
|
+
try {
|
|
2043
|
+
await use(keyspace, {
|
|
2044
|
+
set: (value, original, guard) => keyspace.apply(database, key, [edit.change(value, original)], { type: edit.type, guard }),
|
|
2045
|
+
value: async () => edit.valueOf(await keyspace.read(database, key, { window: READ_WINDOW })),
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
finally {
|
|
2049
|
+
await keyspace.remove(database, [key]);
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
it("givenChange_whenApply_thenValueWritten", async () => {
|
|
2054
|
+
await withScratchKey(async (keyspace, scratch) => {
|
|
2055
|
+
const { edit } = keyspaceFixture();
|
|
2056
|
+
const applied = await scratch.set("one", null, true);
|
|
2057
|
+
expect(await scratch.value()).toBe("one");
|
|
2058
|
+
const options = { type: edit.type, guard: true };
|
|
2059
|
+
expect(applied.commands).toEqual(keyspace.preview({ text: edit.key, hex: null }, [edit.change("one", null)], options));
|
|
2060
|
+
});
|
|
2061
|
+
});
|
|
2062
|
+
it("givenChangedSinceRead_whenApplyGuarded_thenConflictAndNothingWritten", async () => {
|
|
2063
|
+
await withScratchKey(async (_keyspace, scratch) => {
|
|
2064
|
+
await scratch.set("one", null, true);
|
|
2065
|
+
await scratch.set("two", "one", false);
|
|
2066
|
+
expect(await rejectionCode(scratch.set("three", "one", true))).toBe("conflict");
|
|
2067
|
+
expect(await scratch.value()).toBe("two");
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
it("givenChangedSinceRead_whenApplyUnguarded_thenWritten", async () => {
|
|
2071
|
+
await withScratchKey(async (_keyspace, scratch) => {
|
|
2072
|
+
await scratch.set("one", null, true);
|
|
2073
|
+
await scratch.set("two", "one", false);
|
|
2074
|
+
await scratch.set("three", "one", false);
|
|
2075
|
+
expect(await scratch.value()).toBe("three");
|
|
2076
|
+
});
|
|
2077
|
+
});
|
|
2078
|
+
});
|
|
1773
2079
|
describe.runIf(plugin.capabilities.includes("cancel"))("cancel", () => {
|
|
1774
2080
|
function sleepSql() {
|
|
1775
2081
|
const sleep = fixture.statements?.sleep;
|
|
@@ -1795,7 +2101,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1795
2101
|
it("givenIdleSession_whenCancelled_thenResolvesAndSessionWorks", async () => {
|
|
1796
2102
|
await withSession(plugin, fixture, async (session) => {
|
|
1797
2103
|
await session.cancel?.();
|
|
1798
|
-
|
|
2104
|
+
await expectAlive(session);
|
|
1799
2105
|
});
|
|
1800
2106
|
});
|
|
1801
2107
|
it("givenSleepingStatement_whenCancelled_thenExecuteRejectsWithCancelled", async () => {
|
|
@@ -1806,7 +2112,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1806
2112
|
it("givenCancelledStatement_whenNextStatementRun_thenSessionStillWorks", async () => {
|
|
1807
2113
|
await withSession(plugin, fixture, async (session) => {
|
|
1808
2114
|
await runAndCancel(session);
|
|
1809
|
-
|
|
2115
|
+
await expectAlive(session);
|
|
1810
2116
|
});
|
|
1811
2117
|
});
|
|
1812
2118
|
});
|
|
@@ -1815,9 +2121,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1815
2121
|
await withSession(plugin, fixture, async (session) => {
|
|
1816
2122
|
await session.ping?.();
|
|
1817
2123
|
await session.ping?.();
|
|
1818
|
-
|
|
1819
|
-
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
1820
|
-
}
|
|
2124
|
+
await expectAlive(session);
|
|
1821
2125
|
}, { readOnly: false, keepAliveInitialDelayMs: KEEPALIVE_DELAY_MS });
|
|
1822
2126
|
});
|
|
1823
2127
|
it("givenClosedSession_whenPinged_thenRejectsWithConnectionLost", async () => {
|
|
@@ -1826,6 +2130,30 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1826
2130
|
expect(await rejectionCode(session.ping?.() ?? Promise.resolve())).toBe("connectionLost");
|
|
1827
2131
|
});
|
|
1828
2132
|
});
|
|
2133
|
+
describe.runIf(plugin.capabilities.includes("serverInfo"))("serverInfo", () => {
|
|
2134
|
+
it("givenEveryNode_whenServerInfo_thenTitledSectionsWithEntries", async () => {
|
|
2135
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2136
|
+
const nodes = (await session.nodes?.()) ?? [];
|
|
2137
|
+
for (const node of [null, ...nodes.map((listed) => listed.id)]) {
|
|
2138
|
+
const info = await session.serverInfo?.(node);
|
|
2139
|
+
expect(info?.sections.length).toBeGreaterThan(0);
|
|
2140
|
+
for (const section of info?.sections ?? [])
|
|
2141
|
+
expect(section.title).not.toBe("");
|
|
2142
|
+
expect(info?.sections.some((section) => section.entries.length > 0)).toBe(true);
|
|
2143
|
+
if (node !== null)
|
|
2144
|
+
expect(info?.node).toBe(node);
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
});
|
|
2148
|
+
it("givenSlowLog_whenRead_thenAtMostLimitNewestFirst", async () => {
|
|
2149
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2150
|
+
const entries = (await session.slowLog?.(null, SLOW_LOG_LIMIT)) ?? [];
|
|
2151
|
+
expect(entries.length).toBeLessThanOrEqual(SLOW_LOG_LIMIT);
|
|
2152
|
+
const times = entries.map((entry) => entry.at);
|
|
2153
|
+
expect(times).toEqual([...times].sort().reverse());
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
2156
|
+
});
|
|
1829
2157
|
describe.runIf(plugin.capabilities.includes("sessions"))("sessions", () => {
|
|
1830
2158
|
function sessionsFixture() {
|
|
1831
2159
|
if (fixture.sessions === undefined)
|
|
@@ -1896,7 +2224,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1896
2224
|
await withMonitor(async (monitor) => {
|
|
1897
2225
|
await withSleeper(async (sleeper) => {
|
|
1898
2226
|
const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
|
|
1899
|
-
expect(row.query).toContain(sleepSql());
|
|
2227
|
+
expect(row.query).toContain(sessionsFixture().sleepQuery ?? sleepSql());
|
|
1900
2228
|
expect(row.queryMs).not.toBeNull();
|
|
1901
2229
|
});
|
|
1902
2230
|
});
|
|
@@ -1911,7 +2239,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1911
2239
|
// MySQL's SLEEP answers 1 when killed rather than failing; either way it stops early.
|
|
1912
2240
|
expect(["cancelled", "resolved"]).toContain(await running);
|
|
1913
2241
|
expect(Date.now() - started).toBeLessThan(CANCEL_DEADLINE_MS);
|
|
1914
|
-
|
|
2242
|
+
await expectAlive(sleeper);
|
|
1915
2243
|
});
|
|
1916
2244
|
});
|
|
1917
2245
|
});
|
|
@@ -1937,7 +2265,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1937
2265
|
expect(await rejectionCode(monitor.terminateSession({ id, instance: null }))).toBe("statementFailed");
|
|
1938
2266
|
});
|
|
1939
2267
|
});
|
|
1940
|
-
it("givenLockWait_whenList_thenBlockedByFilled", async () => {
|
|
2268
|
+
it.runIf(plugin.capabilities.includes("transactions"))("givenLockWait_whenList_thenBlockedByFilled", async () => {
|
|
1941
2269
|
const write = fixture.statements?.write;
|
|
1942
2270
|
if (write === undefined)
|
|
1943
2271
|
throw new Error("A plugin that declares sessions needs fixture.statements.write.");
|
package/dist/url.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface ConnectionUrlSpec {
|
|
|
24
24
|
readonly key: string;
|
|
25
25
|
readonly values?: Readonly<Record<string, string>> | undefined;
|
|
26
26
|
}[] | undefined;
|
|
27
|
+
readonly sets?: Readonly<Record<string, Readonly<Record<string, ConnectionValue>>>> | undefined;
|
|
27
28
|
}
|
|
28
29
|
interface UrlField {
|
|
29
30
|
readonly key: string;
|
|
@@ -45,7 +46,8 @@ export interface ConnectionForm {
|
|
|
45
46
|
*/
|
|
46
47
|
export declare function parseConnectionUrl(form: ConnectionForm, text: string): ParsedConnectionUrl;
|
|
47
48
|
/**
|
|
48
|
-
* The connection URL of `config`, in the
|
|
49
|
+
* The connection URL of `config`, in the first scheme whose `sets` the config holds, else the first
|
|
50
|
+
* that sets nothing. Never carries a secret field, so
|
|
49
51
|
* the result is safe to copy, show and log; params equal to their field's default are left out.
|
|
50
52
|
*/
|
|
51
53
|
export declare function formatConnectionUrl(form: ConnectionForm, config: Readonly<Record<string, unknown>>): string;
|