@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 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/).
@@ -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
- sets.push(`${quoted} = ${quoted} + @s_${safeCol}_inc`);
37
- params[`s_${safeCol}_inc`] = val.increment;
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
- sets.push(`${quoted} = ${quoted} - @s_${safeCol}_dec`);
42
- params[`s_${safeCol}_dec`] = val.decrement;
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
- sets.push(`${quoted} = ${quoted} * @s_${safeCol}_mul`);
47
- params[`s_${safeCol}_mul`] = val.multiply;
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
- sets.push(`${quoted} = ${quoted} / @s_${safeCol}_div`);
52
- params[`s_${safeCol}_div`] = val.divide;
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
- sets.push(`${quoted} = @s_${safeCol}_set`);
57
- params[`s_${safeCol}_set`] = val.set;
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
- sets.push(`${quoted} = @s_${safeCol}`);
62
- params[`s_${safeCol}`] = val;
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
- query += ` LIMIT ${take ?? 'ALL'} OFFSET ${skip}`;
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 = Object.prototype.hasOwnProperty.call(fields, 'id')
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
- // Bulk INSERT for the common case (same columns, no skipDuplicates)
840
- if (firstCols.length > 0 && !args.skipDuplicates) {
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
- try {
854
- await this.doExecuteRaw(query, params);
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
- if (hasTake)
1080
- query += ` LIMIT ${take}`;
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`;
@@ -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>;
@@ -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 = {};
@@ -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 nestedParams = {};
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}`; params[p] = x; return `@${p}`; });
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}`; params[p] = x; return `@${p}`; });
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[pname] = value;
200
- conditions.push(`${col} = @${pname}`);
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
- if (accessToken) {
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 opts = { method, headers };
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
- const res = await fetch(url, opts);
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,3 @@
1
+ import type { An5SheetsAdapterConfig } from './config';
2
+ /** Deduplicate refresh requests within an adapter; do not expose token responses. */
3
+ export declare function sheetsTokenProvider(config: An5SheetsAdapterConfig): (force?: boolean) => Promise<string | undefined>;
@@ -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
- try {
205
- await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
206
- return { count: batchValues.length };
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.10",
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
- sets.push(`${quoted} = ${quoted} + @s_${safeCol}_inc`);
60
- params[`s_${safeCol}_inc`] = val.increment;
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
- sets.push(`${quoted} = ${quoted} - @s_${safeCol}_dec`);
65
- params[`s_${safeCol}_dec`] = val.decrement;
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
- sets.push(`${quoted} = ${quoted} * @s_${safeCol}_mul`);
70
- params[`s_${safeCol}_mul`] = val.multiply;
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
- sets.push(`${quoted} = ${quoted} / @s_${safeCol}_div`);
75
- params[`s_${safeCol}_div`] = val.divide;
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
- sets.push(`${quoted} = @s_${safeCol}_set`);
80
- params[`s_${safeCol}_set`] = val.set;
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
- sets.push(`${quoted} = @s_${safeCol}`);
86
- params[`s_${safeCol}`] = val;
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
- query += ` LIMIT ${take ?? 'ALL'} OFFSET ${skip}`;
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 = Object.prototype.hasOwnProperty.call(fields, 'id')
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
- // Bulk INSERT for the common case (same columns, no skipDuplicates)
922
- if (firstCols.length > 0 && !args.skipDuplicates) {
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
- try {
938
- await this.doExecuteRaw(query, params);
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
- if (hasTake) query += ` LIMIT ${take}`;
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 nestedParams: Record<string, any> = {};
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`; params[p] = v.not; conditions.push(`${col} <> @${p}`); }
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`; params[p] = v.equals; conditions.push(`${col} = @${p}`); }
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`; params[p] = `%${v.contains}%`; conditions.push(`${col} LIKE @${p}`); }
146
- if (v.startsWith !== undefined) { const p = `${pname}_sw`; params[p] = `${v.startsWith}%`; conditions.push(`${col} LIKE @${p}`); }
147
- if (v.endsWith !== undefined) { const p = `${pname}_ew`; params[p] = `%${v.endsWith}`; conditions.push(`${col} LIKE @${p}`); }
148
- if (v.gte !== undefined) { const p = `${pname}_gte`; params[p] = v.gte; conditions.push(`${col} >= @${p}`); }
149
- if (v.lte !== undefined) { const p = `${pname}_lte`; params[p] = v.lte; conditions.push(`${col} <= @${p}`); }
150
- if (v.gt !== undefined) { const p = `${pname}_gt`; params[p] = v.gt; conditions.push(`${col} > @${p}`); }
151
- if (v.lt !== undefined) { const p = `${pname}_lt`; params[p] = v.lt; conditions.push(`${col} < @${p}`); }
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}`; params[p] = x; return `@${p}`; });
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}`; params[p] = x; return `@${p}`; });
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[pname] = value;
170
- conditions.push(`${col} = @${pname}`);
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
- if (accessToken) {
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 opts: RequestInit = { method, headers };
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
- const res = await fetch(url, opts);
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
- try {
231
- await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
232
- return { count: batchValues.length };
233
- } catch (e) {
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> {