@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.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,251 @@ 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 iterator = await this.client.bitable.v1.appTableRecord.searchWithIterator({
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 allRecords = [];
306
+ for await (const page of iterator) {
307
+ const items = page?.items ?? [];
308
+ allRecords.push(...items.map((item) => normalizeRecord(item.record_id, item.fields, options.normalizeFields !== false)));
270
309
  }
310
+ this.logInfo("fetchAllRecords completed", {
311
+ tableId,
312
+ recordCount: allRecords.length
313
+ });
314
+ return allRecords;
271
315
  });
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
316
  }
279
317
  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 }))
318
+ return this.runLogged("insertList", {
319
+ tableId,
320
+ recordCount: records.length,
321
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
322
+ concurrency: options.concurrency ?? this.defaultConcurrency
323
+ }, async () => {
324
+ if (records.length === 0) {
325
+ return [];
289
326
  }
290
- }), "insert records")));
327
+ const token = this.resolveAppToken(options.appToken);
328
+ const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
329
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("insert records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchCreate({
330
+ path: { app_token: token, table_id: tableId },
331
+ data: {
332
+ records: chunk.map((fields) => ({ fields }))
333
+ }
334
+ }), "insert records")));
335
+ this.logInfo("insertList completed", {
336
+ tableId,
337
+ chunkCount: chunks.length
338
+ });
339
+ return responses;
340
+ });
291
341
  }
292
342
  async batchUpdateRecords(payload) {
293
- return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
343
+ return this.runLogged("batchUpdateRecords", {
344
+ tableId: payload.path.table_id,
345
+ recordCount: payload.data.records.length
346
+ }, async () => this.executeBatchUpdateRecords(payload));
294
347
  }
295
348
  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
- })
349
+ return this.runLogged("updateRecords", {
350
+ tableId,
351
+ inputRecordCount: records.length,
352
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
353
+ concurrency: options.concurrency ?? this.defaultConcurrency
354
+ }, async () => {
355
+ if (records.length === 0) {
356
+ return [];
357
+ }
358
+ const token = this.resolveAppToken(options.appToken);
359
+ const chunks = chunkArray(records, options.chunkSize ?? FEISHU_BATCH_LIMIT);
360
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk, index) => {
361
+ const batchRecords = chunk.map((record) => {
362
+ const { recordId, fields } = splitUpdateRecord(record);
363
+ if (Object.keys(fields).length === 0) {
364
+ return null;
365
+ }
366
+ return {
367
+ record_id: recordId,
368
+ fields
369
+ };
370
+ }).filter((record) => Boolean(record));
371
+ if (batchRecords.length === 0) {
372
+ this.logInfo("updateRecords skipped empty chunk", {
373
+ tableId,
374
+ chunkIndex: index,
375
+ inputChunkSize: chunk.length
376
+ });
377
+ return {
378
+ code: 0,
379
+ msg: "ok",
380
+ data: {
381
+ records: []
382
+ }
383
+ };
315
384
  }
316
- };
317
- if (options.userIdType || options.ignoreConsistencyCheck !== undefined) {
318
- payload.params = {
319
- user_id_type: options.userIdType,
320
- ignore_consistency_check: options.ignoreConsistencyCheck
385
+ const payload = {
386
+ path: {
387
+ app_token: token,
388
+ table_id: tableId
389
+ },
390
+ data: {
391
+ records: batchRecords
392
+ }
321
393
  };
322
- }
323
- return this.batchUpdateRecords(payload);
394
+ if (options.userIdType || options.ignoreConsistencyCheck !== undefined) {
395
+ payload.params = {
396
+ user_id_type: options.userIdType,
397
+ ignore_consistency_check: options.ignoreConsistencyCheck
398
+ };
399
+ }
400
+ return this.executeBatchUpdateRecords(payload);
401
+ });
402
+ this.logInfo("updateRecords completed", {
403
+ tableId,
404
+ chunkCount: chunks.length
405
+ });
406
+ return responses;
324
407
  });
325
408
  }
326
409
  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
