@yuuko1410/feishu-bitable 0.0.2 → 0.0.4

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.cjs CHANGED
@@ -170,7 +170,8 @@ function normalizeRecordFields(fields) {
170
170
  }
171
171
  function splitUpdateRecord(record) {
172
172
  const { record_id: recordId, ...fields } = record;
173
- return { recordId, fields };
173
+ const normalizedFields = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
174
+ return { recordId, fields: normalizedFields };
174
175
  }
175
176
  async function toBuffer(file) {
176
177
  if (typeof file === "string") {
@@ -223,18 +224,44 @@ function isFileLike(value) {
223
224
  }
224
225
 
225
226
  // src/client.ts
227
+ var DEFAULT_LOGGER = {
228
+ info(message, meta) {
229
+ if (meta) {
230
+ console.info(`[feishu-bitable] ${message}`, meta);
231
+ return;
232
+ }
233
+ console.info(`[feishu-bitable] ${message}`);
234
+ },
235
+ warn(message, meta) {
236
+ if (meta) {
237
+ console.warn(`[feishu-bitable] ${message}`, meta);
238
+ return;
239
+ }
240
+ console.warn(`[feishu-bitable] ${message}`);
241
+ },
242
+ error(message, meta) {
243
+ if (meta) {
244
+ console.error(`[feishu-bitable] ${message}`, meta);
245
+ return;
246
+ }
247
+ console.error(`[feishu-bitable] ${message}`);
248
+ }
249
+ };
250
+
226
251
  class Bitable {
227
252
  client;
228
253
  defaultAppToken;
229
254
  maxRetries;
230
255
  retryDelayMs;
231
256
  defaultConcurrency;
257
+ logger;
232
258
  constructor(optionsOrToken, appId, appSecret) {
233
259
  const options = this.resolveConstructorOptions(optionsOrToken, appId, appSecret);
234
260
  this.defaultAppToken = options.defaultAppToken;
235
261
  this.maxRetries = Math.max(1, options.maxRetries ?? 5);
236
262
  this.retryDelayMs = Math.max(100, options.retryDelayMs ?? 1000);
237
263
  this.defaultConcurrency = Math.max(1, options.defaultConcurrency ?? 1);
264
+ this.logger = options.logger === null ? null : options.logger ?? DEFAULT_LOGGER;
238
265
  this.client = options.sdkClient ?? new lark.Client({
239
266
  appId: options.appId,
240
267
  appSecret: options.appSecret,
@@ -250,159 +277,295 @@ class Bitable {
250
277
  });
251
278
  }
252
279
  async fetchAllRecords(tableId, options = {}, appToken) {
253
- const token = this.resolveAppToken(appToken);
254
- const pageSize = Math.max(1, Math.min(options.pageSize ?? FEISHU_BATCH_LIMIT, FEISHU_BATCH_LIMIT));
255
- const iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator({
256
- path: {
257
- app_token: token,
258
- table_id: tableId
259
- },
260
- params: {
261
- page_size: pageSize,
262
- user_id_type: "open_id"
263
- },
264
- data: {
265
- view_id: options.viewId,
266
- field_names: options.fieldNames,
267
- filter: options.filter,
268
- sort: options.sort,
269
- automatic_fields: options.automaticFields
280
+ return this.runLogged("fetchAllRecords", {
281
+ tableId,
282
+ pageSize: options.pageSize ?? FEISHU_BATCH_LIMIT,
283
+ hasViewId: Boolean(options.viewId),
284
+ fieldCount: options.fieldNames?.length ?? 0
285
+ }, async () => {
286
+ const token = this.resolveAppToken(appToken);
287
+ const pageSize = Math.max(1, Math.min(options.pageSize ?? FEISHU_BATCH_LIMIT, FEISHU_BATCH_LIMIT));
288
+ const request = {
289
+ path: {
290
+ app_token: token,
291
+ table_id: tableId
292
+ },
293
+ params: {
294
+ page_size: pageSize,
295
+ user_id_type: "open_id"
296
+ },
297
+ data: {
298
+ view_id: options.viewId,
299
+ field_names: options.fieldNames,
300
+ filter: options.filter,
301
+ sort: options.sort,
302
+ automatic_fields: options.automaticFields
303
+ }
304
+ };
305
+ const iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator(this.logRequest("fetchAllRecords request", request));
306
+ const allRecords = [];
307
+ for await (const page of iterator) {
308
+ const items = page?.items ?? [];
309
+ allRecords.push(...items.map((item) => normalizeRecord(item.record_id, item.fields, options.normalizeFields !== false)));
270
310
  }
311
+ this.logInfo("fetchAllRecords completed", {
312
+ tableId,
313
+ recordCount: allRecords.length
314
+ });
315
+ return allRecords;
271
316
  });
272
- const allRecords = [];
273
- for await (const page of iterator) {
274
- const items = page?.items ?? [];
275
- allRecords.push(...items.map((item) => normalizeRecord(item.record_id, item.fields, options.normalizeFields !== false)));
276
- }
277
- return allRecords;
278
317
  }
279
318
  async insertList(tableId, records, options = {}) {
280
- if (records.length === 0) {
281
- return [];
282
- }
283
- const token = this.resolveAppToken(options.appToken);
284
- const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
285
- return runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate({
286
- path: { app_token: token, table_id: tableId },
287
- data: {
288
- records: chunk.map((fields) => ({ fields }))
319
+ return this.runLogged("insertList", {
320
+ tableId,
321
+ recordCount: records.length,
322
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
323
+ concurrency: options.concurrency ?? this.defaultConcurrency
324
+ }, async () => {
325
+ if (records.length === 0) {
326
+ return [];
289
327
  }
290
- }), "insert records")));
328
+ const token = this.resolveAppToken(options.appToken);
329
+ const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
330
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => {
331
+ const request = {
332
+ path: { app_token: token, table_id: tableId },
333
+ data: {
334
+ records: chunk.map((fields) => ({ fields }))
335
+ }
336
+ };
337
+ return assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate(this.logRequest("insertList request", request)), "insert records");
338
+ }));
339
+ this.logInfo("insertList completed", {
340
+ tableId,
341
+ chunkCount: chunks.length
342
+ });
343
+ return responses;
344
+ });
291
345
  }
292
346
  async batchUpdateRecords(payload) {
293
- return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
347
+ return this.runLogged("batchUpdateRecords", {
348
+ tableId: payload.path.table_id,
349
+ recordCount: payload.data.records.length
350
+ }, async () => this.executeBatchUpdateRecords(payload));
294
351
  }
295
352
  async updateRecords(tableId, records, options = {}) {
296
- if (records.length === 0) {
297
- return [];
298
- }
299
- const token = this.resolveAppToken(options.appToken);
300
- const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
301
- return runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => {
302
- const payload = {
303
- path: {
304
- app_token: token,
305
- table_id: tableId
306
- },
307
- data: {
308
- records: chunk.map((record) => {
309
- const { recordId, fields } = splitUpdateRecord(record);
310
- return {
311
- record_id: recordId,
312
- fields
313
- };
314
- })
353
+ return this.runLogged("updateRecords", {
354
+ tableId,
355
+ inputRecordCount: records.length,
356
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
357
+ concurrency: options.concurrency ?? this.defaultConcurrency
358
+ }, async () => {
359
+ if (records.length === 0) {
360
+ return [];
361
+ }
362
+ const token = this.resolveAppToken(options.appToken);
363
+ const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
364
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk, index) => {
365
+ const batchRecords = chunk.map((record, recordIndex) => {
366
+ const { recordId, fields } = splitUpdateRecord(record);
367
+ if (!recordId || !recordId.trim()) {
368
+ throw new FeishuBitableError(`updateRecords failed: record_id is required for chunk ${index}, item ${recordIndex}`, {
369
+ details: {
370
+ tableId,
371
+ chunkIndex: index,
372
+ recordIndex,
373
+ record
374
+ }
375
+ });
376
+ }
377
+ if (Object.keys(fields).length === 0) {
378
+ return null;
379
+ }
380
+ return {
381
+ record_id: recordId,
382
+ fields
383
+ };
384
+ }).filter((record) => Boolean(record));
385
+ if (batchRecords.length === 0) {
386
+ this.logInfo("updateRecords skipped empty chunk", {
387
+ tableId,
388
+ chunkIndex: index,
389
+ inputChunkSize: chunk.length
390
+ });
391
+ return {
392
+ code: 0,
393
+ msg: "ok",
394
+ data: {
395
+ records: []
396
+ }
397
+ };
315
398
  }
316
- };
317
- if (options.userIdType || options.ignoreConsistencyCheck !== undefined) {
318
- payload.params = {
319
- user_id_type: options.userIdType,
320
- ignore_consistency_check: options.ignoreConsistencyCheck
399
+ const payload = {
400
+ path: {
401
+ app_token: token,
402
+ table_id: tableId
403
+ },
404
+ data: {
405
+ records: batchRecords
406
+ }
321
407
  };
322
- }
323
- return this.batchUpdateRecords(payload);
408
+ if (options.userIdType || options.ignoreConsistencyCheck !== undefined) {
409
+ payload.params = {
410
+ user_id_type: options.userIdType,
411
+ ignore_consistency_check: options.ignoreConsistencyCheck
412
+ };
413
+ }
414
+ return this.executeBatchUpdateRecords(payload);
415
+ });
416
+ this.logInfo("updateRecords completed", {
417
+ tableId,
418
+ chunkCount: chunks.length
419
+ });
420
+ return responses;
324
421
  });
325
422
  }
326
423
  async deleteList(tableId, recordIds, options = {}) {
327
- if (recordIds.length === 0) {
328
- return [];
329
- }
330
- const token = this.resolveAppToken(options.appToken);
331
- const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
332
- return runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete({
333
- path: { app_token: token, table_id: tableId },
334
- data: {
335
- records: chunk
424
+ return this.runLogged("deleteList", {
425
+ tableId,
426
+ recordCount: recordIds.length,
427
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
428
+ concurrency: options.concurrency ?? this.defaultConcurrency
429
+ }, async () => {
430
+ if (recordIds.length === 0) {
431
+ return [];
336
432
  }
337
- }), "delete records")));
433
+ const token = this.resolveAppToken(options.appToken);
434
+ const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
435
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => {
436
+ const request = {
437
+ path: { app_token: token, table_id: tableId },
438
+ data: {
439
+ records: chunk
440
+ }
441
+ };
442
+ return assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete(this.logRequest("deleteList request", request)), "delete records");
443
+ }));
444
+ this.logInfo("deleteList completed", {
445
+ tableId,
446
+ chunkCount: chunks.length
447
+ });
448
+ return responses;
449
+ });
338
450
  }
