@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.
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')
@@ -275,7 +294,8 @@ class An5Adapter {
275
294
  else if (forDialect.startsWith('mysql://') || forDialect.startsWith('mariadb://')) {
276
295
  this._engineType = 'mysql';
277
296
  }
278
- else if (forDialect.startsWith('sqlite://')
297
+ else if (forDialect === ':memory:'
298
+ || forDialect.startsWith('sqlite:')
279
299
  || SQLITE_FILE_SUFFIXES.some((suffix) => forDialect.endsWith(suffix))) {
280
300
  this._engineType = 'sqlite';
281
301
  }
@@ -716,7 +736,8 @@ class AdapterTableClient {
716
736
  query += ` WHERE ${whereSql}`;
717
737
  if (orderSql)
718
738
  query += ` ${orderSql}`;
719
- 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}`;
720
741
  }
721
742
  else {
722
743
  // mssql requires ORDER BY before OFFSET/FETCH
@@ -778,9 +799,7 @@ class AdapterTableClient {
778
799
  }
779
800
  }
780
801
  const fields = (0, metadata_1.getFieldsForModel)(this.modelName);
781
- const idFieldName = Object.prototype.hasOwnProperty.call(fields, 'id')
782
- ? 'id'
783
- : Object.keys(fields).find((name) => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
802
+ const idFieldName = (0, metadata_1.resolveIdField)(fields);
784
803
  const data = { ...rawData };
785
804
  if (idFieldName) {
786
805
  const fieldDef = fields[idFieldName];
@@ -835,8 +854,18 @@ class AdapterTableClient {
835
854
  if (firstRow === undefined)
836
855
  return { count: 0 };
837
856
  const firstCols = Object.keys(firstRow).filter(k => firstRow[k] !== undefined);
838
- // Bulk INSERT for the common case (same columns, no skipDuplicates)
839
- 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) {
840
869
  const params = {};
841
870
  const rowPlaceholders = [];
842
871
  for (let r = 0; r < args.data.length; r++) {
@@ -849,13 +878,8 @@ class AdapterTableClient {
849
878
  rowPlaceholders.push(`(${vals.join(', ')})`);
850
879
  }
851
880
  const query = `INSERT INTO ${this.tableName} (${firstCols.map(c => (0, sql_1.quote)(c, this.dialect)).join(', ')}) VALUES ${rowPlaceholders.join(', ')}`;
852
- try {
853
- await this.doExecuteRaw(query, params);
854
- return { count: args.data.length };
855
- }
856
- catch {
857
- // fallback below if bulk fails (e.g. mixed column sets)
858
- }
881
+ await this.doExecuteRaw(query, params);
882
+ return { count: args.data.length };
859
883
  }
860
884
  // Row-by-row fallback
861
885
  let count = 0;
@@ -865,7 +889,7 @@ class AdapterTableClient {
865
889
  count++;
866
890
  }
867
891
  catch (e) {
868
- if (!args.skipDuplicates)
892
+ if (!args.skipDuplicates || !isDuplicateKeyError(e, this.dialect))
869
893
  throw e;
870
894
  }
871
895
  }
@@ -1045,7 +1069,7 @@ class AdapterTableClient {
1045
1069
  }
1046
1070
  async groupBy(args) {
1047
1071
  const params = {};
1048
- 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());
1049
1073
  const byFields = normalizeByFields(args?.by);
1050
1074
  if (byFields.length === 0)
1051
1075
  throw new Error("groupBy requires 'by' fields");
@@ -1075,9 +1099,8 @@ class AdapterTableClient {
1075
1099
  const skip = toNonNegativeInt(args?.skip);
1076
1100
  const take = toNonNegativeInt(args?.take, 1);
1077
1101
  if (this.dialect === 'postgres' || this.dialect === 'mysql' || this.dialect === 'sqlite') {
1078
- if (hasTake)
1079
- query += ` LIMIT ${take}`;
1080
- query += ` OFFSET ${skip}`;
1102
+ const limit = hasTake ? take : (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
1103
+ query += ` LIMIT ${limit} OFFSET ${skip}`;
1081
1104
  }
1082
1105
  else {
1083
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
  }
@@ -68,9 +78,10 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
68
78
  if (!where)
69
79
  return '';
70
80
  const conditions = [];
81
+ const relations = ctx?.relationMap?.[modelName] || {};
71
82
  const cleanWhere = {};
72
83
  for (const [key, value] of Object.entries(where)) {
73
- if (key.includes('_') &&
84
+ if (key.includes('_') && !relations[key] &&
74
85
  value && typeof value === 'object' &&
75
86
  !(value instanceof Date) &&
76
87
  !isOperatorValue(value)) {
@@ -80,13 +91,13 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
80
91
  cleanWhere[key] = value;
81
92
  }
82
93
  }
83
- const relations = ctx?.relationMap?.[modelName] || {};
84
94
  const colPrefix = ctx?.colPrefix || '';
85
95
  for (const [key, value] of Object.entries(cleanWhere)) {
96
+ if (value === undefined)
97
+ continue;
86
98
  if (key === 'OR' && Array.isArray(value)) {
87
- const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx)).filter(Boolean);
88
- if (sub.length > 0)
89
- conditions.push(`(${sub.join(' OR ')})`);
99
+ const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx) || '1=1');
100
+ conditions.push(sub.length > 0 ? `(${sub.join(' OR ')})` : '1=0');
90
101
  }
91
102
  else if (key === 'AND') {
92
103
  const items = Array.isArray(value) ? value : [value];
@@ -96,12 +107,12 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
96
107
  }
97
108
  else if (key === 'NOT') {
98
109
  const items = Array.isArray(value) ? value : [value];
99
- const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx)).filter(Boolean);
110
+ const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx) || '1=1');
100
111
  if (sub.length > 0)
101
- conditions.push(`NOT (${sub.join(' AND ')})`);
112
+ conditions.push(`NOT (${sub.join(' OR ')})`);
102
113
  }
103
114
  else if (relations[key] && value && typeof value === 'object') {
104
- const clause = buildRelationClause(relations[key], value, modelName, params, dialect, prefix, ctx);
115
+ const clause = buildRelationClause(relations[key], value, modelName, params, dialect, `${prefix}${key}_`, ctx);
105
116
  if (clause)
106
117
  conditions.push(clause);
107
118
  }
@@ -118,15 +129,12 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
118
129
  conditions.push(`${col} IS NOT NULL`);
119
130
  }
120
131
  else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
121
- const nestedParams = {};
122
- const nestedSql = parseWhere(modelName, { [key]: v.not }, nestedParams, dialect, `${prefix}${key}_not_`, ctx);
123
- Object.assign(params, nestedParams);
132
+ const nestedSql = parseWhere(modelName, { [key]: v.not }, params, dialect, `${prefix}${key}_not_`, ctx);
124
133
  if (nestedSql)
125
134
  conditions.push(`NOT (${nestedSql})`);
126
135
  }
127
136
  else {
128
- const p = `${pname}_not`;
129
- params[p] = v.not;
137
+ const p = bindParam(params, `${pname}_not`, v.not);
130
138
  conditions.push(`${col} <> @${p}`);
131
139
  }
132
140
  }
@@ -135,49 +143,41 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
135
143
  conditions.push(`${col} IS NULL`);
136
144
  }
137
145
  else {
138
- const p = `${pname}_eq`;
139
- params[p] = v.equals;
146
+ const p = bindParam(params, `${pname}_eq`, v.equals);
140
147
  conditions.push(`${col} = @${p}`);
141
148
  }
142
149
  }
143
150
  if (v.contains !== undefined) {
144
- const p = `${pname}_co`;
145
- params[p] = `%${v.contains}%`;
151
+ const p = bindParam(params, `${pname}_co`, `%${v.contains}%`);
146
152
  conditions.push(`${col} LIKE @${p}`);
147
153
  }
148
154
  if (v.startsWith !== undefined) {
149
- const p = `${pname}_sw`;
150
- params[p] = `${v.startsWith}%`;
155
+ const p = bindParam(params, `${pname}_sw`, `${v.startsWith}%`);
151
156
  conditions.push(`${col} LIKE @${p}`);
152
157
  }
153
158
  if (v.endsWith !== undefined) {
154
- const p = `${pname}_ew`;
155
- params[p] = `%${v.endsWith}`;
159
+ const p = bindParam(params, `${pname}_ew`, `%${v.endsWith}`);
156
160
  conditions.push(`${col} LIKE @${p}`);
157
161
  }
158
162
  if (v.gte !== undefined) {
159
- const p = `${pname}_gte`;
160
- params[p] = v.gte;
163
+ const p = bindParam(params, `${pname}_gte`, v.gte);
161
164
  conditions.push(`${col} >= @${p}`);
162
165
  }
163
166
  if (v.lte !== undefined) {
164
- const p = `${pname}_lte`;
165
- params[p] = v.lte;
167
+ const p = bindParam(params, `${pname}_lte`, v.lte);
166
168
  conditions.push(`${col} <= @${p}`);
167
169
  }
168
170
  if (v.gt !== undefined) {
169
- const p = `${pname}_gt`;
170
- params[p] = v.gt;
171
+ const p = bindParam(params, `${pname}_gt`, v.gt);
171
172
  conditions.push(`${col} > @${p}`);
172
173
  }
173
174
  if (v.lt !== undefined) {
174
- const p = `${pname}_lt`;
175
- params[p] = v.lt;
175
+ const p = bindParam(params, `${pname}_lt`, v.lt);
176
176
  conditions.push(`${col} < @${p}`);
177
177
  }
178
178
  if (v.in !== undefined) {
179
179
  if (Array.isArray(v.in) && v.in.length > 0) {
180
- 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}`; });
181
181
  conditions.push(`${col} IN (${ps.join(', ')})`);
