@fadhilp/stateql 0.8.1 → 0.10.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.
@@ -0,0 +1,395 @@
1
+ import { createClient, RESP_TYPES, WatchError } from "@redis/client";
2
+ import { AdapterExecutionError, AdapterWriteError } from "./adapters.js";
3
+ import { hash, toJsonSafe } from "./util.js";
4
+ const MAX_ARGUMENTS = 100;
5
+ const MAX_ARGUMENT_BYTES = 256 * 1024;
6
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
7
+ const MAX_COLLECTION_VALUES = 200;
8
+ const READ_COMMANDS = new Set([
9
+ "GET", "MGET", "TYPE", "EXISTS", "TTL", "PTTL", "HGET", "HMGET",
10
+ "LRANGE", "SCAN", "HSCAN", "SSCAN", "ZSCAN",
11
+ ]);
12
+ const WRITE_COMMANDS = new Set([
13
+ "SET", "DEL", "HSET", "HDEL", "LPUSH", "RPUSH", "SADD", "SREM", "ZADD", "ZREM",
14
+ ]);
15
+ export function validateRedisReadCommand(value) {
16
+ const command = validateCommandShape(value);
17
+ if (!READ_COMMANDS.has(command.command)) {
18
+ throw new Error(`Unsupported Redis read command "${command.command}".`);
19
+ }
20
+ const args = command.args ?? [];
21
+ switch (command.command) {
22
+ case "GET":
23
+ case "TYPE":
24
+ case "TTL":
25
+ case "PTTL":
26
+ requireCount(args, 1);
27
+ break;
28
+ case "MGET":
29
+ case "EXISTS":
30
+ requireRange(args, 1, MAX_ARGUMENTS);
31
+ break;
32
+ case "HGET":
33
+ requireCount(args, 2);
34
+ break;
35
+ case "HMGET":
36
+ requireRange(args, 2, MAX_ARGUMENTS + 1);
37
+ break;
38
+ case "LRANGE":
39
+ requireCount(args, 3);
40
+ boundedRange(args[1], args[2]);
41
+ break;
42
+ case "SCAN":
43
+ validateScanArgs(args, false);
44
+ break;
45
+ case "HSCAN":
46
+ case "SSCAN":
47
+ case "ZSCAN":
48
+ validateScanArgs(args, true);
49
+ break;
50
+ }
51
+ return command;
52
+ }
53
+ export function validateRedisWriteCommand(value) {
54
+ const command = validateCommandShape(value);
55
+ if (!WRITE_COMMANDS.has(command.command)) {
56
+ throw new Error(`Unsupported Redis write command "${command.command}".`);
57
+ }
58
+ const args = command.args ?? [];
59
+ switch (command.command) {
60
+ case "SET":
61
+ requireCount(args, 2);
62
+ break;
63
+ case "DEL":
64
+ requireCount(args, 1);
65
+ break;
66
+ case "HSET":
67
+ requireCount(args, 3);
68
+ break;
69
+ case "HDEL":
70
+ requireCount(args, 2);
71
+ break;
72
+ case "LPUSH":
73
+ case "RPUSH":
74
+ case "SADD":
75
+ case "SREM":
76
+ case "ZREM":
77
+ requireCount(args, 2);
78
+ break;
79
+ case "ZADD":
80
+ requireCount(args, 3);
81
+ if (!Number.isFinite(Number(args[1])))
82
+ throw new Error("Redis ZADD score must be finite.");
83
+ break;
84
+ }
85
+ return command;
86
+ }
87
+ export function serializeRedisCommand(command) {
88
+ return JSON.stringify({ command: command.command.toUpperCase(), args: command.args ?? [] });
89
+ }
90
+ export function deserializeRedisCommand(value) {
91
+ try {
92
+ return validateRedisWriteCommand(JSON.parse(value));
93
+ }
94
+ catch {
95
+ throw new Error("Stored Redis command is invalid.");
96
+ }
97
+ }
98
+ export class RedisAdapter {
99
+ readOnly;
100
+ context;
101
+ confidence = "ttl_based";
102
+ client;
103
+ connected = false;
104
+ closed = false;
105
+ constructor(source, readOnly, context) {
106
+ this.readOnly = readOnly;
107
+ this.context = context;
108
+ this.client = createClient({
109
+ url: source,
110
+ socket: {
111
+ connectTimeout: remainingMilliseconds(context),
112
+ reconnectStrategy: false,
113
+ },
114
+ });
115
+ this.client.on("error", () => undefined);
116
+ }
117
+ async ping() {
118
+ await this.connect();
119
+ await this.execute(["PING"], false);
120
+ }
121
+ async signature() {
122
+ throwIfStopped(this.context, false);
123
+ return "redis:watched-key";
124
+ }
125
+ async read(input) {
126
+ const command = validateRedisReadCommand(input);
127
+ await this.connect();
128
+ const raw = await this.execute([command.command, ...(command.args ?? [])], false);
129
+ const bytes = Buffer.byteLength(JSON.stringify(toJsonSafe(raw)), "utf8");
130
+ if (bytes > MAX_RESPONSE_BYTES)
131
+ throw new Error("Redis response exceeds the 1 MiB materialization limit.");
132
+ return redisRows(command, raw);
133
+ }
134
+ async precondition(input) {
135
+ const command = validateRedisWriteCommand(input);
136
+ await this.connect();
137
+ const key = command.args[0];
138
+ const [fingerprint, expiresAt] = await Promise.all([this.keyFingerprint(key), this.expirationIdentity(key)]);
139
+ return { key, fingerprint, expiresAt };
140
+ }
141
+ async write(input, precondition) {
142
+ const command = validateRedisWriteCommand(input);
143
+ if (this.readOnly)
144
+ throw new AdapterWriteError("Connection is read-only.", false);
145
+ await this.connect();
146
+ const key = command.args[0];
147
+ if (precondition && precondition.key !== key) {
148
+ throw new AdapterWriteError("Redis plan key does not match its command.", false);
149
+ }
150
+ let dispatched = false;
151
+ try {
152
+ let raw;
153
+ if (precondition) {
154
+ await this.execute(["WATCH", key], false);
155
+ const [fingerprint, expiresAt] = await Promise.all([this.keyFingerprint(key), this.expirationIdentity(key)]);
156
+ const expiryChanged = precondition.expiresAt < 0
157
+ ? expiresAt !== precondition.expiresAt
158
+ : expiresAt < 0 || Math.abs(expiresAt - precondition.expiresAt) > 2_000;
159
+ if (fingerprint !== precondition.fingerprint || expiryChanged) {
160
+ await this.execute(["UNWATCH"], false).catch(() => undefined);
161
+ throw new AdapterWriteError("ROW_CONFLICT: The Redis key changed. Reload before editing.", false);
162
+ }
163
+ const transaction = this.client.multi();
164
+ transaction.addCommand([command.command, ...(command.args ?? [])]);
165
+ dispatched = true;
166
+ const result = await withDeadline(transaction.exec(), this.context, true, () => this.stop());
167
+ if (result === null)
168
+ throw new AdapterWriteError("ROW_CONFLICT: The Redis key changed. Reload before editing.", false);
169
+ raw = result[0];
170
+ }
171
+ else {
172
+ dispatched = true;
173
+ raw = await this.execute([command.command, ...(command.args ?? [])], true);
174
+ }
175
+ if (raw instanceof Error)
176
+ throw new AdapterWriteError(raw.message, false);
177
+ const normalized = normalizeScalar(raw);
178
+ return {
179
+ affectedRows: redisAffectedRows(command.command, normalized),
180
+ outcome: { acknowledged: true, result: normalized },
181
+ };
182
+ }
183
+ catch (error) {
184
+ if (error instanceof AdapterWriteError)
185
+ throw error;
186
+ if (error instanceof WatchError)
187
+ throw new AdapterWriteError("ROW_CONFLICT: The Redis key changed. Reload before editing.", false);
188
+ if (error instanceof AdapterExecutionError)
189
+ throw error;
190
+ throw new AdapterWriteError(errorText(error), dispatched);
191
+ }
192
+ }
193
+ async listObjects(filter) {
194
+ if (filter.kind !== undefined && filter.kind !== "key")
195
+ throw new Error(`Redis does not support catalog kind "${filter.kind}".`);
196
+ if (filter.schema !== undefined)
197
+ throw new Error("Redis keys do not have schemas.");
198
+ const cursor = filter.offset === undefined ? "0" : String(filter.offset);
199
+ if (!/^\d+$/.test(cursor))
200
+ throw new Error("Redis offset must be an opaque numeric SCAN cursor.");
201
+ const limit = boundedLimit(filter.limit);
202
+ const args = [cursor];
203
+ if (filter.search)
204
+ args.push("MATCH", `*${escapeGlob(filter.search)}*`);
205
+ args.push("COUNT", String(limit));
206
+ const result = await this.read({ command: "SCAN", args });
207
+ if (result.rows.length > MAX_COLLECTION_VALUES)
208
+ throw new Error("Redis SCAN page exceeded the hard response bound; retry with a smaller COUNT.");
209
+ return {
210
+ objects: result.rows.map((row) => ({ kind: "key", name: String(row.key), identity: String(row.key) })),
211
+ next_offset: result.nextCursor ?? null,
212
+ supported_kinds: ["key"],
213
+ };
214
+ }
215
+ async describeObject(object) {
216
+ if (object.kind !== "key" || !object.name || object.schema !== undefined)
217
+ throw new Error("Redis describeObject requires a key identity.");
218
+ await this.connect();
219
+ const key = object.name;
220
+ const type = String(await this.execute(["TYPE", key], false));
221
+ const ttl = Number(await this.execute(["PTTL", key], false));
222
+ let definition = { type, ttl_ms: ttl };
223
+ switch (type) {
224
+ case "none": break;
225
+ case "string": {
226
+ const length = Number(await this.execute(["STRLEN", key], false));
227
+ if (length > MAX_RESPONSE_BYTES)
228
+ definition = { ...definition, length, value_omitted: true };
229
+ else
230
+ definition = { ...definition, length, value: await this.execute(["GET", key], false) };
231
+ break;
232
+ }
233
+ case "hash":
234
+ definition = { ...definition, ...(await this.scanValue("HSCAN", key)) };
235
+ break;
236
+ case "list": {
237
+ const length = Number(await this.execute(["LLEN", key], false));
238
+ definition = { ...definition, length, values: await this.execute(["LRANGE", key, "0", "99"], false), truncated: length > 100 };
239
+ break;
240
+ }
241
+ case "set":
242
+ definition = { ...definition, ...(await this.scanValue("SSCAN", key)) };
243
+ break;
244
+ case "zset":
245
+ definition = { ...definition, ...(await this.scanValue("ZSCAN", key)) };
246
+ break;
247
+ default: definition = { ...definition, value_omitted: true, reason: "Unsupported Redis value type." };
248
+ }
249
+ if (Buffer.byteLength(JSON.stringify(toJsonSafe(definition)), "utf8") > MAX_RESPONSE_BYTES)
250
+ throw new Error("Redis value page exceeds 1 MiB.");
251
+ return { object: { kind: "key", name: key, identity: key }, definition: toJsonSafe(definition) };
252
+ }
253
+ async close() {
254
+ if (this.closed)
255
+ return;
256
+ this.closed = true;
257
+ if (this.client.isOpen)
258
+ this.client.destroy();
259
+ this.connected = false;
260
+ }
261
+ async scanValue(command, key) {
262
+ const raw = await this.execute([command, key, "0", "COUNT", "100"], false);
263
+ const cursor = String(raw[0]);
264
+ return { values: toJsonSafe(raw[1]), next_cursor: cursor === "0" ? null : cursor, truncated: cursor !== "0" };
265
+ }
266
+ async expirationIdentity(key) {
267
+ const ttl = Number(await this.execute(["PTTL", key], false));
268
+ return ttl >= 0 ? Date.now() + ttl : ttl;
269
+ }
270
+ async keyFingerprint(key) {
271
+ const usage = await this.execute(["MEMORY", "USAGE", key], false);
272
+ if (typeof usage === "number" && usage > MAX_RESPONSE_BYTES) {
273
+ throw new AdapterWriteError("Redis key is too large for guarded writes.", false);
274
+ }
275
+ const [type, dump] = await Promise.all([
276
+ this.execute(["TYPE", key], false),
277
+ withDeadline(this.client.sendCommand(["DUMP", key], { typeMapping: { [RESP_TYPES.BLOB_STRING]: Buffer } }), this.context, false, () => this.stop()),
278
+ ]);
279
+ const encoded = Buffer.isBuffer(dump) ? dump.toString("base64") : dump;
280
+ if (Buffer.byteLength(JSON.stringify(encoded), "utf8") > MAX_RESPONSE_BYTES * 2) {
281
+ throw new AdapterWriteError("Redis key is too large for guarded writes.", false);
282
+ }
283
+ return hash({ type, dump: encoded });
284
+ }
285
+ async connect() {
286
+ throwIfStopped(this.context, false);
287
+ if (this.closed)
288
+ throw new Error("Redis adapter is closed.");
289
+ if (this.connected)
290
+ return;
291
+ await withDeadline(this.client.connect(), this.context, false, () => this.stop());
292
+ this.connected = true;
293
+ }
294
+ async execute(command, outcomeUnknown) {
295
+ throwIfStopped(this.context, outcomeUnknown);
296
+ return withDeadline(this.client.sendCommand(command), this.context, outcomeUnknown, () => this.stop());
297
+ }
298
+ stop() { if (this.client.isOpen)
299
+ this.client.destroy(); this.closed = true; }
300
+ }
301
+ function validateCommandShape(value) {
302
+ if (!value || typeof value !== "object" || Array.isArray(value))
303
+ throw new Error("Redis command must be an object.");
304
+ const record = value;
305
+ if (Object.keys(record).some((key) => key !== "command" && key !== "args"))
306
+ throw new Error("Redis command contains unknown fields.");
307
+ if (typeof record.command !== "string" || !/^[A-Za-z]+$/.test(record.command))
308
+ throw new Error("Redis command name is invalid.");
309
+ const args = record.args ?? [];
310
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string") || args.some((arg) => arg.includes("\0")))
311
+ throw new Error("Redis command arguments must be strings without NUL bytes.");
312
+ if (args.length > MAX_ARGUMENTS + 4 || Buffer.byteLength(JSON.stringify(args), "utf8") > MAX_ARGUMENT_BYTES)
313
+ throw new Error("Redis command arguments exceed safety bounds.");
314
+ return { command: record.command.toUpperCase(), args: args };
315
+ }
316
+ function validateScanArgs(args, keyed) {
317
+ const base = keyed ? 2 : 1;
318
+ requireRange(args, base, base + 4);
319
+ if (!/^\d+$/.test(args[keyed ? 1 : 0]))
320
+ throw new Error("Redis SCAN cursor must be numeric.");
321
+ for (let index = base; index < args.length; index += 2) {
322
+ const option = args[index]?.toUpperCase();
323
+ const value = args[index + 1];
324
+ if (!value || (option !== "MATCH" && option !== "COUNT"))
325
+ throw new Error("Redis SCAN only supports MATCH and COUNT.");
326
+ if (option === "COUNT" && (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > MAX_COLLECTION_VALUES))
327
+ throw new Error(`Redis SCAN COUNT must be 1-${MAX_COLLECTION_VALUES}.`);
328
+ }
329
+ }
330
+ function requireCount(args, count) { if (args.length !== count)
331
+ throw new Error(`Redis command requires ${count} argument(s).`); }
332
+ function requireRange(args, minimum, maximum) { if (args.length < minimum || args.length > maximum)
333
+ throw new Error(`Redis command requires ${minimum}-${maximum} arguments.`); }
334
+ function boundedRange(startText, stopText) {
335
+ const start = Number(startText);
336
+ const stop = Number(stopText);
337
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(stop) || start < 0 || stop < start || stop - start >= MAX_COLLECTION_VALUES)
338
+ throw new Error(`Redis LRANGE must request at most ${MAX_COLLECTION_VALUES} non-negative elements.`);
339
+ }
340
+ function boundedLimit(value) { const limit = value ?? 50; if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_COLLECTION_VALUES)
341
+ throw new Error(`limit must be 1-${MAX_COLLECTION_VALUES}.`); return limit; }
342
+ function escapeGlob(value) { if (!value || value.length > 200 || value.includes("\0"))
343
+ throw new Error("Redis search must be 1-200 characters."); return value.replace(/[\\*?\[\]]/g, "\\$&"); }
344
+ function redisRows(command, raw) {
345
+ if (["SCAN", "HSCAN", "SSCAN", "ZSCAN"].includes(command.command)) {
346
+ if (!Array.isArray(raw) || raw.length !== 2 || !Array.isArray(raw[1]))
347
+ throw new Error("Unexpected Redis SCAN response.");
348
+ const values = raw[1];
349
+ const field = command.command === "SCAN" ? "key" : command.command === "HSCAN" ? "field" : "value";
350
+ const rows = command.command === "HSCAN" || command.command === "ZSCAN"
351
+ ? Array.from({ length: Math.ceil(values.length / 2) }, (_, index) => ({ [field]: values[index * 2], value: values[index * 2 + 1] }))
352
+ : values.map((value) => ({ [field]: value }));
353
+ const cursor = String(raw[0]);
354
+ return { rows: toJsonSafe(rows), columns: inferColumns(rows), nextCursor: cursor === "0" ? null : cursor };
355
+ }
356
+ if (Array.isArray(raw)) {
357
+ const rows = raw.map((value, index) => ({ index, value }));
358
+ return { rows: toJsonSafe(rows), columns: inferColumns(rows) };
359
+ }
360
+ const rows = [{ value: raw }];
361
+ return { rows: toJsonSafe(rows), columns: inferColumns(rows) };
362
+ }
363
+ function inferColumns(rows) { const first = rows[0] ?? {}; return Object.keys(first).map((name) => ({ name, type: typeof first[name] })); }
364
+ function normalizeScalar(value) { if (value === null || typeof value === "string" || typeof value === "number")
365
+ return value; return JSON.stringify(toJsonSafe(value)); }
366
+ function redisAffectedRows(command, result) { if (typeof result === "number")
367
+ return result; return command === "SET" && result === "OK" ? 1 : 0; }
368
+ function remainingMilliseconds(context) { const remaining = context.deadline - Date.now(); if (remaining <= 0)
369
+ throw new AdapterExecutionError("Database operation timed out.", "timeout", false); return Math.min(remaining, 2_147_483_647); }
370
+ function throwIfStopped(context, outcomeUnknown) { if (context.signal?.aborted)
371
+ throw new AdapterExecutionError("Database operation was cancelled.", "aborted", outcomeUnknown); if (context.deadline <= Date.now())
372
+ throw new AdapterExecutionError("Database operation timed out.", "timeout", outcomeUnknown); }
373
+ async function withDeadline(promise, context, outcomeUnknown, stop) {
374
+ throwIfStopped(context, outcomeUnknown);
375
+ let timer;
376
+ let abort;
377
+ const stopped = new Promise((_, reject) => {
378
+ timer = setTimeout(() => { stop(); reject(new AdapterExecutionError("Database operation timed out.", "timeout", outcomeUnknown)); }, remainingMilliseconds(context));
379
+ timer.unref();
380
+ if (context.signal) {
381
+ abort = () => { stop(); reject(new AdapterExecutionError("Database operation was cancelled.", "aborted", outcomeUnknown)); };
382
+ context.signal.addEventListener("abort", abort, { once: true });
383
+ }
384
+ });
385
+ try {
386
+ return await Promise.race([promise, stopped]);
387
+ }
388
+ finally {
389
+ if (timer)
390
+ clearTimeout(timer);
391
+ if (abort && context.signal)
392
+ context.signal.removeEventListener("abort", abort);
393
+ }
394
+ }
395
+ function errorText(error) { return error instanceof Error ? error.message : String(error); }
@@ -57,7 +57,7 @@ function execute(request) {
57
57
  case "write": {
58
58
  if (readOnly)
59
59
  throw new Error("Connection is read-only.");
60
- const [sql, params] = request.args;
60
+ const [sql, params, expectedRows] = request.args;
61
61
  try {
62
62
  database.exec("BEGIN");
63
63
  }
@@ -66,6 +66,8 @@ function execute(request) {
66
66
  }
67
67
  try {
68
68
  const result = bindRun(database.prepare(sql), params);
69
+ if (expectedRows === 1 && Number(result.changes) !== 1)
70
+ throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
69
71
  database.exec("COMMIT");
70
72
  return { affectedRows: Number(result.changes) };
71
73
  }
@@ -98,6 +100,8 @@ function execute(request) {
98
100
  try {
99
101
  for (const operation of operations) {
100
102
  const result = bindRun(database.prepare(operation.sql), parseJson(operation.parameters, `operation "${operation.id}" parameters`, isSqlParameters));
103
+ if (operation.expectedRows === 1 && Number(result.changes) !== 1)
104
+ throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
101
105
  results.push({ affectedRows: Number(result.changes) });
102
106
  }
103
107
  }
@@ -145,6 +149,14 @@ function execute(request) {
145
149
  const [kind, table] = request.args;
146
150
  return inspect(database, kind, table);
147
151
  }
152
+ case "listObjects": {
153
+ const [filter] = request.args;
154
+ return listObjects(database, filter);
155
+ }
156
+ case "describeObject": {
157
+ const [object] = request.args;
158
+ return describeObject(database, object);
159
+ }
148
160
  }
149
161
  }
150
162
  function inspect(database, kind, table) {
@@ -160,6 +172,11 @@ function inspect(database, kind, table) {
160
172
  if (!table)
161
173
  throw new Error(`Table is required for inspect ${kind}.`);
162
174
  const quoted = quoteSqliteLiteral(table);
175
+ if (kind === "editable") {
176
+ const object = database.prepare("SELECT type FROM sqlite_master WHERE name = ?").get(table);
177
+ const columns = database.prepare(`PRAGMA table_xinfo(${quoted})`).all();
178
+ return { writable: object?.type === "table", columns: columns.map(column => ({ name: String(column.name), type: String(column.type).toLowerCase(), nullable: !column.notnull && !column.pk, generated: Number(column.hidden) !== 0, key: Number(column.pk) })) };
179
+ }
163
180
  const columns = database
164
181
  .prepare(`PRAGMA table_info(${quoted})`)
165
182
  .all();
@@ -199,6 +216,37 @@ function inspect(database, kind, table) {
199
216
  foreign_keys: foreignKeys.length,
200
217
  };
201
218
  }
219
+ function listObjects(database, filter) {
220
+ const supported = ["table", "view", "trigger"];
221
+ if (filter.kind !== undefined && !supported.includes(filter.kind))
222
+ throw new Error(`SQLite does not support catalog kind "${filter.kind}".`);
223
+ if (filter.schema !== undefined && filter.schema !== "main")
224
+ throw new Error("SQLite only supports the main schema.");
225
+ const offset = filter.offset ?? 0;
226
+ const limit = filter.limit ?? 50;
227
+ if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200)
228
+ throw new Error("Invalid SQLite catalog page bounds.");
229
+ if (filter.search !== undefined && (!filter.search || filter.search.length > 200 || filter.search.includes("\0")))
230
+ throw new Error("Invalid SQLite catalog search.");
231
+ const types = filter.kind ? [filter.kind] : [...supported];
232
+ const placeholders = types.map(() => "?").join(", ");
233
+ const rows = database.prepare(`SELECT type AS kind, 'main' AS schema, name, 'main.' || name AS identity
234
+ FROM sqlite_master WHERE type IN (${placeholders}) AND name NOT LIKE 'sqlite_%'
235
+ AND (? IS NULL OR instr(lower(name), lower(?)) > 0)
236
+ ORDER BY kind, name LIMIT ? OFFSET ?`).all(...types, filter.search ?? null, filter.search ?? null, limit + 1, offset);
237
+ const more = rows.length > limit;
238
+ return { objects: rows.slice(0, limit), next_offset: more ? offset + limit : null, supported_kinds: [...supported] };
239
+ }
240
+ function describeObject(database, object) {
241
+ if (!["table", "view", "trigger"].includes(object.kind) || (object.schema !== undefined && object.schema !== "main"))
242
+ throw new Error(`SQLite does not support catalog kind "${object.kind}".`);
243
+ const row = database.prepare("SELECT type AS kind, 'main' AS schema, name, 'main.' || name AS identity, sql AS definition FROM sqlite_master WHERE type = ? AND name = ?").get(object.kind, object.name);
244
+ if (!row)
245
+ throw new Error("Catalog object was not found.");
246
+ const definition = row.definition === null ? null : String(row.definition);
247
+ delete row.definition;
248
+ return { object: row, definition };
249
+ }
202
250
  function bindAll(statement, params) {
203
251
  if (Array.isArray(params))
204
252
  return statement.all(...params);
@@ -1,4 +1,5 @@
1
- import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, BatchOptions, CapabilitiesData, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
1
+ import { type TableChange } from "./table-editor.js";
2
+ import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
2
3
  export declare class StateQL {
3
4
  static forActor(options: StateQLActorOptions): StateQL;
4
5
  private readonly store;
@@ -17,18 +18,20 @@ export declare class StateQL {
17
18
  private readonly credentialResolver?;
18
19
  private readonly now;
19
20
  private closed;
21
+ private readonly tableResults;
22
+ private readonly editTokens;
23
+ private panelRows?;
20
24
  constructor(options?: StateQLOptions);
21
25
  close(): void;
22
26
  [Symbol.dispose](): void;
23
27
  connect(target?: string, options?: ConnectOptions): Promise<Response<ConnectionData>>;
24
28
  addProfile(name: string, target?: string, options?: ProfileOptions): Promise<Response<ProfileData>>;
29
+ updateProfile(name: string, changes: ProfileUpdateOptions): Promise<Response<ProfileData>>;
25
30
  listProfiles(): Promise<Response<ProfilesData>>;
26
31
  showProfile(name: string): Promise<Response<ProfileData>>;
27
32
  removeProfile(name: string): Promise<Response<RemovedProfileData>>;
28
33
  disconnect(): Promise<Response<DisconnectData>>;
29
- snapshot(options?: {
30
- historyLimit?: number;
31
- }): StateQLSnapshot;
34
+ snapshot(options?: StateQLSnapshotOptions): StateQLSnapshot;
32
35
  status(): Promise<Response<StatusData>>;
33
36
  linkActor(session: string, actorId: string): Promise<Response<ActorLinkData>>;
34
37
  unlinkActor(session: string, actorId: string): Promise<Response<ActorUnlinkData>>;
@@ -41,23 +44,68 @@ export declare class StateQL {
41
44
  closeSession(): Promise<Response<CloseSessionData>>;
42
45
  query(sql: string, options?: QueryOptions): Promise<Response<ResultData>>;
43
46
  mongoQuery(command: MongoReadCommand, options?: MongoQueryOptions): Promise<Response<ResultData>>;
47
+ redisQuery(command: RedisCommand, options?: RedisQueryOptions): Promise<Response<ResultData>>;
44
48
  show(idOrAlias: string): Promise<Response<ResultData>>;
45
49
  filter(idOrAlias: string, predicate: string, options?: FilterOptions): Promise<Response<ResultData>>;
46
50
  rows(idOrAlias: string, options?: RowsOptions): Promise<Response<RowsData>>;
47
51
  count(idOrAlias: string): Promise<Response<CountData>>;
48
52
  columns(idOrAlias: string): Promise<Response<ColumnsData>>;
49
53
  setAlias(name: string, id: string): Promise<Response<AliasData>>;
54
+ /** Owned, full-value pages for host UIs. Reading a page does not add a command to history. */
55
+ readMaterialized(id: string, options?: RowsOptions & {
56
+ signal?: AbortSignal;
57
+ }): RowsData & {
58
+ columns: Column[];
59
+ row_tokens: Array<string | null>;
60
+ writable_columns: string[];
61
+ editing_reason?: string;
62
+ };
63
+ /** No caller-selected filesystem paths. The host delivers these bounded attachment bytes. */
64
+ serializeResult(id: string, format: "json" | "jsonl" | "csv", signal?: AbortSignal, origin?: CommandOrigin): Promise<Response<{
65
+ content: string;
66
+ format: string;
67
+ rows: number;
68
+ }>>;
69
+ readTable(table: {
70
+ schema?: string;
71
+ name: string;
72
+ }, limit?: number, options?: QueryOptions & {
73
+ origin?: CommandOrigin;
74
+ }): Promise<Response<ResultData & {
75
+ table: {
76
+ schema?: string;
77
+ name: string;
78
+ };
79
+ sample_limit: number;
80
+ query: string;
81
+ }>>;
82
+ planTableUpdate(token: string, changes: TableChange, options?: ExecutionOptions & {
83
+ origin?: CommandOrigin;
84
+ }): Promise<Response<PlanData>>;
85
+ planTableUpdates(changes: Array<{
86
+ row_token: string;
87
+ changes: TableChange;
88
+ }>, options?: ExecutionOptions & {
89
+ origin?: CommandOrigin;
90
+ }): Promise<Response<PlanData>>;
91
+ private editableMetadata;
92
+ private panelResult;
93
+ private fullResultRows;
50
94
  exportResult(idOrAlias: string, output: string, format?: "json" | "jsonl" | "csv"): Promise<Response<ExportData>>;
51
95
  exec(sql: string, options?: ExecOptions): Promise<Response<ExecData>>;
52
96
  mongoExec(command: MongoWriteCommand, options?: MongoExecOptions): Promise<Response<ExecData>>;
97
+ redisExec(command: RedisCommand, options?: RedisExecOptions): Promise<Response<ExecData>>;
53
98
  receipt(id: string): Promise<Response<OperationData>>;
54
99
  beginTransaction(isolation?: string): Promise<Response<TransactionData>>;
55
100
  transactionStatus(id?: string): Promise<Response<TransactionData>>;
56
101
  commitTransaction(id?: string, options?: ExecutionOptions): Promise<Response<CommitTransactionData>>;
57
102
  rollbackTransaction(id?: string): Promise<Response<RollbackTransactionData>>;
58
103
  inspect(kind: string, table?: string, options?: ExecutionOptions): Promise<Response<unknown>>;
104
+ listObjects(filter?: ListObjectsFilter, options?: ExecutionOptions): Promise<Response<ListObjectsData>>;
105
+ describeObject(object: CatalogObject, options?: ExecutionOptions): Promise<Response<DescribeObjectData>>;
59
106
  plan(sql: string, options?: PlanOptions): Promise<Response<PlanData>>;
60
107
  mongoPlan(command: MongoWriteCommand, options?: MongoPlanOptions): Promise<Response<PlanData>>;
108
+ redisPlan(command: RedisCommand, options?: RedisPlanOptions): Promise<Response<PlanData>>;
61
109
  apply(planId: string, options?: ExecutionOptions): Promise<Response<ApplyData>>;
62
110
  history(limit?: number, options?: HistoryOptions): Promise<Response<HistoryData>>;
63
111
  doctor(): Promise<Response<DoctorData>>;
@@ -67,12 +115,15 @@ export declare class StateQL {
67
115
  batch(commands: Iterable<BatchCommand> | AsyncIterable<BatchCommand>, options?: BatchOptions): AsyncGenerator<Response<unknown>>;
68
116
  private performExec;
69
117
  private performMongoExec;
118
+ private performRedisExec;
119
+ private performTableBatch;
70
120
  private batchFailure;
71
121
  private withResult;
72
122
  private rejectDuringStagedTransaction;
73
123
  private requireResult;
74
124
  private requireConnection;
75
125
  private requireMongoConnection;
126
+ private requireRedisConnection;
76
127
  private rejectMongoSql;
77
128
  private requireActiveTransaction;
78
129
  private requireSelectedSession;
@@ -82,6 +133,7 @@ export declare class StateQL {
82
133
  private resolveCredential;
83
134
  private openAdapter;
84
135
  private openMongoAdapter;
136
+ private openRedisAdapter;
85
137
  private executionContext;
86
138
  private resultData;
87
139
  private cacheValid;