@an5/adapters 0.2.9 → 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.
@@ -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[] {
@@ -317,7 +335,8 @@ export class An5Adapter {
317
335
  } else if (forDialect.startsWith('mysql://') || forDialect.startsWith('mariadb://')) {
318
336
  this._engineType = 'mysql';
319
337
  } else if (
320
- forDialect.startsWith('sqlite://')
338
+ forDialect === ':memory:'
339
+ || forDialect.startsWith('sqlite:')
321
340
  || SQLITE_FILE_SUFFIXES.some((suffix) => forDialect.endsWith(suffix))
322
341
  ) {
323
342
  this._engineType = 'sqlite';
@@ -788,7 +807,8 @@ export class AdapterTableClient<T = any> {
788
807
  query = `SELECT ${cols} FROM ${this.tableName}`;
789
808
  if (whereSql) query += ` WHERE ${whereSql}`;
790
809
  if (orderSql) query += ` ${orderSql}`;
791
- 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}`;
792
812
  } else {
793
813
  // mssql requires ORDER BY before OFFSET/FETCH
794
814
  query = `SELECT ${cols} FROM ${this.tableName}${this.nolock}`;
@@ -853,9 +873,7 @@ export class AdapterTableClient<T = any> {
853
873
  }
854
874
 
855
875
  const fields = getFieldsForModel(this.modelName);
856
- const idFieldName = Object.prototype.hasOwnProperty.call(fields, 'id')
857
- ? 'id'
858
- : Object.keys(fields).find((name) => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
876
+ const idFieldName = resolveIdField(fields);
859
877
 
860
878
  const data: any = { ...rawData };
861
879
  if (idFieldName) {
@@ -917,8 +935,20 @@ export class AdapterTableClient<T = any> {
917
935
  if (firstRow === undefined) return { count: 0 };
918
936
  const firstCols = Object.keys(firstRow).filter(k => (firstRow as any)[k] !== undefined);
919
937
 
920
- // Bulk INSERT for the common case (same columns, no skipDuplicates)
921
- 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) {
922
952
  const params: Record<string, any> = {};
923
953
  const rowPlaceholders: string[] = [];
924
954
 
@@ -933,19 +963,15 @@ export class AdapterTableClient<T = any> {
933
963
  }
934
964
 
935
965
  const query = `INSERT INTO ${this.tableName} (${firstCols.map(c => quote(c, this.dialect)).join(', ')}) VALUES ${rowPlaceholders.join(', ')}`;
936
- try {
937
- await this.doExecuteRaw(query, params);
938
- return { count: args.data.length };
939
- } catch {
940
- // fallback below if bulk fails (e.g. mixed column sets)
941
- }
966
+ await this.doExecuteRaw(query, params);
967
+ return { count: args.data.length };
942
968
  }
943
969
 
944
970
  // Row-by-row fallback
945
971
  let count = 0;
946
972
  for (const row of args.data) {
947
973
  try { await this.create({ data: row }); count++; }
948
- catch (e) { if (!args.skipDuplicates) throw e; }
974
+ catch (e) { if (!args.skipDuplicates || !isDuplicateKeyError(e, this.dialect)) throw e; }
949
975
  }
950
976
  return { count };
951
977
  }
@@ -1131,7 +1157,7 @@ export class AdapterTableClient<T = any> {
1131
1157
 
1132
1158
  async groupBy(args: any): Promise<any[]> {
1133
1159
  const params: Record<string, any> = {};
1134
- const whereSql = parseWhere(this.modelName, args?.where, params, this.dialect);
1160
+ const whereSql = parseWhere(this.modelName, args?.where, params, this.dialect, '', this.whereContext());
1135
1161
  const byFields = normalizeByFields(args?.by);
1136
1162
  if (byFields.length === 0) throw new Error("groupBy requires 'by' fields");
1137
1163
  const byCols = byFields.map((b: string) => quote(b, this.dialect)).join(', ');
@@ -1150,8 +1176,8 @@ export class AdapterTableClient<T = any> {
1150
1176
  const skip = toNonNegativeInt(args?.skip);
1151
1177
  const take = toNonNegativeInt(args?.take, 1);
1152
1178
  if (this.dialect === 'postgres' || this.dialect === 'mysql' || this.dialect === 'sqlite') {
1153
- if (hasTake) query += ` LIMIT ${take}`;
1154
- query += ` OFFSET ${skip}`;
1179
+ const limit = hasTake ? take : (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
1180
+ query += ` LIMIT ${limit} OFFSET ${skip}`;
1155
1181
  } else {
1156
1182
  query += ` OFFSET ${skip} ROWS`;
1157
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
  }
@@ -88,10 +97,11 @@ export function parseWhere(
88
97
  if (!where) return '';
89
98
  const conditions: string[] = [];
90
99
 
100
+ const relations = ctx?.relationMap?.[modelName] || {};
91
101
  const cleanWhere: Record<string, any> = {};
92
102
  for (const [key, value] of Object.entries(where)) {
93
103
  if (
94
- key.includes('_') &&
104
+ key.includes('_') && !relations[key] &&
95
105
  value && typeof value === 'object' &&
96
106
  !(value instanceof Date) &&
97
107
  !isOperatorValue(value)
@@ -102,23 +112,23 @@ export function parseWhere(
102
112
  }
103
113
  }
104
114
 
105
- const relations = ctx?.relationMap?.[modelName] || {};
106
115
  const colPrefix = ctx?.colPrefix || '';
107
116
 
108
117
  for (const [key, value] of Object.entries(cleanWhere)) {
118
+ if (value === undefined) continue;
109
119
  if (key === 'OR' && Array.isArray(value)) {
110
- const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx)).filter(Boolean);
111
- if (sub.length > 0) conditions.push(`(${sub.join(' OR ')})`);
120
+ const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx) || '1=1');
121
+ conditions.push(sub.length > 0 ? `(${sub.join(' OR ')})` : '1=0');
112
122
  } else if (key === 'AND') {
113
123
  const items = Array.isArray(value) ? value : [value];
114
124
  const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}and_${i}_`, ctx)).filter(Boolean);
