@fadhilp/stateql 0.9.0 → 0.10.1
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 +149 -5
- package/dist/src/adapters.d.ts +7 -2
- package/dist/src/adapters.js +167 -0
- 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 +44 -0
- package/dist/src/mongodb.d.ts +4 -2
- package/dist/src/mongodb.js +53 -2
- package/dist/src/redis.d.ts +44 -0
- package/dist/src/redis.js +395 -0
- package/dist/src/sqlite-process.js +41 -0
- package/dist/src/stateql.d.ts +18 -4
- package/dist/src/stateql.js +650 -62
- package/dist/src/store.d.ts +25 -2
- package/dist/src/store.js +116 -12
- package/dist/src/table-editor.d.ts +5 -0
- package/dist/src/table-editor.js +22 -0
- package/dist/src/types.d.ts +80 -3
- package/package.json +4 -2
package/dist/src/mongodb.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AdapterContext } from "./adapters.js";
|
|
2
2
|
import type { ConnectionRecord } from "./store.js";
|
|
3
|
-
import type { Column, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, Row } from "./types.js";
|
|
3
|
+
import type { CatalogObject, Column, DescribeObjectData, ListObjectsData, ListObjectsFilter, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, Row } from "./types.js";
|
|
4
4
|
interface MongoReadResult {
|
|
5
5
|
rows: Row[];
|
|
6
6
|
columns: Column[];
|
|
@@ -35,8 +35,10 @@ export declare class MongoAdapter {
|
|
|
35
35
|
signature(): Promise<string>;
|
|
36
36
|
read(command: MongoReadCommand, maxRows: number): Promise<MongoReadResult>;
|
|
37
37
|
write(command: MongoWriteCommand, expectedRows?: 1): Promise<MongoWriteResult>;
|
|
38
|
-
writeBatch(commands: MongoWriteCommand[], isolation: string): Promise<MongoWriteResult[]>;
|
|
38
|
+
writeBatch(commands: MongoWriteCommand[], isolation: string, expectedRows?: boolean): Promise<MongoWriteResult[]>;
|
|
39
39
|
inspect(kind: string, name?: string): Promise<unknown>;
|
|
40
|
+
listObjects(filter: ListObjectsFilter): Promise<ListObjectsData>;
|
|
41
|
+
describeObject(object: CatalogObject): Promise<DescribeObjectData>;
|
|
40
42
|
close(): Promise<void>;
|
|
41
43
|
private connect;
|
|
42
44
|
private executeWrite;
|
package/dist/src/mongodb.js
CHANGED
|
@@ -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
|
-
|
|
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);
|