@fadhilp/stateql 0.9.0 → 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.
@@ -257,7 +257,7 @@ export class MongoAdapter {
257
257
  throw new AdapterWriteError(errorText(error), knownNoWrite(error) ? false : true);
258
258
  }
259
259
  }
260
- async writeBatch(commands, isolation) {
260
+ async writeBatch(commands, isolation, expectedRows = false) {
261
261
  if (isolation.toLowerCase() !== "snapshot") {
262
262
  throw new BatchWriteError(`Unsupported MongoDB isolation level "${isolation}".`, false);
263
263
  }
@@ -295,7 +295,10 @@ export class MongoAdapter {
295
295
  for (const command of values) {
296
296
  throwIfStopped(this.context, dispatched);
297
297
  dispatched = true;
298
- results.push(await withContext(this.executeWrite(command, session), this.context, () => this.stop(), true));
298
+ const result = await withContext(this.executeWrite(command, session), this.context, () => this.stop(), true);
299
+ if (expectedRows && result.outcome.matched_count !== 1)
300
+ throw new Error("ROW_CONFLICT: A document changed or was removed.");
301
+ results.push(result);
299
302
  }
300
303
  throwIfStopped(this.context, dispatched);
301
304
  committing = true;
@@ -373,6 +376,51 @@ export class MongoAdapter {
373
376
  throw readError(error, this.context);
374
377
  }
375
378
  }
379
+ async listObjects(filter) {
380
+ if (filter.kind !== undefined && filter.kind !== "collection" && filter.kind !== "view")
381
+ throw new Error(`MongoDB does not support catalog kind "${filter.kind}".`);
382
+ if (filter.schema !== undefined)
383
+ throw new Error("MongoDB collections do not use schemas.");
384
+ const offset = filter.offset ?? 0;
385
+ const limit = filter.limit ?? 50;
386
+ if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200)
387
+ throw new Error("Invalid MongoDB catalog page bounds.");
388
+ if (filter.search !== undefined && (!filter.search || filter.search.length > 200 || filter.search.includes("\0")))
389
+ throw new Error("Invalid MongoDB catalog search.");
390
+ await this.connect();
391
+ const query = {};
392
+ if (filter.kind === "collection")
393
+ query.type = "collection";
394
+ if (filter.kind === "view")
395
+ query.type = "view";
396
+ const cursor = this.client.db(this.databaseName).listCollections(query, {
397
+ nameOnly: true,
398
+ signal: operationSignal(this.context),
399
+ maxTimeMS: remainingMilliseconds(this.context),
400
+ timeoutMS: remainingMilliseconds(this.context),
401
+ batchSize: Math.min(limit + 1, 201),
402
+ });
403
+ const fetched = await collectCursor(cursor, offset + limit + 1, this.context);
404
+ const rows = fetched.slice(offset);
405
+ const more = rows.length > limit;
406
+ return {
407
+ objects: rows.slice(0, limit).map((row) => ({ kind: row.type === "view" ? "view" : "collection", name: String(row.name), identity: String(row.name) })),
408
+ next_offset: more ? offset + limit : null,
409
+ supported_kinds: ["collection", "view"],
410
+ };
411
+ }
412
+ async describeObject(object) {
413
+ if ((object.kind !== "collection" && object.kind !== "view") || object.schema !== undefined)
414
+ throw new Error(`MongoDB does not support catalog kind "${object.kind}".`);
415
+ const rows = await collectCursor(this.client.db(this.databaseName).listCollections({ name: object.name }, { nameOnly: false, signal: operationSignal(this.context), maxTimeMS: remainingMilliseconds(this.context), timeoutMS: remainingMilliseconds(this.context), batchSize: 1 }), 1, this.context);
416
+ const found = rows[0];
417
+ if (!found)
418
+ throw new Error("Catalog object was not found.");
419
+ return ejsonSafe({
420
+ object: { kind: found.type === "view" ? "view" : "collection", name: found.name, identity: found.name },
421
+ definition: { type: found.type, options: found.options ?? {} },
422
+ });
423
+ }
376
424
  async close() {
377
425
  if (this.closed)
378
426
  return;
@@ -786,6 +834,9 @@ function ejsonSafe(value) {
786
834
  return null;
787
835
  return JSON.parse(BSON.EJSON.stringify(value, { relaxed: false }));
788
836
  }
837
+ function escapeRegex(value) {
838
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
839
+ }
789
840
  function remainingMilliseconds(context) {
790
841
  return Math.max(1, Math.min(2_147_483_647, Math.ceil(context.deadline - Date.now())));
791
842
  }
@@ -0,0 +1,44 @@
1
+ import { type AdapterContext } from "./adapters.js";
2
+ import type { CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, RedisCommand, RedisWriteOutcome, Row, Column } from "./types.js";
3
+ export interface RedisReadResult {
4
+ rows: Row[];
5
+ columns: Column[];
6
+ nextCursor?: string | null;
7
+ }
8
+ export interface RedisPrecondition {
9
+ key: string;
10
+ fingerprint: string;
11
+ /** Absolute expiry in epoch milliseconds, or Redis -1/-2 sentinel. */
12
+ expiresAt: number;
13
+ }
14
+ export interface RedisWriteResult {
15
+ affectedRows: number;
16
+ outcome: RedisWriteOutcome;
17
+ }
18
+ export declare function validateRedisReadCommand(value: unknown): RedisCommand;
19
+ export declare function validateRedisWriteCommand(value: unknown): RedisCommand;
20
+ export declare function serializeRedisCommand(command: RedisCommand): string;
21
+ export declare function deserializeRedisCommand(value: string): RedisCommand;
22
+ export declare class RedisAdapter {
23
+ private readonly readOnly;
24
+ private readonly context;
25
+ readonly confidence: "ttl_based";
26
+ private readonly client;
27
+ private connected;
28
+ private closed;
29
+ constructor(source: string, readOnly: boolean, context: AdapterContext);
30
+ ping(): Promise<void>;
31
+ signature(): Promise<string>;
32
+ read(input: RedisCommand): Promise<RedisReadResult>;
33
+ precondition(input: RedisCommand): Promise<RedisPrecondition>;
34
+ write(input: RedisCommand, precondition?: RedisPrecondition): Promise<RedisWriteResult>;
35
+ listObjects(filter: ListObjectsFilter): Promise<ListObjectsData>;
36
+ describeObject(object: CatalogObject): Promise<DescribeObjectData>;
37
+ close(): Promise<void>;
38
+ private scanValue;
39
+ private expirationIdentity;
40
+ private keyFingerprint;
41
+ private connect;
42
+ private execute;
43
+ private stop;
44
+ }
@@ -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); }
@@ -100,6 +100,8 @@ function execute(request) {
100
100
  try {
101
101
  for (const operation of operations) {
102
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.");
103
105
  results.push({ affectedRows: Number(result.changes) });
104
106
  }
105
107
  }
@@ -147,6 +149,14 @@ function execute(request) {
147
149
  const [kind, table] = request.args;
148
150
  return inspect(database, kind, table);
149
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
+ }
150
160
  }
