@an5/adapters 0.1.3 → 0.2.1

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.
@@ -5,10 +5,73 @@ import { SheetsTableClient } from './tableClient';
5
5
  import { SheetMeta } from './types';
6
6
  import { withRetry } from './retry';
7
7
 
8
+ // ─── Fetch-based API proxy for OAuth Access Token (browser-compatible) ────────
9
+
10
+ function createFetchApi(spreadsheetId: string, accessToken: string) {
11
+ const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`;
12
+ const headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' };
13
+
14
+ async function request<T>(method: string, path: string, body?: any): Promise<T> {
15
+ const url = path.startsWith('http') ? path : `${base}${path}`;
16
+ const opts: RequestInit = { method, headers };
17
+ if (body) opts.body = JSON.stringify(body);
18
+ const res = await fetch(url, opts);
19
+ if (!res.ok) {
20
+ const text = await res.text().catch(() => '');
21
+ const err = new Error(`Google Sheets API error (${res.status}): ${text}`);
22
+ (err as any).status = res.status;
23
+ (err as any).body = text;
24
+ const retryAfter = res.headers.get('Retry-After');
25
+ if (retryAfter) (err as any).retryAfter = parseInt(retryAfter, 10);
26
+ throw err;
27
+ }
28
+ const text = await res.text();
29
+ return text ? JSON.parse(text) : (undefined as T);
30
+ }
31
+
32
+ function fmtRange(range: string): string {
33
+ return `/${encodeURIComponent(range)}`;
34
+ }
35
+
36
+ return {
37
+ spreadsheets: {
38
+ get: async (params: any) => {
39
+ const data = await request<any>('GET', `?fields=${params.fields || ''}`);
40
+ return { data };
41
+ },
42
+ batchUpdate: async (params: any) => {
43
+ const data = await request<any>('POST', ':batchUpdate', params.requestBody);
44
+ return { data };
45
+ },
46
+ values: {
47
+ get: async (params: any) => {
48
+ const data = await request<any>('GET', fmtRange(params.range));
49
+ return { data };
50
+ },
51
+ update: async (params: any) => {
52
+ const qs = `?valueInputOption=${params.valueInputOption || 'RAW'}`;
53
+ const data = await request<any>('PUT', `${fmtRange(params.range)}${qs}`, params.requestBody);
54
+ return { data };
55
+ },
56
+ append: async (params: any) => {
57
+ const qs = `?valueInputOption=${params.valueInputOption || 'RAW'}&insertDataOption=${params.insertDataOption || 'INSERT_ROWS'}`;
58
+ await request<any>('POST', `${fmtRange(params.range)}:append${qs}`, params.requestBody);
59
+ return { data: { updates: { updatedRows: (params.requestBody?.values || []).length } } };
60
+ },
61
+ clear: async (params: any) => {
62
+ await request<any>('POST', `${fmtRange(params.range)}:clear`);
63
+ return { data: {} };
64
+ },
65
+ },
66
+ },
67
+ };
68
+ }
69
+
8
70
  // ─── Adapter ──────────────────────────────────────────────────────────────────
9
71
 
10
72
  export class An5SheetsAdapter {
11
73
  private sheets: sheets_v4.Sheets | null = null;
74
+ private fetchApi: ReturnType<typeof createFetchApi> | null = null;
12
75
  config: ReturnType<typeof resolveConfig>;
13
76
  private sheetsCache: Promise<SheetMeta[]> | null = null;
14
77
 
@@ -16,7 +79,17 @@ export class An5SheetsAdapter {
16
79
  this.config = resolveConfig(config);
17
80
  }
18
81
 
19
- async getSheets(): Promise<sheets_v4.Sheets> {
82
+ private get isOAuth(): boolean {
83
+ return !!(this.config as any).accessToken;
84
+ }
85
+
86
+ async getSheets(): Promise<any> {
87
+ if (this.isOAuth) {
88
+ if (!this.fetchApi) {
89
+ this.fetchApi = createFetchApi(this.config.spreadsheetId, (this.config as any).accessToken);
90
+ }
91
+ return this.fetchApi;
92
+ }
20
93
  if (!this.sheets) {
21
94
  const auth = new google.auth.JWT({
22
95
  email: this.config.clientEmail,
@@ -38,31 +111,31 @@ export class An5SheetsAdapter {
38
111
 
39
112
  async listSheets(): Promise<string[]> {
40
113
  const api = await this.getApi();
41
- const res = await withRetry(() =>
114
+ const res: any = await withRetry(() =>
42
115
  api.spreadsheets.get({
43
116
  spreadsheetId: this.config.spreadsheetId,
44
117
  fields: 'sheets.properties.title',
45
118
  })
46
119
  );
47
- return (res.data.sheets || []).map(s => s.properties?.title || '');
120
+ return (res.data.sheets || []).map((s: any) => s.properties?.title || '');
48
121
  }
49
122
 
50
123
  async getSheetMeta(name: string): Promise<SheetMeta | null> {
51
124
  const api = await this.getApi();
52
125
  if (this.sheetsCache == null) {
53
126
  this.sheetsCache = withRetry(async () => {
54
- const res = await api.spreadsheets.get({
127
+ const res: any = await api.spreadsheets.get({
55
128
  spreadsheetId: this.config.spreadsheetId,
56
129
  fields: 'sheets.properties',
57
130
  });
58
- return (res.data.sheets || []).map(s => ({
131
+ return (res.data.sheets || []).map((s: any) => ({
59
132
  sheetId: s.properties?.sheetId ?? -1,
60
133
  title: s.properties?.title ?? '',
61
- })).filter(m => m.sheetId >= 0);
134
+ })).filter((m: SheetMeta) => m.sheetId >= 0);
62
135
  });
63
136
  }
64
137
  const all = await this.sheetsCache;
65
- return all.find(m => m.title === name) || null;
138
+ return all.find((m: SheetMeta) => m.title === name) || null;
66
139
  }
67
140
 
68
141
  async deleteSheet(name: string): Promise<void> {
@@ -86,6 +159,7 @@ export class An5SheetsAdapter {
86
159
 
87
160
  async $disconnect(): Promise<void> {
88
161
  this.sheets = null;
162
+ this.fetchApi = null;
89
163
  this.sheetsCache = null;
90
164
  }
91
165
 
@@ -126,7 +200,7 @@ export class An5SheetsAdapter {
126
200
 
127
201
  async readRange<T = any>(range: string): Promise<T[][]> {
128
202
  const api = await this.getApi();
129
- const res = await withRetry(() =>
203
+ const res: any = await withRetry(() =>
130
204
  api.spreadsheets.values.get({
131
205
  spreadsheetId: this.config.spreadsheetId,
132
206
  range,
@@ -7,11 +7,22 @@ export interface An5SheetsAdapterConfig {
7
7
  private_key: string;
8
8
  };
9
9
  sheetMapping?: Record<string, string>;
10
+ /** OAuth2 access token for browser-based authentication (e.g. from Firebase Auth Google sign-in).
11
+ * When provided, the adapter uses raw fetch() instead of googleapis JWT, making it compatible
12
+ * with browser environments. Mutually exclusive with clientEmail/privateKey/credentials. */
13
+ accessToken?: string;
10
14
  }
11
15
  export declare function normalizeKey(key: string): string;
12
16
  export declare function resolveConfig(config: An5SheetsAdapterConfig): {
17
+ spreadsheetId: string;
18
+ accessToken: string;
19
+ sheetMapping: Record<string, string> | undefined;
20
+ clientEmail?: undefined;
21
+ privateKey?: undefined;
22
+ } | {
13
23
  spreadsheetId: string;
14
24
  clientEmail: string;
15
25
  privateKey: string;
16
26
  sheetMapping: Record<string, string> | undefined;
27
+ accessToken?: undefined;
17
28
  };
@@ -12,6 +12,13 @@ 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) {
16
+ return {
17
+ spreadsheetId: config.spreadsheetId,
18
+ accessToken: config.accessToken,
19
+ sheetMapping: config.sheetMapping,
20
+ };
21
+ }
15
22
  if (config.credentials) {
16
23
  return {
17
24
  spreadsheetId: config.spreadsheetId,
@@ -6,6 +6,10 @@ export interface An5SheetsAdapterConfig {
6
6
  privateKey?: string;
7
7
  credentials?: { client_email: string; private_key: string };
8
8
  sheetMapping?: Record<string, string>;
9
+ /** OAuth2 access token for browser-based authentication (e.g. from Firebase Auth Google sign-in).
10
+ * When provided, the adapter uses raw fetch() instead of googleapis JWT, making it compatible
11
+ * with browser environments. Mutually exclusive with clientEmail/privateKey/credentials. */
12
+ accessToken?: string;
9
13
  }
10
14
 
11
15
  export function normalizeKey(key: string): string {
@@ -16,6 +20,13 @@ export function normalizeKey(key: string): string {
16
20
  }
17
21
 
18
22
  export function resolveConfig(config: An5SheetsAdapterConfig) {
23
+ if (config.accessToken) {
24
+ return {
25
+ spreadsheetId: config.spreadsheetId,
26
+ accessToken: config.accessToken,
27
+ sheetMapping: config.sheetMapping,
28
+ };
29
+ }
19
30
  if (config.credentials) {
20
31
  return {
21
32
  spreadsheetId: config.spreadsheetId,
@@ -95,7 +95,7 @@ function buildOrderBy(orderBy) {
95
95
  const parts = [];
96
96
  for (const entry of entries) {
97
97
  for (const [key, dir] of Object.entries(entry)) {
98
- parts.push(`${key} ${dir.toUpperCase()}`);
98
+ parts.push(`${key} ${String(dir || 'asc').toUpperCase()}`);
99
99
  }
100
100
  }
101
101
  return parts.join(', ');
@@ -75,7 +75,7 @@ export function buildOrderBy(orderBy: any): string {
75
75
  const parts: string[] = [];
76
76
  for (const entry of entries) {
77
77
  for (const [key, dir] of Object.entries(entry)) {
78
- parts.push(`${key} ${(dir as string).toUpperCase()}`);
78
+ parts.push(`${key} ${String(dir || 'asc').toUpperCase()}`);
79
79
  }
80
80
  }
81
81
  return parts.join(', ');
@@ -5,19 +5,32 @@ exports.BASE_DELAY = exports.MAX_RETRIES = void 0;
5
5
  exports.withRetry = withRetry;
6
6
  exports.MAX_RETRIES = 3;
7
7
  exports.BASE_DELAY = 1000;
8
+ function isRateLimitError(err) {
9
+ const code = err?.code || err?.status || err?.response?.status;
10
+ if (code === 429)
11
+ return true;
12
+ if (code === 500 || code === 503)
13
+ return true;
14
+ if (code === 403) {
15
+ const reason = err?.errors?.[0]?.reason || '';
16
+ if (reason === 'rateLimitExceeded')
17
+ return true;
18
+ if (typeof err?.body === 'string' && err.body.includes('rateLimitExceeded'))
19
+ return true;
20
+ if (typeof err?.message === 'string' && err.message.includes('rateLimitExceeded'))
21
+ return true;
22
+ }
23
+ return false;
24
+ }
8
25
  async function withRetry(fn, attempt = 1) {
9
26
  try {
10
27
  return await fn();
11
28
  }
12
29
  catch (err) {
13
- const code = err?.code || err?.status || err?.response?.status;
14
- if ((code === 429 || code === 500 || code === 503) && attempt <= exports.MAX_RETRIES) {
15
- const delay = exports.BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 500;
16
- await new Promise(r => setTimeout(r, delay));
17
- return withRetry(fn, attempt + 1);
18
- }
19
- if (code === 403 && err?.errors?.[0]?.reason === 'rateLimitExceeded' && attempt <= exports.MAX_RETRIES) {
20
- const delay = exports.BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 500;
30
+ if (isRateLimitError(err) && attempt <= exports.MAX_RETRIES) {
31
+ const delay = err.retryAfter
32
+ ? err.retryAfter * 1000
33
+ : exports.BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 500;
21
34
  await new Promise(r => setTimeout(r, delay));
22
35
  return withRetry(fn, attempt + 1);
23
36
  }
@@ -3,18 +3,27 @@
3
3
  export const MAX_RETRIES = 3;
4
4
  export const BASE_DELAY = 1000;
5
5
 
6
+ function isRateLimitError(err: any): boolean {
7
+ const code = err?.code || err?.status || err?.response?.status;
8
+ if (code === 429) return true;
9
+ if (code === 500 || code === 503) return true;
10
+ if (code === 403) {
11
+ const reason = err?.errors?.[0]?.reason || '';
12
+ if (reason === 'rateLimitExceeded') return true;
13
+ if (typeof err?.body === 'string' && err.body.includes('rateLimitExceeded')) return true;
14
+ if (typeof err?.message === 'string' && err.message.includes('rateLimitExceeded')) return true;
15
+ }
16
+ return false;
17
+ }
18
+
6
19
  export async function withRetry<T>(fn: () => Promise<T>, attempt = 1): Promise<T> {
7
20
  try {
8
21
  return await fn();
9
22
  } catch (err: any) {
10
- const code = err?.code || err?.status || err?.response?.status;
11
- if ((code === 429 || code === 500 || code === 503) && attempt <= MAX_RETRIES) {
12
- const delay = BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 500;
13
- await new Promise(r => setTimeout(r, delay));
14
- return withRetry(fn, attempt + 1);
15
- }
16
- if (code === 403 && err?.errors?.[0]?.reason === 'rateLimitExceeded' && attempt <= MAX_RETRIES) {
17
- const delay = BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 500;
23
+ if (isRateLimitError(err) && attempt <= MAX_RETRIES) {
24
+ const delay = err.retryAfter
25
+ ? err.retryAfter * 1000
26
+ : BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 500;
18
27
  await new Promise(r => setTimeout(r, delay));
19
28
  return withRetry(fn, attempt + 1);
20
29
  }
@@ -315,13 +315,14 @@ class SheetsTableClient {
315
315
  result._count = rows.length;
316
316
  if (args._sum) {
317
317
  for (const f of Object.keys(args._sum)) {
318
- result[`_sum_${f}`] = rows.reduce((s, r) => s + (Number(r[f]) || 0), 0);
318
+ const nums = rows.map((r) => Number(r[f])).filter(n => !isNaN(n));
319
+ result[`_sum_${f}`] = nums.length > 0 ? nums.reduce((s, n) => s + n, 0) : null;
319
320
  }
320
321
  }
321
322
  if (args._avg) {
322
323
  for (const f of Object.keys(args._avg)) {
323
- const sum = rows.reduce((s, r) => s + (Number(r[f]) || 0), 0);
324
- result[`_avg_${f}`] = rows.length > 0 ? sum / rows.length : 0;
324
+ const nums = rows.map((r) => Number(r[f])).filter(n => !isNaN(n));
325
+ result[`_avg_${f}`] = nums.length > 0 ? nums.reduce((s, n) => s + n, 0) / nums.length : null;
325
326
  }
326
327
  }
327
328
  if (args._min) {
@@ -43,7 +43,7 @@ export class SheetsTableClient<T = any> {
43
43
 
44
44
  private async readAllRows(): Promise<{ headers: string[]; rows: Record<string, any>[] }> {
45
45
  const api = await this.adapter.getSheets();
46
- const res = await withRetry(() =>
46
+ const res: any = await withRetry(() =>
47
47
  api.spreadsheets.values.get({
48
48
  spreadsheetId: this.adapter.config.spreadsheetId,
49
49
  range: `${this.escSheetName}!A:ZZ`,
@@ -68,7 +68,7 @@ export class SheetsTableClient<T = any> {
68
68
  const api = await this.adapter.getSheets();
69
69
  await this.ensureSheetExists();
70
70
 
71
- const res = await withRetry(() =>
71
+ const res: any = await withRetry(() =>
72
72
  api.spreadsheets.values.get({
73
73
  spreadsheetId: this.adapter.config.spreadsheetId,
74
74
  range: `${this.escSheetName}!A1:ZZ1`,
@@ -351,13 +351,14 @@ export class SheetsTableClient<T = any> {
351
351
  if (args._count !== undefined) result._count = rows.length;
352
352
  if (args._sum) {
353
353
  for (const f of Object.keys(args._sum)) {
354
- result[`_sum_${f}`] = rows.reduce((s, r: any) => s + (Number(r[f]) || 0), 0);
354
+ const nums = rows.map((r: any) => Number(r[f])).filter(n => !isNaN(n));
355
+ result[`_sum_${f}`] = nums.length > 0 ? nums.reduce((s, n) => s + n, 0) : null;
355
356
  }
356
357
  }
357
358
  if (args._avg) {
358
359
  for (const f of Object.keys(args._avg)) {
359
- const sum = rows.reduce((s, r: any) => s + (Number(r[f]) || 0), 0);
360
- result[`_avg_${f}`] = rows.length > 0 ? sum / rows.length : 0;
360
+ const nums = rows.map((r: any) => Number(r[f])).filter(n => !isNaN(n));
361
+ result[`_avg_${f}`] = nums.length > 0 ? nums.reduce((s, n) => s + n, 0) / nums.length : null;
361
362
  }
362
363
  }
363
364
  if (args._min) {