410
+ return this.runLogged("deleteList", {
411
+ tableId,
412
+ recordCount: recordIds.length,
413
+ chunkSize: options.chunkSize ?? FEISHU_BATCH_LIMIT,
414
+ concurrency: options.concurrency ?? this.defaultConcurrency
415
+ }, async () => {
416
+ if (recordIds.length === 0) {
417
+ return [];
336
418
  }
337
- }), "delete records")));
419
+ const token = this.resolveAppToken(options.appToken);
420
+ const chunks = chunkArray(recordIds, options.chunkSize ?? FEISHU_BATCH_LIMIT);
421
+ const responses = await runWithConcurrency(chunks, options.concurrency ?? this.defaultConcurrency, async (chunk) => this.withRetry("delete records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchDelete({
422
+ path: { app_token: token, table_id: tableId },
423
+ data: {
424
+ records: chunk
425
+ }
426
+ }), "delete records")));
427
+ this.logInfo("deleteList completed", {
428
+ tableId,
429
+ chunkCount: chunks.length
430
+ });
431
+ return responses;
432
+ });
338
433
  }
339
434
  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({
435
+ return this.runLogged("uploadFile", {
436
+ parentType: options.parentType,
437
+ parentNode: options.parentNode,
438
+ hasExtra: Boolean(options.extra)
439
+ }, async () => {
440
+ const buffer = await toBuffer(options.file);
441
+ const fileName = options.fileName ?? inferFileName(options.file);
442
+ this.logInfo("uploadFile resolved input", {
443
+ fileName,
444
+ size: buffer.byteLength,
445
+ mode: buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT ? "simple" : "multipart"
446
+ });
447
+ if (buffer.byteLength <= FEISHU_SIMPLE_UPLOAD_LIMIT) {
448
+ return await this.withRetry("upload file", async () => this.client.drive.v1.media.uploadAll({
449
+ data: {
450
+ file_name: fileName,
451
+ parent_type: options.parentType,
452
+ parent_node: options.parentNode,
453
+ size: buffer.byteLength,
454
+ extra: options.extra,
455
+ file: buffer
456
+ }
457
+ })) ?? {};
458
+ }
459
+ const prepare = assertFeishuResponse(await this.withRetry("prepare multipart upload", async () => this.client.drive.v1.media.uploadPrepare({
344
460
  data: {
345
461
  file_name: fileName,
346
462
  parent_type: options.parentType,
347
463
  parent_node: options.parentNode,
348
- size: buffer.byteLength,
349
- extra: options.extra,
350
- file: buffer
464
+ size: buffer.byteLength
351
465
  }
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
466
+ })), "prepare multipart upload");
467
+ const uploadId = prepare.data?.upload_id;
468
+ const blockSize = prepare.data?.block_size;
469
+ const blockNum = prepare.data?.block_num;
470
+ if (!uploadId || !blockSize || !blockNum) {
471
+ throw new FeishuBitableError("prepare multipart upload failed: missing upload metadata", {
472
+ details: prepare
473
+ });
360
474
  }
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
368
- });
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({
475
+ for (let index = 0;index < blockNum; index++) {
476
+ const start = index * blockSize;
477
+ const end = Math.min(start + blockSize, buffer.byteLength);
478
+ const chunk = buffer.subarray(start, end);
479
+ await this.withRetry(`upload file chunk ${index + 1}/${blockNum}`, async () => this.client.drive.v1.media.uploadPart({
480
+ data: {
481
+ upload_id: uploadId,
482
+ seq: index,
483
+ size: chunk.byteLength,
484
+ file: chunk
485
+ }
486
+ }));
487
+ }
488
+ const finish = assertFeishuResponse(await this.withRetry("finish multipart upload", async () => this.client.drive.v1.media.uploadFinish({
375
489
  data: {
376
490
  upload_id: uploadId,
377
- seq: index,
378
- size: chunk.byteLength,
379
- file: chunk
491
+ block_num: blockNum
380
492
  }
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
387
- }
388
- })), "finish multipart upload");
389
- return {
390
- file_token: finish.data?.file_token
391
- };
493
+ })), "finish multipart upload");
494
+ return {
495
+ file_token: finish.data?.file_token
496
+ };
497
+ });
392
498
  }
393
499
  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());
