turbine-orm 0.14.0 → 0.15.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/dist/adapters/cockroachdb.js +1 -1
- package/dist/adapters/index.d.ts +7 -4
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/yugabytedb.js +1 -1
- package/dist/cjs/adapters/cockroachdb.js +1 -1
- package/dist/cjs/adapters/index.js +1 -1
- package/dist/cjs/adapters/yugabytedb.js +1 -1
- package/dist/cjs/cli/studio.js +45 -7
- package/dist/cjs/client.js +48 -1
- package/dist/cjs/errors.js +44 -1
- package/dist/cjs/generate.js +86 -0
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/nested-write.js +467 -0
- package/dist/cjs/query/builder.js +205 -10
- package/dist/cli/studio.d.ts +10 -2
- package/dist/cli/studio.js +45 -7
- package/dist/client.d.ts +23 -0
- package/dist/client.js +47 -1
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +41 -0
- package/dist/generate.js +86 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -2
- package/dist/nested-write.d.ts +95 -0
- package/dist/nested-write.js +461 -0
- package/dist/query/builder.d.ts +28 -12
- package/dist/query/builder.js +173 -11
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +76 -8
- package/package.json +2 -2
|
@@ -11,10 +11,44 @@
|
|
|
11
11
|
* Schema-driven: all column names, types, and relations come from introspected
|
|
12
12
|
* metadata — nothing is hardcoded.
|
|
13
13
|
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
14
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
48
|
exports.QueryInterface = void 0;
|
|
16
49
|
const dialect_js_1 = require("../dialect.js");
|
|
17
50
|
const errors_js_1 = require("../errors.js");
|
|
51
|
+
const nested_write_js_1 = require("../nested-write.js");
|
|
18
52
|
const schema_js_1 = require("../schema.js");
|
|
19
53
|
const utils_js_1 = require("./utils.js");
|
|
20
54
|
// ---------------------------------------------------------------------------
|
|
@@ -100,6 +134,28 @@ function findArrayUniqueKey(value) {
|
|
|
100
134
|
}
|
|
101
135
|
return null;
|
|
102
136
|
}
|
|
137
|
+
/** Known text search operator keys */
|
|
138
|
+
const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
|
|
139
|
+
/** Check if a value is a TextSearchFilter object */
|
|
140
|
+
function isTextSearchFilter(value) {
|
|
141
|
+
if (value === null ||
|
|
142
|
+
value === undefined ||
|
|
143
|
+
typeof value !== 'object' ||
|
|
144
|
+
Array.isArray(value) ||
|
|
145
|
+
value instanceof Date) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const keys = Object.keys(value);
|
|
149
|
+
// Must have 'search' key and only known text search keys
|
|
150
|
+
return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Validate a text search config name. Only alphanumeric characters and
|
|
154
|
+
* underscores are allowed to prevent SQL injection via the config parameter.
|
|
155
|
+
*/
|
|
156
|
+
function validateTextSearchConfig(config) {
|
|
157
|
+
return /^[a-zA-Z0-9_]+$/.test(config);
|
|
158
|
+
}
|
|
103
159
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
104
160
|
class QueryInterface {
|
|
105
161
|
pool;
|
|
@@ -131,6 +187,10 @@ class QueryInterface {
|
|
|
131
187
|
columnArrayTypeMap;
|
|
132
188
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
133
189
|
deepWithWarned = new Set();
|
|
190
|
+
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
191
|
+
txScoped;
|
|
192
|
+
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
193
|
+
options;
|
|
134
194
|
constructor(pool, table, schema, middlewares, options) {
|
|
135
195
|
this.pool = pool;
|
|
136
196
|
this.table = table;
|
|
@@ -149,6 +209,8 @@ class QueryInterface {
|
|
|
149
209
|
this.preparedStatementsEnabled = options?.preparedStatements ?? true;
|
|
150
210
|
this.sqlCacheEnabled = options?.sqlCache !== false;
|
|
151
211
|
this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
212
|
+
this.txScoped = options?._txScoped ?? false;
|
|
213
|
+
this.options = options;
|
|
152
214
|
// Pre-compute column type lookup maps (TASK-26)
|
|
153
215
|
this.columnPgTypeMap = new Map();
|
|
154
216
|
this.columnArrayTypeMap = new Map();
|
|
@@ -726,6 +788,9 @@ class QueryInterface {
|
|
|
726
788
|
// -------------------------------------------------------------------------
|
|
727
789
|
async create(args) {
|
|
728
790
|
return this.executeWithMiddleware('create', args, async () => {
|
|
791
|
+
if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
|
|
792
|
+
return this.nestedCreate(args);
|
|
793
|
+
}
|
|
729
794
|
const deferred = this.buildCreate(args);
|
|
730
795
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
731
796
|
return deferred.transform(result);
|
|
@@ -808,6 +873,9 @@ class QueryInterface {
|
|
|
808
873
|
// -------------------------------------------------------------------------
|
|
809
874
|
async update(args) {
|
|
810
875
|
return this.executeWithMiddleware('update', args, async () => {
|
|
876
|
+
if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
|
|
877
|
+
return this.nestedUpdate(args);
|
|
878
|
+
}
|
|
811
879
|
const deferred = this.buildUpdate(args);
|
|
812
880
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
813
881
|
return deferred.transform(result);
|
|
@@ -816,32 +884,62 @@ class QueryInterface {
|
|
|
816
884
|
buildUpdate(args) {
|
|
817
885
|
const dataObj = args.data;
|
|
818
886
|
const whereObj = args.where;
|
|
887
|
+
const lock = args.optimisticLock;
|
|
819
888
|
const setFp = this.fingerprintSet(dataObj);
|
|
820
889
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
821
|
-
const ck = `u:${setFp}|${whereFp}`;
|
|
890
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}`;
|
|
822
891
|
const params = [];
|
|
823
|
-
const
|
|
892
|
+
const buildSql = () => {
|
|
824
893
|
const freshParams = [];
|
|
825
894
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
826
895
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
896
|
+
if (lock) {
|
|
897
|
+
const versionCol = this.toSqlColumn(lock.field);
|
|
898
|
+
setClauses.push(`${versionCol} = ${versionCol} + 1`);
|
|
899
|
+
}
|
|
827
900
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
828
|
-
|
|
901
|
+
let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
902
|
+
if (lock) {
|
|
903
|
+
const versionCol = this.toSqlColumn(lock.field);
|
|
904
|
+
freshParams.push(lock.expected);
|
|
905
|
+
const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
|
|
906
|
+
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
907
|
+
}
|
|
829
908
|
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
830
909
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
910
|
+
};
|
|
911
|
+
let sql;
|
|
912
|
+
let preparedName;
|
|
913
|
+
if (ck) {
|
|
914
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
915
|
+
sql = entry.sql;
|
|
916
|
+
preparedName = entry.name;
|
|
917
|
+
if (whereFp === '') {
|
|
918
|
+
this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
|
|
919
|
+
}
|
|
835
920
|
}
|
|
836
|
-
|
|
921
|
+
else {
|
|
922
|
+
sql = buildSql();
|
|
923
|
+
}
|
|
924
|
+
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
837
925
|
this.collectSetParams(dataObj, params);
|
|
838
926
|
this.collectWhereParams(whereObj, params);
|
|
927
|
+
if (lock) {
|
|
928
|
+
params.push(lock.expected);
|
|
929
|
+
}
|
|
839
930
|
return {
|
|
840
|
-
sql
|
|
931
|
+
sql,
|
|
841
932
|
params,
|
|
842
933
|
transform: (result) => {
|
|
843
934
|
const row = result.rows[0];
|
|
844
935
|
if (!row) {
|
|
936
|
+
if (lock) {
|
|
937
|
+
throw new errors_js_1.OptimisticLockError({
|
|
938
|
+
table: this.table,
|
|
939
|
+
versionField: lock.field,
|
|
940
|
+
expectedVersion: lock.expected,
|
|
941
|
+
});
|
|
942
|
+
}
|
|
845
943
|
throw new errors_js_1.NotFoundError({
|
|
846
944
|
table: this.table,
|
|
847
945
|
where: args.where,
|
|
@@ -851,7 +949,75 @@ class QueryInterface {
|
|
|
851
949
|
return this.parseRow(row, this.table);
|
|
852
950
|
},
|
|
853
951
|
tag: `${this.table}.update`,
|
|
854
|
-
preparedName
|
|
952
|
+
preparedName,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
// -------------------------------------------------------------------------
|
|
956
|
+
// Nested write helpers (shared by create + update)
|
|
957
|
+
// -------------------------------------------------------------------------
|
|
958
|
+
async nestedCreate(args) {
|
|
959
|
+
const data = args.data;
|
|
960
|
+
if (this.txScoped) {
|
|
961
|
+
const ctx = this.buildNestedCtx();
|
|
962
|
+
return (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
|
|
963
|
+
}
|
|
964
|
+
return this.runInImplicitTx(async (ctx) => {
|
|
965
|
+
const result = await (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
|
|
966
|
+
return result;
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
async nestedUpdate(args) {
|
|
970
|
+
const data = args.data;
|
|
971
|
+
const where = args.where;
|
|
972
|
+
if (this.txScoped) {
|
|
973
|
+
const ctx = this.buildNestedCtx();
|
|
974
|
+
return (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
|
|
975
|
+
}
|
|
976
|
+
return this.runInImplicitTx(async (ctx) => {
|
|
977
|
+
const result = await (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
|
|
978
|
+
return result;
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
async runInImplicitTx(fn) {
|
|
982
|
+
const client = await this.pool.connect();
|
|
983
|
+
try {
|
|
984
|
+
await client.query('BEGIN');
|
|
985
|
+
const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('../client.js')));
|
|
986
|
+
// biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
|
|
987
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
|
|
988
|
+
// biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
|
|
989
|
+
const ctx = { schema: this.schema, tx: tx };
|
|
990
|
+
const result = await fn(ctx);
|
|
991
|
+
await client.query('COMMIT');
|
|
992
|
+
return result;
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
try {
|
|
996
|
+
await client.query('ROLLBACK');
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// Best-effort rollback — connection may have died.
|
|
1000
|
+
}
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
client.release();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
buildNestedCtx() {
|
|
1008
|
+
const pool = this.pool;
|
|
1009
|
+
const schema = this.schema;
|
|
1010
|
+
const middlewares = this.middlewares;
|
|
1011
|
+
const opts = { ...this.options, _txScoped: true };
|
|
1012
|
+
return {
|
|
1013
|
+
schema,
|
|
1014
|
+
tx: this.makeTxProxy(pool, schema, middlewares, opts),
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// biome-ignore lint/suspicious/noExplicitAny: bridges MiddlewareFn[] ↔ Middleware[] and QI ↔ NestedWriteContext type gap
|
|
1018
|
+
makeTxProxy(pool, schema, middlewares, opts) {
|
|
1019
|
+
return {
|
|
1020
|
+
table: (name) => new QueryInterface(pool, name, schema, middlewares, opts),
|
|
855
1021
|
};
|
|
856
1022
|
}
|
|
857
1023
|
// -------------------------------------------------------------------------
|
|
@@ -1541,6 +1707,12 @@ class QueryInterface {
|
|
|
1541
1707
|
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
1542
1708
|
continue;
|
|
1543
1709
|
}
|
|
1710
|
+
// Text search filter
|
|
1711
|
+
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
1712
|
+
const cfg = value.config ?? 'english';
|
|
1713
|
+
parts.push(`${key}:fts(${cfg})`);
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1544
1716
|
// Plain equality
|
|
1545
1717
|
parts.push(`${key}:eq`);
|
|
1546
1718
|
}
|
|
@@ -1656,6 +1828,11 @@ class QueryInterface {
|
|
|
1656
1828
|
continue;
|
|
1657
1829
|
}
|
|
1658
1830
|
}
|
|
1831
|
+
// Text search filter
|
|
1832
|
+
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
1833
|
+
params.push(value.search);
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1659
1836
|
// Operator objects
|
|
1660
1837
|
if (isWhereOperator(value)) {
|
|
1661
1838
|
this.collectOperatorParams(value, params);
|
|
@@ -2030,6 +2207,12 @@ class QueryInterface {
|
|
|
2030
2207
|
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
2031
2208
|
}
|
|
2032
2209
|
}
|
|
2210
|
+
// Handle full-text search filter
|
|
2211
|
+
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
2212
|
+
const tsClause = this.buildTextSearchClause(column, value, params);
|
|
2213
|
+
andClauses.push(tsClause);
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2033
2216
|
// Handle operator objects
|
|
2034
2217
|
if (isWhereOperator(value)) {
|
|
2035
2218
|
const opClauses = this.buildOperatorClauses(column, value, params);
|
|
@@ -2672,6 +2855,18 @@ class QueryInterface {
|
|
|
2672
2855
|
}
|
|
2673
2856
|
return clauses;
|
|
2674
2857
|
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2860
|
+
* The config name is validated to prevent injection (only alphanumeric + underscore).
|
|
2861
|
+
*/
|
|
2862
|
+
buildTextSearchClause(column, filter, params) {
|
|
2863
|
+
const config = filter.config ?? 'english';
|
|
2864
|
+
if (!validateTextSearchConfig(config)) {
|
|
2865
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
|
|
2866
|
+
}
|
|
2867
|
+
params.push(filter.search);
|
|
2868
|
+
return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${this.p(params.length)})`;
|
|
2869
|
+
}
|
|
2675
2870
|
/**
|
|
2676
2871
|
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
2677
2872
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
package/dist/cli/studio.d.ts
CHANGED
|
@@ -45,8 +45,16 @@ export interface StudioContext {
|
|
|
45
45
|
options: StudioOptions;
|
|
46
46
|
authToken: string;
|
|
47
47
|
stateDir: string;
|
|
48
|
-
/** Resolved statement timeout
|
|
49
|
-
|
|
48
|
+
/** Resolved statement timeout (adapter-aware) — parameterized SQL + values. */
|
|
49
|
+
statementTimeout: {
|
|
50
|
+
sql: string;
|
|
51
|
+
params: unknown[];
|
|
52
|
+
};
|
|
53
|
+
/** Rate limiter state — tracks requests per authenticated session. */
|
|
54
|
+
rateLimiter: Map<string, {
|
|
55
|
+
count: number;
|
|
56
|
+
resetAt: number;
|
|
57
|
+
}>;
|
|
50
58
|
}
|
|
51
59
|
/**
|
|
52
60
|
* Start the Studio server. Returns a handle with the session token, a pre-built
|
package/dist/cli/studio.js
CHANGED
|
@@ -59,8 +59,12 @@ export async function startStudio(options) {
|
|
|
59
59
|
});
|
|
60
60
|
const authToken = randomBytes(24).toString('hex');
|
|
61
61
|
const stateDir = pathResolve(options.stateDir ?? '.turbine');
|
|
62
|
-
const
|
|
63
|
-
|
|
62
|
+
const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
63
|
+
sql: `SET LOCAL statement_timeout = $1`,
|
|
64
|
+
params: ['30s'],
|
|
65
|
+
};
|
|
66
|
+
const rateLimiter = new Map();
|
|
67
|
+
const ctx = { pool, metadata, options, authToken, stateDir, statementTimeout, rateLimiter };
|
|
64
68
|
const server = createServer((req, res) => {
|
|
65
69
|
handleRequest(req, res, ctx).catch((err) => {
|
|
66
70
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -137,6 +141,14 @@ async function handleRequest(req, res, ctx) {
|
|
|
137
141
|
sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
|
|
138
142
|
return;
|
|
139
143
|
}
|
|
144
|
+
// Rate limiting — 100 requests per 60 seconds per authenticated session.
|
|
145
|
+
const rateLimitResult = checkRateLimit(ctx.rateLimiter, ctx.authToken);
|
|
146
|
+
if (!rateLimitResult.allowed) {
|
|
147
|
+
const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000);
|
|
148
|
+
res.setHeader('Retry-After', String(retryAfter));
|
|
149
|
+
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
140
152
|
if (pathname === '/api/schema' && req.method === 'GET') {
|
|
141
153
|
return apiSchema(res, ctx);
|
|
142
154
|
}
|
|
@@ -177,6 +189,26 @@ function isAuthorized(req, expectedToken) {
|
|
|
177
189
|
}
|
|
178
190
|
return false;
|
|
179
191
|
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Rate limiting
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
const RATE_LIMIT_WINDOW_MS = 60_000; // 60 seconds
|
|
196
|
+
const RATE_LIMIT_MAX_REQUESTS = 100;
|
|
197
|
+
function checkRateLimit(limiter, token) {
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const entry = limiter.get(token);
|
|
200
|
+
if (!entry || now >= entry.resetAt) {
|
|
201
|
+
// Start a new window
|
|
202
|
+
const resetAt = now + RATE_LIMIT_WINDOW_MS;
|
|
203
|
+
limiter.set(token, { count: 1, resetAt });
|
|
204
|
+
return { allowed: true, resetAt };
|
|
205
|
+
}
|
|
206
|
+
entry.count++;
|
|
207
|
+
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
|
|
208
|
+
return { allowed: false, resetAt: entry.resetAt };
|
|
209
|
+
}
|
|
210
|
+
return { allowed: true, resetAt: entry.resetAt };
|
|
211
|
+
}
|
|
180
212
|
function constantTimeEqual(a, b) {
|
|
181
213
|
if (a.length !== b.length)
|
|
182
214
|
return false;
|
|
@@ -262,7 +294,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
262
294
|
let mainWhere = '';
|
|
263
295
|
if (hasSearch && pattern !== null) {
|
|
264
296
|
mainValues.push(pattern);
|
|
265
|
-
const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3`);
|
|
297
|
+
const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3 ESCAPE '\\'`);
|
|
266
298
|
mainWhere = `WHERE (${conds.join(' OR ')})`;
|
|
267
299
|
}
|
|
268
300
|
// Count query: $1 = pattern (if search)
|
|
@@ -270,7 +302,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
270
302
|
let countWhere = '';
|
|
271
303
|
if (hasSearch && pattern !== null) {
|
|
272
304
|
countValues.push(pattern);
|
|
273
|
-
const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1`);
|
|
305
|
+
const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1 ESCAPE '\\'`);
|
|
274
306
|
countWhere = `WHERE (${conds.join(' OR ')})`;
|
|
275
307
|
}
|
|
276
308
|
const qualifiedTable = `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
|
|
@@ -279,7 +311,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
279
311
|
const client = await ctx.pool.connect();
|
|
280
312
|
try {
|
|
281
313
|
await client.query('BEGIN READ ONLY');
|
|
282
|
-
await client.query(ctx.
|
|
314
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
283
315
|
const result = await client.query(sql, mainValues);
|
|
284
316
|
const countResult = await client.query(countSql, countValues);
|
|
285
317
|
await client.query('COMMIT');
|
|
@@ -336,6 +368,10 @@ async function apiQuery(req, res, ctx) {
|
|
|
336
368
|
sendJson(res, 400, { error: 'missing sql' });
|
|
337
369
|
return;
|
|
338
370
|
}
|
|
371
|
+
if (rawSql.length > 10_000) {
|
|
372
|
+
sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
339
375
|
if (!isReadOnlyStatement(rawSql)) {
|
|
340
376
|
sendJson(res, 400, {
|
|
341
377
|
error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
|
|
@@ -345,7 +381,7 @@ async function apiQuery(req, res, ctx) {
|
|
|
345
381
|
const client = await ctx.pool.connect();
|
|
346
382
|
try {
|
|
347
383
|
await client.query('BEGIN READ ONLY');
|
|
348
|
-
await client.query(ctx.
|
|
384
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
349
385
|
const started = Date.now();
|
|
350
386
|
const result = await client.query(rawSql);
|
|
351
387
|
const elapsedMs = Date.now() - started;
|
|
@@ -397,7 +433,7 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
397
433
|
const client = await ctx.pool.connect();
|
|
398
434
|
try {
|
|
399
435
|
await client.query('BEGIN READ ONLY');
|
|
400
|
-
await client.query(ctx.
|
|
436
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
401
437
|
const started = Date.now();
|
|
402
438
|
const result = await client.query(deferred.sql, deferred.params);
|
|
403
439
|
const elapsedMs = Date.now() - started;
|
|
@@ -593,6 +629,7 @@ function sendJson(res, status, body) {
|
|
|
593
629
|
'Cache-Control': 'no-store',
|
|
594
630
|
'X-Content-Type-Options': 'nosniff',
|
|
595
631
|
'Referrer-Policy': 'no-referrer',
|
|
632
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
|
|
596
633
|
});
|
|
597
634
|
res.end(payload);
|
|
598
635
|
}
|
|
@@ -611,6 +648,7 @@ function sendHtml(res, status, body) {
|
|
|
611
648
|
'X-Content-Type-Options': 'nosniff',
|
|
612
649
|
'X-Frame-Options': 'DENY',
|
|
613
650
|
'Referrer-Policy': 'no-referrer',
|
|
651
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
|
|
614
652
|
});
|
|
615
653
|
res.end(body);
|
|
616
654
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ import { type ErrorMessageMode } from './errors.js';
|
|
|
27
27
|
import { type PipelineOptions, type PipelineResults } from './pipeline.js';
|
|
28
28
|
import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
|
|
29
29
|
import type { SchemaMetadata } from './schema.js';
|
|
30
|
+
export interface RetryOptions {
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
baseDelay?: number;
|
|
33
|
+
maxDelay?: number;
|
|
34
|
+
onRetry?: (error: unknown, attempt: number) => void;
|
|
35
|
+
}
|
|
36
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
30
37
|
/**
|
|
31
38
|
* Minimal pg-compatible query result.
|
|
32
39
|
* `pg.Pool`, `@neondatabase/serverless` Pool, `@vercel/postgres` Pool and
|
|
@@ -316,6 +323,22 @@ export declare class TurbineClient {
|
|
|
316
323
|
* ```
|
|
317
324
|
*/
|
|
318
325
|
$transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
|
|
326
|
+
/**
|
|
327
|
+
* Execute an async function with automatic retry on retryable errors.
|
|
328
|
+
*
|
|
329
|
+
* Only errors with `isRetryable === true` (DeadlockError, SerializationFailureError)
|
|
330
|
+
* are retried. Uses exponential backoff with jitter.
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```ts
|
|
334
|
+
* const result = await db.$retry(() =>
|
|
335
|
+
* db.$transaction(async (tx) => {
|
|
336
|
+
* // ... serializable transaction logic
|
|
337
|
+
* }, { isolationLevel: 'Serializable' })
|
|
338
|
+
* );
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
$retry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
319
342
|
/**
|
|
320
343
|
* Test the database connection.
|
|
321
344
|
* Throws if the connection fails.
|
package/dist/client.js
CHANGED
|
@@ -25,6 +25,30 @@ import pg from 'pg';
|
|
|
25
25
|
import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
|
|
26
26
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
27
27
|
import { QueryInterface } from './query/index.js';
|
|
28
|
+
export async function withRetry(fn, options) {
|
|
29
|
+
const maxAttempts = options?.maxAttempts ?? 3;
|
|
30
|
+
const baseDelay = options?.baseDelay ?? 50;
|
|
31
|
+
const maxDelay = options?.maxDelay ?? 5000;
|
|
32
|
+
let lastError;
|
|
33
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
lastError = err;
|
|
39
|
+
const isRetryable = err &&
|
|
40
|
+
typeof err === 'object' &&
|
|
41
|
+
'isRetryable' in err &&
|
|
42
|
+
err.isRetryable === true;
|
|
43
|
+
if (!isRetryable || attempt === maxAttempts - 1)
|
|
44
|
+
throw err;
|
|
45
|
+
options?.onRetry?.(err, attempt + 1);
|
|
46
|
+
const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * baseDelay, maxDelay);
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw lastError;
|
|
51
|
+
}
|
|
28
52
|
/** Maps isolation level names to SQL */
|
|
29
53
|
const ISOLATION_LEVELS = {
|
|
30
54
|
ReadUncommitted: 'READ UNCOMMITTED',
|
|
@@ -73,7 +97,8 @@ export class TransactionClient {
|
|
|
73
97
|
// Create a QueryInterface that uses the transaction client as its "pool"
|
|
74
98
|
// We use a proxy pool that routes queries through the transaction client
|
|
75
99
|
const txPool = this.createTxPool();
|
|
76
|
-
|
|
100
|
+
const txOpts = { ...this.queryOptions, _txScoped: true };
|
|
101
|
+
qi = new QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
|
|
77
102
|
this.tableCache.set(name, qi);
|
|
78
103
|
}
|
|
79
104
|
return qi;
|
|
@@ -535,6 +560,27 @@ export class TurbineClient {
|
|
|
535
560
|
}
|
|
536
561
|
}
|
|
537
562
|
// -------------------------------------------------------------------------
|
|
563
|
+
// Retry — automatic retry for retryable errors (deadlock, serialization)
|
|
564
|
+
// -------------------------------------------------------------------------
|
|
565
|
+
/**
|
|
566
|
+
* Execute an async function with automatic retry on retryable errors.
|
|
567
|
+
*
|
|
568
|
+
* Only errors with `isRetryable === true` (DeadlockError, SerializationFailureError)
|
|
569
|
+
* are retried. Uses exponential backoff with jitter.
|
|
570
|
+
*
|
|
571
|
+
* @example
|
|
572
|
+
* ```ts
|
|
573
|
+
* const result = await db.$retry(() =>
|
|
574
|
+
* db.$transaction(async (tx) => {
|
|
575
|
+
* // ... serializable transaction logic
|
|
576
|
+
* }, { isolationLevel: 'Serializable' })
|
|
577
|
+
* );
|
|
578
|
+
* ```
|
|
579
|
+
*/
|
|
580
|
+
async $retry(fn, options) {
|
|
581
|
+
return withRetry(fn, options);
|
|
582
|
+
}
|
|
583
|
+
// -------------------------------------------------------------------------
|
|
538
584
|
// Connection lifecycle
|
|
539
585
|
// -------------------------------------------------------------------------
|
|
540
586
|
/**
|
package/dist/errors.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare const TurbineErrorCode: {
|
|
|
20
20
|
readonly DEADLOCK_DETECTED: "TURBINE_E012";
|
|
21
21
|
readonly SERIALIZATION_FAILURE: "TURBINE_E013";
|
|
22
22
|
readonly PIPELINE: "TURBINE_E014";
|
|
23
|
+
readonly OPTIMISTIC_LOCK: "TURBINE_E015";
|
|
24
|
+
readonly EXCLUSION_VIOLATION: "TURBINE_E016";
|
|
23
25
|
};
|
|
24
26
|
export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErrorCode];
|
|
25
27
|
/** Base error class for all Turbine errors */
|
|
@@ -208,6 +210,16 @@ export declare class CheckConstraintError extends TurbineError {
|
|
|
208
210
|
cause?: unknown;
|
|
209
211
|
});
|
|
210
212
|
}
|
|
213
|
+
export declare class ExclusionConstraintError extends TurbineError {
|
|
214
|
+
readonly constraint?: string;
|
|
215
|
+
readonly table?: string;
|
|
216
|
+
constructor(opts?: {
|
|
217
|
+
constraint?: string;
|
|
218
|
+
table?: string;
|
|
219
|
+
message?: string;
|
|
220
|
+
cause?: unknown;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
211
223
|
/** Result slot for a single query in a non-transactional pipeline */
|
|
212
224
|
export type PipelineResultSlot = {
|
|
213
225
|
status: 'ok';
|
|
@@ -251,6 +263,16 @@ export declare class PipelineError extends TurbineError {
|
|
|
251
263
|
cause?: unknown;
|
|
252
264
|
});
|
|
253
265
|
}
|
|
266
|
+
export declare class OptimisticLockError extends TurbineError {
|
|
267
|
+
readonly table: string;
|
|
268
|
+
readonly versionField: string;
|
|
269
|
+
readonly expectedVersion: unknown;
|
|
270
|
+
constructor(opts: {
|
|
271
|
+
table: string;
|
|
272
|
+
versionField: string;
|
|
273
|
+
expectedVersion: unknown;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
254
276
|
/**
|
|
255
277
|
* Translate a pg driver error into a typed Turbine error.
|
|
256
278
|
* If the error doesn't match a known constraint code, returns it unchanged.
|
|
@@ -260,6 +282,7 @@ export declare class PipelineError extends TurbineError {
|
|
|
260
282
|
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
261
283
|
* 23502 (not_null_violation) -> NotNullViolationError
|
|
262
284
|
* 23514 (check_violation) -> CheckConstraintError
|
|
285
|
+
* 23P01 (exclusion_violation) -> ExclusionConstraintError
|
|
263
286
|
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
264
287
|
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
265
288
|
*
|