339
451
  async uploadFile(options) {
340
- const buffer = await toBuffer(options.file);
341
- const fileName = options.fileName ?? inferFileName(options.file);
342
- if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
343
- return await this.withRetry("upload file", async () => this.client.drive.v1.media.uploadAll({
344
- data: {
345
- file_name: fileName,
346
- parent_type: options.parentType,
347
- parent_node: options.parentNode,
348
- size: buffer.byteLength,
349
- extra: options.extra,
350
- file: buffer
351
- }
352
- })) ?? {};
353
- }
354
- const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => this.client.drive.v1.media.uploadPrepare({
355
- data: {
356
- file_name: fileName,
357
- parent_type: options.parentType,
358
- parent_node: options.parentNode,
359
- size: buffer.byteLength
360
- }
361
- })), "prepare multipart upload");
362
- const uploadId = prepare.data?.upload_id;
363
- const blockSize = prepare.data?.block_size;
364
- const blockNum = prepare.data?.block_num;
365
- if (!uploadId || !blockSize || !blockNum) {
366
- throw new FeishuBitableError("prepare multipart upload failed: missing upload metadata", {
367
- details: prepare
452
+ return this.runLogged("uploadFile", {
453
+ parentType: options.parentType,
454
+ parentNode: options.parentNode,
455
+ hasExtra: Boolean(options.extra)
456
+ }, async () => {
457
+ const buffer = await toBuffer(options.file);
458
+ const fileName = options.fileName ?? inferFileName(options.file);
459
+ this.logInfo("uploadFile resolved input", {
460
+ fileName,
461
+ size: buffer.byteLength,
462
+ mode: buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT ? "simple" : "multipart"
368
463
  });
369
- }
370
- for (let index = 0;index < blockNum; index++) {
371
- const start = index * blockSize;
372
- const end = Math.min(start + blockSize, buffer.byteLength);
373
- const chunk = buffer.subarray(start, end);
374
- await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => this.client.drive.v1.media.uploadPart({
375
- data: {
376
- upload_id: uploadId,
377
- seq: index,
378
- size: chunk.byteLength,
379
- file: chunk
380
- }
381
- }));
382
- }
383
- const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => this.client.drive.v1.media.uploadFinish({
384
- data: {
385
- upload_id: uploadId,
386
- block_num: blockNum
464
+ if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
465
+ return await this.withRetry("upload file", async () => {
466
+ const request = {
467
+ data: {
468
+ file_name: fileName,
469
+ parent_type: options.parentType,
470
+ parent_node: options.parentNode,
471
+ size: buffer.byteLength,
472
+ extra: options.extra,
473
+ file: buffer
474
+ }
475
+ };
476
+ this.logRequest("uploadFile request", {
477
+ data: {
478
+ ...request.data,
479
+ file: `[Buffer ${buffer.byteLength} bytes]`
480
+ }
481
+ });
482
+ return this.client.drive.v1.media.uploadAll(request);
483
+ }) ?? {};
387
484
  }
388
- })), "finish multipart upload");
389
- return {
390
- file_token: finish.data?.file_token
391
- };
485
+ const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => {
486
+ const request = {
487
+ data: {
488
+ file_name: fileName,
489
+ parent_type: options.parentType,
490
+ parent_node: options.parentNode,
491
+ size: buffer.byteLength
492
+ }
493
+ };
494
+ return this.client.drive.v1.media.uploadPrepare(this.logRequest("uploadPrepare request", request));
495
+ }), "prepare multipart upload");
496
+ const uploadId = prepare.data?.upload_id;
497
+ const blockSize = prepare.data?.block_size;
498
+ const blockNum = prepare.data?.block_num;
499
+ if (!uploadId || !blockSize || !blockNum) {
500
+ throw new FeishuBitableError("prepare multipart upload failed: missing upload metadata", {
501
+ details: prepare
502
+ });
503
+ }
504
+ for (let index = 0;index < blockNum; index++) {
505
+ const start = index * blockSize;
506
+ const end = Math.min(start + blockSize, buffer.byteLength);
507
+ const chunk = buffer.subarray(start, end);
508
+ await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => {
509
+ const request = {
510
+ data: {
511
+ upload_id: uploadId,
512
+ seq: index,
513
+ size: chunk.byteLength,
514
+ file: chunk
515
+ }
516
+ };
517
+ this.logRequest("uploadPart request", {
518
+ data: {
519
+ ...request.data,
520
+ file: `[Buffer ${chunk.byteLength} bytes]`
521
+ }
522
+ });
523
+ return this.client.drive.v1.media.uploadPart(request);
524
+ });
525
+ }
526
+ const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => {
527
+ const request = {
528
+ data: {
529
+ upload_id: uploadId,
530
+ block_num: blockNum
531
+ }
532
+ };
533
+ return this.client.drive.v1.media.uploadFinish(this.logRequest("uploadFinish request", request));
534
+ }), "finish multipart upload");
535
+ return {
536
+ file_token: finish.data?.file_token
537
+ };
538
+ });
392
539
  }