115
125
  if (sub.length > 0) conditions.push(`(${sub.join(' AND ')})`);
116
126
  } else if (key === 'NOT') {
117
127
  const items = Array.isArray(value) ? value : [value];
118
- const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx)).filter(Boolean);
119
- if (sub.length > 0) conditions.push(`NOT (${sub.join(' AND ')})`);
128
+ const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx) || '1=1');
129
+ if (sub.length > 0) conditions.push(`NOT (${sub.join(' OR ')})`);
120
130
  } else if (relations[key] && value && typeof value === 'object') {
121
- const clause = buildRelationClause(relations[key], value, modelName, params, dialect, prefix, ctx);
131
+ const clause = buildRelationClause(relations[key], value, modelName, params, dialect, `${prefix}${key}_`, ctx);
122
132
  if (clause) conditions.push(clause);
123
133
  } else {
124
134
  const pname = sanitizeParamName(`${prefix}${key}`);
@@ -131,26 +141,24 @@ export function parseWhere(
131
141
  if (v.not !== undefined) {
132
142
  if (v.not === null) { conditions.push(`${col} IS NOT NULL`); }
133
143
  else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
134
- const nestedParams: Record<string, any> = {};
135
- const nestedSql = parseWhere(modelName, { [key]: v.not }, nestedParams, dialect, `${prefix}${key}_not_`, ctx);
136
- Object.assign(params, nestedParams);
144
+ const nestedSql = parseWhere(modelName, { [key]: v.not }, params, dialect, `${prefix}${key}_not_`, ctx);
137
145
  if (nestedSql) conditions.push(`NOT (${nestedSql})`);
138
- } 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}`); }
139
147
  }
140
148
  if (v.equals !== undefined) {
141
149
  if (v.equals === null) { conditions.push(`${col} IS NULL`); }
142
- 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}`); }
143
151
  }
144
- if (v.contains !== undefined) { const p = `${pname}_co`; params[p] = `%${v.contains}%`; conditions.push(`${col} LIKE @${p}`); }
145
- if (v.startsWith !== undefined) { const p = `${pname}_sw`; params[p] = `${v.startsWith}%`; conditions.push(`${col} LIKE @${p}`); }
146
- if (v.endsWith !== undefined) { const p = `${pname}_ew`; params[p] = `%${v.endsWith}`; conditions.push(`${col} LIKE @${p}`); }
147
- if (v.gte !== undefined) { const p = `${pname}_gte`; params[p] = v.gte; conditions.push(`${col} >= @${p}`); }
148
- if (v.lte !== undefined) { const p = `${pname}_lte`; params[p] = v.lte; conditions.push(`${col} <= @${p}`); }
149
- if (v.gt !== undefined) { const p = `${pname}_gt`; params[p] = v.gt; conditions.push(`${col} > @${p}`); }
150
- 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}`); }
151
159
  if (v.in !== undefined) {
152
160
  if (Array.isArray(v.in) && v.in.length > 0) {
153
- 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}`; });
154
162
  conditions.push(`${col} IN (${ps.join(', ')})`);
