@pierre/storage 0.0.11 → 0.1.1

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/src/commit.ts CHANGED
@@ -1,10 +1,16 @@
1
+ import { inferRefUpdateReason, RefUpdateError } from './errors';
2
+ import type { CommitPackAckRaw } from './schemas';
3
+ import { commitPackAckSchema, commitPackResponseSchema, errorEnvelopeSchema } from './schemas';
1
4
  import type {
5
+ BlobLike,
2
6
  CommitBuilder,
3
7
  CommitFileOptions,
4
8
  CommitFileSource,
5
- CommitResponse,
9
+ CommitResult,
6
10
  CommitTextFileOptions,
7
11
  CreateCommitOptions,
12
+ ReadableStreamLike,
13
+ RefUpdate,
8
14
  } from './types';
9
15
 
10
16
  const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
@@ -21,37 +27,22 @@ const BufferCtor: NodeBufferConstructor | undefined = (
21
27
  globalThis as { Buffer?: NodeBufferConstructor }
22
28
  ).Buffer;
23
29
 
24
- interface ReadableStreamReaderLike<T> {
25
- read(): Promise<{ value?: T; done: boolean }>;
26
- releaseLock?(): void;
27
- }
28
-
29
- interface ReadableStreamLike<T> {
30
- getReader(): ReadableStreamReaderLike<T>;
31
- }
32
-
33
- interface BlobLike {
34
- stream(): unknown;
35
- }
36
-
37
30
  type ChunkSegment = {
38
31
  chunk: Uint8Array;
39
32
  eof: boolean;
40
33
  };
41
34
 
