@beyondplusmm/doehpos-sdk 0.1.0
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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +54 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +96 -0
- package/dist/errors.js.map +1 -0
- package/dist/idempotency.d.ts +17 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +30 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/delivery.d.ts +26 -0
- package/dist/modules/delivery.d.ts.map +1 -0
- package/dist/modules/delivery.js +46 -0
- package/dist/modules/delivery.js.map +1 -0
- package/dist/modules/experimental/kitchen.d.ts +30 -0
- package/dist/modules/experimental/kitchen.d.ts.map +1 -0
- package/dist/modules/experimental/kitchen.js +32 -0
- package/dist/modules/experimental/kitchen.js.map +1 -0
- package/dist/modules/experimental/loyalty.d.ts +30 -0
- package/dist/modules/experimental/loyalty.d.ts.map +1 -0
- package/dist/modules/experimental/loyalty.js +34 -0
- package/dist/modules/experimental/loyalty.js.map +1 -0
- package/dist/modules/experimental/marketplace.d.ts +31 -0
- package/dist/modules/experimental/marketplace.d.ts.map +1 -0
- package/dist/modules/experimental/marketplace.js +32 -0
- package/dist/modules/experimental/marketplace.js.map +1 -0
- package/dist/modules/experimental/rider.d.ts +29 -0
- package/dist/modules/experimental/rider.d.ts.map +1 -0
- package/dist/modules/experimental/rider.js +32 -0
- package/dist/modules/experimental/rider.js.map +1 -0
- package/dist/queue.d.ts +78 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +105 -0
- package/dist/queue.js.map +1 -0
- package/dist/transport.d.ts +36 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +105 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
- package/src/client.ts +92 -0
- package/src/config.ts +30 -0
- package/src/errors.ts +119 -0
- package/src/idempotency.ts +33 -0
- package/src/index.ts +64 -0
- package/src/modules/delivery.ts +57 -0
- package/src/modules/experimental/kitchen.ts +51 -0
- package/src/modules/experimental/loyalty.ts +52 -0
- package/src/modules/experimental/marketplace.ts +52 -0
- package/src/modules/experimental/rider.ts +50 -0
- package/src/queue.ts +157 -0
- package/src/transport.ts +140 -0
- package/src/types.ts +65 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kitchen — EXPERIMENTAL.
|
|
3
|
+
*
|
|
4
|
+
* @experimental Schema/golden-client-derived; not yet exercised by the Expo
|
|
5
|
+
* reference app. Shapes may change before they graduate to the stable contract.
|
|
6
|
+
* Promotion rule: experimental -> reference-app exercised -> stable.
|
|
7
|
+
*/
|
|
8
|
+
import type { Transport } from "../../transport.js";
|
|
9
|
+
import type { CallOptions } from "../../types.js";
|
|
10
|
+
import { generateIdempotencyKey } from "../../idempotency.js";
|
|
11
|
+
|
|
12
|
+
export interface TicketCreate {
|
|
13
|
+
station: string;
|
|
14
|
+
items: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface TicketResponse {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
idempotent?: boolean;
|
|
19
|
+
ticket: { id: string; [k: string]: unknown };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PATH_ID = /^[A-Za-z0-9_]+$/;
|
|
23
|
+
|
|
24
|
+
export class KitchenModule {
|
|
25
|
+
constructor(private readonly transport: Transport) {}
|
|
26
|
+
|
|
27
|
+
/** @experimental */
|
|
28
|
+
async createTicket(input: TicketCreate, opts: CallOptions = {}): Promise<TicketResponse> {
|
|
29
|
+
const { body } = await this.transport.request<TicketResponse>({
|
|
30
|
+
method: "POST",
|
|
31
|
+
path: "/v1/kitchen/tickets",
|
|
32
|
+
body: input,
|
|
33
|
+
idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey("kitchen"),
|
|
34
|
+
traceId: opts.traceId,
|
|
35
|
+
signal: opts.signal,
|
|
36
|
+
});
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @experimental */
|
|
41
|
+
async getTicket(id: string, opts: CallOptions = {}): Promise<TicketResponse> {
|
|
42
|
+
if (!PATH_ID.test(id)) throw new RangeError(`invalid ticket id ${JSON.stringify(id)}`);
|
|
43
|
+
const { body } = await this.transport.request<TicketResponse>({
|
|
44
|
+
method: "GET",
|
|
45
|
+
path: `/v1/kitchen/tickets/${id}`,
|
|
46
|
+
traceId: opts.traceId,
|
|
47
|
+
signal: opts.signal,
|
|
48
|
+
});
|
|
49
|
+
return body;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loyalty — EXPERIMENTAL.
|
|
3
|
+
*
|
|
4
|
+
* @experimental Schema/golden-client-derived; not yet exercised by the Expo
|
|
5
|
+
* reference app. `earn` auto-provisions the member account. Member ids must
|
|
6
|
+
* match [A-Za-z0-9_]+ (no hyphens).
|
|
7
|
+
*/
|
|
8
|
+
import type { Transport } from "../../transport.js";
|
|
9
|
+
import type { CallOptions } from "../../types.js";
|
|
10
|
+
import { generateIdempotencyKey } from "../../idempotency.js";
|
|
11
|
+
|
|
12
|
+
export interface EarnInput {
|
|
13
|
+
points: number;
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface AccountResponse {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
idempotent?: boolean;
|
|
19
|
+
account: { balance: number; [k: string]: unknown };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MEMBER_ID = /^[A-Za-z0-9_]+$/;
|
|
23
|
+
|
|
24
|
+
export class LoyaltyModule {
|
|
25
|
+
constructor(private readonly transport: Transport) {}
|
|
26
|
+
|
|
27
|
+
/** @experimental */
|
|
28
|
+
async earn(memberId: string, input: EarnInput, opts: CallOptions = {}): Promise<AccountResponse> {
|
|
29
|
+
if (!MEMBER_ID.test(memberId)) throw new RangeError(`invalid member id ${JSON.stringify(memberId)}`);
|
|
30
|
+
const { body } = await this.transport.request<AccountResponse>({
|
|
31
|
+
method: "POST",
|
|
32
|
+
path: `/v1/loyalty/members/${memberId}/earn`,
|
|
33
|
+
body: input,
|
|
34
|
+
idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey("loyalty"),
|
|
35
|
+
traceId: opts.traceId,
|
|
36
|
+
signal: opts.signal,
|
|
37
|
+
});
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @experimental */
|
|
42
|
+
async getMember(memberId: string, opts: CallOptions = {}): Promise<AccountResponse> {
|
|
43
|
+
if (!MEMBER_ID.test(memberId)) throw new RangeError(`invalid member id ${JSON.stringify(memberId)}`);
|
|
44
|
+
const { body } = await this.transport.request<AccountResponse>({
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: `/v1/loyalty/members/${memberId}`,
|
|
47
|
+
traceId: opts.traceId,
|
|
48
|
+
signal: opts.signal,
|
|
49
|
+
});
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace — EXPERIMENTAL.
|
|
3
|
+
*
|
|
4
|
+
* @experimental Schema/golden-client-derived; not yet exercised by the Expo
|
|
5
|
+
* reference app. Money is integer minor units.
|
|
6
|
+
*/
|
|
7
|
+
import type { Transport } from "../../transport.js";
|
|
8
|
+
import type { CallOptions, Currency } from "../../types.js";
|
|
9
|
+
import { generateIdempotencyKey } from "../../idempotency.js";
|
|
10
|
+
|
|
11
|
+
export interface ListingCreate {
|
|
12
|
+
title: string;
|
|
13
|
+
currency: Currency;
|
|
14
|
+
price_minor: number;
|
|
15
|
+
stock: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ListingResponse {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
idempotent?: boolean;
|
|
20
|
+
listing: { id: string; [k: string]: unknown };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PATH_ID = /^[A-Za-z0-9_]+$/;
|
|
24
|
+
|
|
25
|
+
export class MarketplaceModule {
|
|
26
|
+
constructor(private readonly transport: Transport) {}
|
|
27
|
+
|
|
28
|
+
/** @experimental */
|
|
29
|
+
async createListing(input: ListingCreate, opts: CallOptions = {}): Promise<ListingResponse> {
|
|
30
|
+
const { body } = await this.transport.request<ListingResponse>({
|
|
31
|
+
method: "POST",
|
|
32
|
+
path: "/v1/marketplace/listings",
|
|
33
|
+
body: input,
|
|
34
|
+
idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey("market"),
|
|
35
|
+
traceId: opts.traceId,
|
|
36
|
+
signal: opts.signal,
|
|
37
|
+
});
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @experimental */
|
|
42
|
+
async getListing(id: string, opts: CallOptions = {}): Promise<ListingResponse> {
|
|
43
|
+
if (!PATH_ID.test(id)) throw new RangeError(`invalid listing id ${JSON.stringify(id)}`);
|
|
44
|
+
const { body } = await this.transport.request<ListingResponse>({
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: `/v1/marketplace/listings/${id}`,
|
|
47
|
+
traceId: opts.traceId,
|
|
48
|
+
signal: opts.signal,
|
|
49
|
+
});
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rider — EXPERIMENTAL.
|
|
3
|
+
*
|
|
4
|
+
* @experimental Schema/golden-client-derived; not yet exercised by the Expo
|
|
5
|
+
* reference app.
|
|
6
|
+
*/
|
|
7
|
+
import type { Transport } from "../../transport.js";
|
|
8
|
+
import type { CallOptions } from "../../types.js";
|
|
9
|
+
import { generateIdempotencyKey } from "../../idempotency.js";
|
|
10
|
+
|
|
11
|
+
export interface JobCreate {
|
|
12
|
+
pickup: string;
|
|
13
|
+
dropoff: string;
|
|
14
|
+
}
|
|
15
|
+
export interface JobResponse {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
idempotent?: boolean;
|
|
18
|
+
job: { id: string; [k: string]: unknown };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PATH_ID = /^[A-Za-z0-9_]+$/;
|
|
22
|
+
|
|
23
|
+
export class RiderModule {
|
|
24
|
+
constructor(private readonly transport: Transport) {}
|
|
25
|
+
|
|
26
|
+
/** @experimental */
|
|
27
|
+
async createJob(input: JobCreate, opts: CallOptions = {}): Promise<JobResponse> {
|
|
28
|
+
const { body } = await this.transport.request<JobResponse>({
|
|
29
|
+
method: "POST",
|
|
30
|
+
path: "/v1/rider/jobs",
|
|
31
|
+
body: input,
|
|
32
|
+
idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey("rider"),
|
|
33
|
+
traceId: opts.traceId,
|
|
34
|
+
signal: opts.signal,
|
|
35
|
+
});
|
|
36
|
+
return body;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @experimental */
|
|
40
|
+
async getJob(id: string, opts: CallOptions = {}): Promise<JobResponse> {
|
|
41
|
+
if (!PATH_ID.test(id)) throw new RangeError(`invalid job id ${JSON.stringify(id)}`);
|
|
42
|
+
const { body } = await this.transport.request<JobResponse>({
|
|
43
|
+
method: "GET",
|
|
44
|
+
path: `/v1/rider/jobs/${id}`,
|
|
45
|
+
traceId: opts.traceId,
|
|
46
|
+
signal: opts.signal,
|
|
47
|
+
});
|
|
48
|
+
return body;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OfflineQueue — durable, idempotency-owning mutation queue.
|
|
3
|
+
*
|
|
4
|
+
* This is the heart of the mobile design. The rule it enforces:
|
|
5
|
+
*
|
|
6
|
+
* The idempotency key is minted ONCE at enqueue() time, persisted with the
|
|
7
|
+
* payload, and reused on EVERY flush attempt — forever, until the server
|
|
8
|
+
* accepts it. This is what makes "press create in airplane mode, reconnect,
|
|
9
|
+
* sync" produce exactly one order rather than one-per-retry.
|
|
10
|
+
*
|
|
11
|
+
* Retry policy mirrors the SDK transport:
|
|
12
|
+
* - transport failure / 429 -> keep the item, try again next flush
|
|
13
|
+
* - any other non-2xx (4xx validation, etc.) -> the item can NEVER succeed,
|
|
14
|
+
* so it is removed and surfaced as a dead letter (do not loop forever)
|
|
15
|
+
*
|
|
16
|
+
* Storage is pluggable (AsyncStorage on RN, a file/Map in Node/tests) so the
|
|
17
|
+
* SDK core stays runtime-agnostic.
|
|
18
|
+
*/
|
|
19
|
+
import type { DeliveryModule } from "./modules/delivery.js";
|
|
20
|
+
import type { OrderCreate, OrderResponse } from "./types.js";
|
|
21
|
+
import { DoehApiError, isRetryable } from "./errors.js";
|
|
22
|
+
import { generateIdempotencyKey } from "./idempotency.js";
|
|
23
|
+
|
|
24
|
+
export interface QueueStorage {
|
|
25
|
+
load(): Promise<string | null>;
|
|
26
|
+
save(serialized: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An in-memory storage — handy for tests and as a reference implementation. */
|
|
30
|
+
export class MemoryStorage implements QueueStorage {
|
|
31
|
+
private value: string | null = null;
|
|
32
|
+
async load(): Promise<string | null> {
|
|
33
|
+
return this.value;
|
|
34
|
+
}
|
|
35
|
+
async save(serialized: string): Promise<void> {
|
|
36
|
+
this.value = serialized;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface QueuedMutation {
|
|
41
|
+
/** Local id (also the user-visible "pending order" handle). */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Minted once at enqueue; reused on every attempt. */
|
|
44
|
+
idempotencyKey: string;
|
|
45
|
+
module: "delivery";
|
|
46
|
+
payload: OrderCreate;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
attempts: number;
|
|
49
|
+
lastError?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DeadLetter {
|
|
53
|
+
mutation: QueuedMutation;
|
|
54
|
+
error: DoehApiError;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FlushResult {
|
|
58
|
+
/** Mutations the server accepted this flush. */
|
|
59
|
+
succeeded: { mutation: QueuedMutation; response: OrderResponse }[];
|
|
60
|
+
/** Mutations that can never succeed — removed from the queue. */
|
|
61
|
+
deadLettered: DeadLetter[];
|
|
62
|
+
/** Mutations still pending (transport/429) — remain queued. */
|
|
63
|
+
remaining: QueuedMutation[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class OfflineQueue {
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly delivery: DeliveryModule,
|
|
69
|
+
private readonly storage: QueueStorage,
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
private async read(): Promise<QueuedMutation[]> {
|
|
73
|
+
const raw = await this.storage.load();
|
|
74
|
+
if (!raw) return [];
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
return Array.isArray(parsed) ? (parsed as QueuedMutation[]) : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async write(items: QueuedMutation[]): Promise<void> {
|
|
84
|
+
await this.storage.save(JSON.stringify(items));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Current pending items (read-only snapshot). */
|
|
88
|
+
async pending(): Promise<QueuedMutation[]> {
|
|
89
|
+
return this.read();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enqueue a delivery create. Mints and persists the idempotency key now;
|
|
94
|
+
* that key is never regenerated. Returns the local handle.
|
|
95
|
+
*/
|
|
96
|
+
async enqueue(payload: OrderCreate): Promise<QueuedMutation> {
|
|
97
|
+
const mutation: QueuedMutation = {
|
|
98
|
+
id: generateIdempotencyKey("local"),
|
|
99
|
+
idempotencyKey: generateIdempotencyKey("delivery"),
|
|
100
|
+
module: "delivery",
|
|
101
|
+
payload,
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
attempts: 0,
|
|
104
|
+
};
|
|
105
|
+
const items = await this.read();
|
|
106
|
+
items.push(mutation);
|
|
107
|
+
await this.write(items);
|
|
108
|
+
return mutation;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Attempt every pending mutation in FIFO order, reusing each stored key.
|
|
113
|
+
* Stops draining on the first retryable failure (likely still offline) so we
|
|
114
|
+
* don't hammer a dead network, but always persists progress.
|
|
115
|
+
*/
|
|
116
|
+
async flush(): Promise<FlushResult> {
|
|
117
|
+
const items = await this.read();
|
|
118
|
+
const result: FlushResult = { succeeded: [], deadLettered: [], remaining: [] };
|
|
119
|
+
const keep: QueuedMutation[] = [];
|
|
120
|
+
let networkDown = false;
|
|
121
|
+
|
|
122
|
+
for (const m of items) {
|
|
123
|
+
if (networkDown) {
|
|
124
|
+
keep.push(m);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
m.attempts += 1;
|
|
128
|
+
try {
|
|
129
|
+
const response = await this.delivery.create(m.payload, {
|
|
130
|
+
idempotencyKey: m.idempotencyKey, // SAME key, every attempt
|
|
131
|
+
});
|
|
132
|
+
result.succeeded.push({ mutation: m, response });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (isRetryable(err)) {
|
|
135
|
+
// Still offline / rate-limited — keep it and stop draining.
|
|
136
|
+
m.lastError = (err as Error).message;
|
|
137
|
+
keep.push(m);
|
|
138
|
+
networkDown = true;
|
|
139
|
+
} else if (err instanceof DoehApiError) {
|
|
140
|
+
// Terminal: this payload will never be accepted. Dead-letter it.
|
|
141
|
+
result.deadLettered.push({ mutation: m, error: err });
|
|
142
|
+
} else {
|
|
143
|
+
// A client-side guard (e.g. bad amount) — also terminal.
|
|
144
|
+
m.lastError = (err as Error).message;
|
|
145
|
+
result.deadLettered.push({
|
|
146
|
+
mutation: m,
|
|
147
|
+
error: new DoehApiError(0, "CLIENT_VALIDATION", { body: m.lastError }),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
result.remaining = keep;
|
|
154
|
+
await this.write(keep);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport: one place that knows how to shape, send, and retry a request.
|
|
3
|
+
*
|
|
4
|
+
* Faithful to the validated golden client:
|
|
5
|
+
* - Authorization: Bearer <key>
|
|
6
|
+
* - User-Agent is MANDATORY (the edge/WAF rejects default library agents
|
|
7
|
+
* before they ever reach the API).
|
|
8
|
+
* - Trace-Id is sent and echoed; surfaced on errors for log correlation.
|
|
9
|
+
* - Idempotency-Key (NOT X-Idempotency-Key) enables safe create retries.
|
|
10
|
+
* - 429 and transport failures are retried with linear backoff; nothing else.
|
|
11
|
+
*/
|
|
12
|
+
import { DEFAULTS } from "./config.js";
|
|
13
|
+
import { DoehTransportError, mapApiError, isRetryable } from "./errors.js";
|
|
14
|
+
import { generateIdempotencyKey } from "./idempotency.js";
|
|
15
|
+
|
|
16
|
+
/** Pluggable fetch (defaults to global; inject for RN or tests). */
|
|
17
|
+
export type FetchLike = typeof fetch;
|
|
18
|
+
|
|
19
|
+
export interface TransportConfig {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
apiKey: string;
|
|
22
|
+
userAgent: string;
|
|
23
|
+
fetch: FetchLike;
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
maxRetries: number;
|
|
26
|
+
backoffBaseMs: number;
|
|
27
|
+
/** Hook for tests to make backoff instant. */
|
|
28
|
+
sleep?: (ms: number) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RequestSpec {
|
|
32
|
+
method: "GET" | "POST";
|
|
33
|
+
path: string;
|
|
34
|
+
body?: unknown;
|
|
35
|
+
idempotencyKey?: string;
|
|
36
|
+
traceId?: string;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/** Send no Authorization header (used only to prove the auth gate). */
|
|
39
|
+
anonymous?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RawResponse<T> {
|
|
43
|
+
status: number;
|
|
44
|
+
body: T;
|
|
45
|
+
traceId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
49
|
+
|
|
50
|
+
function parseBody(text: string): unknown {
|
|
51
|
+
if (!text) return {};
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
// A non-JSON body (e.g. a WAF/proxy HTML error) — surface it structured.
|
|
56
|
+
return { ok: false, code: "NON_JSON_RESPONSE", raw: text.slice(0, 200) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class Transport {
|
|
61
|
+
constructor(private readonly cfg: TransportConfig) {}
|
|
62
|
+
|
|
63
|
+
/** Send one logical request, retrying transport errors and 429 only. */
|
|
64
|
+
async request<T>(spec: RequestSpec): Promise<RawResponse<T>> {
|
|
65
|
+
const sleep = this.cfg.sleep ?? defaultSleep;
|
|
66
|
+
const traceId = spec.traceId ?? generateIdempotencyKey("trace");
|
|
67
|
+
const max = this.cfg.maxRetries;
|
|
68
|
+
|
|
69
|
+
let attempt = 0;
|
|
70
|
+
// eslint-disable-next-line no-constant-condition
|
|
71
|
+
while (true) {
|
|
72
|
+
try {
|
|
73
|
+
return await this.attempt<T>(spec, traceId);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (isRetryable(err) && attempt < max) {
|
|
76
|
+
attempt += 1;
|
|
77
|
+
await sleep(this.cfg.backoffBaseMs * attempt);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async attempt<T>(spec: RequestSpec, traceId: string): Promise<RawResponse<T>> {
|
|
86
|
+
const url = this.cfg.baseUrl.replace(/\/$/, "") + spec.path;
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": this.cfg.userAgent,
|
|
90
|
+
"Trace-Id": traceId,
|
|
91
|
+
};
|
|
92
|
+
if (!spec.anonymous) headers["Authorization"] = `Bearer ${this.cfg.apiKey}`;
|
|
93
|
+
if (spec.idempotencyKey) headers["Idempotency-Key"] = spec.idempotencyKey;
|
|
94
|
+
|
|
95
|
+
// Compose the caller signal with our timeout.
|
|
96
|
+
const timeout = new AbortController();
|
|
97
|
+
const timer = setTimeout(() => timeout.abort(), this.cfg.timeoutMs);
|
|
98
|
+
const signal = spec.signal
|
|
99
|
+
? anySignal([spec.signal, timeout.signal])
|
|
100
|
+
: timeout.signal;
|
|
101
|
+
|
|
102
|
+
let res: Response;
|
|
103
|
+
try {
|
|
104
|
+
res = await this.cfg.fetch(url, {
|
|
105
|
+
method: spec.method,
|
|
106
|
+
headers,
|
|
107
|
+
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
const aborted = (cause as { name?: string })?.name === "AbortError";
|
|
112
|
+
throw new DoehTransportError(
|
|
113
|
+
aborted ? `request timed out after ${this.cfg.timeoutMs}ms` : "network request failed",
|
|
114
|
+
{ cause, timeout: aborted },
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = await res.text();
|
|
121
|
+
const body = parseBody(text);
|
|
122
|
+
if (res.status >= 200 && res.status < 300) {
|
|
123
|
+
return { status: res.status, body: body as T, traceId };
|
|
124
|
+
}
|
|
125
|
+
throw mapApiError(res.status, body, traceId);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Minimal AbortSignal.any polyfill (not available on all RN runtimes). */
|
|
130
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
for (const s of signals) {
|
|
133
|
+
if (s.aborted) {
|
|
134
|
+
controller.abort();
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
s.addEventListener("abort", () => controller.abort(), { once: true });
|
|
138
|
+
}
|
|
139
|
+
return controller.signal;
|
|
140
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types, mirrored from the public OpenAPI specs and the golden client.
|
|
3
|
+
*
|
|
4
|
+
* Money is ALWAYS integer minor units (1500 == 15.00). Never a decimal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Currency = "MMK" | "THB" | "USD" | "CNY" | "SGD" | "INR";
|
|
8
|
+
|
|
9
|
+
export type OrderStatus =
|
|
10
|
+
| "pending"
|
|
11
|
+
| "confirmed"
|
|
12
|
+
| "dispatched"
|
|
13
|
+
| "delivered"
|
|
14
|
+
| "cancelled";
|
|
15
|
+
|
|
16
|
+
/** Request body for POST /v1/delivery/orders. */
|
|
17
|
+
export interface OrderCreate {
|
|
18
|
+
currency: Currency;
|
|
19
|
+
/** Integer minor units, >= 1. e.g. 1500 = 15.00. */
|
|
20
|
+
amount_minor: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Order {
|
|
24
|
+
id: string;
|
|
25
|
+
status: OrderStatus;
|
|
26
|
+
shop_id: number;
|
|
27
|
+
branch_id: number;
|
|
28
|
+
currency: Currency;
|
|
29
|
+
amount_minor: number;
|
|
30
|
+
created_at_utc: string;
|
|
31
|
+
/** created_at rendered in the branch's native timezone. */
|
|
32
|
+
created_at_local: string;
|
|
33
|
+
idempotency_key?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OrderResponse {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
/** true if this was an idempotent replay (HTTP 200 instead of 201). */
|
|
39
|
+
idempotent?: boolean;
|
|
40
|
+
order: Order;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The stable error envelope returned for every non-2xx response. */
|
|
44
|
+
export interface ErrorBody {
|
|
45
|
+
ok: false;
|
|
46
|
+
/** Stable, append-only error code (part of the API ABI). */
|
|
47
|
+
code: string;
|
|
48
|
+
/** Internal verification step (diagnostic only). */
|
|
49
|
+
step?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Per-call options that map onto request headers. */
|
|
53
|
+
export interface CallOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Idempotency key. STRONGLY recommended on every create. For offline-safe
|
|
56
|
+
* retries the key must be minted once when the mutation is created and reused
|
|
57
|
+
* on every attempt — see OfflineQueue. If omitted on a create, the SDK mints
|
|
58
|
+
* one for this single call only.
|
|
59
|
+
*/
|
|
60
|
+
idempotencyKey?: string;
|
|
61
|
+
/** Caller trace id, propagated and echoed on the response. */
|
|
62
|
+
traceId?: string;
|
|
63
|
+
/** Per-call AbortSignal (in addition to the configured timeout). */
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
}
|