@yuuko1410/feishu-bitable 0.0.2 → 0.0.3

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/lib/index.js CHANGED
@@ -96,7 +96,8 @@ function normalizeRecordFields(fields) {
96
96
  }
97
97
  function splitUpdateRecord(record) {
98
98
  const { record_id: recordId, ...fields } = record;
99
- return { recordId, fields };
99
+ const normalizedFields = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
100
+ return { recordId, fields: normalizedFields };
100
101
  }
101
102
  async function toBuffer(file) {
102
103
  if (typeof file === "string") {
@@ -149,18 +150,44 @@ function isFileLike(value) {
149
150
  }
150
151
 
151
152
  // src/client.ts
153
+ var DEFAULT_LOGGER = {
154
+ info(message, meta) {
155
+ if (meta) {
156
+ console.info(`[feishu-bitable] ${message}`, meta);
157
+ return;
158
+ }
159
+ console.info(`[feishu-bitable] ${message}`);
160
+ },
161
+ warn(message, meta) {
162
+ if (meta) {
163
+ console.warn(`[feishu-bitable] ${message}`, meta);
164
+ return;
165
+ }
166
+ console.warn(`[feishu-bitable] ${message}`);
167
+ },
168
+ error(message, meta) {
169
+ if (meta) {
170
+ console.error(`[feishu-bitable] ${message}`, meta);
171
+ return;
172
+ }
173
+ console.error(`[feishu-bitable] ${message}`);
174
+ }
175
+ };
176
+
152
177
  class Bitable {
153
178
  client;
154
179
  defaultAppToken;
155
180
  maxRetries;
156
181
  retryDelayMs;
157
182
  defaultConcurrency;
183
+ logger;
158
184
  constructor(optionsOrToken, appId, appSecret) {
159
185
  const options = this.resolveConstructorOptions(optionsOrToken, appId, appSecret);
160
186
  this.defaultAppToken = options.defaultAppToken;
161
187
  this.maxRetries = Math.max(1, options.maxRetries ?? 5);
162
188
  this.retryDelayMs = Math.max(100, options.retryDelayMs ?? 1000);
163
189
  this.defaultConcurrency = Math.max(1, options.defaultConcurrency ?? 1);
190
+ this.logger = options.logger === null ? null : options.logger ?? DEFAULT_LOGGER;
164
191
  this.client = options.sdkClient ?? new lark.Client({
165
192
  appId: options.appId,
166
193
  appSecret: options.appSecret,
@@ -176,159 +203,251 @@ class Bitable {
176
203
  });
177
204
  }
178
205
  async fetchAllRecords(tableId, options = {}, appToken) {
179
- const token = this.resolveAppToken(appToken);
180
- const pageSize = Math.max(1, Math.min(options.pageSize ?? FEISHU_BATCH_LIMIT, FEISHU_BATCH_LIMIT));
181
- const iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator({
182
- path: {
183
- app_token: token,
184
- table_id: tableId
185
- },
186
- params: {
187
- page_size: pageSize,
188
- user_id_type: "open_id"
189
- },
190
- data: {
191
- view_id: options.viewId,
192
- field_names: options.fieldNames,
193
- filter: options.filter,
194
- sort: options.sort,
195
- automatic_fields: options.automaticFields
206
+ return this.runLogged("fetchAllRecords", {
207
+ tableId,
208
+ pageSize: options.pageSize ?? FEISHU_BATCH_LIMIT,
209
+ hasViewId: Boolean(options.viewId),
210
+ fieldCount: options.fieldNames?.length ?? 0
211
+ }, async () => {
212
+ const token = this.resolveAppToken(appToken);
213
+ const pageSize = Math.max(1, Math.min(options.pageSize ?? FEISHU_BATCH_LIMIT, FEISHU_BATCH_LIMIT));
214
+ const iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator({
215
+ path: {
216
+ app_token: token,
217
+ table_id: tableId
218
+ },
219
+ params: {
220
+ page_size: pageSize,
221
+ user_id_type: "open_id"
222
+ },
223
+ data: {
224
+ view_id: options.viewId,
225
+ field_names: options.fieldNames,
226
+ filter: options.filter,
227
+ sort: options.sort,
228
+ automatic_fields: options.automaticFields
229
+ }
230
+ });
231
+ const allRecords = [];
232
+ for await (const page of iterator) {
233
+ const items = page?.items ?? [];
234
+ allRecords.push(...items.map((item) => normalizeRecord(item.record_id, item.fields, options.normalizeFields !== false)));
196
235
  }
236
+ this.logInfo("fetchAllRecords completed", {
237
+ tableId,
238
+ recordCount: allRecords.length
239
+ });
240
+ return allRecords;
197
241
  });
198
- const allRecords = [];
199
- for await (const page of iterator) {
200
- const items = page?.items ?? [];
201
- allRecords.push(...items.map((item) => normalizeRecord(item.record_id, item.fields, options.normalizeFields !== false)));
202
- }
203
- return allRecords;
204
242
  }
205
243
  async insertList(tableId, records, options = {}) {
206
- if (records.length === 0) {
207
- return [];
208
- }
209
- const token = this.resolveAppToken(options.appToken);
210
- const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
211
- return runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate({
212
- path: { app_token: token, table_id: tableId },
213
- data: {
214
- records: chunk.map((fields) => ({ fields }))
244
+ return this.runLogged("insertList", {
245
+ tableId,
246
+ recordCount: records.length,
247
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
248
+ concurrency: options.concurrency ?? this.defaultConcurrency
249
+ }, async () => {
250
+ if (records.length === 0) {
251
+ return [];
215
252
  }
216
- }), "insert records")));
253
+ const token = this.resolveAppToken(options.appToken);
254
+ const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
255
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate({
256
+ path: { app_token: token, table_id: tableId },
257
+ data: {
258
+ records: chunk.map((fields) => ({ fields }))
259
+ }
260
+ }), "insert records")));
261
+ this.logInfo("insertList completed", {
262
+ tableId,
263
+ chunkCount: chunks.length
264
+ });
265
+ return responses;
266
+ });
217
267
  }
218
268
  async batchUpdateRecords(payload) {
219
- return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
269
+ return this.runLogged("batchUpdateRecords", {
270
+ tableId: payload.path.table_id,
271
+ recordCount: payload.data.records.length
272
+ }, async () => this.executeBatchUpdateRecords(payload));
220
273
  }
221
274
  async updateRecords(tableId, records, options = {}) {
222
- if (records.length === 0) {
223
- return [];
224
- }
225
- const token = this.resolveAppToken(options.appToken);
226
- const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
227
- return runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => {
228
- const payload = {
229
- path: {
230
- app_token: token,
231
- table_id: tableId
232
- },
233
- data: {
234
- records: chunk.map((record) => {
235
- const { recordId, fields } = splitUpdateRecord(record);
236
- return {
237
- record_id: recordId,
238
- fields
239
- };
240
- })
275
+ return this.runLogged("updateRecords", {
276
+ tableId,
277
+ inputRecordCount: records.length,
278
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
279
+ concurrency: options.concurrency ?? this.defaultConcurrency
280
+ }, async () => {
281
+ if (records.length === 0) {
282
+ return [];
283
+ }
284
+ const token = this.resolveAppToken(options.appToken);
285
+ const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
286
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk, index) => {
287
+ const batchRecords = chunk.map((record) => {
288
+ const { recordId, fields } = splitUpdateRecord(record);
289
+ if (Object.keys(fields).length === 0) {
290
+ return null;
291
+ }
292
+ return {
293
+ record_id: recordId,
294
+ fields
295
+ };
296
+ }).filter((record) => Boolean(record));
297
+ if (batchRecords.length === 0) {
298
+ this.logInfo("updateRecords skipped empty chunk", {
299
+ tableId,
300
+ chunkIndex: index,
301
+ inputChunkSize: chunk.length
302
+ });
303
+ return {
304
+ code: 0,
305
+ msg: "ok",
306
+ data: {
307
+ records: []
308
+ }
309
+ };
241
310
  }
242
- };
243
- if (options.userIdType || options.ignoreConsistencyCheck !== undefined) {
244
- payload.params = {
245
- user_id_type: options.userIdType,
246
- ignore_consistency_check: options.ignoreConsistencyCheck
311
+ const payload = {
312
+ path: {
313
+ app_token: token,
314
+ table_id: tableId
315
+ },
316
+ data: {
317
+ records: batchRecords
318
+ }
247
319
  };
248
- }
249
- return this.batchUpdateRecords(payload);
320
+ if (options.userIdType || options.ignoreConsistencyCheck !== undefined) {
321
+ payload.params = {
322
+ user_id_type: options.userIdType,
323
+ ignore_consistency_check: options.ignoreConsistencyCheck
324
+ };
325
+ }
326
+ return this.executeBatchUpdateRecords(payload);
327
+ });
328
+ this.logInfo("updateRecords completed", {
329
+ tableId,
330
+ chunkCount: chunks.length
331
+ });
332
+ return responses;
250
333
  });
251
334
  }
252
335
  async deleteList(tableId, recordIds, options = {}) {
253
- if (recordIds.length === 0) {
254
- return [];
255
- }
256
- const token = this.resolveAppToken(options.appToken);
257
- const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
258
- return runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete({
259
- path: { app_token: token, table_id: tableId },
260
- data: {
261
- records: chunk
336
+ return this.runLogged("deleteList", {
337
+ tableId,
338
+ recordCount: recordIds.length,
339
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
340
+ concurrency: options.concurrency ?? this.defaultConcurrency
341
+ }, async () => {
342
+ if (recordIds.length === 0) {
343
+ return [];
262
344
  }
263
- }), "delete records")));
345
+ const token = this.resolveAppToken(options.appToken);
346
+ const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
347
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete({
348
+ path: { app_token: token, table_id: tableId },
349
+ data: {
350
+ records: chunk
351
+ }
352
+ }), "delete records")));
353
+ this.logInfo("deleteList completed", {
354
+ tableId,
355
+ chunkCount: chunks.length
356
+ });
357
+ return responses;
358
+ });
264
359
  }
