@wocart/storefront 0.0.0-stage → 1.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +144 -2
  5. package/USAGE.md +1470 -0
  6. package/dist/abandoned-checkout.d.ts +22 -0
  7. package/dist/abandoned-checkout.js +89 -0
  8. package/dist/actions.d.ts +117 -0
  9. package/dist/actions.js +196 -0
  10. package/dist/astro.d.ts +6 -0
  11. package/dist/astro.js +3 -0
  12. package/dist/basket.d.ts +87 -0
  13. package/dist/basket.js +258 -0
  14. package/dist/client/cart.d.ts +30 -0
  15. package/dist/client/cart.js +26 -0
  16. package/dist/client/categories.d.ts +25 -0
  17. package/dist/client/categories.js +37 -0
  18. package/dist/client/config.d.ts +29 -0
  19. package/dist/client/config.js +41 -0
  20. package/dist/client/core.d.ts +88 -0
  21. package/dist/client/core.js +566 -0
  22. package/dist/client/errors.d.ts +19 -0
  23. package/dist/client/errors.js +31 -0
  24. package/dist/client/geo.d.ts +31 -0
  25. package/dist/client/geo.js +39 -0
  26. package/dist/client/index.d.ts +40 -0
  27. package/dist/client/index.js +81 -0
  28. package/dist/client/orders.d.ts +68 -0
  29. package/dist/client/orders.js +76 -0
  30. package/dist/client/page-data.d.ts +30 -0
  31. package/dist/client/page-data.js +36 -0
  32. package/dist/client/pages.d.ts +33 -0
  33. package/dist/client/pages.js +44 -0
  34. package/dist/client/paths.d.ts +8 -0
  35. package/dist/client/paths.js +10 -0
  36. package/dist/client/products.d.ts +56 -0
  37. package/dist/client/products.js +78 -0
  38. package/dist/client/shipping.d.ts +36 -0
  39. package/dist/client/shipping.js +47 -0
  40. package/dist/data/communes.d.ts +25 -0
  41. package/dist/data/communes.js +1690 -0
  42. package/dist/data/wilayas.d.ts +6 -0
  43. package/dist/data/wilayas.js +64 -0
  44. package/dist/domain.d.ts +341 -0
  45. package/dist/domain.js +214 -0
  46. package/dist/endpoints/proxy.d.ts +111 -0
  47. package/dist/endpoints/proxy.js +161 -0
  48. package/dist/index.d.ts +42 -0
  49. package/dist/index.js +30 -0
  50. package/dist/locale-routing.d.ts +59 -0
  51. package/dist/locale-routing.js +62 -0
  52. package/dist/locales.d.ts +15 -0
  53. package/dist/locales.js +8 -0
  54. package/dist/meta-tracking.d.ts +34 -0
  55. package/dist/meta-tracking.js +72 -0
  56. package/dist/middleware.d.ts +100 -0
  57. package/dist/middleware.js +72 -0
  58. package/dist/offer-view.d.ts +10 -0
  59. package/dist/offer-view.js +13 -0
  60. package/dist/otp.d.ts +80 -0
  61. package/dist/otp.js +295 -0
  62. package/dist/phone.d.ts +2 -0
  63. package/dist/phone.js +34 -0
  64. package/dist/product-view.d.ts +12 -0
  65. package/dist/product-view.js +42 -0
  66. package/dist/routes.d.ts +45 -0
  67. package/dist/routes.js +59 -0
  68. package/dist/schemas.d.ts +753 -0
  69. package/dist/schemas.js +377 -0
  70. package/dist/server.d.ts +4 -0
  71. package/dist/server.js +2 -0
  72. package/dist/types.d.ts +418 -0
  73. package/dist/types.js +1 -0
  74. package/dist/utils/image.d.ts +140 -0
  75. package/dist/utils/image.js +131 -0
  76. package/dist/utils/money.d.ts +39 -0
  77. package/dist/utils/money.js +43 -0
  78. package/dist/utils/product-image.d.ts +37 -0
  79. package/dist/utils/product-image.js +38 -0
  80. package/dist/whatsapp.d.ts +55 -0
  81. package/dist/whatsapp.js +89 -0
  82. package/package.json +76 -4
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Deep API Client - Core Infrastructure
3
+ *
4
+ * Hides: caching, request deduplication, retry logic, validation,
5
+ * header management, cancellation, and URL construction.
6
+ *
7
+ * Deep module principle: Maximum leverage for namespace implementations.
8
+ * One infrastructure layer serves all API methods.
9
+ */
10
+ import { StorefrontError, isStorefrontError } from "./errors.js";
11
+ /**
12
+ * Result type for mutations (POST/PATCH).
13
+ *
14
+ * `domainError` is **always present on a failure the SDK produced** — every
15
+ * transport, HTTP, schema and abort path sets it, so a consumer branches on
16
+ * `result.domainError.category` without an optional-chain. It is declared as
17
+ * a required field on purpose: a new failure path that forgets it does not
18
+ * compile.
19
+ *
20
+ * `error` stays as the human-readable message (`domainError.message`), `code`
21
+ * stays an open string because the server's error codes are open (`HTTP_<status>`
22
+ * for gateway failures, the server's own code otherwise — a closed union would
23
+ * reject codes this SDK has not seen yet), and the index signature carries the
24
+ * server's flattened `context` (OTP's `windowSeconds`/`attemptsRemaining`/
25
+ * `terminal`, field issues) so a proxy can forward it without re-declaring each
26
+ * key. Read models do not come back as a `ClientResult` — reads degrade to
27
+ * `null`/`[]`/`{}` instead (see USAGE.md, "Failure and result contracts").
28
+ */
29
+ let cacheGeneration = 0;
30
+ const ABORT_MESSAGE = "The request was cancelled";
31
+ const TIMEOUT_MESSAGE = "The request timed out";
32
+ const MALFORMED_MESSAGE = "The server response could not be read";
33
+ const SCHEMA_MESSAGE = "Schema validation failed";
34
+ /**
35
+ * The server's own error categories, translated to SDK categories. This is the
36
+ * wire vocabulary of the platform's ERROR_CATEGORIES, not a second copy of it:
37
+ * the server always wins when it names a category, and the status-code mapping
38
+ * below only covers failures that arrive without one (gateways, proxies).
39
+ */
40
+ const WIRE_CATEGORIES = {
41
+ VALIDATION: "validation",
42
+ AUTHENTICATION: "authentication",
43
+ BUSINESS_LOGIC: "business",
44
+ SYSTEM: "system",
45
+ };
46
+ function isRecord(value) {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+ function readString(source, key) {
50
+ if (!isRecord(source))
51
+ return undefined;
52
+ const value = source[key];
53
+ return typeof value === "string" && value.length > 0 ? value : undefined;
54
+ }
55
+ function readNumber(source, key) {
56
+ if (!isRecord(source))
57
+ return undefined;
58
+ const value = source[key];
59
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
60
+ }
61
+ function categorizeFailure(status, wireCategory) {
62
+ if (typeof wireCategory === "string" && WIRE_CATEGORIES[wireCategory]) {
63
+ return WIRE_CATEGORIES[wireCategory];
64
+ }
65
+ if (status === 401 || status === 403)
66
+ return "authentication";
67
+ if (status === 400 || status === 422)
68
+ return "validation";
69
+ if (status === 404 || status === 409 || status === 429)
70
+ return "business";
71
+ if (status !== undefined && status >= 500)
72
+ return "system";
73
+ return "unknown";
74
+ }
75
+ /**
76
+ * What the platform actually did. A 2xx means it accepted the command, a 4xx is
77
+ * a clean refusal, and everything else — 5xx, dropped connections, timeouts —
78
+ * leaves the outcome unproven. Only a proven outcome may be replayed.
79
+ */
80
+ function determineSideEffectOutcome(status) {
81
+ if (status === undefined)
82
+ return "unknown";
83
+ if (status >= 200 && status < 300)
84
+ return "committed";
85
+ if (status >= 400 && status < 500)
86
+ return "rejected";
87
+ return "unknown";
88
+ }
89
+ /**
90
+ * A write is replayable only when the server refused it *and* said when to come
91
+ * back: retrying a dropped connection is how a shopper ends up with two orders.
92
+ * Reads are safe to repeat unless the request itself was rejected.
93
+ */
94
+ function isReplayable(status, readOnly) {
95
+ if (readOnly)
96
+ return status === 429 || status >= 500;
97
+ return status === 429;
98
+ }
99
+ /** Field-level errors from the server's Zod failures (`context.fields`). */
100
+ function readFieldIssues(context) {
101
+ if (!isRecord(context) || !Array.isArray(context.fields))
102
+ return undefined;
103
+ const issues = context.fields.flatMap((entry) => {
104
+ if (!isRecord(entry))
105
+ return [];
106
+ return [
107
+ {
108
+ field: typeof entry.path === "string" ? entry.path : "",
109
+ code: typeof entry.code === "string" ? entry.code : "INVALID",
110
+ message: typeof entry.message === "string" ? entry.message : "Invalid value",
111
+ },
112
+ ];
113
+ });
114
+ return issues.length > 0 ? issues : undefined;
115
+ }
116
+ function composeSignal(signal, timeoutMs) {
117
+ if (!signal && timeoutMs === undefined) {
118
+ return { signal: undefined, aborted: () => false, timedOut: () => false, cleanup: () => { } };
119
+ }
120
+ if (timeoutMs === undefined) {
121
+ return { signal, aborted: () => signal.aborted, timedOut: () => false, cleanup: () => { } };
122
+ }
123
+ const controller = new AbortController();
124
+ let timedOut = false;
125
+ const timer = setTimeout(() => {
126
+ timedOut = true;
127
+ controller.abort();
128
+ }, timeoutMs);
129
+ const forward = () => controller.abort();
130
+ if (signal) {
131
+ if (signal.aborted)
132
+ forward();
133
+ else
134
+ signal.addEventListener("abort", forward, { once: true });
135
+ }
136
+ return {
137
+ signal: controller.signal,
138
+ aborted: () => signal?.aborted === true,
139
+ timedOut: () => timedOut,
140
+ cleanup: () => {
141
+ clearTimeout(timer);
142
+ signal?.removeEventListener("abort", forward);
143
+ },
144
+ };
145
+ }
146
+ function abortedError(operation) {
147
+ return {
148
+ category: "transport",
149
+ code: "REQUEST_ABORTED",
150
+ message: ABORT_MESSAGE,
151
+ retryable: false,
152
+ operation,
153
+ sideEffect: "not_started",
154
+ };
155
+ }
156
+ /**
157
+ * A failure that happened before the server answered — or after it stopped
158
+ * answering. `readOnly` marks reads: only a read may be replayed on one of
159
+ * these, because a write's outcome is unproven and replaying it is how a
160
+ * shopper ends up with two orders.
161
+ */
162
+ function transportFailure(cause, signal, operation, readOnly) {
163
+ const sideEffect = readOnly ? "not_started" : "unknown";
164
+ if (signal.timedOut()) {
165
+ return {
166
+ category: "transport",
167
+ code: "REQUEST_TIMEOUT",
168
+ message: TIMEOUT_MESSAGE,
169
+ retryable: readOnly,
170
+ operation,
171
+ sideEffect,
172
+ };
173
+ }
174
+ if (signal.aborted()) {
175
+ return { ...abortedError(operation), sideEffect };
176
+ }
177
+ return {
178
+ category: "transport",
179
+ code: "NETWORK_ERROR",
180
+ message: cause instanceof Error ? cause.message : "Network error",
181
+ retryable: readOnly,
182
+ operation,
183
+ sideEffect,
184
+ };
185
+ }
186
+ /**
187
+ * A 2xx response the SDK could not read or validate. The server already
188
+ * committed the command — only the answer went missing — so a write must not be
189
+ * replayed on the strength of it.
190
+ */
191
+ function unreadableSuccess(code, message, operation, readOnly) {
192
+ return {
193
+ category: "system",
194
+ code,
195
+ message,
196
+ retryable: readOnly,
197
+ operation,
198
+ sideEffect: readOnly ? "not_started" : "committed",
199
+ };
200
+ }
201
+ /**
202
+ * A failure the server reported itself: its message, its code, its category, and
203
+ * every diagnostic it chose to publish in `context`.
204
+ */
205
+ function serverFailure(status, body, operation, readOnly) {
206
+ const context = isRecord(body) ? body.context : undefined;
207
+ const message = readString(body, "error") ??
208
+ readString(body, "message") ??
209
+ (status !== undefined ? `HTTP ${status}` : "Request failed");
210
+ const code = readString(body, "code");
211
+ const retryAfterSeconds = readNumber(context, "windowSeconds") ?? readNumber(body, "retryAfterSeconds");
212
+ const fieldIssues = readFieldIssues(context);
213
+ const requestId = readString(context, "requestId");
214
+ const domainError = {
215
+ category: categorizeFailure(status, isRecord(body) ? body.category : undefined),
216
+ code: code ?? (status !== undefined ? `HTTP_${status}` : "HTTP_ERROR"),
217
+ message,
218
+ retryable: isReplayable(status, readOnly),
219
+ operation,
220
+ sideEffect: determineSideEffectOutcome(status),
221
+ ...(fieldIssues !== undefined ? { fieldIssues } : {}),
222
+ ...(requestId !== undefined ? { requestId } : {}),
223
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
224
+ };
225
+ return {
226
+ message,
227
+ code,
228
+ flatContext: isRecord(context) ? context : undefined,
229
+ domainError,
230
+ };
231
+ }
232
+ async function readBody(response) {
233
+ try {
234
+ return { ok: true, value: await response.json() };
235
+ }
236
+ catch {
237
+ return { ok: false };
238
+ }
239
+ }
240
+ /** How long a read may take, retries included, unless the client sets another limit. */
241
+ export const DEFAULT_READ_TIMEOUT_MS = 10_000;
242
+ /** A positive limit is used as given; `0` means no limit; anything else falls back to the default. */
243
+ function resolveReadTimeout(value) {
244
+ if (value === 0)
245
+ return undefined;
246
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : DEFAULT_READ_TIMEOUT_MS;
247
+ }
248
+ /**
249
+ * Create client core infrastructure
250
+ *
251
+ * @param config - Client configuration
252
+ * @returns Core infrastructure for namespace implementations
253
+ */
254
+ export function createClientCore(config) {
255
+ const { serverUrl, apiKey } = config;
256
+ const readTimeoutMs = resolveReadTimeout(config.readTimeoutMs);
257
+ // Finished values only, never a pending request. A client may outlive the
258
+ // request that made it (one per isolate); a request that waits on another
259
+ // request's pending read never settles on Cloudflare Workers once that other
260
+ // request is cancelled, and every later caller would wait behind it.
261
+ const valueCache = new Map();
262
+ let observedCacheGeneration = cacheGeneration;
263
+ function resetClearedCaches() {
264
+ if (observedCacheGeneration === cacheGeneration)
265
+ return;
266
+ valueCache.clear();
267
+ observedCacheGeneration = cacheGeneration;
268
+ }
269
+ /**
270
+ * Build full URL from path
271
+ */
272
+ function buildUrl(path) {
273
+ const base = serverUrl.replace(/\/$/, "");
274
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
275
+ return `${base}${cleanPath}`;
276
+ }
277
+ /**
278
+ * Build request headers
279
+ */
280
+ function buildHeaders(customHeaders) {
281
+ return {
282
+ "X-Store-API-Key": apiKey,
283
+ "Content-Type": "application/json",
284
+ ...customHeaders,
285
+ };
286
+ }
287
+ /**
288
+ * Execute fetch with retry logic.
289
+ *
290
+ * Retries are for reads only — callers pass `retries: 0` for anything that
291
+ * writes. An aborted signal is never retried and never worth waiting on.
292
+ */
293
+ async function fetchWithRetry(url, options, retries, signal) {
294
+ let lastError = null;
295
+ for (let attempt = 0; attempt <= retries; attempt++) {
296
+ if (signal?.aborted) {
297
+ throw new Error(ABORT_MESSAGE);
298
+ }
299
+ try {
300
+ return await fetch(url, options);
301
+ }
302
+ catch (error) {
303
+ lastError = error instanceof Error ? error : new Error("Network error");
304
+ if (signal?.aborted || attempt === retries) {
305
+ break;
306
+ }
307
+ // Exponential backoff: 100ms, 200ms
308
+ const delay = 100 * Math.pow(2, attempt);
309
+ await new Promise((resolve) => setTimeout(resolve, delay));
310
+ }
311
+ }
312
+ throw lastError ?? new Error("Network error");
313
+ }
314
+ /**
315
+ * GET request with opt-in caching.
316
+ *
317
+ * Every call makes its own request: a call never waits on another call's
318
+ * pending request, so no caller can be held by work that belongs to someone
319
+ * else. Pass `{ cache: true }` for read-mostly data (config, categories,
320
+ * geo): the finished value is kept and later calls reuse it.
321
+ *
322
+ * Rejected and nullish results are never cached — a transient failure
323
+ * or an absent record must not be pinned for the isolate's lifetime.
324
+ *
325
+ * A read is bounded by the client's `readTimeoutMs` (10 seconds unless the
326
+ * client sets another), retries included, so a server that stops answering
327
+ * degrades the read instead of holding the page.
328
+ *
329
+ * Pass `{ envelope: true }` for endpoints that carry metadata outside
330
+ * `data` (e.g. pagination totals) and need the full response body.
331
+ *
332
+ * A failing read never resolves to a wrong value: it throws a
333
+ * `StorefrontError` carrying the same `DomainError` the mutation path
334
+ * returns. A read the caller cancels never retries and is never cached.
335
+ */
336
+ async function get(path, schema, opts) {
337
+ resetClearedCaches();
338
+ const url = buildUrl(path);
339
+ const operation = opts?.operation ?? `GET ${path}`;
340
+ const request = composeSignal(opts?.signal, opts?.timeoutMs ?? readTimeoutMs);
341
+ // A caller-supplied signal opts the read out of retries and the cache: its
342
+ // abort is the caller's decision, and an abandoned read must not be kept.
343
+ const callerCancels = opts?.signal !== undefined;
344
+ const cacheable = opts?.cache === true && !callerCancels;
345
+ try {
346
+ if (cacheable && valueCache.has(url)) {
347
+ return valueCache.get(url);
348
+ }
349
+ const value = await (async () => {
350
+ const response = await fetchWithRetry(url, {
351
+ method: "GET",
352
+ headers: buildHeaders(),
353
+ ...(request.signal ? { signal: request.signal } : {}),
354
+ }, callerCancels ? 0 : 2, request.signal);
355
+ if (!response.ok) {
356
+ const body = await readBody(response);
357
+ throw new StorefrontError(serverFailure(response.status, body.ok ? body.value : undefined, operation, true)
358
+ .domainError);
359
+ }
360
+ const body = await readBody(response);
361
+ if (!body.ok) {
362
+ throw new StorefrontError(unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, true));
363
+ }
364
+ const json = body.value;
365
+ if (opts?.envelope) {
366
+ if (schema) {
367
+ const parsed = schema.safeParse(json);
368
+ if (!parsed.success) {
369
+ throw new StorefrontError(unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, true));
370
+ }
371
+ return parsed.data;
372
+ }
373
+ return json;
374
+ }
375
+ const data = isRecord(json) ? json.data : undefined;
376
+ // Validate with schema if provided
377
+ if (schema) {
378
+ const parsed = schema.safeParse(data);
379
+ if (!parsed.success) {
380
+ throw new StorefrontError(unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, true));
381
+ }
382
+ return parsed.data;
383
+ }
384
+ return data;
385
+ })();
386
+ if (cacheable && value != null)
387
+ valueCache.set(url, value);
388
+ return value;
389
+ }
390
+ catch (error) {
391
+ if (error instanceof StorefrontError)
392
+ throw error;
393
+ throw new StorefrontError(transportFailure(error, request, operation, true));
394
+ }
395
+ finally {
396
+ request.cleanup();
397
+ }
398
+ }
399
+ /**
400
+ * POST request with error handling.
401
+ *
402
+ * By default the `{ data: ... }` envelope is unwrapped and `data`
403
+ * returned. Pass `topLevelData: true` for endpoints whose payload sits at
404
+ * the top level (e.g. `POST /store/abandoned` → `{ success, id }`).
405
+ *
406
+ * On failure the error result carries `code`, every field of the server's
407
+ * `context` object (`windowSeconds`, `attemptsRemaining`, `terminal`)
408
+ * flattened into the result — the checkout UI reads these — and the whole
409
+ * failure again as `domainError`, the serializable contract a caller may
410
+ * branch on. A POST never throws: an aborted or dropped request leaves the
411
+ * outcome `unknown`, never a retry of a possible duplicate.
412
+ */
413
+ async function post(path, body, opts) {
414
+ const url = buildUrl(path);
415
+ const operation = opts?.operation ?? `POST ${path}`;
416
+ const request = composeSignal(opts?.signal, opts?.timeoutMs);
417
+ if (request.aborted()) {
418
+ request.cleanup();
419
+ const domainError = abortedError(operation);
420
+ return { success: false, error: domainError.message, domainError };
421
+ }
422
+ try {
423
+ const response = await fetchWithRetry(url, {
424
+ method: "POST",
425
+ headers: buildHeaders(opts?.headers),
426
+ body: JSON.stringify(body),
427
+ ...(request.signal ? { signal: request.signal } : {}),
428
+ }, 0, request.signal);
429
+ const read = await readBody(response);
430
+ if (!response.ok) {
431
+ const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
432
+ return {
433
+ success: false,
434
+ error: failure.message,
435
+ ...(failure.code !== undefined ? { code: failure.code } : {}),
436
+ domainError: failure.domainError,
437
+ ...failure.flatContext,
438
+ };
439
+ }
440
+ if (!read.ok || !isRecord(read.value)) {
441
+ const domainError = unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, false);
442
+ return { success: false, error: domainError.message, domainError };
443
+ }
444
+ const payload = read.value;
445
+ const data = opts?.topLevelData ? payload : payload.data;
446
+ // Validate with schema if provided
447
+ if (opts?.schema) {
448
+ const parsed = opts.schema.safeParse(data);
449
+ if (!parsed.success) {
450
+ const domainError = unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, false);
451
+ return { success: false, error: domainError.message, domainError };
452
+ }
453
+ return { success: true, data: parsed.data };
454
+ }
455
+ return { success: true, data: data };
456
+ }
457
+ catch (error) {
458
+ const domainError = transportFailure(error, request, operation, false);
459
+ return { success: false, error: domainError.message, domainError };
460
+ }
461
+ finally {
462
+ request.cleanup();
463
+ }
464
+ }
465
+ /**
466
+ * PATCH request with error handling
467
+ */
468
+ async function patch(path, body, optsOrSchema) {
469
+ const url = buildUrl(path);
470
+ const opts = optsOrSchema && "safeParse" in optsOrSchema
471
+ ? { schema: optsOrSchema }
472
+ : (optsOrSchema ?? {});
473
+ const operation = opts.operation ?? `PATCH ${path}`;
474
+ const request = composeSignal(opts.signal, opts.timeoutMs);
475
+ if (request.aborted()) {
476
+ request.cleanup();
477
+ const domainError = abortedError(operation);
478
+ return { success: false, error: domainError.message, domainError };
479
+ }
480
+ try {
481
+ const response = await fetchWithRetry(url, {
482
+ method: "PATCH",
483
+ headers: buildHeaders(opts.headers),
484
+ body: JSON.stringify(body),
485
+ ...(request.signal ? { signal: request.signal } : {}),
486
+ }, 0, request.signal);
487
+ const read = await readBody(response);
488
+ if (!response.ok) {
489
+ const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
490
+ return {
491
+ success: false,
492
+ error: failure.message,
493
+ ...(failure.code !== undefined ? { code: failure.code } : {}),
494
+ domainError: failure.domainError,
495
+ ...failure.flatContext,
496
+ };
497
+ }
498
+ if (!read.ok || !isRecord(read.value)) {
499
+ const domainError = unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, false);
500
+ return { success: false, error: domainError.message, domainError };
501
+ }
502
+ const data = read.value.data;
503
+ // Validate with schema if provided
504
+ if (opts.schema) {
505
+ const parsed = opts.schema.safeParse(data);
506
+ if (!parsed.success) {
507
+ const domainError = unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, false);
508
+ return { success: false, error: domainError.message, domainError };
509
+ }
510
+ return { success: true, data: parsed.data };
511
+ }
512
+ return { success: true, data: data };
513
+ }
514
+ catch (error) {
515
+ const domainError = transportFailure(error, request, operation, false);
516
+ return { success: false, error: domainError.message, domainError };
517
+ }
518
+ finally {
519
+ request.cleanup();
520
+ }
521
+ }
522
+ return {
523
+ config,
524
+ get,
525
+ post,
526
+ patch,
527
+ };
528
+ }
529
+ /**
530
+ * Report a read that a namespace swallowed.
531
+ *
532
+ * Namespaces degrade instead of throwing — that is their contract — so this
533
+ * is the operator's only signal that a call did not complete. A failure the
534
+ * core produced already carries its DomainError; anything else (a bug inside
535
+ * a namespace, a storage exception) is normalised to the same shape, so a
536
+ * consumer's handler branches on one contract. A throwing hook never turns a
537
+ * degraded read into a thrown one.
538
+ */
539
+ export function reportDegradedRead(config, error, operation) {
540
+ const onError = config?.onError;
541
+ if (!onError)
542
+ return;
543
+ const domainError = isStorefrontError(error)
544
+ ? error.domainError
545
+ : {
546
+ category: "transport",
547
+ code: "NETWORK_ERROR",
548
+ message: error instanceof Error ? error.message : "Network error",
549
+ retryable: true,
550
+ operation,
551
+ sideEffect: "not_started",
552
+ };
553
+ try {
554
+ onError(domainError);
555
+ }
556
+ catch {
557
+ // Observability must never escalate a degraded read.
558
+ }
559
+ }
560
+ /**
561
+ * Clear the request cache
562
+ * Useful for testing or forcing fresh data
563
+ */
564
+ export function clearCache() {
565
+ cacheGeneration += 1;
566
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * SDK error type for read failures.
3
+ *
4
+ * Reads resolve their "absent" outcomes (null, [], {}) through the namespaces;
5
+ * everything else is a real failure and arrives as one of these. It stays an
6
+ * `Error` so existing `catch`/`instanceof Error` call sites keep working, and it
7
+ * carries a plain, serializable `DomainError` so a caller can branch on
8
+ * category, code, retryability and side-effect outcome instead of a string.
9
+ */
10
+ import type { DomainError } from "../domain.js";
11
+ export declare class StorefrontError extends Error {
12
+ readonly domainError: DomainError;
13
+ constructor(domainError: DomainError);
14
+ }
15
+ /**
16
+ * Recognises a StorefrontError across a realm boundary (worker, iframe, Astro
17
+ * action), where `instanceof` fails but the serialized shape survives.
18
+ */
19
+ export declare function isStorefrontError(value: unknown): value is StorefrontError;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * SDK error type for read failures.
3
+ *
4
+ * Reads resolve their "absent" outcomes (null, [], {}) through the namespaces;
5
+ * everything else is a real failure and arrives as one of these. It stays an
6
+ * `Error` so existing `catch`/`instanceof Error` call sites keep working, and it
7
+ * carries a plain, serializable `DomainError` so a caller can branch on
8
+ * category, code, retryability and side-effect outcome instead of a string.
9
+ */
10
+ export class StorefrontError extends Error {
11
+ domainError;
12
+ constructor(domainError) {
13
+ super(domainError.message);
14
+ this.name = "StorefrontError";
15
+ this.domainError = domainError;
16
+ }
17
+ }
18
+ /**
19
+ * Recognises a StorefrontError across a realm boundary (worker, iframe, Astro
20
+ * action), where `instanceof` fails but the serialized shape survives.
21
+ */
22
+ export function isStorefrontError(value) {
23
+ if (value instanceof StorefrontError)
24
+ return true;
25
+ if (typeof value !== "object" || value === null)
26
+ return false;
27
+ const candidate = value;
28
+ return (candidate.name === "StorefrontError" &&
29
+ typeof candidate.domainError === "object" &&
30
+ candidate.domainError !== null);
31
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Geo Namespace - Wilayas & Communes
3
+ *
4
+ * Deep module: hides where the wilaya dataset lives and the communes lookup
5
+ * behind two calls.
6
+ *
7
+ * `wilayas()` resolves the bundled static dataset — Algeria's 58 wilayas
8
+ * are stable reference data, so no network round-trip. It stays async so the
9
+ * data can move server-side without touching call sites.
10
+ *
11
+ * `communes()` reads the server and is cached per wilaya (reference data);
12
+ * failures resolve to `[]` — never throw. Wilaya IDs are 1–58; anything
13
+ * else is a 400 upstream, surfaced as `[]` (no communes to show).
14
+ */
15
+ import { type ClientCore } from "./core.js";
16
+ import type { Commune, Wilaya } from "../types.js";
17
+ export interface GeoNamespace {
18
+ /**
19
+ * Algeria's 58 wilayas, with Arabic names for RTL checkout forms.
20
+ */
21
+ wilayas(): Promise<Wilaya[]>;
22
+ /**
23
+ * Communes for one wilaya (1–58), cached. Resolves to `[]` on failure
24
+ * or an unknown wilaya.
25
+ */
26
+ communes(wilayaId: number | string): Promise<Commune[]>;
27
+ }
28
+ /**
29
+ * Create geo namespace
30
+ */
31
+ export declare function createGeoNamespace(core: ClientCore): GeoNamespace;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Geo Namespace - Wilayas & Communes
3
+ *
4
+ * Deep module: hides where the wilaya dataset lives and the communes lookup
5
+ * behind two calls.
6
+ *
7
+ * `wilayas()` resolves the bundled static dataset — Algeria's 58 wilayas
8
+ * are stable reference data, so no network round-trip. It stays async so the
9
+ * data can move server-side without touching call sites.
10
+ *
11
+ * `communes()` reads the server and is cached per wilaya (reference data);
12
+ * failures resolve to `[]` — never throw. Wilaya IDs are 1–58; anything
13
+ * else is a 400 upstream, surfaced as `[]` (no communes to show).
14
+ */
15
+ import { z } from "zod";
16
+ import { CommuneSchema } from "../schemas.js";
17
+ import { STOREFRONT_OPERATIONS } from "../domain.js";
18
+ import { reportDegradedRead } from "./core.js";
19
+ import { WILAYAS } from "../data/wilayas.js";
20
+ /**
21
+ * Create geo namespace
22
+ */
23
+ export function createGeoNamespace(core) {
24
+ return {
25
+ async wilayas() {
26
+ return WILAYAS;
27
+ },
28
+ async communes(wilayaId) {
29
+ try {
30
+ const communes = await core.get(`/store/communes/${encodeURIComponent(String(wilayaId))}`, z.array(CommuneSchema).nullable(), { cache: true, operation: STOREFRONT_OPERATIONS.geoCommunes });
31
+ return communes ?? [];
32
+ }
33
+ catch (error) {
34
+ reportDegradedRead(core.config, error, "geo.communes");
35
+ return [];
36
+ }
37
+ },
38
+ };
39
+ }