155
163
  } else {
156
164
  conditions.push('1=0');
@@ -158,15 +166,15 @@ export function parseWhere(
158
166
  }
159
167
  if (v.notIn !== undefined) {
160
168
  if (Array.isArray(v.notIn) && v.notIn.length > 0) {
161
- 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}`; });
162
170
  conditions.push(`${col} NOT IN (${ps.join(', ')})`);
163
171
  } else {
164
172
  conditions.push('1=1');
165
173
  }
166
174
  }
167
175
  } else {
168
- params[pname] = value;
169
- conditions.push(`${col} = @${pname}`);
176
+ const p = bindParam(params, pname, value);
177
+ conditions.push(`${col} = @${p}`);
170
178
  }
171
179
  }
172
180
  }
@@ -203,28 +211,28 @@ function buildRelationClause(
203
211
 
204
212
  if (isMany) {
205
213
  if (value.some !== undefined) {
206
- const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, prefix, ctx, childAlias);
214
+ const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, `${prefix}some_`, ctx, childAlias);
207
215
  parts.push(buildExists(inner));
208
216
  }
209
217
  if (value.none !== undefined) {
210
- const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, prefix, ctx, childAlias);
218
+ const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, `${prefix}none_`, ctx, childAlias);
211
219
  parts.push(`NOT ${buildExists(inner)}`);
212
220
  }
213
221
  if (value.every !== undefined) {
214
222
  // "every" = no related row violates the condition.
215
- const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, prefix, ctx, childAlias);
223
+ const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, `${prefix}every_`, ctx, childAlias);
216
224
  const negated = inner ? `NOT (${inner})` : '';
217
225
  const violated = `EXISTS (SELECT 1 FROM ${childTable} AS ${childAlias} WHERE ${correlation}${negated ? ` AND ${negated}` : ' AND 1=0'})`;
218
226
  parts.push(`NOT ${violated}`);
219
227
  }
220
228
  } else {
221
229
  if (value.is !== undefined) {
222
- const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, prefix, ctx, childAlias);
223
- parts.push(buildExists(inner));
230
+ const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, `${prefix}is_`, ctx, childAlias);
231
+ parts.push(value.is === null ? `NOT ${buildExists(inner)}` : buildExists(inner));
224
232
  }
225
233
  if (value.isNot !== undefined) {
226
- const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, prefix, ctx, childAlias);
227
- parts.push(`NOT ${buildExists(inner)}`);
234
+ const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, `${prefix}isNot_`, ctx, childAlias);
235
+ parts.push(value.isNot === null ? buildExists(inner) : `NOT ${buildExists(inner)}`);
228
236
  }
229
237
  }
230
238
 
@@ -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
  };
@@ -21,9 +21,7 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
21
21
  key.includes('_') &&
22
22
  value && typeof value === 'object' &&
23
23
  !(value instanceof Date) &&
24
- !(value as any).in && !(value as any).contains &&
25
- !(value as any).not && !(value as any).gte &&
26
- !(value as any).lte && !(value as any).gt && !(value as any).lt
24
+ !['equals', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'not', 'gte', 'lte', 'gt', 'lt'].some(op => op in value)
27
25
  ) {
28
26
  Object.assign(cleanWhere, value);
29
27
  } else {
@@ -32,12 +30,14 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
32
30
  }
33
31
 
34
32
  for (const [key, value] of Object.entries(cleanWhere)) {
33
+ if (value === undefined) continue;
35
34
  if (key === 'OR' && Array.isArray(value)) {
36
35
  if (!value.some((v: any) => matchWhere(row, v))) return false;
37
36
  continue;
38
37
  }
39
- if (key === 'AND' && Array.isArray(value)) {
40
- if (!value.every((v: any) => matchWhere(row, v))) return false;
38
+ if (key === 'AND') {
39
+ const clauses = Array.isArray(value) ? value : [value];
40
+ if (!clauses.every((v: any) => matchWhere(row, v))) return false;
41
41
  continue;
42
42
  }
43
43
  if (key === 'NOT') {
@@ -57,17 +57,20 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
57
57
 
58
58
  if (v.not !== undefined) {
59
59
  if (v.not === null) { if (cellVal === null || cellVal === undefined) return false; }
60
+ else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
61
+ if (matchWhere(row, { [key]: v.not })) return false;
62
+ }
60
63
  else if (normalize(cellVal) == normalize(v.not)) return false;
61
64
  }
62
65
  if (v.equals !== undefined && normalize(cellVal) != normalize(v.equals)) return false;
63
66
  if (v.contains !== undefined) {
64
- if (!cellVal || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
67
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
65
68
  }
66
69
  if (v.startsWith !== undefined) {
67
- if (!cellVal || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
70
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
68
71
  }
69
72
  if (v.endsWith !== undefined) {
70
- if (!cellVal || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
73
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
71
74
  }
72
75
  if (v.gte !== undefined && !(Number(cellVal) >= Number(v.gte))) return false;
73
76
  if (v.lte !== undefined && !(Number(cellVal) <= Number(v.lte))) return false;
@@ -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> {
@@ -14,7 +14,8 @@ export class SqliteEngine implements QueryEngine {
14
14
  if (!BetterSqlite3) throw new Error('better-sqlite3 package is required for SQLite support. Run: npm install better-sqlite3');
15
15
  this.filePath = (adapterConfig.connectionString ?? '')
16
16
  .replace(/^sqlite:\/\/\//i, '/')
17
- .replace(/^sqlite:\/\//i, '');
17
+ .replace(/^sqlite:\/\//i, '')
18
+ .replace(/^sqlite:/i, '');
18
19
  }
19
20
 
20
21
  private getDb(): any {