265
360
  async uploadFile(options) {
266
- const buffer = await toBuffer(options.file);
267
- const fileName = options.fileName ?? inferFileName(options.file);
268
- if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
269
- return await this.withRetry("upload file", async () => this.client.drive.v1.media.uploadAll({
361
+ return this.runLogged("uploadFile", {
362
+ parentType: options.parentType,
363
+ parentNode: options.parentNode,
364
+ hasExtra: Boolean(options.extra)
365
+ }, async () => {
366
+ const buffer = await toBuffer(options.file);
367
+ const fileName = options.fileName ?? inferFileName(options.file);
368
+ this.logInfo("uploadFile resolved input", {
369
+ fileName,
370
+ size: buffer.byteLength,
371
+ mode: buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT ? "simple" : "multipart"
372
+ });
373
+ if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
374
+ return await this.withRetry("upload file", async () => this.client.drive.v1.media.uploadAll({
375
+ data: {
376
+ file_name: fileName,
377
+ parent_type: options.parentType,
378
+ parent_node: options.parentNode,
379
+ size: buffer.byteLength,
380
+ extra: options.extra,
381
+ file: buffer
382
+ }
383
+ })) ?? {};
384
+ }
385
+ const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => this.client.drive.v1.media.uploadPrepare({
270
386
  data: {
271
387
  file_name: fileName,
272
388
  parent_type: options.parentType,
273
389
  parent_node: options.parentNode,
274
- size: buffer.byteLength,
275
- extra: options.extra,
276
- file: buffer
390
+ size: buffer.byteLength
277
391
  }
278
- })) ?? {};
279
- }
280
- const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => this.client.drive.v1.media.uploadPrepare({
281
- data: {
282
- file_name: fileName,
283
- parent_type: options.parentType,
284
- parent_node: options.parentNode,
285
- size: buffer.byteLength
392
+ })), "prepare multipart upload");
393
+ const uploadId = prepare.data?.upload_id;
394
+ const blockSize = prepare.data?.block_size;
395
+ const blockNum = prepare.data?.block_num;
396
+ if (!uploadId || !blockSize || !blockNum) {
397
+ throw new FeishuBitableError("prepare multipart upload failed: missing upload metadata", {
398
+ details: prepare
399
+ });
286
400
  }
287
- })), "prepare multipart upload");
288
- const uploadId = prepare.data?.upload_id;
289
- const blockSize = prepare.data?.block_size;
290
- const blockNum = prepare.data?.block_num;
291
- if (!uploadId || !blockSize || !blockNum) {
292
- throw new FeishuBitableError("prepare multipart upload failed: missing upload metadata", {
293
- details: prepare
294
- });
295
- }
296
- for (let index = 0;index < blockNum; index++) {
297
- const start = index * blockSize;
298
- const end = Math.min(start + blockSize, buffer.byteLength);
299
- const chunk = buffer.subarray(start, end);
300
- await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => this.client.drive.v1.media.uploadPart({
401
+ for (let index = 0;index < blockNum; index++) {
402
+ const start = index * blockSize;
403
+ const end = Math.min(start + blockSize, buffer.byteLength);
404
+ const chunk = buffer.subarray(start, end);
405
+ await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => this.client.drive.v1.media.uploadPart({
406
+ data: {
407
+ upload_id: uploadId,
408
+ seq: index,
409
+ size: chunk.byteLength,
410
+ file: chunk
411
+ }
412
+ }));
413
+ }
414
+ const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => this.client.drive.v1.media.uploadFinish({
301
415
  data: {
302
416
  upload_id: uploadId,
303
- seq: index,
304
- size: chunk.byteLength,
305
- file: chunk
417
+ block_num: blockNum
306
418
  }
307
- }));
308
- }
309
- const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => this.client.drive.v1.media.uploadFinish({
310
- data: {
311
- upload_id: uploadId,
312
- block_num: blockNum
313
- }
314
- })), "finish multipart upload");
315
- return {
316
- file_token: finish.data?.file_token
317
- };
419
+ })), "finish multipart upload");
420
+ return {
421
+ file_token: finish.data?.file_token
422
+ };
423
+ });
318
424
  }