182
182
  }
183
183
  else {
@@ -186,7 +186,7 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
186
186
  }
187
187
  if (v.notIn !== undefined) {
188
188
  if (Array.isArray(v.notIn) && v.notIn.length > 0) {
189
- 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}`; });
190
190
  conditions.push(`${col} NOT IN (${ps.join(', ')})`);
191
191
  }
192
192
  else {
@@ -195,8 +195,8 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
195
195
  }
196
196
  }
197
197
  else {
198
- params[pname] = value;
199
- conditions.push(`${col} = @${pname}`);
198
+ const p = bindParam(params, pname, value);
199
+ conditions.push(`${col} = @${p}`);
200
200
  }
201
201
  }
202
202
  }
@@ -220,16 +220,16 @@ function buildRelationClause(relation, value, modelName, params, dialect, prefix
220
220
  const parts = [];
221
221
  if (isMany) {
222
222
  if (value.some !== undefined) {
223
- const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, prefix, ctx, childAlias);
223
+ const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, `${prefix}some_`, ctx, childAlias);
224
224
  parts.push(buildExists(inner));
225
225
  }
226
226
  if (value.none !== undefined) {
227
- const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, prefix, ctx, childAlias);
227
+ const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, `${prefix}none_`, ctx, childAlias);
228
228
  parts.push(`NOT ${buildExists(inner)}`);
229
229
  }
230
230
  if (value.every !== undefined) {
231
231
  // "every" = no related row violates the condition.
232
- const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, prefix, ctx, childAlias);
232
+ const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, `${prefix}every_`, ctx, childAlias);
233
233
  const negated = inner ? `NOT (${inner})` : '';
234
234
  const violated = `EXISTS (SELECT 1 FROM ${childTable} AS ${childAlias} WHERE ${correlation}${negated ? ` AND ${negated}` : ' AND 1=0'})`;
235
235
  parts.push(`NOT ${violated}`);
@@ -237,12 +237,12 @@ function buildRelationClause(relation, value, modelName, params, dialect, prefix
237
237
  }
238
238
  else {
239
239
  if (value.is !== undefined) {
240
- const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, prefix, ctx, childAlias);
241
- parts.push(buildExists(inner));
240
+ const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, `${prefix}is_`, ctx, childAlias);
241
+ parts.push(value.is === null ? `NOT ${buildExists(inner)}` : buildExists(inner));
242
242
  }
243
243
  if (value.isNot !== undefined) {
244
- const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, prefix, ctx, childAlias);
245
- parts.push(`NOT ${buildExists(inner)}`);
244
+ const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, `${prefix}isNot_`, ctx, childAlias);
245
+ parts.push(value.isNot === null ? buildExists(inner) : `NOT ${buildExists(inner)}`);
246
246
  }
247
247
  }
248
248
  return parts.length > 0 ? parts.join(' AND ') : null;
@@ -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
  };
@@ -25,9 +25,7 @@ function matchWhere(row, where) {
25
25
  if (key.includes('_') &&
26
26
  value && typeof value === 'object' &&
27
27
  !(value instanceof Date) &&
28
- !value.in && !value.contains &&
29
- !value.not && !value.gte &&
30
- !value.lte && !value.gt && !value.lt) {
28
+ !['equals', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'not', 'gte', 'lte', 'gt', 'lt'].some(op => op in value)) {
31
29
  Object.assign(cleanWhere, value);
32
30
  }
33
31
  else {
@@ -35,13 +33,16 @@ function matchWhere(row, where) {
35
33
  }
36
34
  }
37
35
  for (const [key, value] of Object.entries(cleanWhere)) {
36
+ if (value === undefined)
37
+ continue;
38
38
  if (key === 'OR' && Array.isArray(value)) {
39
39
  if (!value.some((v) => matchWhere(row, v)))
40
40
  return false;
41
41
  continue;
42
42
  }
43
- if (key === 'AND' && Array.isArray(value)) {
44
- if (!value.every((v) => matchWhere(row, v)))
43
+ if (key === 'AND') {
44
+ const clauses = Array.isArray(value) ? value : [value];
45
+ if (!clauses.every((v) => matchWhere(row, v)))
45
46
  return false;
46
47
  continue;
47
48
  }
@@ -65,21 +66,25 @@ function matchWhere(row, where) {
65
66
  if (cellVal === null || cellVal === undefined)
66
67
  return false;
67
68
  }
69
+ else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
70
+ if (matchWhere(row, { [key]: v.not }))
71
+ return false;
72
+ }
68
73
  else if (normalize(cellVal) == normalize(v.not))
69
74
  return false;
70
75
  }
71
76
  if (v.equals !== undefined && normalize(cellVal) != normalize(v.equals))
72
77
  return false;
73
78
  if (v.contains !== undefined) {
74
- if (!cellVal || !normalize(String(cellVal)).includes(normalize(v.contains)))
79
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).includes(normalize(v.contains)))
75
80
  return false;
76
81
  }
77
82
  if (v.startsWith !== undefined) {
78
- if (!cellVal || !normalize(String(cellVal)).startsWith(normalize(v.startsWith)))
83
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).startsWith(normalize(v.startsWith)))
79
84
  return false;
80
85
  }
81
86
  if (v.endsWith !== undefined) {
82
- if (!cellVal || !normalize(String(cellVal)).endsWith(normalize(v.endsWith)))
87
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).endsWith(normalize(v.endsWith)))
83
88
  return false;
84
89
  }
85
90
  if (v.gte !== undefined && !(Number(cellVal) >= Number(v.gte)))
@@ -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>;