@an5/adapters 0.2.10 → 0.2.11
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 +30 -0
- package/dist/an5Adapter.js +52 -30
- package/dist/base/metadata.d.ts +2 -0
- package/dist/base/metadata.js +10 -0
- package/dist/base/sql.d.ts +2 -0
- package/dist/base/sql.js +24 -25
- package/dist/googlesheets/adapter.js +16 -8
- package/dist/googlesheets/config.d.ts +13 -0
- package/dist/googlesheets/config.js +5 -1
- package/dist/googlesheets/oauth.d.ts +3 -0
- package/dist/googlesheets/oauth.js +43 -0
- package/dist/googlesheets/parseConnectionString.js +12 -0
- package/dist/googlesheets/tableClient.js +5 -23
- package/package.json +9 -4
- package/typescript/src/an5Adapter.ts +54 -29
- package/typescript/src/base/metadata.ts +8 -0
- package/typescript/src/base/sql.ts +23 -16
- package/typescript/src/googlesheets/adapter.ts +17 -8
- package/typescript/src/googlesheets/config.ts +10 -1
- package/typescript/src/googlesheets/oauth.ts +31 -0
- package/typescript/src/googlesheets/parseConnectionString.ts +8 -0
- package/typescript/src/googlesheets/tableClient.ts +5 -19
package/README.md
CHANGED
|
@@ -276,8 +276,38 @@ db.transaction(perform_transfer)
|
|
|
276
276
|
| `groupBy(args)` | Group by fields with aggregations and pagination |
|
|
277
277
|
| `vectorSearch(args)` | Semantic vector similarity search |
|
|
278
278
|
|
|
279
|
+
Generated TypeScript field metadata marks schema primary keys with `isId: true`.
|
|
280
|
+
SQL and Google Sheets `create` use this marker to identify custom primary-key
|
|
281
|
+
names before falling back to `id` or `...Id` for older metadata. Regenerate the
|
|
282
|
+
client to enable this behavior for existing custom-key schemas.
|
|
283
|
+
|
|
284
|
+
`createMany` uses a bulk statement when every row supplies the same columns.
|
|
285
|
+
Rows with different column sets or missing generated UUID keys are inserted
|
|
286
|
+
individually so omitted columns retain their defaults and UUID keys are assigned. Bulk failures are propagated without retrying
|
|
287
|
+
individual rows. Wrap mixed-column inserts in `$transaction` when they must
|
|
288
|
+
succeed or roll back together.
|
|
289
|
+
|
|
290
|
+
For SQL adapters, `skipDuplicates: true` skips only unique or primary-key
|
|
291
|
+
violations. Connection failures and other constraint errors are propagated.
|
|
292
|
+
Google Sheets does not enforce unique constraints; this flag does not deduplicate
|
|
293
|
+
sheet rows, and failed batch appends are propagated without individual retries.
|
|
294
|
+
|
|
295
|
+
For SQL adapters, `findMany` and `groupBy` accept `skip` without `take` to
|
|
296
|
+
return all remaining rows or groups. Supply `orderBy` for stable row pagination;
|
|
297
|
+
`groupBy` orders by its grouping fields when pagination has no explicit ordering.
|
|
298
|
+
With relation metadata configured, `groupBy.where` supports the same relation
|
|
299
|
+
filters as `findMany.where`, including `some`, `none`, `every`, `is`, and `isNot`.
|
|
300
|
+
|
|
279
301
|
---
|
|
280
302
|
|
|
281
303
|
## License
|
|
282
304
|
|
|
283
305
|
MIT
|
|
306
|
+
|
|
307
|
+
### Google Sheets desktop OAuth
|
|
308
|
+
|
|
309
|
+
Google Sheets connections accept `accessToken`, `refreshToken`, `oauthClientId`, optional `oauthClientSecret`, and `tokenExpiresAt` (Unix milliseconds). The VS Code connection UI can generate and store this URI after browser sign-in and spreadsheet selection. Values in the semicolon-delimited URI must be URL encoded. Keep offline credentials in secure storage; do not commit them.
|
|
310
|
+
|
|
311
|
+
The adapter refreshes expired access tokens before requests, shares concurrent refresh requests, and retries authentication failures once after refreshing. Revoked credentials report a reconnect error. Existing service account, API key and access-token-only configurations remain supported.
|
|
312
|
+
|
|
313
|
+
For web applications, use a Google Web OAuth client and per-user access tokens. The browser adapter rejects offline desktop credentials and service account private keys. A browser connection manager must handle account selection, token reacquisition and SQLite persistence; see the [browser connection design](https://an5orm.github.io/docs/guides/browser-connections/).
|
package/dist/an5Adapter.js
CHANGED
|
@@ -28,38 +28,57 @@ function sanitizeParamName(name) {
|
|
|
28
28
|
const cleaned = String(name).replace(/[^A-Za-z0-9_]/g, '_');
|
|
29
29
|
return /^[A-Za-z]/.test(cleaned) ? cleaned : `p_${cleaned}`;
|
|
30
30
|
}
|
|
31
|
+
/** Match only unique/primary-key violations, never general constraint errors. */
|
|
32
|
+
function isDuplicateKeyError(error, dialect) {
|
|
33
|
+
if (!error || typeof error !== 'object')
|
|
34
|
+
return false;
|
|
35
|
+
const e = error;
|
|
36
|
+
if (dialect === 'postgres')
|
|
37
|
+
return e.code === '23505';
|
|
38
|
+
if (dialect === 'mysql')
|
|
39
|
+
return e.code === 'ER_DUP_ENTRY' || e.errno === 1062;
|
|
40
|
+
if (dialect === 'sqlite') {
|
|
41
|
+
return e.code === 'SQLITE_CONSTRAINT_UNIQUE' || e.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
|
|
42
|
+
|| e.errcode === 2067 || e.errcode === 1555;
|
|
43
|
+
}
|
|
44
|
+
if (dialect === 'mssql') {
|
|
45
|
+
const number = e.number ?? e.originalError?.info?.number;
|
|
46
|
+
return number === 2601 || number === 2627;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
31
50
|
function appendUpdateSet(sets, params, col, val, dialect) {
|
|
32
51
|
const quoted = (0, sql_1.quote)(col, dialect);
|
|
33
52
|
const safeCol = sanitizeParamName(col);
|
|
34
53
|
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
|
35
54
|
if (val.increment !== undefined) {
|
|
36
|
-
|
|
37
|
-
|
|
55
|
+
const p = (0, sql_1.bindParam)(params, `s_${safeCol}_inc`, val.increment);
|
|
56
|
+
sets.push(`${quoted} = ${quoted} + @${p}`);
|
|
38
57
|
return;
|
|
39
58
|
}
|
|
40
59
|
if (val.decrement !== undefined) {
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
const p = (0, sql_1.bindParam)(params, `s_${safeCol}_dec`, val.decrement);
|
|
61
|
+
sets.push(`${quoted} = ${quoted} - @${p}`);
|
|
43
62
|
return;
|
|
44
63
|
}
|
|
45
64
|
if (val.multiply !== undefined) {
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
const p = (0, sql_1.bindParam)(params, `s_${safeCol}_mul`, val.multiply);
|
|
66
|
+
sets.push(`${quoted} = ${quoted} * @${p}`);
|
|
48
67
|
return;
|
|
49
68
|
}
|
|
50
69
|
if (val.divide !== undefined) {
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
const p = (0, sql_1.bindParam)(params, `s_${safeCol}_div`, val.divide);
|
|
71
|
+
sets.push(`${quoted} = ${quoted} / @${p}`);
|
|
53
72
|
return;
|
|
54
73
|
}
|
|
55
74
|
if (val.set !== undefined) {
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
const p = (0, sql_1.bindParam)(params, `s_${safeCol}_set`, val.set);
|
|
76
|
+
sets.push(`${quoted} = @${p}`);
|
|
58
77
|
return;
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
const p = (0, sql_1.bindParam)(params, `s_${safeCol}`, val);
|
|
81
|
+
sets.push(`${quoted} = @${p}`);
|
|
63
82
|
}
|
|
64
83
|
function selectedAggregateFields(fields) {
|
|
65
84
|
if (!fields || typeof fields !== 'object')
|
|
@@ -717,7 +736,8 @@ class AdapterTableClient {
|
|
|
717
736
|
query += ` WHERE ${whereSql}`;
|
|
718
737
|
if (orderSql)
|
|
719
738
|
query += ` ${orderSql}`;
|
|
720
|
-
|
|
739
|
+
const limit = take ?? (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
|
|
740
|
+
query += ` LIMIT ${limit} OFFSET ${skip}`;
|
|
721
741
|
}
|
|
722
742
|
else {
|
|
723
743
|
// mssql requires ORDER BY before OFFSET/FETCH
|
|
@@ -779,9 +799,7 @@ class AdapterTableClient {
|
|
|
779
799
|
}
|
|
780
800
|
}
|
|
781
801
|
const fields = (0, metadata_1.getFieldsForModel)(this.modelName);
|
|
782
|
-
const idFieldName =
|
|
783
|
-
? 'id'
|
|
784
|
-
: Object.keys(fields).find((name) => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
802
|
+
const idFieldName = (0, metadata_1.resolveIdField)(fields);
|
|
785
803
|
const data = { ...rawData };
|
|
786
804
|
if (idFieldName) {
|
|
787
805
|
const fieldDef = fields[idFieldName];
|
|
@@ -836,8 +854,18 @@ class AdapterTableClient {
|
|
|
836
854
|
if (firstRow === undefined)
|
|
837
855
|
return { count: 0 };
|
|
838
856
|
const firstCols = Object.keys(firstRow).filter(k => firstRow[k] !== undefined);
|
|
839
|
-
|
|
840
|
-
|
|
857
|
+
const sameColumns = args.data.every(row => {
|
|
858
|
+
const cols = Object.keys(row).filter(k => row[k] !== undefined);
|
|
859
|
+
return cols.length === firstCols.length && firstCols.every(col => cols.includes(col));
|
|
860
|
+
});
|
|
861
|
+
const fields = (0, metadata_1.getFieldsForModel)(this.modelName);
|
|
862
|
+
const idFieldName = (0, metadata_1.resolveIdField)(fields);
|
|
863
|
+
const idDef = idFieldName ? fields[idFieldName] : undefined;
|
|
864
|
+
const idType = typeof idDef === 'string' ? idDef : (idDef?.ts || idDef?.sql || idDef?.type || '');
|
|
865
|
+
const generatesId = ['string', 'uuid', 'uniqueidentifier', 'nvarchar', 'varchar', 'text'].includes(String(idType).toLowerCase());
|
|
866
|
+
const needsGeneratedId = idFieldName && generatesId && args.data.some(row => !row[idFieldName]);
|
|
867
|
+
// Rows needing generated IDs follow create's default handling.
|
|
868
|
+
if (firstCols.length > 0 && sameColumns && !needsGeneratedId && !args.skipDuplicates) {
|
|
841
869
|
const params = {};
|
|
842
870
|
const rowPlaceholders = [];
|
|
843
871
|
for (let r = 0; r < args.data.length; r++) {
|
|
@@ -850,13 +878,8 @@ class AdapterTableClient {
|
|
|
850
878
|
rowPlaceholders.push(`(${vals.join(', ')})`);
|
|
851
879
|
}
|
|
852
880
|
const query = `INSERT INTO ${this.tableName} (${firstCols.map(c => (0, sql_1.quote)(c, this.dialect)).join(', ')}) VALUES ${rowPlaceholders.join(', ')}`;
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
return { count: args.data.length };
|
|
856
|
-
}
|
|
857
|
-
catch {
|
|
858
|
-
// fallback below if bulk fails (e.g. mixed column sets)
|
|
859
|
-
}
|
|
881
|
+
await this.doExecuteRaw(query, params);
|
|
882
|
+
return { count: args.data.length };
|
|
860
883
|
}
|
|
861
884
|
// Row-by-row fallback
|
|
862
885
|
let count = 0;
|
|
@@ -866,7 +889,7 @@ class AdapterTableClient {
|
|
|
866
889
|
count++;
|
|
867
890
|
}
|
|
868
891
|
catch (e) {
|
|
869
|
-
if (!args.skipDuplicates)
|
|
892
|
+
if (!args.skipDuplicates || !isDuplicateKeyError(e, this.dialect))
|
|
870
893
|
throw e;
|
|
871
894
|
}
|
|
872
895
|
}
|
|
@@ -1046,7 +1069,7 @@ class AdapterTableClient {
|
|
|
1046
1069
|
}
|
|
1047
1070
|
async groupBy(args) {
|
|
1048
1071
|
const params = {};
|
|
1049
|
-
const whereSql = (0, sql_1.parseWhere)(this.modelName, args?.where, params, this.dialect);
|
|
1072
|
+
const whereSql = (0, sql_1.parseWhere)(this.modelName, args?.where, params, this.dialect, '', this.whereContext());
|
|
1050
1073
|
const byFields = normalizeByFields(args?.by);
|
|
1051
1074
|
if (byFields.length === 0)
|
|
1052
1075
|
throw new Error("groupBy requires 'by' fields");
|
|
@@ -1076,9 +1099,8 @@ class AdapterTableClient {
|
|
|
1076
1099
|
const skip = toNonNegativeInt(args?.skip);
|
|
1077
1100
|
const take = toNonNegativeInt(args?.take, 1);
|
|
1078
1101
|
if (this.dialect === 'postgres' || this.dialect === 'mysql' || this.dialect === 'sqlite') {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
query += ` OFFSET ${skip}`;
|
|
1102
|
+
const limit = hasTake ? take : (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
|
|
1103
|
+
query += ` LIMIT ${limit} OFFSET ${skip}`;
|
|
1082
1104
|
}
|
|
1083
1105
|
else {
|
|
1084
1106
|
query += ` OFFSET ${skip} ROWS`;
|
package/dist/base/metadata.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ModelFieldMeta {
|
|
|
11
11
|
isId?: boolean;
|
|
12
12
|
[key: string]: any;
|
|
13
13
|
}
|
|
14
|
+
/** Prefer schema primary-key metadata; retain name inference for legacy clients. */
|
|
15
|
+
export declare function resolveIdField(fields: Record<string, any>): string | undefined;
|
|
14
16
|
export interface AdapterMetadata {
|
|
15
17
|
modelToTable?: Record<string, string>;
|
|
16
18
|
modelFields?: Record<string, any>;
|
package/dist/base/metadata.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveIdField = resolveIdField;
|
|
3
4
|
exports.setAdapterMetadata = setAdapterMetadata;
|
|
4
5
|
exports.getModelToTable = getModelToTable;
|
|
5
6
|
exports.getFieldsForModel = getFieldsForModel;
|
|
6
7
|
exports.getRelationMap = getRelationMap;
|
|
7
8
|
exports.getRelationsForModel = getRelationsForModel;
|
|
8
9
|
exports.createAdapterProxy = createAdapterProxy;
|
|
10
|
+
/** Prefer schema primary-key metadata; retain name inference for legacy clients. */
|
|
11
|
+
function resolveIdField(fields) {
|
|
12
|
+
const primaryKey = Object.keys(fields).find(name => fields[name]?.isId === true);
|
|
13
|
+
if (primaryKey)
|
|
14
|
+
return primaryKey;
|
|
15
|
+
if (Object.prototype.hasOwnProperty.call(fields, 'id'))
|
|
16
|
+
return 'id';
|
|
17
|
+
return Object.keys(fields).find(name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
18
|
+
}
|
|
9
19
|
let modelToTable = {};
|
|
10
20
|
let modelFields = {};
|
|
11
21
|
let relationMap = {};
|
package/dist/base/sql.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { RelationDef } from './metadata';
|
|
|
3
3
|
export declare function quote(name: string, dialect: Dialect): string;
|
|
4
4
|
/** Quote a full table reference (may already be quoted, and may contain schema parts). */
|
|
5
5
|
export declare function quoteTable(name: string, dialect: Dialect): string;
|
|
6
|
+
/** Allocate parameters without overwriting values from sibling clauses. */
|
|
7
|
+
export declare function bindParam(params: Record<string, any>, name: string, value: any): string;
|
|
6
8
|
export interface WhereContext {
|
|
7
9
|
/** Relations of the current model keyed by relation name. */
|
|
8
10
|
relationMap?: Record<string, Record<string, RelationDef>>;
|
package/dist/base/sql.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.quote = quote;
|
|
4
4
|
exports.quoteTable = quoteTable;
|
|
5
|
+
exports.bindParam = bindParam;
|
|
5
6
|
exports.parseWhere = parseWhere;
|
|
6
7
|
exports.buildOrderBy = buildOrderBy;
|
|
7
8
|
// ─── Quoting ───────────────────────────────────────────────────────────────────────
|
|
@@ -38,6 +39,15 @@ function sanitizeParamName(name) {
|
|
|
38
39
|
const cleaned = String(name).replace(/[^A-Za-z0-9_]/g, '_');
|
|
39
40
|
return /^[A-Za-z]/.test(cleaned) ? cleaned : `p_${cleaned}`;
|
|
40
41
|
}
|
|
42
|
+
/** Allocate parameters without overwriting values from sibling clauses. */
|
|
43
|
+
function bindParam(params, name, value) {
|
|
44
|
+
let candidate = name;
|
|
45
|
+
let suffix = 1;
|
|
46
|
+
while (Object.prototype.hasOwnProperty.call(params, candidate))
|
|
47
|
+
candidate = `${name}_${suffix++}`;
|
|
48
|
+
params[candidate] = value;
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
41
51
|
function normalizeSortDirection(dir) {
|
|
42
52
|
return typeof dir === 'string' && dir.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
43
53
|
}
|
|
@@ -119,15 +129,12 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
|
|
|
119
129
|
conditions.push(`${col} IS NOT NULL`);
|
|
120
130
|
}
|
|
121
131
|
else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
|
|
122
|
-
const
|
|
123
|
-
const nestedSql = parseWhere(modelName, { [key]: v.not }, nestedParams, dialect, `${prefix}${key}_not_`, ctx);
|
|
124
|
-
Object.assign(params, nestedParams);
|
|
132
|
+
const nestedSql = parseWhere(modelName, { [key]: v.not }, params, dialect, `${prefix}${key}_not_`, ctx);
|
|
125
133
|
if (nestedSql)
|
|
126
134
|
conditions.push(`NOT (${nestedSql})`);
|
|
127
135
|
}
|
|
128
136
|
else {
|
|
129
|
-
const p = `${pname}_not
|
|
130
|
-
params[p] = v.not;
|
|
137
|
+
const p = bindParam(params, `${pname}_not`, v.not);
|
|
131
138
|
conditions.push(`${col} <> @${p}`);
|
|
132
139
|
}
|
|
133
140
|
}
|
|
@@ -136,49 +143,41 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
|
|
|
136
143
|
conditions.push(`${col} IS NULL`);
|
|
137
144
|
}
|
|
138
145
|
else {
|
|
139
|
-
const p = `${pname}_eq
|
|
140
|
-
params[p] = v.equals;
|
|
146
|
+
const p = bindParam(params, `${pname}_eq`, v.equals);
|
|
141
147
|
conditions.push(`${col} = @${p}`);
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
150
|
if (v.contains !== undefined) {
|
|
145
|
-
const p = `${pname}_co
|
|
146
|
-
params[p] = `%${v.contains}%`;
|
|
151
|
+
const p = bindParam(params, `${pname}_co`, `%${v.contains}%`);
|
|
147
152
|
conditions.push(`${col} LIKE @${p}`);
|
|
148
153
|
}
|
|
149
154
|
if (v.startsWith !== undefined) {
|
|
150
|
-
const p = `${pname}_sw
|
|
151
|
-
params[p] = `${v.startsWith}%`;
|
|
155
|
+
const p = bindParam(params, `${pname}_sw`, `${v.startsWith}%`);
|
|
152
156
|
conditions.push(`${col} LIKE @${p}`);
|
|
153
157
|
}
|
|
154
158
|
if (v.endsWith !== undefined) {
|
|
155
|
-
const p = `${pname}_ew
|
|
156
|
-
params[p] = `%${v.endsWith}`;
|
|
159
|
+
const p = bindParam(params, `${pname}_ew`, `%${v.endsWith}`);
|
|
157
160
|
conditions.push(`${col} LIKE @${p}`);
|
|
158
161
|
}
|
|
159
162
|
if (v.gte !== undefined) {
|
|
160
|
-
const p = `${pname}_gte
|
|
161
|
-
params[p] = v.gte;
|
|
163
|
+
const p = bindParam(params, `${pname}_gte`, v.gte);
|
|
162
164
|
conditions.push(`${col} >= @${p}`);
|
|
163
165
|
}
|
|
164
166
|
if (v.lte !== undefined) {
|
|
165
|
-
const p = `${pname}_lte
|
|
166
|
-
params[p] = v.lte;
|
|
167
|
+
const p = bindParam(params, `${pname}_lte`, v.lte);
|
|
167
168
|
conditions.push(`${col} <= @${p}`);
|
|
168
169
|
}
|
|
169
170
|
if (v.gt !== undefined) {
|
|
170
|
-
const p = `${pname}_gt
|
|
171
|
-
params[p] = v.gt;
|
|
171
|
+
const p = bindParam(params, `${pname}_gt`, v.gt);
|
|
172
172
|
conditions.push(`${col} > @${p}`);
|
|
173
173
|
}
|
|
174
174
|
if (v.lt !== undefined) {
|
|
175
|
-
const p = `${pname}_lt
|
|
176
|
-
params[p] = v.lt;
|
|
175
|
+
const p = bindParam(params, `${pname}_lt`, v.lt);
|
|
177
176
|
conditions.push(`${col} < @${p}`);
|
|
178
177
|
}
|
|
179
178
|
if (v.in !== undefined) {
|
|
180
179
|
if (Array.isArray(v.in) && v.in.length > 0) {
|
|
181
|
-
const ps = v.in.map((x, i) => { const p = `${pname}_in${i}
|
|
180
|
+
const ps = v.in.map((x, i) => { const p = bindParam(params, `${pname}_in${i}`, x); return `@${p}`; });
|
|
182
181
|
conditions.push(`${col} IN (${ps.join(', ')})`);
|
|
183
182
|
}
|
|
184
183
|
else {
|
|
@@ -187,7 +186,7 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
|
|
|
187
186
|
}
|
|
188
187
|
if (v.notIn !== undefined) {
|
|
189
188
|
if (Array.isArray(v.notIn) && v.notIn.length > 0) {
|
|
190
|
-
const ps = v.notIn.map((x, i) => { const p = `${pname}_notin${i}
|
|
189
|
+
const ps = v.notIn.map((x, i) => { const p = bindParam(params, `${pname}_notin${i}`, x); return `@${p}`; });
|
|
191
190
|
conditions.push(`${col} NOT IN (${ps.join(', ')})`);
|
|
192
191
|
}
|
|
193
192
|
else {
|
|
@@ -196,8 +195,8 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
|
|
|
196
195
|
}
|
|
197
196
|
}
|
|
198
197
|
else {
|
|
199
|
-
params
|
|
200
|
-
conditions.push(`${col} = @${
|
|
198
|
+
const p = bindParam(params, pname, value);
|
|
199
|
+
conditions.push(`${col} = @${p}`);
|
|
201
200
|
}
|
|
202
201
|
}
|
|
203
202
|
}
|
|
@@ -3,27 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.An5SheetsAdapter = void 0;
|
|
4
4
|
exports.createAn5SheetsAdapter = createAn5SheetsAdapter;
|
|
5
5
|
const config_1 = require("./config");
|
|
6
|
+
const oauth_1 = require("./oauth");
|
|
6
7
|
const sqlExecutor_1 = require("./sqlExecutor");
|
|
7
8
|
const tableClient_1 = require("./tableClient");
|
|
8
9
|
const retry_1 = require("./retry");
|
|
9
10
|
const metadata_1 = require("../base/metadata");
|
|
10
11
|
// ─── Fetch-based API proxy for OAuth Access Token / API Key (browser-compatible) ──
|
|
11
|
-
function createFetchApi(spreadsheetId, accessToken, apiKey) {
|
|
12
|
+
function createFetchApi(spreadsheetId, accessToken, apiKey, oauth = { spreadsheetId }) {
|
|
12
13
|
const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`;
|
|
13
14
|
const headers = { 'Content-Type': 'application/json' };
|
|
14
|
-
|
|
15
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
16
|
-
}
|
|
15
|
+
const tokenProvider = (0, oauth_1.sheetsTokenProvider)({ ...oauth, ...(accessToken ? { accessToken } : {}) });
|
|
17
16
|
async function request(method, path, body) {
|
|
18
17
|
let url = path.startsWith('http') ? path : `${base}${path}`;
|
|
19
18
|
if (apiKey) {
|
|
20
19
|
const sep = url.includes('?') ? '&' : '?';
|
|
21
20
|
url += `${sep}key=${encodeURIComponent(apiKey)}`;
|
|
22
21
|
}
|
|
23
|
-
const
|
|
22
|
+
const token = await tokenProvider();
|
|
23
|
+
const opts = { method, headers: { ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) } };
|
|
24
24
|
if (body)
|
|
25
25
|
opts.body = JSON.stringify(body);
|
|
26
|
-
|
|
26
|
+
let res = await fetch(url, opts);
|
|
27
|
+
if (res.status === 401 && oauth.refreshToken) {
|
|
28
|
+
const refreshed = await tokenProvider(true);
|
|
29
|
+
res = await fetch(url, { ...opts, headers: { ...headers, Authorization: `Bearer ${refreshed}` } });
|
|
30
|
+
}
|
|
27
31
|
if (!res.ok) {
|
|
28
32
|
const text = await res.text().catch(() => '');
|
|
29
33
|
const err = new Error(`Google Sheets API error (${res.status}): ${text}`);
|
|
@@ -79,17 +83,21 @@ class An5SheetsAdapter {
|
|
|
79
83
|
this.sheets = null;
|
|
80
84
|
this.fetchApi = null;
|
|
81
85
|
this.sheetsCache = null;
|
|
86
|
+
const browser = typeof window !== 'undefined' || (typeof self !== 'undefined' && typeof process === 'undefined');
|
|
87
|
+
if (browser && (config.refreshToken || config.oauthClientSecret || config.privateKey || config.credentials)) {
|
|
88
|
+
throw new Error('Browser Google Sheets connections require a user access token. Keep desktop OAuth refresh tokens and service account keys outside the browser.');
|
|
89
|
+
}
|
|
82
90
|
this.config = (0, config_1.resolveConfig)(config);
|
|
83
91
|
// Dynamic model access (`db.user` / `db.User`), same as An5Adapter.
|
|
84
92
|
return (0, metadata_1.createAdapterProxy)(this, (name) => this.table(name));
|
|
85
93
|
}
|
|
86
94
|
get isFetchMode() {
|
|
87
|
-
return !!this.config.accessToken || !!this.config.apiKey;
|
|
95
|
+
return !!this.config.accessToken || !!this.config.refreshToken || !!this.config.apiKey;
|
|
88
96
|
}
|
|
89
97
|
async getSheets() {
|
|
90
98
|
if (this.isFetchMode) {
|
|
91
99
|
if (!this.fetchApi) {
|
|
92
|
-
this.fetchApi = createFetchApi(this.config.spreadsheetId, this.config.accessToken, this.config.apiKey);
|
|
100
|
+
this.fetchApi = createFetchApi(this.config.spreadsheetId, this.config.accessToken, this.config.apiKey, this.config);
|
|
93
101
|
}
|
|
94
102
|
return this.fetchApi;
|
|
95
103
|
}
|
|
@@ -11,6 +11,11 @@ export interface An5SheetsAdapterConfig {
|
|
|
11
11
|
* When provided, the adapter uses raw fetch() instead of googleapis JWT, making it compatible
|
|
12
12
|
* with browser environments. Mutually exclusive with clientEmail/privateKey/credentials. */
|
|
13
13
|
accessToken?: string;
|
|
14
|
+
/** Offline desktop OAuth credentials. Keep these in secure storage, never source control. */
|
|
15
|
+
refreshToken?: string;
|
|
16
|
+
oauthClientId?: string;
|
|
17
|
+
oauthClientSecret?: string;
|
|
18
|
+
tokenExpiresAt?: number;
|
|
14
19
|
/** Google API key for browser-based API access. */
|
|
15
20
|
apiKey?: string;
|
|
16
21
|
}
|
|
@@ -18,12 +23,20 @@ export declare function normalizeKey(key: string): string;
|
|
|
18
23
|
export declare function resolveConfig(config: An5SheetsAdapterConfig): {
|
|
19
24
|
spreadsheetId: string;
|
|
20
25
|
accessToken: string | undefined;
|
|
26
|
+
refreshToken: string | undefined;
|
|
27
|
+
oauthClientId: string | undefined;
|
|
28
|
+
oauthClientSecret: string | undefined;
|
|
29
|
+
tokenExpiresAt: number | undefined;
|
|
21
30
|
apiKey: string | undefined;
|
|
22
31
|
sheetMapping: Record<string, string> | undefined;
|
|
23
32
|
clientEmail?: never;
|
|
24
33
|
privateKey?: never;
|
|
25
34
|
} | {
|
|
26
35
|
accessToken?: never;
|
|
36
|
+
refreshToken?: never;
|
|
37
|
+
oauthClientId?: never;
|
|
38
|
+
oauthClientSecret?: never;
|
|
39
|
+
tokenExpiresAt?: never;
|
|
27
40
|
apiKey?: never;
|
|
28
41
|
spreadsheetId: string;
|
|
29
42
|
clientEmail: string;
|
|
@@ -12,10 +12,14 @@ function normalizeKey(key) {
|
|
|
12
12
|
return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`;
|
|
13
13
|
}
|
|
14
14
|
function resolveConfig(config) {
|
|
15
|
-
if (config.accessToken || config.apiKey) {
|
|
15
|
+
if (config.accessToken || config.refreshToken || config.apiKey) {
|
|
16
16
|
return {
|
|
17
17
|
spreadsheetId: config.spreadsheetId,
|
|
18
18
|
accessToken: config.accessToken,
|
|
19
|
+
refreshToken: config.refreshToken,
|
|
20
|
+
oauthClientId: config.oauthClientId,
|
|
21
|
+
oauthClientSecret: config.oauthClientSecret,
|
|
22
|
+
tokenExpiresAt: config.tokenExpiresAt,
|
|
19
23
|
apiKey: config.apiKey,
|
|
20
24
|
sheetMapping: config.sheetMapping,
|
|
21
25
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sheetsTokenProvider = sheetsTokenProvider;
|
|
4
|
+
/** Deduplicate refresh requests within an adapter; do not expose token responses. */
|
|
5
|
+
function sheetsTokenProvider(config) {
|
|
6
|
+
let token = config.accessToken;
|
|
7
|
+
let expiresAt = config.tokenExpiresAt;
|
|
8
|
+
let pending;
|
|
9
|
+
return async (force = false) => {
|
|
10
|
+
if (token && !force && (expiresAt === undefined || expiresAt > Date.now() + 60000))
|
|
11
|
+
return token;
|
|
12
|
+
if (!config.refreshToken || !config.oauthClientId) {
|
|
13
|
+
if (force || expiresAt !== undefined)
|
|
14
|
+
throw new Error('Google access expired. Reconnect your Google account.');
|
|
15
|
+
return token;
|
|
16
|
+
}
|
|
17
|
+
if (!pending)
|
|
18
|
+
pending = (async () => {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
21
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
22
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', client_id: config.oauthClientId, refresh_token: config.refreshToken, ...(config.oauthClientSecret ? { client_secret: config.oauthClientSecret } : {}) }),
|
|
23
|
+
signal: AbortSignal.timeout(20000),
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
throw new Error();
|
|
27
|
+
const result = await response.json();
|
|
28
|
+
if (!result.access_token || typeof result.access_token !== 'string' || typeof result.expires_in !== 'number' || !Number.isFinite(result.expires_in) || result.expires_in <= 0)
|
|
29
|
+
throw new Error();
|
|
30
|
+
token = result.access_token;
|
|
31
|
+
expiresAt = Date.now() + result.expires_in * 1000;
|
|
32
|
+
return token;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('Google access expired or was revoked. Reconnect your Google account.');
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
pending = undefined;
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
return pending;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -36,6 +36,18 @@ function parseSheetsConnectionString(url) {
|
|
|
36
36
|
result.privateKey = config.privatekey;
|
|
37
37
|
if (config.accesstoken)
|
|
38
38
|
result.accessToken = config.accesstoken;
|
|
39
|
+
if (config.refreshtoken)
|
|
40
|
+
result.refreshToken = config.refreshtoken;
|
|
41
|
+
if (config.oauthclientid)
|
|
42
|
+
result.oauthClientId = config.oauthclientid;
|
|
43
|
+
if (config.oauthclientsecret)
|
|
44
|
+
result.oauthClientSecret = config.oauthclientsecret;
|
|
45
|
+
if (config.tokenexpiresat) {
|
|
46
|
+
const expiry = Number(config.tokenexpiresat);
|
|
47
|
+
if (!Number.isFinite(expiry) || expiry < 0)
|
|
48
|
+
throw new Error('Invalid OAuth token expiry.');
|
|
49
|
+
result.tokenExpiresAt = expiry;
|
|
50
|
+
}
|
|
39
51
|
if (config.apikey)
|
|
40
52
|
result.apiKey = config.apikey;
|
|
41
53
|
if (config.sheetmapping) {
|
|
@@ -6,11 +6,6 @@ const metadata_1 = require("../base/metadata");
|
|
|
6
6
|
const helpers_1 = require("./helpers");
|
|
7
7
|
const retry_1 = require("./retry");
|
|
8
8
|
// ─── Utility ──────────────────────────────────────────────────────────────────
|
|
9
|
-
function resolveIdField(fields) {
|
|
10
|
-
if (Object.prototype.hasOwnProperty.call(fields, 'id'))
|
|
11
|
-
return 'id';
|
|
12
|
-
return Object.keys(fields).find(name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
13
|
-
}
|
|
14
9
|
// ─── Table Client ─────────────────────────────────────────────────────────────
|
|
15
10
|
class SheetsTableClient {
|
|
16
11
|
constructor(adapter, modelName, sheetMapping) {
|
|
@@ -156,7 +151,7 @@ class SheetsTableClient {
|
|
|
156
151
|
return rows.length;
|
|
157
152
|
}
|
|
158
153
|
assignId(data, fields) {
|
|
159
|
-
const idFieldName = resolveIdField(fields);
|
|
154
|
+
const idFieldName = (0, metadata_1.resolveIdField)(fields);
|
|
160
155
|
if (!idFieldName)
|
|
161
156
|
return;
|
|
162
157
|
const fieldDef = fields[idFieldName];
|
|
@@ -201,23 +196,10 @@ class SheetsTableClient {
|
|
|
201
196
|
}
|
|
202
197
|
return this.rowToValues(data, headers);
|
|
203
198
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
catch (e) {
|
|
209
|
-
if (!args.skipDuplicates)
|
|
210
|
-
throw e;
|
|
211
|
-
let count = 0;
|
|
212
|
-
for (const row of args.data) {
|
|
213
|
-
try {
|
|
214
|
-
await this.create({ data: row });
|
|
215
|
-
count++;
|
|
216
|
-
}
|
|
217
|
-
catch { /* skip */ }
|
|
218
|
-
}
|
|
219
|
-
return { count };
|
|
220
|
-
}
|
|
199
|
+
// Sheets has no unique constraints. An append failure must propagate;
|
|
200
|
+
// retrying individual rows could duplicate a batch the service accepted.
|
|
201
|
+
await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
|
|
202
|
+
return { count: batchValues.length };
|
|
221
203
|
}
|
|
222
204
|
async update(args) {
|
|
223
205
|
const { headers, rows } = await this.readAllRows();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@an5/adapters",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Standalone language adapters for AN5 ORM - TypeScript, Python, .NET, Go, Rust, and Google Sheets query execution.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"pretest": "npm run build",
|
|
59
59
|
"prepack": "npm run build",
|
|
60
60
|
"build:python": "(python -m build --outdir dist-py || python3 -m build --outdir dist-py)",
|
|
61
|
-
"test": "node test/unit.test.js && node test/package.test.js && node test/nbase.test.js && node --test test/version-sync.test.js && node --test test/query-semantics.test.js && node test/query-relations.integration.test.js",
|
|
61
|
+
"test": "node test/unit.test.js && node test/package.test.js && node test/nbase.test.js && node --test test/version-sync.test.js && node --test test/query-semantics.test.js && node test/query-relations.integration.test.js && node --test test/sheets-oauth.test.js",
|
|
62
62
|
"test:package:smoke": "node scripts/package-smoke.js",
|
|
63
63
|
"test:integration:live": "node test/live-db.integration.test.js && node test/query-relations.integration.test.js",
|
|
64
64
|
"test:python": "node scripts/python-tests.js",
|
|
@@ -75,17 +75,22 @@
|
|
|
75
75
|
"@types/mssql": "^9.1.8",
|
|
76
76
|
"@types/node": "^20.11.0",
|
|
77
77
|
"@types/pg": "^8.11.0",
|
|
78
|
-
"typescript": "^7.0.2"
|
|
78
|
+
"typescript": "^7.0.2",
|
|
79
|
+
"better-sqlite3": "^11.5.0"
|
|
79
80
|
},
|
|
80
81
|
"publishConfig": {
|
|
81
82
|
"access": "public"
|
|
82
83
|
},
|
|
83
84
|
"peerDependencies": {
|
|
84
|
-
"mysql2": "^3.0.0"
|
|
85
|
+
"mysql2": "^3.0.0",
|
|
86
|
+
"better-sqlite3": "^11.0.0"
|
|
85
87
|
},
|
|
86
88
|
"peerDependenciesMeta": {
|
|
87
89
|
"mysql2": {
|
|
88
90
|
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"better-sqlite3": {
|
|
93
|
+
"optional": true
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
}
|
|
@@ -26,10 +26,11 @@ import {
|
|
|
26
26
|
type NBaseAdapterConfig,
|
|
27
27
|
type NBaseVectorClient,
|
|
28
28
|
} from './nbase';
|
|
29
|
-
import { buildOrderBy, parseWhere, quote } from './base/sql';
|
|
29
|
+
import { bindParam, buildOrderBy, parseWhere, quote } from './base/sql';
|
|
30
30
|
import {
|
|
31
31
|
createAdapterProxy,
|
|
32
32
|
getFieldsForModel,
|
|
33
|
+
resolveIdField,
|
|
33
34
|
getModelToTable,
|
|
34
35
|
getRelationMap,
|
|
35
36
|
getRelationsForModel,
|
|
@@ -51,39 +52,56 @@ function sanitizeParamName(name: string): string {
|
|
|
51
52
|
return /^[A-Za-z]/.test(cleaned) ? cleaned : `p_${cleaned}`;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** Match only unique/primary-key violations, never general constraint errors. */
|
|
56
|
+
function isDuplicateKeyError(error: unknown, dialect: Dialect): boolean {
|
|
57
|
+
if (!error || typeof error !== 'object') return false;
|
|
58
|
+
const e = error as any;
|
|
59
|
+
if (dialect === 'postgres') return e.code === '23505';
|
|
60
|
+
if (dialect === 'mysql') return e.code === 'ER_DUP_ENTRY' || e.errno === 1062;
|
|
61
|
+
if (dialect === 'sqlite') {
|
|
62
|
+
return e.code === 'SQLITE_CONSTRAINT_UNIQUE' || e.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
|
|
63
|
+
|| e.errcode === 2067 || e.errcode === 1555;
|
|
64
|
+
}
|
|
65
|
+
if (dialect === 'mssql') {
|
|
66
|
+
const number = e.number ?? e.originalError?.info?.number;
|
|
67
|
+
return number === 2601 || number === 2627;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
function appendUpdateSet(sets: string[], params: Record<string, any>, col: string, val: any, dialect: Dialect): void {
|
|
55
73
|
const quoted = quote(col, dialect);
|
|
56
74
|
const safeCol = sanitizeParamName(col);
|
|
57
75
|
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
|
58
76
|
if (val.increment !== undefined) {
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
const p = bindParam(params, `s_${safeCol}_inc`, val.increment);
|
|
78
|
+
sets.push(`${quoted} = ${quoted} + @${p}`);
|
|
61
79
|
return;
|
|
62
80
|
}
|
|
63
81
|
if (val.decrement !== undefined) {
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
const p = bindParam(params, `s_${safeCol}_dec`, val.decrement);
|
|
83
|
+
sets.push(`${quoted} = ${quoted} - @${p}`);
|
|
66
84
|
return;
|
|
67
85
|
}
|
|
68
86
|
if (val.multiply !== undefined) {
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
const p = bindParam(params, `s_${safeCol}_mul`, val.multiply);
|
|
88
|
+
sets.push(`${quoted} = ${quoted} * @${p}`);
|
|
71
89
|
return;
|
|
72
90
|
}
|
|
73
91
|
if (val.divide !== undefined) {
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const p = bindParam(params, `s_${safeCol}_div`, val.divide);
|
|
93
|
+
sets.push(`${quoted} = ${quoted} / @${p}`);
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
96
|
if (val.set !== undefined) {
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
const p = bindParam(params, `s_${safeCol}_set`, val.set);
|
|
98
|
+
sets.push(`${quoted} = @${p}`);
|
|
81
99
|
return;
|
|
82
100
|
}
|
|
83
101
|
}
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
const p = bindParam(params, `s_${safeCol}`, val);
|
|
104
|
+
sets.push(`${quoted} = @${p}`);
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
function selectedAggregateFields(fields: any): string[] {
|
|
@@ -789,7 +807,8 @@ export class AdapterTableClient<T = any> {
|
|
|
789
807
|
query = `SELECT ${cols} FROM ${this.tableName}`;
|
|
790
808
|
if (whereSql) query += ` WHERE ${whereSql}`;
|
|
791
809
|
if (orderSql) query += ` ${orderSql}`;
|
|
792
|
-
|
|
810
|
+
const limit = take ?? (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
|
|
811
|
+
query += ` LIMIT ${limit} OFFSET ${skip}`;
|
|
793
812
|
} else {
|
|
794
813
|
// mssql requires ORDER BY before OFFSET/FETCH
|
|
795
814
|
query = `SELECT ${cols} FROM ${this.tableName}${this.nolock}`;
|
|
@@ -854,9 +873,7 @@ export class AdapterTableClient<T = any> {
|
|
|
854
873
|
}
|
|
855
874
|
|
|
856
875
|
const fields = getFieldsForModel(this.modelName);
|
|
857
|
-
const idFieldName =
|
|
858
|
-
? 'id'
|
|
859
|
-
: Object.keys(fields).find((name) => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
876
|
+
const idFieldName = resolveIdField(fields);
|
|
860
877
|
|
|
861
878
|
const data: any = { ...rawData };
|
|
862
879
|
if (idFieldName) {
|
|
@@ -918,8 +935,20 @@ export class AdapterTableClient<T = any> {
|
|
|
918
935
|
if (firstRow === undefined) return { count: 0 };
|
|
919
936
|
const firstCols = Object.keys(firstRow).filter(k => (firstRow as any)[k] !== undefined);
|
|
920
937
|
|
|
921
|
-
|
|
922
|
-
|
|
938
|
+
const sameColumns = args.data.every(row => {
|
|
939
|
+
const cols = Object.keys(row).filter(k => (row as any)[k] !== undefined);
|
|
940
|
+
return cols.length === firstCols.length && firstCols.every(col => cols.includes(col));
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const fields = getFieldsForModel(this.modelName);
|
|
944
|
+
const idFieldName = resolveIdField(fields);
|
|
945
|
+
const idDef = idFieldName ? fields[idFieldName] : undefined;
|
|
946
|
+
const idType = typeof idDef === 'string' ? idDef : (idDef?.ts || idDef?.sql || idDef?.type || '');
|
|
947
|
+
const generatesId = ['string', 'uuid', 'uniqueidentifier', 'nvarchar', 'varchar', 'text'].includes(String(idType).toLowerCase());
|
|
948
|
+
const needsGeneratedId = idFieldName && generatesId && args.data.some(row => !(row as any)[idFieldName]);
|
|
949
|
+
|
|
950
|
+
// Rows needing generated IDs follow create's default handling.
|
|
951
|
+
if (firstCols.length > 0 && sameColumns && !needsGeneratedId && !args.skipDuplicates) {
|
|
923
952
|
const params: Record<string, any> = {};
|
|
924
953
|
const rowPlaceholders: string[] = [];
|
|
925
954
|
|
|
@@ -934,19 +963,15 @@ export class AdapterTableClient<T = any> {
|
|
|
934
963
|
}
|
|
935
964
|
|
|
936
965
|
const query = `INSERT INTO ${this.tableName} (${firstCols.map(c => quote(c, this.dialect)).join(', ')}) VALUES ${rowPlaceholders.join(', ')}`;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
return { count: args.data.length };
|
|
940
|
-
} catch {
|
|
941
|
-
// fallback below if bulk fails (e.g. mixed column sets)
|
|
942
|
-
}
|
|
966
|
+
await this.doExecuteRaw(query, params);
|
|
967
|
+
return { count: args.data.length };
|
|
943
968
|
}
|
|
944
969
|
|
|
945
970
|
// Row-by-row fallback
|
|
946
971
|
let count = 0;
|
|
947
972
|
for (const row of args.data) {
|
|
948
973
|
try { await this.create({ data: row }); count++; }
|
|
949
|
-
catch (e) { if (!args.skipDuplicates) throw e; }
|
|
974
|
+
catch (e) { if (!args.skipDuplicates || !isDuplicateKeyError(e, this.dialect)) throw e; }
|
|
950
975
|
}
|
|
951
976
|
return { count };
|
|
952
977
|
}
|
|
@@ -1132,7 +1157,7 @@ export class AdapterTableClient<T = any> {
|
|
|
1132
1157
|
|
|
1133
1158
|
async groupBy(args: any): Promise<any[]> {
|
|
1134
1159
|
const params: Record<string, any> = {};
|
|
1135
|
-
const whereSql = parseWhere(this.modelName, args?.where, params, this.dialect);
|
|
1160
|
+
const whereSql = parseWhere(this.modelName, args?.where, params, this.dialect, '', this.whereContext());
|
|
1136
1161
|
const byFields = normalizeByFields(args?.by);
|
|
1137
1162
|
if (byFields.length === 0) throw new Error("groupBy requires 'by' fields");
|
|
1138
1163
|
const byCols = byFields.map((b: string) => quote(b, this.dialect)).join(', ');
|
|
@@ -1151,8 +1176,8 @@ export class AdapterTableClient<T = any> {
|
|
|
1151
1176
|
const skip = toNonNegativeInt(args?.skip);
|
|
1152
1177
|
const take = toNonNegativeInt(args?.take, 1);
|
|
1153
1178
|
if (this.dialect === 'postgres' || this.dialect === 'mysql' || this.dialect === 'sqlite') {
|
|
1154
|
-
|
|
1155
|
-
query += ` OFFSET ${skip}`;
|
|
1179
|
+
const limit = hasTake ? take : (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
|
|
1180
|
+
query += ` LIMIT ${limit} OFFSET ${skip}`;
|
|
1156
1181
|
} else {
|
|
1157
1182
|
query += ` OFFSET ${skip} ROWS`;
|
|
1158
1183
|
if (hasTake) query += ` FETCH NEXT ${take} ROWS ONLY`;
|
|
@@ -13,6 +13,14 @@ export interface ModelFieldMeta {
|
|
|
13
13
|
[key: string]: any;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Prefer schema primary-key metadata; retain name inference for legacy clients. */
|
|
17
|
+
export function resolveIdField(fields: Record<string, any>): string | undefined {
|
|
18
|
+
const primaryKey = Object.keys(fields).find(name => fields[name]?.isId === true);
|
|
19
|
+
if (primaryKey) return primaryKey;
|
|
20
|
+
if (Object.prototype.hasOwnProperty.call(fields, 'id')) return 'id';
|
|
21
|
+
return Object.keys(fields).find(name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export interface AdapterMetadata {
|
|
17
25
|
modelToTable?: Record<string, string>;
|
|
18
26
|
modelFields?: Record<string, any>;
|
|
@@ -39,6 +39,15 @@ function sanitizeParamName(name: string): string {
|
|
|
39
39
|
return /^[A-Za-z]/.test(cleaned) ? cleaned : `p_${cleaned}`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Allocate parameters without overwriting values from sibling clauses. */
|
|
43
|
+
export function bindParam(params: Record<string, any>, name: string, value: any): string {
|
|
44
|
+
let candidate = name;
|
|
45
|
+
let suffix = 1;
|
|
46
|
+
while (Object.prototype.hasOwnProperty.call(params, candidate)) candidate = `${name}_${suffix++}`;
|
|
47
|
+
params[candidate] = value;
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function normalizeSortDirection(dir: unknown): 'ASC' | 'DESC' {
|
|
43
52
|
return typeof dir === 'string' && dir.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
44
53
|
}
|
|
@@ -132,26 +141,24 @@ export function parseWhere(
|
|
|
132
141
|
if (v.not !== undefined) {
|
|
133
142
|
if (v.not === null) { conditions.push(`${col} IS NOT NULL`); }
|
|
134
143
|
else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
|
|
135
|
-
const
|
|
136
|
-
const nestedSql = parseWhere(modelName, { [key]: v.not }, nestedParams, dialect, `${prefix}${key}_not_`, ctx);
|
|
137
|
-
Object.assign(params, nestedParams);
|
|
144
|
+
const nestedSql = parseWhere(modelName, { [key]: v.not }, params, dialect, `${prefix}${key}_not_`, ctx);
|
|
138
145
|
if (nestedSql) conditions.push(`NOT (${nestedSql})`);
|
|
139
|
-
} else { const p = `${pname}_not
|
|
146
|
+
} else { const p = bindParam(params, `${pname}_not`, v.not); conditions.push(`${col} <> @${p}`); }
|
|
140
147
|
}
|
|
141
148
|
if (v.equals !== undefined) {
|
|
142
149
|
if (v.equals === null) { conditions.push(`${col} IS NULL`); }
|
|
143
|
-
else { const p = `${pname}_eq
|
|
150
|
+
else { const p = bindParam(params, `${pname}_eq`, v.equals); conditions.push(`${col} = @${p}`); }
|
|
144
151
|
}
|
|
145
|
-
if (v.contains !== undefined) { const p = `${pname}_co
|
|
146
|
-
if (v.startsWith !== undefined) { const p = `${pname}_sw
|
|
147
|
-
if (v.endsWith !== undefined) { const p = `${pname}_ew
|
|
148
|
-
if (v.gte !== undefined) { const p = `${pname}_gte
|
|
149
|
-
if (v.lte !== undefined) { const p = `${pname}_lte
|
|
150
|
-
if (v.gt !== undefined) { const p = `${pname}_gt
|
|
151
|
-
if (v.lt !== undefined) { const p = `${pname}_lt
|
|
152
|
+
if (v.contains !== undefined) { const p = bindParam(params, `${pname}_co`, `%${v.contains}%`); conditions.push(`${col} LIKE @${p}`); }
|
|
153
|
+
if (v.startsWith !== undefined) { const p = bindParam(params, `${pname}_sw`, `${v.startsWith}%`); conditions.push(`${col} LIKE @${p}`); }
|
|
154
|
+
if (v.endsWith !== undefined) { const p = bindParam(params, `${pname}_ew`, `%${v.endsWith}`); conditions.push(`${col} LIKE @${p}`); }
|
|
155
|
+
if (v.gte !== undefined) { const p = bindParam(params, `${pname}_gte`, v.gte); conditions.push(`${col} >= @${p}`); }
|
|
156
|
+
if (v.lte !== undefined) { const p = bindParam(params, `${pname}_lte`, v.lte); conditions.push(`${col} <= @${p}`); }
|
|
157
|
+
if (v.gt !== undefined) { const p = bindParam(params, `${pname}_gt`, v.gt); conditions.push(`${col} > @${p}`); }
|
|
158
|
+
if (v.lt !== undefined) { const p = bindParam(params, `${pname}_lt`, v.lt); conditions.push(`${col} < @${p}`); }
|
|
152
159
|
if (v.in !== undefined) {
|
|
153
160
|
if (Array.isArray(v.in) && v.in.length > 0) {
|
|
154
|
-
const ps = v.in.map((x: any, i: number) => { const p = `${pname}_in${i}
|
|
161
|
+
const ps = v.in.map((x: any, i: number) => { const p = bindParam(params, `${pname}_in${i}`, x); return `@${p}`; });
|
|
155
162
|
conditions.push(`${col} IN (${ps.join(', ')})`);
|
|
156
163
|
} else {
|
|
157
164
|
conditions.push('1=0');
|
|
@@ -159,15 +166,15 @@ export function parseWhere(
|
|
|
159
166
|
}
|
|
160
167
|
if (v.notIn !== undefined) {
|
|
161
168
|
if (Array.isArray(v.notIn) && v.notIn.length > 0) {
|
|
162
|
-
const ps = v.notIn.map((x: any, i: number) => { const p = `${pname}_notin${i}
|
|
169
|
+
const ps = v.notIn.map((x: any, i: number) => { const p = bindParam(params, `${pname}_notin${i}`, x); return `@${p}`; });
|
|
163
170
|
conditions.push(`${col} NOT IN (${ps.join(', ')})`);
|
|
164
171
|
} else {
|
|
165
172
|
conditions.push('1=1');
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
} else {
|
|
169
|
-
params
|
|
170
|
-
conditions.push(`${col} = @${
|
|
176
|
+
const p = bindParam(params, pname, value);
|
|
177
|
+
conditions.push(`${col} = @${p}`);
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
180
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { An5SheetsAdapterConfig, resolveConfig } from './config';
|
|
2
|
+
import { sheetsTokenProvider } from './oauth';
|
|
2
3
|
import type { sheets_v4 } from 'googleapis';
|
|
3
4
|
import { execQuery } from './sqlExecutor';
|
|
4
5
|
import { SheetsTableClient } from './tableClient';
|
|
@@ -8,12 +9,10 @@ import { createAdapterProxy } from '../base/metadata';
|
|
|
8
9
|
|
|
9
10
|
// ─── Fetch-based API proxy for OAuth Access Token / API Key (browser-compatible) ──
|
|
10
11
|
|
|
11
|
-
function createFetchApi(spreadsheetId: string, accessToken?: string, apiKey?: string) {
|
|
12
|
+
function createFetchApi(spreadsheetId: string, accessToken?: string, apiKey?: string, oauth: An5SheetsAdapterConfig = { spreadsheetId }) {
|
|
12
13
|
const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`;
|
|
13
14
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
14
|
-
|
|
15
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
16
|
-
}
|
|
15
|
+
const tokenProvider = sheetsTokenProvider({ ...oauth, ...(accessToken ? { accessToken } : {}) });
|
|
17
16
|
|
|
18
17
|
async function request<T>(method: string, path: string, body?: any): Promise<T> {
|
|
19
18
|
let url = path.startsWith('http') ? path : `${base}${path}`;
|
|
@@ -21,9 +20,14 @@ function createFetchApi(spreadsheetId: string, accessToken?: string, apiKey?: st
|
|
|
21
20
|
const sep = url.includes('?') ? '&' : '?';
|
|
22
21
|
url += `${sep}key=${encodeURIComponent(apiKey)}`;
|
|
23
22
|
}
|
|
24
|
-
const
|
|
23
|
+
const token = await tokenProvider();
|
|
24
|
+
const opts: RequestInit = { method, headers: { ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) } };
|
|
25
25
|
if (body) opts.body = JSON.stringify(body);
|
|
26
|
-
|
|
26
|
+
let res = await fetch(url, opts);
|
|
27
|
+
if (res.status === 401 && oauth.refreshToken) {
|
|
28
|
+
const refreshed = await tokenProvider(true);
|
|
29
|
+
res = await fetch(url, { ...opts, headers: { ...headers, Authorization: `Bearer ${refreshed}` } });
|
|
30
|
+
}
|
|
27
31
|
if (!res.ok) {
|
|
28
32
|
const text = await res.text().catch(() => '');
|
|
29
33
|
const err = new Error(`Google Sheets API error (${res.status}): ${text}`);
|
|
@@ -85,13 +89,17 @@ export class An5SheetsAdapter {
|
|
|
85
89
|
private sheetsCache: Promise<SheetMeta[]> | null = null;
|
|
86
90
|
|
|
87
91
|
constructor(config: An5SheetsAdapterConfig) {
|
|
92
|
+
const browser = typeof window !== 'undefined' || (typeof self !== 'undefined' && typeof process === 'undefined');
|
|
93
|
+
if (browser && (config.refreshToken || config.oauthClientSecret || config.privateKey || config.credentials)) {
|
|
94
|
+
throw new Error('Browser Google Sheets connections require a user access token. Keep desktop OAuth refresh tokens and service account keys outside the browser.');
|
|
95
|
+
}
|
|
88
96
|
this.config = resolveConfig(config);
|
|
89
97
|
// Dynamic model access (`db.user` / `db.User`), same as An5Adapter.
|
|
90
98
|
return createAdapterProxy(this, (name) => this.table(name));
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
private get isFetchMode(): boolean {
|
|
94
|
-
return !!(this.config as any).accessToken || !!(this.config as any).apiKey;
|
|
102
|
+
return !!(this.config as any).accessToken || !!(this.config as any).refreshToken || !!(this.config as any).apiKey;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
async getSheets(): Promise<any> {
|
|
@@ -100,7 +108,8 @@ export class An5SheetsAdapter {
|
|
|
100
108
|
this.fetchApi = createFetchApi(
|
|
101
109
|
this.config.spreadsheetId,
|
|
102
110
|
(this.config as any).accessToken,
|
|
103
|
-
(this.config as any).apiKey
|
|
111
|
+
(this.config as any).apiKey,
|
|
112
|
+
this.config as An5SheetsAdapterConfig
|
|
104
113
|
);
|
|
105
114
|
}
|
|
106
115
|
return this.fetchApi;
|
|
@@ -10,6 +10,11 @@ export interface An5SheetsAdapterConfig {
|
|
|
10
10
|
* When provided, the adapter uses raw fetch() instead of googleapis JWT, making it compatible
|
|
11
11
|
* with browser environments. Mutually exclusive with clientEmail/privateKey/credentials. */
|
|
12
12
|
accessToken?: string;
|
|
13
|
+
/** Offline desktop OAuth credentials. Keep these in secure storage, never source control. */
|
|
14
|
+
refreshToken?: string;
|
|
15
|
+
oauthClientId?: string;
|
|
16
|
+
oauthClientSecret?: string;
|
|
17
|
+
tokenExpiresAt?: number;
|
|
13
18
|
/** Google API key for browser-based API access. */
|
|
14
19
|
apiKey?: string;
|
|
15
20
|
}
|
|
@@ -22,10 +27,14 @@ export function normalizeKey(key: string): string {
|
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function resolveConfig(config: An5SheetsAdapterConfig) {
|
|
25
|
-
if (config.accessToken || config.apiKey) {
|
|
30
|
+
if (config.accessToken || config.refreshToken || config.apiKey) {
|
|
26
31
|
return {
|
|
27
32
|
spreadsheetId: config.spreadsheetId,
|
|
28
33
|
accessToken: config.accessToken,
|
|
34
|
+
refreshToken: config.refreshToken,
|
|
35
|
+
oauthClientId: config.oauthClientId,
|
|
36
|
+
oauthClientSecret: config.oauthClientSecret,
|
|
37
|
+
tokenExpiresAt: config.tokenExpiresAt,
|
|
29
38
|
apiKey: config.apiKey,
|
|
30
39
|
sheetMapping: config.sheetMapping,
|
|
31
40
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { An5SheetsAdapterConfig } from './config';
|
|
2
|
+
|
|
3
|
+
/** Deduplicate refresh requests within an adapter; do not expose token responses. */
|
|
4
|
+
export function sheetsTokenProvider(config: An5SheetsAdapterConfig) {
|
|
5
|
+
let token = config.accessToken;
|
|
6
|
+
let expiresAt = config.tokenExpiresAt;
|
|
7
|
+
let pending: Promise<string> | undefined;
|
|
8
|
+
return async (force = false): Promise<string | undefined> => {
|
|
9
|
+
if (token && !force && (expiresAt === undefined || expiresAt > Date.now() + 60000)) return token;
|
|
10
|
+
if (!config.refreshToken || !config.oauthClientId) {
|
|
11
|
+
if (force || expiresAt !== undefined) throw new Error('Google access expired. Reconnect your Google account.');
|
|
12
|
+
return token;
|
|
13
|
+
}
|
|
14
|
+
if (!pending) pending = (async () => {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
17
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
18
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', client_id: config.oauthClientId!, refresh_token: config.refreshToken!, ...(config.oauthClientSecret ? { client_secret: config.oauthClientSecret } : {}) }),
|
|
19
|
+
signal: AbortSignal.timeout(20000),
|
|
20
|
+
});
|
|
21
|
+
if (!response.ok) throw new Error();
|
|
22
|
+
const result = await response.json() as { access_token?: string; expires_in?: number };
|
|
23
|
+
if (!result.access_token || typeof result.access_token !== 'string' || typeof result.expires_in !== 'number' || !Number.isFinite(result.expires_in) || result.expires_in <= 0) throw new Error();
|
|
24
|
+
token = result.access_token; expiresAt = Date.now() + result.expires_in * 1000;
|
|
25
|
+
return token;
|
|
26
|
+
} catch { throw new Error('Google access expired or was revoked. Reconnect your Google account.'); }
|
|
27
|
+
finally { pending = undefined; }
|
|
28
|
+
})();
|
|
29
|
+
return pending;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -33,6 +33,14 @@ export function parseSheetsConnectionString(url: string): An5SheetsAdapterConfig
|
|
|
33
33
|
if (config.clientemail) result.clientEmail = config.clientemail;
|
|
34
34
|
if (config.privatekey) result.privateKey = config.privatekey;
|
|
35
35
|
if (config.accesstoken) result.accessToken = config.accesstoken;
|
|
36
|
+
if (config.refreshtoken) result.refreshToken = config.refreshtoken;
|
|
37
|
+
if (config.oauthclientid) result.oauthClientId = config.oauthclientid;
|
|
38
|
+
if (config.oauthclientsecret) result.oauthClientSecret = config.oauthclientsecret;
|
|
39
|
+
if (config.tokenexpiresat) {
|
|
40
|
+
const expiry = Number(config.tokenexpiresat);
|
|
41
|
+
if (!Number.isFinite(expiry) || expiry < 0) throw new Error('Invalid OAuth token expiry.');
|
|
42
|
+
result.tokenExpiresAt = expiry;
|
|
43
|
+
}
|
|
36
44
|
if (config.apikey) result.apiKey = config.apikey;
|
|
37
45
|
|
|
38
46
|
if (config.sheetmapping) {
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { generateUUID } from '../base/uuid';
|
|
2
|
-
import { getFieldsForModel } from '../base/metadata';
|
|
2
|
+
import { getFieldsForModel, resolveIdField } from '../base/metadata';
|
|
3
3
|
import type { An5SheetsAdapter } from './adapter';
|
|
4
4
|
import { buildOrderBy, coerceCell, esc, matchWhere, resolveSheetName, sortRows } from './helpers';
|
|
5
5
|
import { withRetry } from './retry';
|
|
6
6
|
|
|
7
7
|
// ─── Utility ──────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
9
|
-
function resolveIdField(fields: Record<string, any>): string | undefined {
|
|
10
|
-
if (Object.prototype.hasOwnProperty.call(fields, 'id')) return 'id';
|
|
11
|
-
return Object.keys(fields).find(
|
|
12
|
-
name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id'
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
9
|
// ─── Table Client ─────────────────────────────────────────────────────────────
|
|
17
10
|
|
|
18
11
|
export class SheetsTableClient<T = any> {
|
|
@@ -227,17 +220,10 @@ export class SheetsTableClient<T = any> {
|
|
|
227
220
|
return this.rowToValues(data, headers);
|
|
228
221
|
});
|
|
229
222
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!args.skipDuplicates) throw e;
|
|
235
|
-
let count = 0;
|
|
236
|
-
for (const row of args.data) {
|
|
237
|
-
try { await this.create({ data: row }); count++; } catch { /* skip */ }
|
|
238
|
-
}
|
|
239
|
-
return { count };
|
|
240
|
-
}
|
|
223
|
+
// Sheets has no unique constraints. An append failure must propagate;
|
|
224
|
+
// retrying individual rows could duplicate a batch the service accepted.
|
|
225
|
+
await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
|
|
226
|
+
return { count: batchValues.length };
|
|
241
227
|
}
|
|
242
228
|
|
|
243
229
|
async update(args: { where: any; data: Partial<T> }): Promise<T> {
|