zinkee 0.1.32 → 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.32",
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
 
@@ -123,13 +133,13 @@ export class RecordsApi {
123
133
  );
124
134
  }
125
135
 
126
- deleteRecord(
136
+ deleteRecords(
127
137
  schema: NormalizedUuidOrSlug,
128
- record: NormalizedUuidOrSlug,
138
+ recordIds: string[],
129
139
  ): Promise<Record<string, unknown>> {
130
140
  return this.client.requestJson<Record<string, unknown>>(
131
- `/api/v2/schemas/${selectorValue(schema)}/records/${selectorValue(record)}`,
132
- { method: "DELETE" },
141
+ `/api/v2/schemas/${selectorValue(schema)}/records:delete`,
142
+ jsonOptions({ recordIds }, "POST"),
133
143
  );
134
144
  }
135
145
 
@@ -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
+ ],
412
414
  });
413
415
  });
414
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
+ ],
458
+ });
459
+ });
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();
@@ -567,13 +659,147 @@ describe("records commands", () => {
567
659
  "records",
568
660
  "delete",
569
661
  "contacts",
570
- "record-1",
662
+ "--record",
663
+ "550e8400-e29b-41d4-a716-446655440000",
571
664
  ]),
572
665
  ).rejects.toThrowError('Command "records delete" is not allowed in read-only mode.');
573
666
 
574
667
  expect(fetchMock).not.toHaveBeenCalled();
575
668
  });
576
669
 