151
161
  }
152
162
  function inspect(database, kind, table) {
@@ -206,6 +216,37 @@ function inspect(database, kind, table) {
206
216
  foreign_keys: foreignKeys.length,
207
217
  };
208
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
+ }
209
250
  function bindAll(statement, params) {
210
251
  if (Array.isArray(params))
211
252
  return statement.all(...params);
@@ -1,5 +1,5 @@
1
1
  import { type TableChange } from "./table-editor.js";
2
- import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, 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, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.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";
3
3
  export declare class StateQL {
4
4
  static forActor(options: StateQLActorOptions): StateQL;
5
5
  private readonly store;
@@ -26,13 +26,12 @@ export declare class StateQL {
26
26
  [Symbol.dispose](): void;
27
27
  connect(target?: string, options?: ConnectOptions): Promise<Response<ConnectionData>>;
28
28
  addProfile(name: string, target?: string, options?: ProfileOptions): Promise<Response<ProfileData>>;
29
+ updateProfile(name: string, changes: ProfileUpdateOptions): Promise<Response<ProfileData>>;
29
30
  listProfiles(): Promise<Response<ProfilesData>>;
30
31
  showProfile(name: string): Promise<Response<ProfileData>>;
31
32
  removeProfile(name: string): Promise<Response<RemovedProfileData>>;
32
33
  disconnect(): Promise<Response<DisconnectData>>;
33
- snapshot(options?: {
34
- historyLimit?: number;
35
- }): StateQLSnapshot;
34
+ snapshot(options?: StateQLSnapshotOptions): StateQLSnapshot;
36
35
  status(): Promise<Response<StatusData>>;
37
36
  linkActor(session: string, actorId: string): Promise<Response<ActorLinkData>>;
38
37
  unlinkActor(session: string, actorId: string): Promise<Response<ActorUnlinkData>>;
@@ -45,6 +44,7 @@ export declare class StateQL {
45
44
  closeSession(): Promise<Response<CloseSessionData>>;
46
45
  query(sql: string, options?: QueryOptions): Promise<Response<ResultData>>;
47
46
  mongoQuery(command: MongoReadCommand, options?: MongoQueryOptions): Promise<Response<ResultData>>;
47
+ redisQuery(command: RedisCommand, options?: RedisQueryOptions): Promise<Response<ResultData>>;
48
48
  show(idOrAlias: string): Promise<Response<ResultData>>;
49
49
  filter(idOrAlias: string, predicate: string, options?: FilterOptions): Promise<Response<ResultData>>;
50
50
  rows(idOrAlias: string, options?: RowsOptions): Promise<Response<RowsData>>;
@@ -82,20 +82,30 @@ export declare class StateQL {
82
82
  planTableUpdate(token: string, changes: TableChange, options?: ExecutionOptions & {
83
83
  origin?: CommandOrigin;
84
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>>;
85
91
  private editableMetadata;
86
92
  private panelResult;
87
93
  private fullResultRows;
88
94
  exportResult(idOrAlias: string, output: string, format?: "json" | "jsonl" | "csv"): Promise<Response<ExportData>>;
89
95
  exec(sql: string, options?: ExecOptions): Promise<Response<ExecData>>;
90
96
  mongoExec(command: MongoWriteCommand, options?: MongoExecOptions): Promise<Response<ExecData>>;
97
+ redisExec(command: RedisCommand, options?: RedisExecOptions): Promise<Response<ExecData>>;
91
98
  receipt(id: string): Promise<Response<OperationData>>;
92
99
  beginTransaction(isolation?: string): Promise<Response<TransactionData>>;
93
100
  transactionStatus(id?: string): Promise<Response<TransactionData>>;
94
101
  commitTransaction(id?: string, options?: ExecutionOptions): Promise<Response<CommitTransactionData>>;
95
102
  rollbackTransaction(id?: string): Promise<Response<RollbackTransactionData>>;
96
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>>;
97
106
  plan(sql: string, options?: PlanOptions): Promise<Response<PlanData>>;
98
107
  mongoPlan(command: MongoWriteCommand, options?: MongoPlanOptions): Promise<Response<PlanData>>;
108
+ redisPlan(command: RedisCommand, options?: RedisPlanOptions): Promise<Response<PlanData>>;
99
109
  apply(planId: string, options?: ExecutionOptions): Promise<Response<ApplyData>>;
100
110
  history(limit?: number, options?: HistoryOptions): Promise<Response<HistoryData>>;
101
111
  doctor(): Promise<Response<DoctorData>>;
@@ -105,12 +115,15 @@ export declare class StateQL {
105
115
  batch(commands: Iterable<BatchCommand> | AsyncIterable<BatchCommand>, options?: BatchOptions): AsyncGenerator<Response<unknown>>;
106
116
  private performExec;
107
117
  private performMongoExec;
118
+ private performRedisExec;
119
+ private performTableBatch;
108
120
  private batchFailure;
109
121
  private withResult;
110
122
  private rejectDuringStagedTransaction;
111
123
  private requireResult;
112
124
  private requireConnection;
113
125
  private requireMongoConnection;
126
+ private requireRedisConnection;
114
127
  private rejectMongoSql;
115
128
  private requireActiveTransaction;
116
129
  private requireSelectedSession;
@@ -120,6 +133,7 @@ export declare class StateQL {
120
133
  private resolveCredential;
121
134
  private openAdapter;
122
135
  private openMongoAdapter;
136
+ private openRedisAdapter;
123
137
  private executionContext;
124
138
  private resultData;
125
139
  private cacheValid;