@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.
@@ -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
- readonly typeProbe: {
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 `sessions`, which also needs `statements.sleep` and `write`. */
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;
@@ -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).toContain("sql");
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.typeProbe.expected);
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.typeProbe.expected.find((row) => row[0] === PROBE_ROW_ID);
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.typeProbe.copyTable);
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.typeProbe.expected);
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.typeProbe.copyTable);
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.typeProbe.copyTable);
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.typeProbe.expected.map((row) => row.map(exportedText));
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.typeProbe.expected);
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
- expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
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
- expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
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
- if (plugin.capabilities.includes("sql")) {
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
- expect(await collect((await sleeper.execute(selectOne)).rows)).toEqual([[1]]);
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.");