@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.
@@ -16,8 +16,10 @@ export function detectDriver(target) {
16
16
  return "mysql";
17
17
  if (/^mongodb(?:\+srv)?:\/\//i.test(target))
18
18
  return "mongodb";
19
+ if (/^rediss?:\/\//i.test(target))
20
+ return "redis";
19
21
  if (/^[a-z][a-z\d+.-]*:\/\//i.test(target)) {
20
- throw new StateQLError("UNSUPPORTED_DRIVER", "Only MongoDB, MySQL, PostgreSQL, and SQLite are supported.");
22
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Only MongoDB, MySQL, PostgreSQL, Redis, and SQLite are supported.");
21
23
  }
22
24
  return "sqlite";
23
25
  }
@@ -44,6 +46,20 @@ export function mongoDatabaseName(target) {
44
46
  }
45
47
  throw new StateQLError("INVALID_COMMAND", "MongoDB URL must include an explicit database name.");
46
48
  }
49
+ export function redisDatabaseName(target) {
50
+ try {
51
+ const url = new URL(target);
52
+ if (!url.hostname || !["redis:", "rediss:"].includes(url.protocol.toLowerCase()))
53
+ throw new Error();
54
+ const path = url.pathname.replace(/^\//, "");
55
+ if (path && !/^\d+$/.test(path))
56
+ throw new Error();
57
+ return `db${path || "0"}`;
58
+ }
59
+ catch {
60
+ throw new StateQLError("INVALID_COMMAND", "Invalid Redis URL or database number.");
61
+ }
62
+ }
47
63
  export function credentialSource(value, expectedDriver, referenceSource = "secret_env") {
48
64
  const sourceLabel = referenceSource === "credential_ref"
49
65
  ? "Credential reference"
@@ -51,7 +67,7 @@ export function credentialSource(value, expectedDriver, referenceSource = "secre
51
67
  const explicitSqlite = /^sqlite:(?!\/\/)/i.test(value);
52
68
  const driver = explicitSqlite ? "sqlite" : detectDriver(value);
53
69
  if (driver === "sqlite" && (!explicitSqlite || value.length === 7)) {
54
- throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source; MongoDB URLs are also supported.`, {
70
+ throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a complete PostgreSQL/MySQL/Redis URL or an explicit sqlite: source; MongoDB URLs are also supported.`, {
55
71
  suggestedAction: "Store the full database URL, or prefix an SQLite path with sqlite:.",
56
72
  });
57
73
  }
@@ -1,4 +1,5 @@
1
1
  export { StateQL } from "./stateql.js";
2
2
  export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
3
3
  export type { CredentialResolutionFailure } from "./errors.js";
4
- export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialSource, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfilesData, PurgeData, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
4
+ export type { TableChange, TableIdentity, TableUpdate } from "./table-editor.js";
5
+ export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CatalogObject, CatalogObjectKind, DescribeObjectData, ListObjectsData, ListObjectsFilter, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialSource, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryCategory, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfileUpdateOptions, ProfilesData, PurgeData, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, RedisWriteOutcome, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StateQLSnapshotOptions, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
@@ -80,6 +80,41 @@ const MIGRATIONS = [
80
80
  apply: migrateCredentialRefs,
81
81
  validate: validateCredentialRefs,
82
82
  },
83
+ {
84
+ name: "history_target_v1",
85
+ apply(db) { addColumn(db, "history", "target", "TEXT"); },
86
+ validate(db) { requireColumns(db, "history", ["target"]); },
87
+ },
88
+ {
89
+ name: "generated_aliases_v1",
90
+ apply(db) {
91
+ addColumn(db, "aliases", "generated", "INTEGER NOT NULL DEFAULT 0");
92
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS aliases_generated_result ON aliases(result_id) WHERE generated = 1");
93
+ },
94
+ validate(db) {
95
+ requireColumns(db, "aliases", ["generated"]);
96
+ requireIndexes(db, ["aliases_generated_result"]);
97
+ },
98
+ },
99
+ {
100
+ name: "history_classification_v1",
101
+ apply(db) {
102
+ addColumn(db, "history", "category", "TEXT NOT NULL DEFAULT 'management'");
103
+ addColumn(db, "history", "internal", "INTEGER NOT NULL DEFAULT 0");
104
+ db.exec(`
105
+ UPDATE history SET category = 'statement'
106
+ WHERE category = 'management' AND command IN
107
+ ('query','exec','plan','apply','filter','mongo.query','mongo.exec','mongo.plan','redis.query','redis.exec','redis.plan');
108
+ UPDATE history SET category = 'introspection'
109
+ WHERE category = 'management' AND (command LIKE 'inspect.%' OR command IN ('objects.list','object.describe','table.read'));
110
+ `);
111
+ db.exec("CREATE INDEX IF NOT EXISTS history_session_category ON history(session_id, category, internal)");
112
+ },
113
+ validate(db) {
114
+ requireColumns(db, "history", ["category", "internal"]);
115
+ requireIndexes(db, ["history_session_category"]);
116
+ },
117
+ },
83
118
  ];
84
119
  export function runMigrations(db, now) {
85
120
  db.exec(`
@@ -181,6 +216,7 @@ function createInitialSchema(db) {
181
216
  session_id TEXT NOT NULL,
182
217
  name TEXT NOT NULL,
183
218
  result_id TEXT NOT NULL,
219
+ generated INTEGER NOT NULL DEFAULT 0,
184
220
  PRIMARY KEY(session_id, name),
185
221
  FOREIGN KEY(result_id) REFERENCES results(id)
186
222
  );
@@ -244,6 +280,8 @@ function createInitialSchema(db) {
244
280
  actor_id TEXT NOT NULL,
245
281
  command TEXT NOT NULL,
246
282
  origin TEXT NOT NULL DEFAULT 'legacy',
283
+ category TEXT NOT NULL DEFAULT 'management',
284
+ internal INTEGER NOT NULL DEFAULT 0,
247
285
  sql TEXT,
248
286
  handle TEXT,
249
287
  executed INTEGER NOT NULL,
@@ -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[];
@@ -34,9 +34,11 @@ export declare class MongoAdapter {
34
34
  ping(): Promise<void>;
35
35
  signature(): Promise<string>;
36
36
  read(command: MongoReadCommand, maxRows: number): Promise<MongoReadResult>;
37
- write(command: MongoWriteCommand): Promise<MongoWriteResult>;
38
- writeBatch(commands: MongoWriteCommand[], isolation: string): Promise<MongoWriteResult[]>;
37
+ write(command: MongoWriteCommand, expectedRows?: 1): 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;
@@ -221,7 +221,7 @@ export class MongoAdapter {
221
221
  await cursor?.close().catch(() => undefined);
222
222
  }
223
223
  }
224
- async write(command) {
224
+ async write(command, expectedRows) {
225
225
  let value;
226
226
  try {
227
227
  value = validateMongoWriteCommand(command);
@@ -240,17 +240,24 @@ export class MongoAdapter {
240
240
  throw error;
241
241
  throw new AdapterWriteError(errorText(error), false);
242
242
  }
243
+ if (expectedRows === 1 && (value.operation !== "updateOne" || value.options?.upsert))
244
+ throw new AdapterWriteError("Conditional edits require updateOne without upsert.", false);
243
245
  try {
244
- return await withContext(this.executeWrite(value), this.context, () => this.stop(), true);
246
+ const result = await withContext(this.executeWrite(value), this.context, () => this.stop(), true);
247
+ if (expectedRows === 1 && result.outcome.matched_count !== 1)
248
+ throw new AdapterWriteError("ROW_CONFLICT: The document changed or was removed.", false);
249
+ return result;
245
250
  }
246
251
  catch (error) {
252
+ if (error instanceof AdapterWriteError)
253
+ throw error;
247
254
  const stopped = writeStoppedError(error, this.context, true);
248
255
  if (stopped)
249
256
  throw stopped;
250
257
  throw new AdapterWriteError(errorText(error), knownNoWrite(error) ? false : true);
251
258
  }
252
259
  }
253
- async writeBatch(commands, isolation) {
260
+ async writeBatch(commands, isolation, expectedRows = false) {
254
261
  if (isolation.toLowerCase() !== "snapshot") {
255
262
  throw new BatchWriteError(`Unsupported MongoDB isolation level "${isolation}".`, false);
256
263
  }
@@ -288,7 +295,10 @@ export class MongoAdapter {
288
295
  for (const command of values) {
289
296
  throwIfStopped(this.context, dispatched);
290
297
  dispatched = true;
291
- 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);
292
302
  }
293
303
  throwIfStopped(this.context, dispatched);
294
304
  committing = true;
@@ -342,6 +352,10 @@ export class MongoAdapter {
342
352
  if (kind === "constraints") {
343
353
  return { collection: collectionName, constraints: [] };
344
354
  }
355
+ if (kind === "editable") {
356
+ const objects = await this.client.db(this.databaseName).listCollections({ name: collectionName }, { nameOnly: true, signal: operationSignal(this.context), maxTimeMS: remainingMilliseconds(this.context) }).toArray();
357
+ return { writable: objects[0]?.type === "collection", columns: [] };
358
+ }
345
359
  const columns = await this.sampleColumns(collectionName);
346
360
  if (kind === "columns")
347
361
  return { collection: collectionName, columns };
@@ -362,6 +376,51 @@ export class MongoAdapter {
362
376
  throw readError(error, this.context);
363
377
  }
364
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
+ }
365
424
  async close() {
366
425
  if (this.closed)
367
426
  return;
@@ -775,6 +834,9 @@ function ejsonSafe(value) {
775
834
  return null;
776
835
  return JSON.parse(BSON.EJSON.stringify(value, { relaxed: false }));
777
836
  }
837
+ function escapeRegex(value) {
838
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
839
+ }
778
840
  function remainingMilliseconds(context) {
779
841
  return Math.max(1, Math.min(2_147_483_647, Math.ceil(context.deadline - Date.now())));
780
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
+ }