319
425
  async downloadFile(fileToken, extra) {
320
- const response = await this.withRetry("download file", async () => this.client.drive.v1.media.download({
321
- path: {
322
- file_token: fileToken
323
- },
324
- params: {
325
- extra
326
- }
327
- }));
328
- return readableToBuffer(response.getReadableStream());
426
+ return this.runLogged("downloadFile", {
427
+ fileToken,
428
+ hasExtra: Boolean(extra)
429
+ }, async () => {
430
+ const response = await this.withRetry("download file", async () => this.client.drive.v1.media.download({
431
+ path: {
432
+ file_token: fileToken
433
+ },
434
+ params: {
435
+ extra
436
+ }
437
+ }));
438
+ const buffer = await readableToBuffer(response.getReadableStream());
439
+ this.logInfo("downloadFile completed", {
440
+ fileToken,
441
+ size: buffer.byteLength
442
+ });
443
+ return buffer;
444
+ });
329
445
  }
330
446
  async downLoadFile(fileToken, extra) {
331
- return this.downloadFile(fileToken, extra);
447
+ return this.runLogged("downLoadFile", {
448
+ fileToken,
449
+ hasExtra: Boolean(extra)
450
+ }, async () => this.downloadFile(fileToken, extra));
332
451
  }
333
452
  resolveConstructorOptions(optionsOrToken, appIdArg, appSecretArg) {
334
453
  const objectMode = typeof optionsOrToken === "object" && optionsOrToken !== null && !Array.isArray(optionsOrToken);
@@ -356,6 +475,9 @@ class Bitable {
356
475
  }
357
476
  return token;
358
477
  }