500
+ return this.runLogged("downloadFile", {
501
+ fileToken,
502
+ hasExtra: Boolean(extra)
503
+ }, async () => {
504
+ const response = await this.withRetry("download file", async () => this.client.drive.v1.media.download({
505
+ path: {
506
+ file_token: fileToken
507
+ },
508
+ params: {
509
+ extra
510
+ }
511
+ }));
512
+ const buffer = await readableToBuffer(response.getReadableStream());
513
+ this.logInfo("downloadFile completed", {
514
+ fileToken,
515
+ size: buffer.byteLength
516
+ });
517
+ return buffer;
518
+ });
403
519
  }
404
520
  async downLoadFile(fileToken, extra) {
405
- return this.downloadFile(fileToken, extra);
521
+ return this.runLogged("downLoadFile", {
522
+ fileToken,
523
+ hasExtra: Boolean(extra)
524
+ }, async () => this.downloadFile(fileToken, extra));
406
525
  }
407
526
  resolveConstructorOptions(optionsOrToken, appIdArg, appSecretArg) {
408
527
  const objectMode = typeof optionsOrToken === "object" && optionsOrToken !== null && !Array.isArray(optionsOrToken);
@@ -430,6 +549,9 @@ class Bitable {
430
549
  }
431
550
  return token;
432
551
  }
552
+ async executeBatchUpdateRecords(payload) {
553
+ return this.withRetry("batch update records", async () => assertFeishuResponse(await this.client.bitable.v1.appTableRecord.batchUpdate(payload), "batch update records"));
554
+ }
433
555
  async withRetry(label, task) {
434
556
  let lastError;
435
557
  for (let attempt = 1;attempt <= this.maxRetries; attempt++) {
@@ -437,16 +559,70 @@ class Bitable {
437
559
  return await task();
438
560
  } catch (error) {
439
561
  lastError = error;
562
+ const meta = {
563
+ label,
564
+ attempt,
565
+ maxRetries: this.maxRetries,
566
+ retryDelayMs: this.retryDelayMs,
567
+ ...this.getErrorMeta(error)
568
+ };
440
569
  if (attempt === this.maxRetries) {
570
+ this.logError("request exhausted retries", meta);
441
571
  break;
442
572
  }
573
+ this.logWarn("request attempt failed, retrying", meta);
443
574
  await sleep(this.retryDelayMs * attempt);
444
575
  }
445
576
  }
446
- throw new FeishuBitableError(`${label} failed after ${this.maxRetries} attempts`, {
447
- cause: lastError
577
+ const causeMessage = lastError instanceof Error ? `: ${lastError.message}` : "";
578
+ throw new FeishuBitableError(`${label} failed after ${this.maxRetries} attempts${causeMessage}`, {
579
+ cause: lastError,
580
+ code: lastError instanceof FeishuBitableError ? lastError.code : undefined,
581
+ details: lastError instanceof FeishuBitableError ? lastError.details : undefined
448
582
  });
449
583
  }
584
+ async runLogged(action, meta, task) {
585
+ this.logInfo(`${action} started`, meta);
586
+ try {
587
+ const result = await task();
588
+ this.logInfo(`${action} succeeded`, meta);
589
+ return result;
590
+ } catch (error) {
591
+ this.logError(`${action} failed`, {
592
+ ...meta,
593
+ ...this.getErrorMeta(error)
594
+ });
595
+ throw error;
596
+ }
597
+ }
598
+ logInfo(message, meta) {
599
+ this.logger?.info(message, meta);
600
+ }
601
+ logWarn(message, meta) {
602
+ this.logger?.warn?.(message, meta);
603
+ }
604
+ logError(message, meta) {
605
+ this.logger?.error?.(message, meta);
606
+ }
607
+ getErrorMeta(error) {
608
+ if (error instanceof FeishuBitableError) {
609
+ return {
610
+ errorName: error.name,
611
+ errorMessage: error.message,
612
+ errorCode: error.code,
613
+ errorDetails: error.details
614
+ };
615
+ }
616
+ if (error instanceof Error) {
617
+ return {
618
+ errorName: error.name,
619
+ errorMessage: error.message
620
+ };
621
+ }
622
+ return {
623
+ errorMessage: String(error)
624
+ };
625
+ }
450
626
  }
451
627
 
452
628
  // src/index.ts
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;