42
35
  interface CommitMetadataPayload {
43
- target_ref: string;
44
- base_ref?: string;
36
+ target_branch: string;
37
+ expected_head_sha?: string;
45
38
  commit_message: string;
46
- author?: {
39
+ author: {
47
40
  name: string;
48
41
  email: string;
49
- date?: string;
50
42
  };
51
43
  committer?: {
52
44
  name: string;
53
45
  email: string;
54
- date?: string;
55
46
  };
56
47
  files: Array<{
57
48
  path: string;
@@ -69,7 +60,7 @@ interface CommitTransportRequest {
69
60
  }
70
61
 
71
62
  interface CommitTransport {
72
- send(request: CommitTransportRequest): Promise<CommitResponse>;
63
+ send(request: CommitTransportRequest): Promise<CommitPackAck>;
73
64
  }
74
65
 
75
66
  interface CommitBuilderDeps {
@@ -86,6 +77,8 @@ type FileOperationState = {
86
77
  streamFactory?: () => AsyncIterable<Uint8Array>;
87
78
  };
88
79
 
80
+ type CommitPackAck = CommitPackAckRaw;
81
+
89
82
  export class CommitBuilderImpl implements CommitBuilder {
90
83
  private readonly options: CreateCommitOptions;
91
84
  private readonly getAuthToken: () => Promise<string>;
@@ -98,18 +91,31 @@ export class CommitBuilderImpl implements CommitBuilder {
98
91
  this.getAuthToken = deps.getAuthToken;
99
92
  this.transport = deps.transport;
100
93
 
101
- const trimmedTarget = this.options.targetRef?.trim();
94
+ const trimmedTarget = this.options.targetBranch?.trim();
102
95
  const trimmedMessage = this.options.commitMessage?.trim();
96
+ const trimmedAuthorName = this.options.author?.name?.trim();
97
+ const trimmedAuthorEmail = this.options.author?.email?.trim();
98
+
103
99
  if (!trimmedTarget) {
104
- throw new Error('createCommit targetRef is required');
100
+ throw new Error('createCommit targetBranch is required');
101
+ }
102
+ if (trimmedTarget.startsWith('refs/')) {
103
+ throw new Error('createCommit targetBranch must not include refs/ prefix');
105
104
  }
106
105
  if (!trimmedMessage) {
107
106
  throw new Error('createCommit commitMessage is required');
108
107
  }
109
- this.options.targetRef = trimmedTarget;
108
+ if (!trimmedAuthorName || !trimmedAuthorEmail) {
109
+ throw new Error('createCommit author name and email are required');
110
+ }
111
+ this.options.targetBranch = trimmedTarget;
110
112
  this.options.commitMessage = trimmedMessage;
111
- if (typeof this.options.baseRef === 'string') {
112
- this.options.baseRef = this.options.baseRef.trim();
113
+ this.options.author = {
114
+ name: trimmedAuthorName,
115
+ email: trimmedAuthorEmail,
116
+ };
117
+ if (typeof this.options.expectedHeadSha === 'string') {
118
+ this.options.expectedHeadSha = this.options.expectedHeadSha.trim();
113
119
  }
114
120
  }
115
121
 
@@ -135,11 +141,21 @@ export class CommitBuilderImpl implements CommitBuilder {
135
141
  contents: string,
136
142
  options?: CommitTextFileOptions,
137
143
  ): CommitBuilder {
138
- const encoding = options?.encoding;
139
- if (encoding && encoding !== 'utf8' && encoding !== 'utf-8') {
140
- throw new Error(`Unsupported encoding "${encoding}". Only UTF-8 is supported.`);
144
+ const encoding = options?.encoding ?? 'utf8';
145
+ const normalizedEncoding = encoding === 'utf-8' ? 'utf8' : encoding;
146
+ let data: Uint8Array;
147
+ if (normalizedEncoding === 'utf8') {
148
+ data = new TextEncoder().encode(contents);
149
+ } else if (BufferCtor) {
150
+ data = BufferCtor.from(
151
+ contents,
152
+ normalizedEncoding as Parameters<NodeBufferConstructor['from']>[1],
153
+ );
154
+ } else {
155
+ throw new Error(
156
+ `Unsupported encoding "${encoding}" in this environment. Non-UTF encodings require Node.js Buffer support.`,
157
+ );
141
158
  }
142
- const data = new TextEncoder().encode(contents);
143
159
  return this.addFile(path, data, options);
144
160
  }
145
161
 
@@ -154,7 +170,7 @@ export class CommitBuilderImpl implements CommitBuilder {
154
170
  return this;
155
171
  }
156
172
 
157
- async send(): Promise<CommitResponse> {
173
+ async send(): Promise<CommitResult> {
158
174
  this.ensureNotSent();
159
175
  this.sent = true;
160
176
 
@@ -167,12 +183,13 @@ export class CommitBuilderImpl implements CommitBuilder {
167
183
  }));
168
184
 
169
185
  const authorization = await this.getAuthToken();
170
- return this.transport.send({
186
+ const ack = await this.transport.send({
171
187
  authorization,
172
188
  signal: this.options.signal,
173
189
  metadata,
174
190
  blobs: blobEntries,
175
191
  });
192
+ return buildCommitResult(ack);
176
193
  }
177
194
 
178
195
  private buildMetadata(): CommitMetadataPayload {
@@ -189,19 +206,23 @@ export class CommitBuilderImpl implements CommitBuilder {
189
206
  });
190
207
 
191
208
  const metadata: CommitMetadataPayload = {
192
- target_ref: this.options.targetRef,
209
+ target_branch: this.options.targetBranch,
193
210
  commit_message: this.options.commitMessage,
211
+ author: {
212
+ name: this.options.author.name,
213
+ email: this.options.author.email,
214
+ },
194
215
  files,
195
216
  };
196
217
 
197
- if (this.options.baseRef) {
198
- metadata.base_ref = this.options.baseRef;
199
- }
200
- if (this.options.author) {
201
- metadata.author = { ...this.options.author };
218
+ if (this.options.expectedHeadSha) {
219
+ metadata.expected_head_sha = this.options.expectedHeadSha;
202
220
  }
203
221
  if (this.options.committer) {
204
- metadata.committer = { ...this.options.committer };
222
+ metadata.committer = {
223
+ name: this.options.committer.name,
224
+ email: this.options.committer.email,
225
+ };
205
226
  }
206
227
 
207
228
  return metadata;
@@ -229,7 +250,7 @@ export class FetchCommitTransport implements CommitTransport {
229
250
  this.url = `${trimmedBase}/api/v${config.version}/repos/commit-pack`;
230
251
  }
231
252
 
232
- async send(request: CommitTransportRequest): Promise<CommitResponse> {
253
+ async send(request: CommitTransportRequest): Promise<CommitPackAck> {
233
254
  const bodyIterable = buildMessageIterable(request.metadata, request.blobs);
234
255
  const body = toRequestBody(bodyIterable);
235
256
 
@@ -245,11 +266,16 @@ export class FetchCommitTransport implements CommitTransport {
245
266
  });
246
267
 
247
268
  if (!response.ok) {
248
- const text = await response.text();
249
- throw new Error(`createCommit request failed (${response.status}): ${text}`);
269
+ const { statusMessage, statusLabel, refUpdate } = await parseCommitPackError(response);
270
+ throw new RefUpdateError(statusMessage, {
271
+ status: statusLabel,
272
+ message: statusMessage,
273
+ refUpdate,
274
+ });
250
275
  }
251
276
 
252
- return (await response.json()) as CommitResponse;
277
+ const ack = commitPackAckSchema.parse(await response.json());
278
+ return ack;
253
279
  }
254
280
  }
255
281
 
@@ -302,6 +328,36 @@ function buildMessageIterable(
302
328
  };
303
329
  }
304
330
 
331
+ function buildCommitResult(ack: CommitPackAck): CommitResult {
332
+ const refUpdate = toRefUpdate(ack.result);
333
+ if (!ack.result.success) {
334
+ throw new RefUpdateError(
335
+ ack.result.message ?? `Commit failed with status ${ack.result.status}`,
336
+ {
337
+ status: ack.result.status,
338
+ message: ack.result.message,
339
+ refUpdate,
340
+ },
341
+ );
342
+ }
343
+ return {
344
+ commitSha: ack.commit.commit_sha,
345
+ treeSha: ack.commit.tree_sha,
346
+ targetBranch: ack.commit.target_branch,
347
+ packBytes: ack.commit.pack_bytes,
348
+ blobCount: ack.commit.blob_count,
349
+ refUpdate,
350
+ };
351
+ }
352
+
353
+ function toRefUpdate(result: CommitPackAck['result']): RefUpdate {
354
+ return {
355
+ branch: result.branch,
356
+ oldSha: result.old_sha,
357
+ newSha: result.new_sha,
358
+ };
359
+ }
360
+
305
361
  async function* chunkify(source: AsyncIterable<Uint8Array>): AsyncIterable<ChunkSegment> {
306
362
  let pending: Uint8Array | null = null;
307
363
  let produced = false;
@@ -369,6 +425,10 @@ async function* toAsyncIterable(source: CommitFileSource): AsyncIterable<Uint8Ar
369
425
  return;
370
426
  }
371
427
  }
428
+ if (isReadableStreamLike(source)) {
429
+ yield* readReadableStream(source);
430
+ return;
431
+ }
372
432
  if (isAsyncIterable(source)) {
373
433
  for await (const chunk of source as AsyncIterable<unknown>) {
374
434
  yield ensureUint8Array(chunk);
@@ -492,5 +552,98 @@ export function createCommitBuilder(deps: CommitBuilderDeps): CommitBuilder {
492
552
  }
493
553
 
494
554
  export function resolveCommitTtlSeconds(options?: { ttl?: number }): number {
495
- return typeof options?.ttl === 'number' && options.ttl > 0 ? options.ttl : DEFAULT_TTL_SECONDS;
555
+ if (typeof options?.ttl === 'number' && options.ttl > 0) {
556
+ return options.ttl;
557
+ }
558
+ return DEFAULT_TTL_SECONDS;
559
+ }
560
+
561
+ async function parseCommitPackError(response: Response): Promise<{
562
+ statusMessage: string;
563
+ statusLabel: string;
564
+ refUpdate?: Partial<RefUpdate>;
565
+ }> {
566
+ const fallbackMessage = `createCommit request failed (${response.status} ${response.statusText})`;
567
+ const cloned = response.clone();
568
+ let jsonBody: unknown;
569
+ try {
570
+ jsonBody = await cloned.json();
571
+ } catch {
572
+ jsonBody = undefined;
573
+ }
574
+
575
+ let textBody: string | undefined;
576
+ if (jsonBody === undefined) {
577
+ try {
578
+ textBody = await response.text();
579
+ } catch {
580
+ textBody = undefined;
581
+ }
582
+ }
583
+
584
+ const defaultStatus = (() => {
585
+ const inferred = inferRefUpdateReason(String(response.status));
586
+ return inferred === 'unknown' ? 'failed' : inferred;
587
+ })();
588
+ let statusLabel = defaultStatus;
589
+ let refUpdate: Partial<RefUpdate> | undefined;
590
+ let message: string | undefined;
591
+
592
+ if (jsonBody !== undefined) {
593
+ const parsedResponse = commitPackResponseSchema.safeParse(jsonBody);
594
+ if (parsedResponse.success) {
595
+ const result = parsedResponse.data.result;
596
+ if (typeof result.status === 'string' && result.status.trim() !== '') {
597
+ statusLabel = result.status.trim() as typeof statusLabel;
598
+ }
599
+ refUpdate = toPartialRefUpdateFields(result.branch, result.old_sha, result.new_sha);
600
+ if (typeof result.message === 'string' && result.message.trim() !== '') {
601
+ message = result.message.trim();
602
+ }
603
+ }
604
+
605
+ if (!message) {
606
+ const parsedError = errorEnvelopeSchema.safeParse(jsonBody);
607
+ if (parsedError.success) {
608
+ const trimmed = parsedError.data.error.trim();
609
+ if (trimmed) {
610
+ message = trimmed;
611
+ }
612
+ }
613
+ }
614
+ }
615
+
616
+ if (!message && typeof jsonBody === 'string' && jsonBody.trim() !== '') {
617
+ message = jsonBody.trim();
618
+ }
619
+
620
+ if (!message && textBody && textBody.trim() !== '') {
621
+ message = textBody.trim();
622
+ }
623
+
624
+ return {
625
+ statusMessage: message ?? fallbackMessage,
626
+ statusLabel,
627
+ refUpdate,
628
+ };
629
+ }
630
+
631
+ function toPartialRefUpdateFields(
632
+ branch?: string | null,
633
+ oldSha?: string | null,
634
+ newSha?: string | null,
635
+ ): Partial<RefUpdate> | undefined {
636
+ const refUpdate: Partial<RefUpdate> = {};
637
+
638
+ if (typeof branch === 'string' && branch.trim() !== '') {
639
+ refUpdate.branch = branch.trim();
640
+ }
641
+ if (typeof oldSha === 'string' && oldSha.trim() !== '') {
642
+ refUpdate.oldSha = oldSha.trim();
643
+ }
644
+ if (typeof newSha === 'string' && newSha.trim() !== '') {
645
+ refUpdate.newSha = newSha.trim();
646
+ }
647
+
648
+ return Object.keys(refUpdate).length > 0 ? refUpdate : undefined;
496
649
  }
package/src/errors.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { RefUpdate, RefUpdateReason } from './types';
2
+
3
+ export interface RefUpdateErrorOptions {
4
+ status: string;
5
+ message?: string;
6
+ refUpdate?: Partial<RefUpdate>;
7
+ reason?: RefUpdateReason;
8
+ }
9
+
10
+ export class RefUpdateError extends Error {
11
+ public readonly status: string;
12
+ public readonly reason: RefUpdateReason;
13
+ public readonly refUpdate?: Partial<RefUpdate>;
14
+
15
+ constructor(message: string, options: RefUpdateErrorOptions) {
16
+ super(message);
17
+ this.name = 'RefUpdateError';
18
+ this.status = options.status;
19
+ this.reason = options.reason ?? inferRefUpdateReason(options.status);
20
+ this.refUpdate = options.refUpdate;
21
+ }
22
+ }
23
+
24
+ const REF_REASON_MAP: Record<string, RefUpdateReason> = {
25
+ precondition_failed: 'precondition_failed',
26
+ conflict: 'conflict',
27
+ not_found: 'not_found',
28
+ invalid: 'invalid',
29
+ timeout: 'timeout',
30
+ unauthorized: 'unauthorized',
31
+ forbidden: 'forbidden',
32
+ unavailable: 'unavailable',
33
+ internal: 'internal',
34
+ failed: 'failed',
35
+ ok: 'unknown',
36
+ };
37
+
38
+ export function inferRefUpdateReason(status?: string): RefUpdateReason {
39
+ if (!status) {
40
+ return 'unknown';
41
+ }
42
+
43
+ const trimmed = status.trim();
44
+ if (trimmed === '') {
45
+ return 'unknown';
46
+ }
47
+
48
+ const label = trimmed.toLowerCase();
49
+ return REF_REASON_MAP[label] ?? 'unknown';
50
+ }
package/src/fetch.ts CHANGED
@@ -1,16 +1,40 @@
1
+ import { errorEnvelopeSchema } from './schemas';
1
2
  import type { ValidAPIVersion, ValidMethod, ValidPath } from './types';
2
3
 
3
4
  interface RequestOptions {
4
5
  allowedStatus?: number[];
5
6
  }
6
7
 
8
+ export class ApiError extends Error {
9
+ public readonly status: number;
10
+ public readonly statusText: string;
11
+ public readonly method: ValidMethod;
12
+ public readonly url: string;
13
+ public readonly body?: unknown;
14
+
15
+ constructor(params: {
16
+ message: string;
17
+ status: number;
18
+ statusText: string;
19
+ method: ValidMethod;
20
+ url: string;
21
+ body?: unknown;
22
+ }) {
23
+ super(params.message);
24
+ this.name = 'ApiError';
25
+ this.status = params.status;
26
+ this.statusText = params.statusText;
27
+ this.method = params.method;
28
+ this.url = params.url;
29
+ this.body = params.body;
30
+ }
31
+ }
32
+
7
33
  export class ApiFetcher {
8
34
  constructor(
9
35
  private readonly API_BASE_URL: string,
10
36
  private readonly version: ValidAPIVersion,
11
- ) {
12
- console.log('api fetcher created', API_BASE_URL, version);
13
- }
37
+ ) {}
14
38
 
15
39
  private getBaseUrl() {
16
40
  return `${this.API_BASE_URL}/api/v${this.version}`;
@@ -46,9 +70,55 @@ export class ApiFetcher {
46
70
 
47
71
  if (!response.ok) {
48
72
  const allowed = options?.allowedStatus ?? [];
49
- if (!allowed.includes(response.status)) {
50
- throw new Error(`Failed to fetch ${method} ${requestUrl}: ${response.statusText}`);
73
+ if (allowed.includes(response.status)) {
74
+ return response;
51
75
  }
76
+
77
+ let errorBody: unknown;
78
+ let message: string | undefined;
79
+ const contentType = response.headers.get('content-type') ?? '';
80
+
81
+ try {
82
+ if (contentType.includes('application/json')) {
83
+ errorBody = await response.json();
84
+ } else {
85
+ const text = await response.text();
86
+ errorBody = text;
87
+ }
88
+ } catch {
89
+ // Fallback to plain text if JSON parse failed after reading body
90
+ try {
91
+ errorBody = await response.text();
92
+ } catch {
93
+ errorBody = undefined;
94
+ }
95
+ }
96
+
97
+ if (typeof errorBody === 'string') {
98
+ const trimmed = errorBody.trim();
99
+ if (trimmed) {
100
+ message = trimmed;
101
+ }
102
+ } else if (errorBody && typeof errorBody === 'object') {
103
+ const parsedError = errorEnvelopeSchema.safeParse(errorBody);
104
+ if (parsedError.success) {
105
+ const trimmed = parsedError.data.error.trim();
106
+ if (trimmed) {
107
+ message = trimmed;
108
+ }
109
+ }
110
+ }
111
+
112
+ throw new ApiError({
113
+ message:
114
+ message ??
115
+ `Request ${method} ${requestUrl} failed with status ${response.status} ${response.statusText}`,
116
+ status: response.status,
117
+ statusText: response.statusText,
118
+ method,
119
+ url: requestUrl,
120
+ body: errorBody,
121
+ });
52
122
  }
53
123
  return response;
54
124
  }