393
540
  async downloadFile(fileToken, extra) {
394
- const response = await this.withRetry("download file", async () => this.client.drive.v1.media.download({
395
- path: {
396
- file_token: fileToken
397
- },
398
- params: {
399
- extra
400
- }
401
- }));
402
- return readableToBuffer(response.getReadableStream());
541
+ return this.runLogged("downloadFile", {
542
+ fileToken,
543
+ hasExtra: Boolean(extra)
544
+ }, async () => {
545
+ const response = await this.withRetry("download file", async () => {
546
+ const request = {
547
+ path: {
548
+ file_token: fileToken
549
+ },
550
+ params: {
551
+ extra
552
+ }
553
+ };
554
+ return this.client.drive.v1.media.download(this.logRequest("downloadFile request", request));
555
+ });
556
+ const buffer = await readableToBuffer(response.getReadableStream());
557
+ this.logInfo("downloadFile completed", {
558
+ fileToken,
559
+ size: buffer.byteLength
560
+ });
561
+ return buffer;
562
+ });
403
563
  }
404
564
  async downLoadFile(fileToken, extra) {
405
- return this.downloadFile(fileToken, extra);
565
+ return this.runLogged("downLoadFile", {
566
+ fileToken,
567
+ hasExtra: Boolean(extra)
568
+ }, async () => this.downloadFile(fileToken, extra));
406
569
  }
