zinkee 0.1.33 → 0.1.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zinkee",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,8 +33,18 @@ export interface RecordQueryInput {
33
33
  offset?: number;
34
34
  }
35
35
 
36
- export interface CreateRecordInput {
37
- [key: string]: unknown;
36
+ export type CreateRecordValues = Record<string, unknown>;
37
+
38
+ export interface CreateRecordError {
39
+ index: number;
40
+ reason: string;
41
+ message: string;
42
+ action: string;
43
+ }
44
+
45
+ export interface CreateRecordsResult {
46
+ created: string[];
47
+ errors: CreateRecordError[];
38
48
  }
39
49
 
40
50
  export interface UpdateRecordInput {
@@ -102,13 +112,13 @@ export class RecordsApi {
102
112
  );
103
113
  }
104
114
 
105
- createRecord(
115
+ createRecords(
106
116
  schema: NormalizedUuidOrSlug,
107
- input: CreateRecordInput,
108
- ): Promise<ApiRecord> {
109
- return this.client.requestJson<ApiRecord>(
117
+ records: CreateRecordValues[],
118
+ ): Promise<CreateRecordsResult> {
119
+ return this.client.requestJson<CreateRecordsResult>(
110
120
  `/api/v2/schemas/${selectorValue(schema)}/records`,
111
- jsonOptions(input, "POST"),
121
+ jsonOptions({ records }, "POST"),
112
122
  );
113
123
  }
114
124
 
@@ -367,15 +367,15 @@ describe("records commands", () => {
367
367
  );
368
368
  });
369
369
 
370
- it("creates records by merging raw JSON with set and set-json assignments", async () => {
370
+ it("creates a single record by merging raw JSON with set and set-json assignments", async () => {
371
371
  const configPath = createApiConfig();
372
372
  const output = createStdoutBuffer();
373
373
  const fetchMock = createFetchMock();
374
374
  mockSchemaLookup(fetchMock);
375
375
  fetchMock.mockResolvedValueOnce(
376
376
  jsonResponse({
377
- id: "record-1",
378
- name: "Ana",
377
+ created: ["record-1"],
378
+ errors: [],
379
379
  }),
380
380
  );
381
381
  const program = buildProgram({ configPath, stdout: output.stdout });
@@ -402,16 +402,108 @@ describe("records commands", () => {
402
402
  );
403
403
  expect(init?.method).toBe("POST");
404
404
  expect(JSON.parse(String(init?.body))).toEqual({
405
- values: {
406
- name: "Ana",
407
- status: "active",
408
- profile: {
409
- tier: "gold",
405
+ records: [
406
+ {
407
+ name: "Ana",
408
+ status: "active",
409
+ profile: {
410
+ tier: "gold",
411
+ },
410
412
  },
411
- },
413
+ ],
414
+ });
415
+ });
416
+
417
+ it("creates multiple records from a --records-file", async () => {
418
+ const configPath = createApiConfig();
419
+ const recordsFile = createTempConfig(
420
+ JSON.stringify([
421
+ { "contact-name": "Ada", status: 2 },
422
+ { "contact-name": "Grace", tags: ["a", "b"] },
423
+ ]),
424
+ );
425
+ const output = createStdoutBuffer();
426
+ const fetchMock = createFetchMock();
427
+ mockSchemaLookup(fetchMock);
428
+ fetchMock.mockResolvedValueOnce(
429
+ jsonResponse({
430
+ created: ["record-1", "record-2"],
431
+ errors: [],
432
+ }),
433
+ );
434
+ const program = buildProgram({ configPath, stdout: output.stdout });
435
+
436
+ await program.parseAsync([
437
+ "node",
438
+ "zinkee",
439
+ "--json",
440
+ "records",
441
+ "create",
442
+ "contacts",
443
+ "--records-file",
444
+ recordsFile,
445
+ ]);
446
+
447
+ const init = fetchMock.mock.calls[1]?.[1];
448
+
449
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
450
+ "https://api.zinkee.test/api/v2/schemas/schema-1/records",
451
+ );
452
+ expect(init?.method).toBe("POST");
453
+ expect(JSON.parse(String(init?.body))).toEqual({
454
+ records: [
455
+ { "contact-name": "Ada", status: 2 },
456
+ { "contact-name": "Grace", tags: ["a", "b"] },
457
+ ],
412
458
  });
413
459
  });
414
460
 
461
+ it("rejects combining --records-file with single-record flags", async () => {
462
+ const configPath = createApiConfig();
463
+ const recordsFile = createTempConfig(JSON.stringify([{ "contact-name": "Ada" }]));
464
+ const output = createStdoutBuffer();
465
+ const fetchMock = createFetchMock();
466
+ const program = buildProgram({ configPath, stdout: output.stdout });
467
+
468
+ await expect(
469
+ program.parseAsync([
470
+ "node",
471
+ "zinkee",
472
+ "records",
473
+ "create",
474
+ "contacts",
475
+ "--records-file",
476
+ recordsFile,
477
+ "--set",
478
+ "contact-name=Grace",
479
+ ]),
480
+ ).rejects.toThrowError(/not both/);
481
+
482
+ expect(fetchMock).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it("rejects a --records-file that is not a JSON array", async () => {
486
+ const configPath = createApiConfig();
487
+ const recordsFile = createTempConfig(JSON.stringify({ "contact-name": "Ada" }));
488
+ const output = createStdoutBuffer();
489
+ const fetchMock = createFetchMock();
490
+ const program = buildProgram({ configPath, stdout: output.stdout });
491
+
492
+ await expect(
493
+ program.parseAsync([
494
+ "node",
495
+ "zinkee",
496
+ "records",
497
+ "create",
498
+ "contacts",
499
+ "--records-file",
500
+ recordsFile,
501
+ ]),
502
+ ).rejects.toThrowError(/JSON array/);
503
+
504
+ expect(fetchMock).not.toHaveBeenCalled();
505
+ });
506
+
415
507
  it("wraps raw-file fields into the set payload", async () => {
416
508
  const configPath = createApiConfig();
417
509
  const output = createStdoutBuffer();
@@ -5,7 +5,7 @@ import { Command, InvalidArgumentError } from "commander";
5
5
 
6
6
  import {
7
7
  type ApiRecord,
8
- type CreateRecordInput,
8
+ type CreateRecordValues,
9
9
  type RecordQueryExpression,
10
10
  type RecordQueryInput,
11
11
  type RecordQueryResult,
@@ -66,6 +66,7 @@ interface RecordsCreateCommandOptions {
66
66
  setJson?: JsonKeyValueAssignment[];
67
67
  raw?: string;
68
68
  rawFile?: string;
69
+ recordsFile?: string;
69
70
  }
70
71
 
71
72
  interface RecordsUpdateCommandOptions extends RecordsCreateCommandOptions {
@@ -473,9 +474,72 @@ function buildRecordQueryInput(
473
474
  return input;
474
475
  }
475
476
 
476
- function buildRecordCreateInput(
477
- options: RecordsCreateCommandOptions,
478
- ): CreateRecordInput {
477
+ function readRecordsFromFile(path: string): CreateRecordValues[] {
478
+ let contents: string;
479
+
480
+ try {
481
+ contents = readFileSync(path, "utf8");
482
+ } catch (error) {
483
+ const message = error instanceof Error ? error.message : "Unable to read file.";
484
+ throw new CliError(
485
+ "invalid_cli_usage",
486
+ `Failed to read records from ${path}: ${message}`,
487
+ );
488
+ }
489
+
490
+ let parsed: unknown;
491
+ try {
492
+ parsed = JSON.parse(contents);
493
+ } catch (error) {
494
+ const message = error instanceof Error ? error.message : "Invalid JSON.";
495
+ throw new CliError(
496
+ "invalid_cli_usage",
497
+ `Failed to parse JSON from --records-file ${path}: ${message}`,
498
+ );
499
+ }
500
+
501
+ if (!Array.isArray(parsed)) {
502
+ throw new CliError(
503
+ "invalid_cli_usage",
504
+ `Expected --records-file ${path} to contain a JSON array of record objects.`,
505
+ );
506
+ }
507
+
508
+ if (parsed.length === 0) {
509
+ throw new CliError(
510
+ "invalid_cli_usage",
511
+ `--records-file ${path} must contain at least one record.`,
512
+ );
513
+ }
514
+
515
+ return parsed.map((record, index) => {
516
+ if (!isJsonObject(record)) {
517
+ throw new CliError(
518
+ "invalid_cli_usage",
519
+ `Record at index ${index} in --records-file ${path} must be a JSON object.`,
520
+ );
521
+ }
522
+ return record;
523
+ });
524
+ }
525
+
526
+ function buildCreateRecords(options: RecordsCreateCommandOptions): CreateRecordValues[] {
527
+ const hasSingleRecordFlags =
528
+ (options.set?.length ?? 0) > 0 ||
529
+ (options.setJson?.length ?? 0) > 0 ||
530
+ options.raw !== undefined ||
531
+ options.rawFile !== undefined;
532
+
533
+ if (options.recordsFile !== undefined) {
534
+ if (hasSingleRecordFlags) {
535
+ throw new CliError(
536
+ "invalid_cli_usage",
537
+ 'Provide either "--records-file" or the single-record flags (--set/--set-json/--raw/--raw-file), not both.',
538
+ );
539
+ }
540
+ return readRecordsFromFile(options.recordsFile);
541
+ }
542
+
479
543
  const values = {
480
544
  ...readRawObject(options.raw, options.rawFile),
481
545
  ...buildAssignmentObject(options.set, options.setJson),
@@ -484,11 +548,11 @@ function buildRecordCreateInput(
484
548
  if (isEmptyObject(values)) {
485
549
  throw new CliError(
486
550
  "invalid_cli_usage",
487
- 'Provide record fields via "--set", "--set-json", "--raw", or "--raw-file".',
551
+ 'Provide record fields via "--set", "--set-json", "--raw", "--raw-file", or load multiple records with "--records-file".',
488
552
  );
489
553
  }
490
554
 
491
- return { values };
555
+ return [values];
492
556
  }
493
557
 
494
558
  function buildRecordUpdateInput(
@@ -684,12 +748,16 @@ export function registerRecordsCommands(
684
748
 
685
749
  records
686
750
  .command("create")
687
- .description("Create a record")
751
+ .description("Create one or more records")
688
752
  .argument("<schema>", "schema uuid or slug", parseUuidOrSlugSelector)
689
753
  .option("--set <field=value>", "set a string field", collectKeyValueAssignments, [])
690
754
  .option("--set-json <field=json>", "set a structured field", collectJsonAssignments, [])
691
755
  .option("--raw <json>", "raw JSON object payload")
692
756
  .option("--raw-file <path>", "path to a raw JSON object payload")
757
+ .option(
758
+ "--records-file <path>",
759
+ "path to a JSON array of record objects (mutually exclusive with --set/--set-json/--raw/--raw-file)",
760
+ )
693
761
  .action(async function action(
694
762
  this: Command,
695
763
  schema: NormalizedUuidOrSlug,
@@ -697,19 +765,24 @@ export function registerRecordsCommands(
697
765
  ) {
698
766
  const { api, schemasApi, json, readOnly } = createRecordsApi(this, filePath);
699
767
  ensureWritable("records create", readOnly, "write");
768
+
769
+ const records = buildCreateRecords(commandOptions);
700
770
  const resolvedSchema = await resolveSchemaSelector(schemasApi, schema);
701
771
 
702
- const data = await api.createRecord(
703
- resolvedSchema,
704
- buildRecordCreateInput(commandOptions),
705
- );
772
+ const data = await api.createRecords(resolvedSchema, records);
706
773
 
707
774
  if (json) {
708
775
  emitJson(stdout, "records create", data, filePath);
709
776
  return;
710
777
  }
711
778
 
712
- writeLine(stdout, `Created record "${getRecordIdentifier(data)}".`);
779
+ writeLine(stdout, `Created ${data.created.length} record(s) in schema "${schema.value}".`);
780
+ if (data.errors.length > 0) {
781
+ writeLine(stdout, `${data.errors.length} record(s) rejected:`);
782
+ for (const error of data.errors) {
783
+ writeLine(stdout, ` - index ${error.index}: ${error.message}`);
784
+ }
785
+ }
713
786
  });
714
787
 
715
788
  records
@@ -677,38 +677,58 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
677
677
  ]),
678
678
  "records create": buildCommandExamples([
679
679
  {
680
- description: "Minimal create using just two plain string fields.",
680
+ description:
681
+ "Create a single record with --set/--set-json (the single-record flags build one record, sent as a one-element 'records' array).",
681
682
  command:
682
683
  `zinkee --json records create contacts --set contact-name="Ada Lovelace" --set contact-email=ada@example.com`,
683
684
  raw: `{
684
- "values": {
685
- "contact-name": "Ada Lovelace",
686
- "contact-email": "ada@example.com"
687
- }
685
+ "records": [
686
+ {
687
+ "contact-name": "Ada Lovelace",
688
+ "contact-email": "ada@example.com"
689
+ }
690
+ ]
688
691
  }`,
689
692
  response: `{
690
- "recordId": "550e8400-e29b-41d4-a716-446655440000",
691
- "values": {
692
- "contact-name": "Ada Lovelace",
693
- "contact-email": "ada@example.com"
694
- }
693
+ "created": [
694
+ "550e8400-e29b-41d4-a716-446655440000"
695
+ ],
696
+ "errors": []
695
697
  }`,
696
698
  },
697
699
  {
698
700
  description:
699
- "Create a record mixing text, option, number, date, reference and member fields. ReferenceField values must match the target's current targetDisplayField (read it from the target record before writing).",
700
- command:
701
- `zinkee --json records create contacts --set contact-name="Grace Hopper" --set contact-email=grace@example.com --set status=2 --set amount=1999.99 --set due-date=2026-06-01T09:00:00Z --set customer-ref="CUST-12" --set owner=6c8a3e0d-4e5f-4a1b-9c2d-3e4f5a6b7c8d`,
701
+ "Create many records from a JSON array file (one object per record, slug -> value). Best-effort: valid records are created and invalid ones are reported per index in 'errors'. ReferenceField values must match the target's current targetDisplayField.",
702
+ command: `zinkee --json records create contacts --records-file ./records.json`,
702
703
  raw: `{
703
- "values": {
704
- "contact-name": "Grace Hopper",
705
- "contact-email": "grace@example.com",
706
- "status": 2,
707
- "amount": 1999.99,
708
- "due-date": "2026-06-01T09:00:00Z",
709
- "customer-ref": "CUST-12",
710
- "owner": "6c8a3e0d-4e5f-4a1b-9c2d-3e4f5a6b7c8d"
711
- }
704
+ "records": [
705
+ {
706
+ "contact-name": "Ada Lovelace",
707
+ "contact-email": "ada@example.com"
708
+ },
709
+ {
710
+ "contact-name": "Grace Hopper",
711
+ "contact-email": "grace@example.com",
712
+ "status": 2,
713
+ "amount": 1999.99,
714
+ "due-date": "2026-06-01T09:00:00Z",
715
+ "customer-ref": "8ad602a1-f6e3-43f6-9ab2-f1c5afc99621",
716
+ "owner": "6c8a3e0d-4e5f-4a1b-9c2d-3e4f5a6b7c8d"
717
+ }
718
+ ]
719
+ }`,
720
+ response: `{
721
+ "created": [
722
+ "550e8400-e29b-41d4-a716-446655440000"
723
+ ],
724
+ "errors": [
725
+ {
726
+ "index": 1,
727
+ "reason": "record_field_not_found",
728
+ "message": "Field 'contact-emial' was not found in schema '2eb8bdcf-6d9c-4fbb-9018-7fa8180f4c5e'.",
729
+ "action": "Use a valid field slug from the schema and retry."
730
+ }
731
+ ]
712
732
  }`,
713
733
  },
714
734
  ]),