478
+ async executeBatchUpdateRecords(payload) {
479
+ return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
480
+ }
359
481
  async withRetry(label, task) {
360
482
  let lastError;
361
483
  for (let attempt = 1;attempt <= this.maxRetries; attempt++) {
@@ -363,16 +485,70 @@ class Bitable {
363
485
  return await task();
364
486
  } catch (error) {
365
487
  lastError = error;
488
+ const meta = {
489
+ label,
490
+ attempt,
491
+ maxRetries: this.maxRetries,
492
+ retryDelayMs: this.retryDelayMs,
493
+ ...this.getErrorMeta(error)
494
+ };
366
495
  if (attempt === this.maxRetries) {
496
+ this.logError("request exhausted retries", meta);
367
497
  break;
368
498
  }
499
+ this.logWarn("request attempt failed, retrying", meta);
369
500
  await sleep(this.retryDelayMs * attempt);
370
501
  }
371
502
  }
372
- throw new FeishuBitableError(`${label} failed after ${this.maxRetries} attempts`, {
373
- cause: lastError
503
+ const causeMessage = lastError instanceof Error ? `: ${lastError.message}` : "";
504
+ throw new FeishuBitableError(`${label} failed after ${this.maxRetries} attempts${causeMessage}`, {
505
+ cause: lastError,
506
+ code: lastError instanceof FeishuBitableError ? lastError.code : undefined,
507
+ details: lastError instanceof FeishuBitableError ? lastError.details : undefined
374
508
  });
375
509
  }
510
+ async runLogged(action, meta, task) {
511
+ this.logInfo(`${action} started`, meta);
512
+ try {
513
+ const result = await task();
514
+ this.logInfo(`${action} succeeded`, meta);
515
+ return result;
516
+ } catch (error) {
517
+ this.logError(`${action} failed`, {
518
+ ...meta,
519
+ ...this.getErrorMeta(error)
520
+ });
521
+ throw error;
522
+ }
523
+ }
524
+ logInfo(message, meta) {
525
+ this.logger?.info(message, meta);
526
+ }
527
+ logWarn(message, meta) {
528
+ this.logger?.warn?.(message, meta);
529
+ }
530
+ logError(message, meta) {
531
+ this.logger?.error?.(message, meta);
532
+ }
533
+ getErrorMeta(error) {
534
+ if (error instanceof FeishuBitableError) {
535
+ return {
536
+ errorName: error.name,
537
+ errorMessage: error.message,
538
+ errorCode: error.code,
539
+ errorDetails: error.details
540
+ };
541
+ }
542
+ if (error instanceof Error) {
543
+ return {
544
+ errorName: error.name,
545
+ errorMessage: error.message
546
+ };
547
+ }
548
+ return {
549
+ errorMessage: String(error)
550
+ };
551
+ }
376
552
  }
377
553
 
378
554
  // src/index.ts
package/lib/types.d.ts CHANGED
@@ -62,6 +62,11 @@ export type BitableFilterGroup = {
62
62
  };
63
63
  export type MediaParentType = "doc_image" | "docx_image" | "sheet_image" | "doc_file" | "docx_file" | "sheet_file" | "vc_virtual_background" | "bitable_image" | "bitable_file" | "moments" | "ccm_import_open" | "calendar" | "base_global" | "lark_ai_media_analysis";
64
64
  export type UploadableFile = string | Buffer | Uint8Array | ArrayBuffer | Blob | BunFileLike;
65
+ export type BitableLogger = {
66
+ info: (message: string, meta?: Record<string, unknown>) => void;
67
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
68
+ error?: (message: string, meta?: Record<string, unknown>) => void;
69
+ };
65
70
  export type BunFileLike = {
66
71
  arrayBuffer(): Promise<ArrayBuffer>;
67
72
  name?: string;
@@ -76,6 +81,7 @@ export interface BitableConstructorOptions {
76
81
  retryDelayMs?: number;
77
82
  defaultConcurrency?: number;
78
83
  sdkClient?: lark.Client;
84
+ logger?: BitableLogger | null;
79
85
  }
80
86
  export interface FetchAllRecordsOptions {
81
87
  viewId?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuuko1410/feishu-bitable",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "基于 Bun + TypeScript + 飞书官方 SDK 的多维表格操作库",
5
5
  "type": "module",
6
6
  "main": "./lib/index.cjs",