407
570
  resolveConstructorOptions(optionsOrToken, appIdArg, appSecretArg) {
408
571
  const objectMode = typeof optionsOrToken === "object" && optionsOrToken !== null && !Array.isArray(optionsOrToken);
@@ -430,6 +593,9 @@ class Bitable {
430
593
  }
431
594
  return token;
432
595
  }
596
+ async executeBatchUpdateRecords(payload) {
597
+ return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(this.logRequest("batchUpdateRecords request", payload)), "batch update records"));
598
+ }
433
599
  async withRetry(label, task) {
434
600
  let lastError;
435
601
  for (let attempt = 1;attempt <= this.maxRetries; attempt++) {
@@ -437,15 +603,75 @@ class Bitable {
437
603
  return await task();
438
604
  } catch (error) {
439
605
  lastError = error;
606
+ const meta = {
607
+ label,
608
+ attempt,
609
+ maxRetries: this.maxRetries,
610
+ retryDelayMs: this.retryDelayMs,
611
+ ...this.getErrorMeta(error)
612
+ };
440
613
  if (attempt === this.maxRetries) {
614
+ this.logError("request exhausted retries", meta);
441
615
  break;
442
616
  }
617
+ this.logWarn("request attempt failed, retrying", meta);
443
618
  await sleep(this.retryDelayMs * attempt);
444
619
  }
445
620
  }
446
- throw new FeishuBitableError(`${label} failed after ${this.maxRetries} attempts`, {
447
- cause: lastError
621
+ const causeMessage = lastError instanceof Error ? `: ${lastError.message}` : "";
622
+ throw new FeishuBitableError(`${label} failed after ${this.maxRetries} attempts${causeMessage}`, {
623
+ cause: lastError,
624
+ code: lastError instanceof FeishuBitableError ? lastError.code : undefined,
625
+ details: lastError instanceof FeishuBitableError ? lastError.details : undefined
626
+ });
627
+ }
628
+ async runLogged(action, meta, task) {
629
+ this.logInfo(`${action} started`, meta);
630
+ try {
631
+ const result = await task();
632
+ this.logInfo(`${action} succeeded`, meta);
633
+ return result;
634
+ } catch (error) {
635
+ this.logError(`${action} failed`, {
636
+ ...meta,
637
+ ...this.getErrorMeta(error)
638
+ });
639
+ throw error;
640
+ }
641
+ }
642
+ logInfo(message, meta) {
643
+ this.logger?.info(message, meta);
644
+ }
645
+ logWarn(message, meta) {
646
+ this.logger?.warn?.(message, meta);
647
+ }
648
+ logError(message, meta) {
649
+ this.logger?.error?.(message, meta);
650
+ }
651
+ getErrorMeta(error) {
652
+ if (error instanceof FeishuBitableError) {
653
+ return {
654
+ errorName: error.name,
655
+ errorMessage: error.message,
656
+ errorCode: error.code,
657
+ errorDetails: error.details
658
+ };
659
+ }
660
+ if (error instanceof Error) {
661
+ return {
662
+ errorName: error.name,
663
+ errorMessage: error.message
664
+ };
665
+ }
666
+ return {
667
+ errorMessage: String(error)
668
+ };
669
+ }
670
+ logRequest(message, payload) {
671
+ this.logInfo(message, {
672
+ payload
448
673
  });
674
+ return payload;
449
675
  }
450
676
  }
451
677
 
package/lib/index.d.ts CHANGED
@@ -2,5 +2,5 @@ import { Bitable } from "./client";
2
2
  export { Bitable };
3
3
  export { FeishuBitableError } from "./errors";
4
4
  export { AppType, Domain, LoggerLevel } from "@larksuiteoapi/node-sdk";
5
- export type { BatchOperationOptions, BitableBatchUpdatePayload, BitableBatchUpdateResponse, BitableConstructorOptions, BitableFieldValue, BitableFilterCondition, BitableFilterGroup, BitableInsertRecord, BitableLocationValue, BitableMemberValue, BitableRecord, BitableRecordFields, BitableSort, BitableTextValue, BitableUpdateRecord, FetchAllRecordsOptions, MediaParentType, UpdateRecordsOptions, UploadFileOptions, UploadableFile, } from "./types";
5
+ export type { BatchOperationOptions, BitableBatchUpdatePayload, BitableBatchUpdateResponse, BitableConstructorOptions, BitableFieldValue, BitableFilterCondition, BitableFilterGroup, BitableInsertRecord, BitableLocationValue, BitableLogger, BitableMemberValue, BitableRecord, BitableRecordFields, BitableSort, BitableTextValue, BitableUpdateRecord, FetchAllRecordsOptions, MediaParentType, UpdateRecordsOptions, UploadFileOptions, UploadableFile, } from "./types";
6
6
  export default Bitable;