@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.
- package/README.md +140 -5
- package/dist/src/adapters.d.ts +8 -3
- package/dist/src/adapters.js +205 -5
- package/dist/src/cli.js +66 -3
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +18 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/migrations.js +38 -0
- package/dist/src/mongodb.d.ts +5 -3
- package/dist/src/mongodb.js +66 -4
- package/dist/src/redis.d.ts +44 -0
- package/dist/src/redis.js +395 -0
- package/dist/src/sqlite-process.js +49 -1
- package/dist/src/stateql.d.ts +56 -4
- package/dist/src/stateql.js +858 -63
- package/dist/src/store.d.ts +24 -2
- package/dist/src/store.js +92 -12
- package/dist/src/table-editor.d.ts +41 -0
- package/dist/src/table-editor.js +137 -0
- package/dist/src/types.d.ts +75 -3
- package/package.json +4 -2
|
@@ -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);
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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;
|