670
+ it("deletes multiple records via repeatable --record flags and de-duplicates", async () => {
671
+ const configPath = createApiConfig();
672
+ const output = createStdoutBuffer();
673
+ const fetchMock = createFetchMock();
674
+ mockSchemaLookup(fetchMock);
675
+ fetchMock.mockResolvedValueOnce(
676
+ jsonResponse({
677
+ recordIds: [
678
+ "550e8400-e29b-41d4-a716-446655440000",
679
+ "550e8400-e29b-41d4-a716-446655440001",
680
+ ],
681
+ deleted: true,
682
+ }),
683
+ );
684
+ const program = buildProgram({ configPath, stdout: output.stdout });
685
+
686
+ await program.parseAsync([
687
+ "node",
688
+ "zinkee",
689
+ "--json",
690
+ "records",
691
+ "delete",
692
+ "contacts",
693
+ "--record",
694
+ "550e8400-e29b-41d4-a716-446655440000",
695
+ "--record",
696
+ "550e8400-e29b-41d4-a716-446655440001",
697
+ "--record",
698
+ "550e8400-e29b-41d4-a716-446655440000",
699
+ ]);
700
+
701
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
702
+ "https://api.zinkee.test/api/v2/schemas/schema-1/records:delete",
703
+ );
704
+
705
+ const init = fetchMock.mock.calls[1]?.[1];
706
+
707
+ expect(init?.method).toBe("POST");
708
+ expect(JSON.parse(String(init?.body))).toEqual({
709
+ recordIds: [
710
+ "550e8400-e29b-41d4-a716-446655440000",
711
+ "550e8400-e29b-41d4-a716-446655440001",
712
+ ],
713
+ });
714
+
715
+ const payload = JSON.parse(output.read());
716
+
717
+ expect(payload.data).toEqual({
718
+ recordIds: [
719
+ "550e8400-e29b-41d4-a716-446655440000",
720
+ "550e8400-e29b-41d4-a716-446655440001",
721
+ ],
722
+ deleted: true,
723
+ });
724
+ });
725
+
726
+ it("loads record ids from a file and merges them with --record", async () => {
727
+ const configPath = createApiConfig();
728
+ const idsFile = createTempConfig(
729
+ "550e8400-e29b-41d4-a716-446655440000\n 550e8400-e29b-41d4-a716-446655440002 \n\n",
730
+ );
731
+ const output = createStdoutBuffer();
732
+ const fetchMock = createFetchMock();
733
+ mockSchemaLookup(fetchMock);
734
+ fetchMock.mockResolvedValueOnce(
735
+ jsonResponse({
736
+ recordIds: [
737
+ "550e8400-e29b-41d4-a716-446655440000",
738
+ "550e8400-e29b-41d4-a716-446655440002",
739
+ ],
740
+ deleted: true,
741
+ }),
742
+ );
743
+ const program = buildProgram({ configPath, stdout: output.stdout });
744
+
745
+ await program.parseAsync([
746
+ "node",
747
+ "zinkee",
748
+ "--json",
749
+ "records",
750
+ "delete",
751
+ "contacts",
752
+ "--record",
753
+ "550e8400-e29b-41d4-a716-446655440000",
754
+ "--records-file",
755
+ idsFile,
756
+ ]);
757
+
758
+ const init = fetchMock.mock.calls[1]?.[1];
759
+
760
+ expect(init?.method).toBe("POST");
761
+ expect(JSON.parse(String(init?.body))).toEqual({
762
+ recordIds: [
763
+ "550e8400-e29b-41d4-a716-446655440000",
764
+ "550e8400-e29b-41d4-a716-446655440002",
765
+ ],
766
+ });
767
+ });
768
+
769
+ it("requires at least one record id for delete", async () => {
770
+ const configPath = createApiConfig();
771
+ const output = createStdoutBuffer();
772
+ const fetchMock = createFetchMock();
773
+ const program = buildProgram({ configPath, stdout: output.stdout });
774
+
775
+ await expect(
776
+ program.parseAsync(["node", "zinkee", "records", "delete", "contacts"]),
777
+ ).rejects.toThrowError(/at least one record id/);
778
+
779
+ expect(fetchMock).not.toHaveBeenCalled();
780
+ });
781
+
782
+ it("rejects a non-uuid record id for delete", async () => {
783
+ const configPath = createApiConfig();
784
+ const output = createStdoutBuffer();
785
+ const fetchMock = createFetchMock();
786
+ const program = buildProgram({ configPath, stdout: output.stdout });
787
+
788
+ await expect(
789
+ program.parseAsync([
790
+ "node",
791
+ "zinkee",
792
+ "records",
793
+ "delete",
794
+ "contacts",
795
+ "--record",
796
+ "not-a-uuid",
797
+ ]),
798
+ ).rejects.toThrow();
799
+
800
+ expect(fetchMock).not.toHaveBeenCalled();
801
+ });
802
+
577
803
  it("clears all records in a schema", async () => {
578
804
  const configPath = createApiConfig();
579
805
  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,12 +66,18 @@ 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 {
72
73
  unset?: string[];
73
74
  }
74
75
 
76
+ interface RecordsDeleteCommandOptions {
77
+ record?: string[];
78
+ recordsFile?: string;
79
+ }
80
+
75
81
  function getCommandIo(stdout: CommandIo | undefined): CommandIo {
76
82
  return stdout ?? (process.stdout as Writable);
77
83
  }
@@ -237,6 +243,71 @@ function dedupeStrings(values: string[]): string[] {
237
243
  return [...new Set(values)];
238
244
  }
239
245
 
246
+ function toRecordUuid(value: string): string | null {
247
+ try {
248
+ const normalized = normalizeUuidOrSlug(value);
249
+ return normalized.kind === "uuid" ? normalized.value : null;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function collectRecordIds(value: string, previous: string[] = []): string[] {
256
+ const uuid = toRecordUuid(value.trim());
257
+ if (!uuid) {
258
+ throw new InvalidArgumentError(`Expected a record UUID, received "${value}".`);
259
+ }
260
+ return [...previous, uuid];
261
+ }
262
+
263
+ function readRecordIdsFromFile(path: string): string[] {
264
+ let contents: string;
265
+
266
+ try {
267
+ contents = readFileSync(path, "utf8");
268
+ } catch (error) {
269
+ const message = error instanceof Error ? error.message : "Unable to read file.";
270
+ throw new CliError(
271
+ "invalid_cli_usage",
272
+ `Failed to read record ids from ${path}: ${message}`,
273
+ );
274
+ }
275
+
276
+ const ids: string[] = [];
277
+
278
+ contents.split(/\r?\n/).forEach((line, index) => {
279
+ const value = line.trim();
280
+ if (!value) {
281
+ return;
282
+ }
283
+ const uuid = toRecordUuid(value);
284
+ if (!uuid) {
285
+ throw new CliError(
286
+ "invalid_cli_usage",
287
+ `Invalid record UUID "${value}" at ${path}:${index + 1}.`,
288
+ );
289
+ }
290
+ ids.push(uuid);
291
+ });
292
+
293
+ return ids;
294
+ }
295
+
296
+ function resolveDeleteRecordIds(options: RecordsDeleteCommandOptions): string[] {
297
+ const flagIds = options.record ?? [];
298
+ const fileIds = options.recordsFile ? readRecordIdsFromFile(options.recordsFile) : [];
299
+ const recordIds = dedupeStrings([...flagIds, ...fileIds]);
300
+
301
+ if (recordIds.length === 0) {
302
+ throw new CliError(
303
+ "invalid_cli_usage",
304
+ 'Provide at least one record id via "--record <uuid>" (repeatable) or "--records-file <path>".',
305
+ );
306
+ }
307
+
308
+ return recordIds;
309
+ }
310
+
240
311
  function collectStringValues(value: string, previous: string[] = []): string[] {
241
312
  return [...previous, value];
242
313
  }
@@ -403,9 +474,72 @@ function buildRecordQueryInput(
403
474
  return input;
404
475
  }
405
476
 
406
- function buildRecordCreateInput(
407
- options: RecordsCreateCommandOptions,
408
- ): 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
+
409
543
  const values = {
410
544
  ...readRawObject(options.raw, options.rawFile),
411
545
  ...buildAssignmentObject(options.set, options.setJson),
@@ -414,11 +548,11 @@ function buildRecordCreateInput(
414
548
  if (isEmptyObject(values)) {
415
549
  throw new CliError(
416
550
  "invalid_cli_usage",
417
- '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".',
418
552
  );
419
553
  }
420
554
 
421
- return { values };
555
+ return [values];
422
556
  }
423
557
 
424
558
  function buildRecordUpdateInput(
@@ -614,12 +748,16 @@ export function registerRecordsCommands(
614
748
 
615
749
  records
616
750
  .command("create")
617
- .description("Create a record")
751
+ .description("Create one or more records")
618
752
  .argument("<schema>", "schema uuid or slug", parseUuidOrSlugSelector)
619
753
  .option("--set <field=value>", "set a string field", collectKeyValueAssignments, [])
620
754
  .option("--set-json <field=json>", "set a structured field", collectJsonAssignments, [])
621
755
  .option("--raw <json>", "raw JSON object payload")
622
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
+ )
623
761
  .action(async function action(
624
762
  this: Command,
625
763
  schema: NormalizedUuidOrSlug,
@@ -627,19 +765,24 @@ export function registerRecordsCommands(
627
765
  ) {
628
766
  const { api, schemasApi, json, readOnly } = createRecordsApi(this, filePath);
629
767
  ensureWritable("records create", readOnly, "write");
768
+
769
+ const records = buildCreateRecords(commandOptions);
630
770
  const resolvedSchema = await resolveSchemaSelector(schemasApi, schema);
631
771
 
632
- const data = await api.createRecord(
633
- resolvedSchema,
634
- buildRecordCreateInput(commandOptions),
635
- );
772
+ const data = await api.createRecords(resolvedSchema, records);
636
773
 
637
774
  if (json) {
638
775
  emitJson(stdout, "records create", data, filePath);
639
776
  return;
640
777
  }
641
778
 
642
- 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
+ }
643
786
  });
644
787
 
645
788
  records
@@ -678,31 +821,35 @@ export function registerRecordsCommands(
678
821
 
679
822
  records
680
823
  .command("delete")
681
- .description("Delete a record")
824
+ .description("Delete one or more records")
682
825
  .argument("<schema>", "schema uuid or slug", parseUuidOrSlugSelector)
683
- .argument("<record>", "record uuid or slug", parseUuidOrSlugSelector)
826
+ .option("--record <uuid>", "record uuid to delete (repeatable)", collectRecordIds, [])
827
+ .option(
828
+ "--records-file <path>",
829
+ "path to a file with one record uuid per line (merged with --record)",
830
+ )
684
831
  .action(async function action(
685
832
  this: Command,
686
833
  schema: NormalizedUuidOrSlug,
687
- record: NormalizedUuidOrSlug,
834
+ commandOptions: RecordsDeleteCommandOptions,
688
835
  ) {
689
836
  const { api, schemasApi, json, readOnly } = createRecordsApi(this, filePath);
690
837
  ensureWritable("records delete", readOnly, "destructive");
838
+
839
+ const recordIds = resolveDeleteRecordIds(commandOptions);
691
840
  const resolvedSchema = await resolveSchemaSelector(schemasApi, schema);
692
841
 
693
- await api.deleteRecord(resolvedSchema, record);
694
- const data = {
695
- schema: resolvedSchema.value,
696
- record: record.value,
697
- deleted: true,
698
- };
842
+ const data = await api.deleteRecords(resolvedSchema, recordIds);
699
843
 
700
844
  if (json) {
701
845
  emitJson(stdout, "records delete", data, filePath);
702
846
  return;
703
847
  }
704
848
 
705
- writeLine(stdout, `Deleted record "${record.value}" from schema "${schema.value}".`);
849
+ writeLine(
850
+ stdout,
851
+ `Deleted ${recordIds.length} record(s) from schema "${schema.value}".`,
852
+ );
706
853
  });
707
854
 
708
855
  records