qumra-pos-storage 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/dist/index.cjs ADDED
@@ -0,0 +1,1335 @@
1
+ 'use strict';
2
+
3
+ var uuidv7 = require('uuidv7');
4
+
5
+ // src/schema/001_initial.ts
6
+ var SQL_001 = (
7
+ /* sql */
8
+ `
9
+ -- ===========================================================================
10
+ -- \u062C\u062F\u0627\u0648\u0644 \u0627\u0644\u0639\u0645\u0644\u064A\u0627\u062A (\u0628\u062A\u062A\u0648\u0644\u0651\u062F \u0645\u062D\u0644\u064A\u0627\u064B \u0648\u0628\u062A\u062A\u0631\u0641\u0639 \u0644\u0644\u0633\u062D\u0627\u0628\u0629)
11
+ -- ===========================================================================
12
+
13
+ CREATE TABLE shifts (
14
+ id TEXT PRIMARY KEY,
15
+ cashier_id TEXT NOT NULL,
16
+ opened_at TEXT NOT NULL,
17
+ closed_at TEXT,
18
+ opening_cash INTEGER NOT NULL, -- \u0642\u0631\u0648\u0634
19
+ counted_cash INTEGER,
20
+ status TEXT NOT NULL -- open | closed
21
+ );
22
+
23
+ CREATE TABLE orders (
24
+ id TEXT PRIMARY KEY,
25
+ shift_id TEXT NOT NULL REFERENCES shifts(id),
26
+ cashier_id TEXT NOT NULL,
27
+ status TEXT NOT NULL, -- completed | voided | refunded
28
+ subtotal INTEGER NOT NULL,
29
+ discount INTEGER NOT NULL DEFAULT 0,
30
+ tax INTEGER NOT NULL DEFAULT 0,
31
+ total INTEGER NOT NULL,
32
+ created_at TEXT NOT NULL,
33
+ sync_status TEXT NOT NULL DEFAULT 'pending'
34
+ );
35
+ CREATE INDEX idx_orders_shift ON orders(shift_id);
36
+ CREATE INDEX idx_orders_created ON orders(created_at);
37
+
38
+ -- snapshot \u0643\u0627\u0645\u0644 \u0644\u0643\u0644 \u0633\u0637\u0631 \u0648\u0642\u062A \u0627\u0644\u0628\u064A\u0639.
39
+ CREATE TABLE order_items (
40
+ id TEXT PRIMARY KEY,
41
+ order_id TEXT NOT NULL REFERENCES orders(id),
42
+ product_id TEXT NOT NULL,
43
+ name_snapshot TEXT NOT NULL, -- \u0627\u0644\u0627\u0633\u0645 \u0648\u0642\u062A \u0627\u0644\u0628\u064A\u0639
44
+ unit_price INTEGER NOT NULL, -- \u0627\u0644\u0633\u0639\u0631 \u0648\u0642\u062A \u0627\u0644\u0628\u064A\u0639
45
+ qty REAL NOT NULL, -- REAL \u0644\u0644\u0633\u0645\u0627\u062D \u0628\u0627\u0644\u0648\u0632\u0646\u064A (\u0643\u064A\u0644\u0648)
46
+ line_total INTEGER NOT NULL
47
+ );
48
+ CREATE INDEX idx_items_order ON order_items(order_id);
49
+
50
+ -- \u0627\u0644\u0637\u0644\u0628 \u0627\u0644\u0648\u0627\u062D\u062F \u0645\u0645\u0643\u0646 \u064A\u062A\u062F\u0641\u0639 \u0628\u0623\u0643\u062A\u0631 \u0645\u0646 \u0637\u0631\u064A\u0642\u0629.
51
+ CREATE TABLE payments (
52
+ id TEXT PRIMARY KEY,
53
+ order_id TEXT NOT NULL REFERENCES orders(id),
54
+ method TEXT NOT NULL, -- cash | card | wallet | ...
55
+ amount INTEGER NOT NULL,
56
+ created_at TEXT NOT NULL
57
+ );
58
+ CREATE INDEX idx_payments_order ON payments(order_id);
59
+
60
+ -- ===========================================================================
61
+ -- \u0627\u0644\u062C\u062F\u0627\u0648\u0644 \u0627\u0644\u0645\u0631\u062C\u0639\u064A\u0629 (\u0628\u062A\u0646\u0632\u0644 \u0645\u0646 \u0627\u0644\u0633\u064A\u0631\u0641\u0631 \u2014 \u0627\u0644\u0633\u064A\u0631\u0641\u0631 \u0645\u0635\u062F\u0631 \u0627\u0644\u062D\u0642\u064A\u0642\u0629)
62
+ -- ===========================================================================
63
+
64
+ CREATE TABLE products (
65
+ id TEXT PRIMARY KEY,
66
+ name TEXT NOT NULL,
67
+ sku TEXT,
68
+ barcode TEXT,
69
+ price INTEGER NOT NULL,
70
+ category_id TEXT,
71
+ is_active INTEGER NOT NULL DEFAULT 1,
72
+ updated_at TEXT NOT NULL,
73
+ is_local INTEGER NOT NULL DEFAULT 0 -- \u0645\u0646\u062A\u062C \u0627\u062A\u0639\u0645\u0644 \u064A\u062F\u0648\u064A \u0623\u0648\u0641\u0644\u0627\u064A\u0646
74
+ );
75
+ CREATE INDEX idx_products_barcode ON products(barcode);
76
+
77
+ CREATE TABLE cashiers (
78
+ id TEXT PRIMARY KEY,
79
+ name TEXT NOT NULL,
80
+ role TEXT NOT NULL,
81
+ pin_hash TEXT NOT NULL,
82
+ pin_salt TEXT NOT NULL,
83
+ is_active INTEGER NOT NULL DEFAULT 1,
84
+ updated_at TEXT NOT NULL
85
+ );
86
+
87
+ CREATE TABLE settings (
88
+ key TEXT PRIMARY KEY,
89
+ value TEXT NOT NULL,
90
+ updated_at TEXT NOT NULL
91
+ );
92
+
93
+ -- ===========================================================================
94
+ -- \u062C\u062F\u0627\u0648\u0644 \u0627\u0644\u0628\u0646\u064A\u0629 \u0627\u0644\u062A\u062D\u062A\u064A\u0629
95
+ -- ===========================================================================
96
+
97
+ -- \u0627\u0644\u0640 Outbox: \u0642\u0644\u0628 \u0627\u0644\u062A\u0634\u063A\u064A\u0644 \u0627\u0644\u0623\u0648\u0641\u0644\u0627\u064A\u0646 \u0643\u0644\u0647.
98
+ CREATE TABLE pending_operations (
99
+ id TEXT PRIMARY KEY, -- UUIDv7 == \u062A\u0631\u062A\u064A\u0628 \u0632\u0645\u0646\u064A
100
+ op_type TEXT NOT NULL, -- order.create | shift.open | ...
101
+ payload TEXT NOT NULL, -- \u0627\u0644\u0646\u0633\u062E\u0629 \u0627\u0644\u0643\u0627\u0645\u0644\u0629 (JSON) \u0644\u0644\u0639\u0645\u0644\u064A\u0629
102
+ idempotency_key TEXT NOT NULL UNIQUE,
103
+ client_ts TEXT NOT NULL,
104
+ attempts INTEGER NOT NULL DEFAULT 0,
105
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | inflight | synced | failed
106
+ last_error TEXT
107
+ );
108
+ CREATE INDEX idx_ops_status ON pending_operations(status, id);
109
+
110
+ -- \u062D\u0627\u0644\u0629 \u0627\u0644\u0645\u0632\u0627\u0645\u0646\u0629 \u0644\u0643\u0644 \u0645\u0648\u0631\u062F (\u0644\u0644\u0640 delta pull).
111
+ CREATE TABLE sync_state (
112
+ resource TEXT PRIMARY KEY, -- products | cashiers | settings
113
+ last_pulled TEXT NOT NULL,
114
+ cursor TEXT
115
+ );
116
+ `
117
+ );
118
+
119
+ // src/schema/002_outbox_inflight.ts
120
+ var SQL_002 = (
121
+ /* sql */
122
+ `
123
+ ALTER TABLE pending_operations ADD COLUMN inflight_at TEXT;
124
+ `
125
+ );
126
+
127
+ // src/schema/index.ts
128
+ var MIGRATIONS = [
129
+ { version: 1, sql: SQL_001 },
130
+ { version: 2, sql: SQL_002 }
131
+ ];
132
+
133
+ // src/time.ts
134
+ var nowISO = () => (/* @__PURE__ */ new Date()).toISOString();
135
+
136
+ // src/migrator.ts
137
+ async function runMigrations(adapter) {
138
+ await adapter.execute(
139
+ `CREATE TABLE IF NOT EXISTS _migrations (
140
+ version INTEGER PRIMARY KEY,
141
+ applied_at TEXT NOT NULL
142
+ );`
143
+ );
144
+ const rows = await adapter.query(
145
+ `SELECT version FROM _migrations`
146
+ );
147
+ const applied = new Set(rows.map((r) => r.version));
148
+ const pending = MIGRATIONS.filter((m) => !applied.has(m.version)).sort(
149
+ (a, b) => a.version - b.version
150
+ );
151
+ for (const migration of pending) {
152
+ await adapter.transaction(async (tx) => {
153
+ await tx.execute(migration.sql);
154
+ await tx.execute(
155
+ `INSERT INTO _migrations (version, applied_at) VALUES (?, ?)`,
156
+ [migration.version, nowISO()]
157
+ );
158
+ });
159
+ }
160
+ }
161
+ var newId = () => uuidv7.uuidv7();
162
+
163
+ // src/repositories/OrdersRepository.ts
164
+ var OrdersRepository = class {
165
+ constructor(adapter, outbox) {
166
+ this.adapter = adapter;
167
+ this.outbox = outbox;
168
+ }
169
+ adapter;
170
+ outbox;
171
+ /**
172
+ * بيع atomic: الطلب + العناصر + المدفوعات + سطر outbox واحد، كلهم في transaction
173
+ * واحدة. بيتحقق إن مجموع المدفوعات = total الطلب قبل الكتابة — الـ validation في
174
+ * الـ core مش في الـ UI. لو أي جزء فشل، كله بيترجع (rollback).
175
+ */
176
+ async create(input) {
177
+ if (input.items.length === 0) {
178
+ throw new Error("Cannot create an order with no items");
179
+ }
180
+ if (input.payments.length === 0) {
181
+ throw new Error("Cannot create an order with no payments");
182
+ }
183
+ const createdAt = input.createdAt ?? nowISO();
184
+ const orderId = newId();
185
+ const discount = input.discount ?? 0;
186
+ const tax = input.tax ?? 0;
187
+ const items = input.items.map((it) => ({
188
+ id: newId(),
189
+ order_id: orderId,
190
+ product_id: it.productId,
191
+ name_snapshot: it.nameSnapshot,
192
+ unit_price: it.unitPrice,
193
+ qty: it.qty,
194
+ line_total: it.lineTotal ?? Math.round(it.unitPrice * it.qty)
195
+ }));
196
+ const subtotal = items.reduce((sum, it) => sum + it.line_total, 0);
197
+ const total = input.total ?? subtotal - discount + tax;
198
+ const payments = input.payments.map((p) => ({
199
+ id: newId(),
200
+ order_id: orderId,
201
+ method: p.method,
202
+ amount: p.amount,
203
+ created_at: createdAt
204
+ }));
205
+ const paymentsTotal = payments.reduce((sum, p) => sum + p.amount, 0);
206
+ if (paymentsTotal !== total) {
207
+ throw new Error(
208
+ `Payments (${paymentsTotal}) do not match order total (${total})`
209
+ );
210
+ }
211
+ const order = {
212
+ id: orderId,
213
+ shift_id: input.shiftId,
214
+ cashier_id: input.cashierId,
215
+ status: input.status ?? "completed",
216
+ subtotal,
217
+ discount,
218
+ tax,
219
+ total,
220
+ created_at: createdAt,
221
+ sync_status: "pending"
222
+ };
223
+ await this.adapter.transaction(async (tx) => {
224
+ await tx.execute(
225
+ `INSERT INTO orders
226
+ (id, shift_id, cashier_id, status, subtotal, discount, tax, total, created_at, sync_status)
227
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
228
+ [
229
+ order.id,
230
+ order.shift_id,
231
+ order.cashier_id,
232
+ order.status,
233
+ order.subtotal,
234
+ order.discount,
235
+ order.tax,
236
+ order.total,
237
+ order.created_at,
238
+ order.sync_status
239
+ ]
240
+ );
241
+ for (const it of items) {
242
+ await tx.execute(
243
+ `INSERT INTO order_items
244
+ (id, order_id, product_id, name_snapshot, unit_price, qty, line_total)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
246
+ [
247
+ it.id,
248
+ it.order_id,
249
+ it.product_id,
250
+ it.name_snapshot,
251
+ it.unit_price,
252
+ it.qty,
253
+ it.line_total
254
+ ]
255
+ );
256
+ }
257
+ for (const p of payments) {
258
+ await tx.execute(
259
+ `INSERT INTO payments (id, order_id, method, amount, created_at)
260
+ VALUES (?, ?, ?, ?, ?)`,
261
+ [p.id, p.order_id, p.method, p.amount, p.created_at]
262
+ );
263
+ }
264
+ await this.outbox.enqueue(tx, {
265
+ opType: "order.create",
266
+ payload: { ...order, items, payments },
267
+ idempotencyKey: `order.create:${order.id}`,
268
+ clientTs: createdAt
269
+ });
270
+ });
271
+ return { ...order, items, payments };
272
+ }
273
+ async void(orderId, opts) {
274
+ const voidedAt = nowISO();
275
+ return this.adapter.transaction(async (tx) => {
276
+ const rows = await tx.query(`SELECT * FROM orders WHERE id = ?`, [
277
+ orderId
278
+ ]);
279
+ const order = rows[0];
280
+ if (!order) throw new Error(`Order not found: ${orderId}`);
281
+ if (order.status === "voided") {
282
+ throw new Error(`Order already voided: ${orderId}`);
283
+ }
284
+ await tx.execute(`UPDATE orders SET status = 'voided' WHERE id = ?`, [
285
+ orderId
286
+ ]);
287
+ const voided = { ...order, status: "voided" };
288
+ await this.outbox.enqueue(tx, {
289
+ opType: "order.void",
290
+ payload: { orderId, reason: opts.reason, voidedAt },
291
+ idempotencyKey: `order.void:${orderId}`,
292
+ clientTs: voidedAt
293
+ });
294
+ return voided;
295
+ });
296
+ }
297
+ async findByShift(shiftId) {
298
+ return this.adapter.query(
299
+ `SELECT * FROM orders WHERE shift_id = ? ORDER BY created_at`,
300
+ [shiftId]
301
+ );
302
+ }
303
+ async getWithDetails(orderId) {
304
+ const orders = await this.adapter.query(
305
+ `SELECT * FROM orders WHERE id = ?`,
306
+ [orderId]
307
+ );
308
+ const order = orders[0];
309
+ if (!order) return null;
310
+ const items = await this.adapter.query(
311
+ `SELECT * FROM order_items WHERE order_id = ?`,
312
+ [orderId]
313
+ );
314
+ const payments = await this.adapter.query(
315
+ `SELECT * FROM payments WHERE order_id = ?`,
316
+ [orderId]
317
+ );
318
+ return { ...order, items, payments };
319
+ }
320
+ };
321
+
322
+ // src/repositories/OutboxRepository.ts
323
+ var OutboxRepository = class {
324
+ constructor(adapter) {
325
+ this.adapter = adapter;
326
+ }
327
+ adapter;
328
+ /** إضافة عملية. اتنادى جوه transaction بتاعة اللي بينادي، ومرّرله الـ tx. */
329
+ async enqueue(tx, input) {
330
+ await tx.execute(
331
+ `INSERT INTO pending_operations
332
+ (id, op_type, payload, idempotency_key, client_ts, attempts, status, last_error)
333
+ VALUES (?, ?, ?, ?, ?, 0, 'pending', NULL)`,
334
+ [
335
+ newId(),
336
+ input.opType,
337
+ JSON.stringify(input.payload),
338
+ input.idempotencyKey,
339
+ input.clientTs
340
+ ]
341
+ );
342
+ }
343
+ /** العمليات المستنية بالترتيب الزمني (UUIDv7) — وهو ترتيب الرفع. */
344
+ async listPending(limit = 50) {
345
+ return this.adapter.query(
346
+ `SELECT * FROM pending_operations
347
+ WHERE status = 'pending'
348
+ ORDER BY id
349
+ LIMIT ?`,
350
+ [limit]
351
+ );
352
+ }
353
+ async countByStatus() {
354
+ const rows = await this.adapter.query(
355
+ `SELECT status, COUNT(*) AS n FROM pending_operations GROUP BY status`
356
+ );
357
+ return Object.fromEntries(rows.map((r) => [r.status, r.n]));
358
+ }
359
+ // -------------------------------------------------------------------------
360
+ // جانب المزامنة (بيستهلكه الـ SyncEngine)
361
+ // -------------------------------------------------------------------------
362
+ /**
363
+ * بيحجز أقدم دفعة pending (بترتيب UUIDv7) ويعلّمها inflight في transaction واحدة —
364
+ * فمفيش دفعتين بترفعوا نفس العملية. بيزوّد attempts وبيسجّل inflight_at.
365
+ */
366
+ async claimBatch(limit = 50, inflightAt = nowISO()) {
367
+ return this.adapter.transaction(async (tx) => {
368
+ const rows = await tx.query(
369
+ `SELECT * FROM pending_operations
370
+ WHERE status = 'pending'
371
+ ORDER BY id
372
+ LIMIT ?`,
373
+ [limit]
374
+ );
375
+ if (rows.length === 0) return [];
376
+ const ids = rows.map((r) => r.id);
377
+ const placeholders = ids.map(() => "?").join(",");
378
+ await tx.execute(
379
+ `UPDATE pending_operations
380
+ SET status = 'inflight', inflight_at = ?, attempts = attempts + 1
381
+ WHERE id IN (${placeholders})`,
382
+ [inflightAt, ...ids]
383
+ );
384
+ return rows.map((r) => ({
385
+ ...r,
386
+ status: "inflight",
387
+ inflight_at: inflightAt,
388
+ attempts: r.attempts + 1
389
+ }));
390
+ });
391
+ }
392
+ /**
393
+ * بيثبّت نتايج الرفع: العمليات الناجحة → synced، والمرفوضة → failed + السبب. وبيعكس
394
+ * الحالة على صف الطلب (orders.sync_status) لعمليات order.* — كله في transaction واحدة.
395
+ */
396
+ async settle(resolutions) {
397
+ if (resolutions.length === 0) return;
398
+ await this.adapter.transaction(async (tx) => {
399
+ for (const { op, outcome, error } of resolutions) {
400
+ if (outcome === "synced") {
401
+ await tx.execute(
402
+ `UPDATE pending_operations SET status = 'synced', last_error = NULL WHERE id = ?`,
403
+ [op.id]
404
+ );
405
+ } else {
406
+ await tx.execute(
407
+ `UPDATE pending_operations SET status = 'failed', last_error = ? WHERE id = ?`,
408
+ [error, op.id]
409
+ );
410
+ }
411
+ if (op.op_type === "order.create" || op.op_type === "order.void") {
412
+ const orderId = op.idempotency_key.slice(op.op_type.length + 1);
413
+ await tx.execute(`UPDATE orders SET sync_status = ? WHERE id = ?`, [
414
+ outcome,
415
+ orderId
416
+ ]);
417
+ }
418
+ }
419
+ });
420
+ }
421
+ /** إرجاع عمليات inflight لـ pending (فشل شبكة أو السيرفر مردّش عليها). */
422
+ async releaseToPending(ids) {
423
+ if (ids.length === 0) return;
424
+ const placeholders = ids.map(() => "?").join(",");
425
+ await this.adapter.execute(
426
+ `UPDATE pending_operations
427
+ SET status = 'pending', inflight_at = NULL
428
+ WHERE id IN (${placeholders})`,
429
+ ids
430
+ );
431
+ }
432
+ /**
433
+ * استرجاع العمليات العالقة inflight (تطبيق اتقفل وسط رفعة): أي inflight إما بلا
434
+ * inflight_at أو أقدم من الـ cutoff بترجع pending. الـ idempotency بيحمي لو وصلت فعلاً.
435
+ */
436
+ async recoverStuckInflight(cutoffIso) {
437
+ await this.adapter.execute(
438
+ `UPDATE pending_operations
439
+ SET status = 'pending', inflight_at = NULL
440
+ WHERE status = 'inflight'
441
+ AND (inflight_at IS NULL OR inflight_at <= ?)`,
442
+ [cutoffIso]
443
+ );
444
+ }
445
+ /** العمليات اللي رُفضت من السيرفر (للعرض للمدير). */
446
+ async listFailed(limit = 50) {
447
+ return this.adapter.query(
448
+ `SELECT * FROM pending_operations
449
+ WHERE status = 'failed'
450
+ ORDER BY id
451
+ LIMIT ?`,
452
+ [limit]
453
+ );
454
+ }
455
+ /** إعادة عمليات فاشلة لـ pending (المدير بيعيد المحاولة). بلا ids = كل الفاشلة. */
456
+ async retryFailed(ids) {
457
+ if (ids && ids.length > 0) {
458
+ const placeholders = ids.map(() => "?").join(",");
459
+ await this.adapter.execute(
460
+ `UPDATE pending_operations
461
+ SET status = 'pending', last_error = NULL
462
+ WHERE status = 'failed' AND id IN (${placeholders})`,
463
+ ids
464
+ );
465
+ } else {
466
+ await this.adapter.execute(
467
+ `UPDATE pending_operations
468
+ SET status = 'pending', last_error = NULL
469
+ WHERE status = 'failed'`
470
+ );
471
+ }
472
+ }
473
+ };
474
+
475
+ // src/repositories/ProductsRepository.ts
476
+ var toProduct = (r) => ({
477
+ ...r,
478
+ is_active: r.is_active === 1,
479
+ is_local: r.is_local === 1
480
+ });
481
+ var ProductsRepository = class {
482
+ constructor(adapter, outbox) {
483
+ this.adapter = adapter;
484
+ this.outbox = outbox;
485
+ }
486
+ adapter;
487
+ outbox;
488
+ /** البحث في المنتجات النشطة بالاسم أو الباركود أو الـ SKU. */
489
+ async search(text) {
490
+ const like = `%${text}%`;
491
+ const rows = await this.adapter.query(
492
+ `SELECT * FROM products
493
+ WHERE is_active = 1
494
+ AND (name LIKE ? OR barcode LIKE ? OR sku LIKE ?)
495
+ ORDER BY name
496
+ LIMIT 50`,
497
+ [like, like, like]
498
+ );
499
+ return rows.map(toProduct);
500
+ }
501
+ async getByBarcode(barcode) {
502
+ const rows = await this.adapter.query(
503
+ `SELECT * FROM products WHERE barcode = ? AND is_active = 1 LIMIT 1`,
504
+ [barcode]
505
+ );
506
+ return rows[0] ? toProduct(rows[0]) : null;
507
+ }
508
+ async getById(id) {
509
+ const rows = await this.adapter.query(
510
+ `SELECT * FROM products WHERE id = ?`,
511
+ [id]
512
+ );
513
+ return rows[0] ? toProduct(rows[0]) : null;
514
+ }
515
+ /** إنشاء منتج يدوي أوفلاين (is_local = 1). بيترفع كـ draft للمراجعة. */
516
+ async createLocal(input) {
517
+ const product = {
518
+ id: newId(),
519
+ name: input.name,
520
+ sku: input.sku ?? null,
521
+ barcode: input.barcode ?? null,
522
+ price: input.price,
523
+ category_id: input.categoryId ?? null,
524
+ is_active: true,
525
+ updated_at: nowISO(),
526
+ is_local: true
527
+ };
528
+ await this.adapter.transaction(async (tx) => {
529
+ await tx.execute(
530
+ `INSERT INTO products
531
+ (id, name, sku, barcode, price, category_id, is_active, updated_at, is_local)
532
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, 1)`,
533
+ [
534
+ product.id,
535
+ product.name,
536
+ product.sku,
537
+ product.barcode,
538
+ product.price,
539
+ product.category_id,
540
+ product.updated_at
541
+ ]
542
+ );
543
+ await this.outbox.enqueue(tx, {
544
+ opType: "product.create_local",
545
+ payload: product,
546
+ idempotencyKey: `product.create_local:${product.id}`,
547
+ clientTs: product.updated_at
548
+ });
549
+ });
550
+ return product;
551
+ }
552
+ /**
553
+ * upsert بالجملة من delta pull بتاع السيرفر (السيرفر مصدر الحقيقة). مفيش سطر
554
+ * outbox — دي بيانات داخلة (inbound)، مش عملية محلية.
555
+ */
556
+ async upsertMany(products) {
557
+ if (products.length === 0) return;
558
+ await this.adapter.transaction(async (tx) => {
559
+ for (const p of products) {
560
+ await tx.execute(
561
+ `INSERT OR REPLACE INTO products
562
+ (id, name, sku, barcode, price, category_id, is_active, updated_at, is_local)
563
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
564
+ [
565
+ p.id,
566
+ p.name,
567
+ p.sku,
568
+ p.barcode,
569
+ p.price,
570
+ p.category_id,
571
+ p.is_active ? 1 : 0,
572
+ p.updated_at,
573
+ p.is_local ? 1 : 0
574
+ ]
575
+ );
576
+ }
577
+ });
578
+ }
579
+ };
580
+
581
+ // src/repositories/ReportsRepository.ts
582
+ var ReportsRepository = class {
583
+ constructor(adapter) {
584
+ this.adapter = adapter;
585
+ }
586
+ adapter;
587
+ async shiftSummary(shiftId) {
588
+ const totals = await this.adapter.query(
589
+ `SELECT COALESCE(SUM(total), 0) AS total, COUNT(*) AS n
590
+ FROM orders
591
+ WHERE shift_id = ? AND status = 'completed'`,
592
+ [shiftId]
593
+ );
594
+ const totalRow = totals[0] ?? { total: 0, n: 0 };
595
+ const byMethodRows = await this.adapter.query(
596
+ `SELECT p.method AS method, COALESCE(SUM(p.amount), 0) AS amount
597
+ FROM payments p
598
+ JOIN orders o ON o.id = p.order_id
599
+ WHERE o.shift_id = ? AND o.status = 'completed'
600
+ GROUP BY p.method`,
601
+ [shiftId]
602
+ );
603
+ const topRows = await this.adapter.query(
604
+ `SELECT oi.product_id AS product_id,
605
+ oi.name_snapshot AS name,
606
+ SUM(oi.qty) AS qty,
607
+ SUM(oi.line_total) AS total
608
+ FROM order_items oi
609
+ JOIN orders o ON o.id = oi.order_id
610
+ WHERE o.shift_id = ? AND o.status = 'completed'
611
+ GROUP BY oi.product_id, oi.name_snapshot
612
+ ORDER BY total DESC
613
+ LIMIT 10`,
614
+ [shiftId]
615
+ );
616
+ return {
617
+ shiftId,
618
+ totalSales: totalRow.total,
619
+ orderCount: totalRow.n,
620
+ byPaymentMethod: Object.fromEntries(
621
+ byMethodRows.map((r) => [r.method, r.amount])
622
+ ),
623
+ topProducts: topRows.map((r) => ({
624
+ productId: r.product_id,
625
+ name: r.name,
626
+ qty: r.qty,
627
+ total: r.total
628
+ }))
629
+ };
630
+ }
631
+ };
632
+
633
+ // src/repositories/ShiftsRepository.ts
634
+ var ShiftsRepository = class {
635
+ constructor(adapter, outbox) {
636
+ this.adapter = adapter;
637
+ this.outbox = outbox;
638
+ }
639
+ adapter;
640
+ outbox;
641
+ async open(input) {
642
+ const shift = {
643
+ id: newId(),
644
+ cashier_id: input.cashierId,
645
+ opened_at: input.openedAt ?? nowISO(),
646
+ closed_at: null,
647
+ opening_cash: input.openingCash,
648
+ counted_cash: null,
649
+ status: "open"
650
+ };
651
+ await this.adapter.transaction(async (tx) => {
652
+ await tx.execute(
653
+ `INSERT INTO shifts
654
+ (id, cashier_id, opened_at, closed_at, opening_cash, counted_cash, status)
655
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
656
+ [
657
+ shift.id,
658
+ shift.cashier_id,
659
+ shift.opened_at,
660
+ shift.closed_at,
661
+ shift.opening_cash,
662
+ shift.counted_cash,
663
+ shift.status
664
+ ]
665
+ );
666
+ await this.outbox.enqueue(tx, {
667
+ opType: "shift.open",
668
+ payload: shift,
669
+ idempotencyKey: `shift.open:${shift.id}`,
670
+ clientTs: shift.opened_at
671
+ });
672
+ });
673
+ return shift;
674
+ }
675
+ async close(shiftId, input) {
676
+ const closedAt = input.closedAt ?? nowISO();
677
+ const shift = await this.adapter.transaction(async (tx) => {
678
+ const existing = await tx.query(
679
+ `SELECT * FROM shifts WHERE id = ?`,
680
+ [shiftId]
681
+ );
682
+ const current = existing[0];
683
+ if (!current) throw new Error(`Shift not found: ${shiftId}`);
684
+ if (current.status !== "open") {
685
+ throw new Error(`Shift already closed: ${shiftId}`);
686
+ }
687
+ await tx.execute(
688
+ `UPDATE shifts
689
+ SET closed_at = ?, counted_cash = ?, status = 'closed'
690
+ WHERE id = ?`,
691
+ [closedAt, input.countedCash, shiftId]
692
+ );
693
+ const closed = {
694
+ ...current,
695
+ closed_at: closedAt,
696
+ counted_cash: input.countedCash,
697
+ status: "closed"
698
+ };
699
+ await this.outbox.enqueue(tx, {
700
+ opType: "shift.close",
701
+ payload: closed,
702
+ idempotencyKey: `shift.close:${shiftId}`,
703
+ clientTs: closedAt
704
+ });
705
+ return closed;
706
+ });
707
+ return shift;
708
+ }
709
+ /** الوردية المفتوحة الوحيدة على الجهاز ده (v1: وردية == جهاز واحد). */
710
+ async getCurrent() {
711
+ const rows = await this.adapter.query(
712
+ `SELECT * FROM shifts WHERE status = 'open' ORDER BY opened_at DESC LIMIT 1`
713
+ );
714
+ return rows[0] ?? null;
715
+ }
716
+ async findById(shiftId) {
717
+ const rows = await this.adapter.query(
718
+ `SELECT * FROM shifts WHERE id = ?`,
719
+ [shiftId]
720
+ );
721
+ return rows[0] ?? null;
722
+ }
723
+ };
724
+
725
+ // src/createStorage.ts
726
+ async function createStorage(adapter) {
727
+ await runMigrations(adapter);
728
+ const outbox = new OutboxRepository(adapter);
729
+ return {
730
+ shifts: new ShiftsRepository(adapter, outbox),
731
+ orders: new OrdersRepository(adapter, outbox),
732
+ products: new ProductsRepository(adapter, outbox),
733
+ reports: new ReportsRepository(adapter),
734
+ outbox,
735
+ adapter,
736
+ close: () => adapter.close()
737
+ };
738
+ }
739
+
740
+ // src/sync/backoff.ts
741
+ var DEFAULT_BACKOFF = {
742
+ baseMs: 1e3,
743
+ // 1s
744
+ capMs: 5 * 60 * 1e3
745
+ // 5 دقايق
746
+ };
747
+ function computeBackoff(attempt, cfg = DEFAULT_BACKOFF, random = Math.random) {
748
+ const exponent = Math.max(0, attempt - 1);
749
+ const raw = cfg.baseMs * 2 ** exponent;
750
+ const capped = Math.min(cfg.capMs, raw);
751
+ const half = capped / 2;
752
+ return Math.round(half + random() * half);
753
+ }
754
+
755
+ // src/sync/resources.ts
756
+ var bool = (v, fallback = true) => v ?? fallback ? 1 : 0;
757
+ var nullableStr = (v) => v == null ? null : String(v);
758
+ var PRODUCTS_RESOURCE = {
759
+ resource: "products",
760
+ table: "products",
761
+ guardLocal: true,
762
+ toRow: (r) => ({
763
+ id: String(r.id),
764
+ name: String(r.name),
765
+ sku: nullableStr(r.sku),
766
+ barcode: nullableStr(r.barcode),
767
+ price: Number(r.price),
768
+ category_id: nullableStr(r.category_id),
769
+ is_active: bool(r.is_active),
770
+ updated_at: String(r.updated_at),
771
+ is_local: 0
772
+ })
773
+ };
774
+ var CASHIERS_RESOURCE = {
775
+ resource: "cashiers",
776
+ table: "cashiers",
777
+ toRow: (r) => ({
778
+ id: String(r.id),
779
+ name: String(r.name),
780
+ role: String(r.role),
781
+ pin_hash: String(r.pin_hash),
782
+ pin_salt: String(r.pin_salt),
783
+ is_active: bool(r.is_active),
784
+ updated_at: String(r.updated_at)
785
+ })
786
+ };
787
+ var SETTINGS_RESOURCE = {
788
+ resource: "settings",
789
+ table: "settings",
790
+ idColumn: "key",
791
+ toRow: (r) => ({
792
+ key: String(r.key),
793
+ value: String(r.value),
794
+ updated_at: String(r.updated_at)
795
+ })
796
+ };
797
+ var DEFAULT_RESOURCES = [
798
+ PRODUCTS_RESOURCE,
799
+ CASHIERS_RESOURCE,
800
+ SETTINGS_RESOURCE
801
+ ];
802
+
803
+ // src/sync/SyncStateRepository.ts
804
+ var SyncStateRepository = class {
805
+ constructor(adapter) {
806
+ this.adapter = adapter;
807
+ }
808
+ adapter;
809
+ async get(resource) {
810
+ const rows = await this.adapter.query(
811
+ `SELECT * FROM sync_state WHERE resource = ?`,
812
+ [resource]
813
+ );
814
+ return rows[0] ?? null;
815
+ }
816
+ async set(resource, lastPulled, cursor) {
817
+ await this.adapter.execute(
818
+ `INSERT OR REPLACE INTO sync_state (resource, last_pulled, cursor)
819
+ VALUES (?, ?, ?)`,
820
+ [resource, lastPulled, cursor]
821
+ );
822
+ }
823
+ };
824
+
825
+ // src/sync/types.ts
826
+ var SyncNetworkError = class extends Error {
827
+ constructor(message, cause) {
828
+ super(message, { cause });
829
+ this.name = "SyncNetworkError";
830
+ }
831
+ };
832
+
833
+ // src/sync/SyncEngine.ts
834
+ var FIVE_MIN = 5 * 60 * 1e3;
835
+ var TWO_MIN = 2 * 60 * 1e3;
836
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
837
+ var errMsg = (err) => err instanceof Error ? err.message : String(err);
838
+ var toPushOp = (op) => ({
839
+ idempotency_key: op.idempotency_key,
840
+ op_type: op.op_type,
841
+ client_ts: op.client_ts,
842
+ payload: JSON.parse(op.payload)
843
+ });
844
+ var SyncEngine = class {
845
+ transport;
846
+ outbox;
847
+ adapter;
848
+ syncState;
849
+ resources;
850
+ batchSize;
851
+ pullIntervalMs;
852
+ stuckInflightMs;
853
+ backoff;
854
+ maxPushRetries;
855
+ getAuthToken;
856
+ now;
857
+ sleep;
858
+ random;
859
+ onError;
860
+ listeners = /* @__PURE__ */ new Set();
861
+ status = {
862
+ online: false,
863
+ paused: false,
864
+ running: false,
865
+ pendingCount: 0,
866
+ inflightCount: 0,
867
+ failedOps: [],
868
+ lastPushAt: null,
869
+ lastPullAt: null,
870
+ lastError: null
871
+ };
872
+ stopped = false;
873
+ pullTimer = null;
874
+ /** يخلّي الـ push single-flight — نداءات requestPush بتتسلسل. */
875
+ pushChain = Promise.resolve();
876
+ constructor(storage, opts) {
877
+ this.transport = opts.transport;
878
+ this.outbox = storage.outbox;
879
+ this.adapter = storage.adapter;
880
+ this.syncState = new SyncStateRepository(storage.adapter);
881
+ this.resources = opts.resources ?? DEFAULT_RESOURCES;
882
+ this.batchSize = opts.batchSize ?? 50;
883
+ this.pullIntervalMs = opts.pullIntervalMs ?? FIVE_MIN;
884
+ this.stuckInflightMs = opts.stuckInflightMs ?? TWO_MIN;
885
+ this.backoff = { ...DEFAULT_BACKOFF, ...opts.backoff };
886
+ this.maxPushRetries = opts.maxPushRetries ?? Number.POSITIVE_INFINITY;
887
+ this.getAuthToken = opts.getAuthToken ?? (() => "");
888
+ this.now = opts.now ?? Date.now;
889
+ this.sleep = opts.sleep ?? defaultSleep;
890
+ this.random = opts.random ?? Math.random;
891
+ this.onError = opts.onError ?? (() => {
892
+ });
893
+ }
894
+ // -------------------------------------------------------------------------
895
+ // الحالة
896
+ // -------------------------------------------------------------------------
897
+ getStatus() {
898
+ return this.snapshot();
899
+ }
900
+ /** اشتراك في تغيّر الحالة. بيتنادى فوراً بالحالة الحالية، ويرجّع دالة إلغاء. */
901
+ onStatusChange(cb) {
902
+ this.listeners.add(cb);
903
+ cb(this.snapshot());
904
+ return () => this.listeners.delete(cb);
905
+ }
906
+ snapshot() {
907
+ return { ...this.status, failedOps: this.status.failedOps.map((f) => ({ ...f })) };
908
+ }
909
+ emit() {
910
+ const snap = this.snapshot();
911
+ for (const cb of this.listeners) {
912
+ try {
913
+ cb(snap);
914
+ } catch (err) {
915
+ this.onError(err, "listener");
916
+ }
917
+ }
918
+ }
919
+ async refreshCounts() {
920
+ const counts = await this.outbox.countByStatus();
921
+ this.status.pendingCount = counts["pending"] ?? 0;
922
+ this.status.inflightCount = counts["inflight"] ?? 0;
923
+ const failed = await this.outbox.listFailed(50);
924
+ this.status.failedOps = failed.map((o) => ({
925
+ id: o.id,
926
+ opType: o.op_type,
927
+ error: o.last_error ?? "unknown"
928
+ }));
929
+ }
930
+ isPaused() {
931
+ return this.getAuthToken() === null;
932
+ }
933
+ isoNow() {
934
+ return new Date(this.now()).toISOString();
935
+ }
936
+ // -------------------------------------------------------------------------
937
+ // الاسترجاع عند الإقلاع
938
+ // -------------------------------------------------------------------------
939
+ /** يرجّع أي عملية inflight عالقة (أقدم من المهلة) لـ pending. اتنادى عند الإقلاع. */
940
+ async recover() {
941
+ const cutoff = new Date(this.now() - this.stuckInflightMs).toISOString();
942
+ await this.outbox.recoverStuckInflight(cutoff);
943
+ await this.refreshCounts();
944
+ this.emit();
945
+ }
946
+ // -------------------------------------------------------------------------
947
+ // Push
948
+ // -------------------------------------------------------------------------
949
+ /**
950
+ * يفضّي الـ outbox في دفعات لحد ما يخلص أو يقابل فشل شبكة/توقّف/pause.
951
+ * مابيرميش لفشل الشبكة — بيرجّعه في النتيجة عشان pushWithBackoff يجمّع ويعيد.
952
+ */
953
+ async pushCycle() {
954
+ const result = {
955
+ synced: 0,
956
+ duplicated: 0,
957
+ failed: 0,
958
+ drained: false,
959
+ networkError: null,
960
+ paused: false
961
+ };
962
+ if (this.isPaused()) {
963
+ this.status.paused = true;
964
+ result.paused = true;
965
+ this.emit();
966
+ return result;
967
+ }
968
+ this.status.paused = false;
969
+ for (; ; ) {
970
+ if (this.stopped) return result;
971
+ const batch = await this.outbox.claimBatch(this.batchSize, this.isoNow());
972
+ if (batch.length === 0) {
973
+ result.drained = true;
974
+ return result;
975
+ }
976
+ let resp;
977
+ try {
978
+ resp = await this.transport.pushBatch(batch.map(toPushOp), {
979
+ token: this.getAuthToken()
980
+ });
981
+ } catch (err) {
982
+ await this.outbox.releaseToPending(batch.map((b) => b.id));
983
+ this.status.online = false;
984
+ this.status.lastError = errMsg(err);
985
+ await this.refreshCounts();
986
+ this.emit();
987
+ result.networkError = new SyncNetworkError(errMsg(err), err);
988
+ return result;
989
+ }
990
+ const byKey = new Map(resp.results.map((r) => [r.idempotency_key, r]));
991
+ const resolutions = [];
992
+ const orphaned = [];
993
+ for (const op of batch) {
994
+ const r = byKey.get(op.idempotency_key);
995
+ if (!r) {
996
+ orphaned.push(op.id);
997
+ continue;
998
+ }
999
+ if (r.status === "applied" || r.status === "duplicate") {
1000
+ resolutions.push({ op, outcome: "synced", error: null });
1001
+ if (r.status === "duplicate") result.duplicated++;
1002
+ else result.synced++;
1003
+ } else {
1004
+ resolutions.push({ op, outcome: "failed", error: r.error ?? "rejected" });
1005
+ result.failed++;
1006
+ }
1007
+ }
1008
+ await this.outbox.settle(resolutions);
1009
+ await this.outbox.releaseToPending(orphaned);
1010
+ this.status.online = true;
1011
+ this.status.lastError = null;
1012
+ this.status.lastPushAt = this.isoNow();
1013
+ await this.refreshCounts();
1014
+ this.emit();
1015
+ }
1016
+ }
1017
+ /**
1018
+ * push مع إعادة محاولة تلقائية + exponential backoff عند فشل الشبكة، لحد ما
1019
+ * يفضّي الـ outbox أو يتوقّف/يتعلّق (pause) أو يوصل سقف المحاولات.
1020
+ */
1021
+ async push() {
1022
+ const agg = {
1023
+ synced: 0,
1024
+ duplicated: 0,
1025
+ failed: 0,
1026
+ drained: false
1027
+ };
1028
+ let attempt = 0;
1029
+ for (; ; ) {
1030
+ if (this.stopped) return agg;
1031
+ const cycle = await this.pushCycle();
1032
+ agg.synced += cycle.synced;
1033
+ agg.duplicated += cycle.duplicated;
1034
+ agg.failed += cycle.failed;
1035
+ agg.drained = cycle.drained;
1036
+ if (cycle.paused) return agg;
1037
+ if (!cycle.networkError) return agg;
1038
+ attempt++;
1039
+ if (attempt > this.maxPushRetries) return agg;
1040
+ await this.sleep(computeBackoff(attempt, this.backoff, this.random));
1041
+ }
1042
+ }
1043
+ /** يجدول push على سلسلة single-flight (نداءات متتالية مبتتزاحمش). */
1044
+ requestPush() {
1045
+ this.pushChain = this.pushChain.catch(() => void 0).then(() => this.push()).catch((err) => this.onError(err, "push"));
1046
+ return this.pushChain.then(() => void 0);
1047
+ }
1048
+ // -------------------------------------------------------------------------
1049
+ // Pull
1050
+ // -------------------------------------------------------------------------
1051
+ /** ينزّل delta لكل مورد ويكتبه محلياً. بيرمي SyncNetworkError عند فشل الشبكة. */
1052
+ async pull() {
1053
+ const outcome = { upserted: 0, deleted: 0 };
1054
+ if (this.isPaused()) {
1055
+ this.status.paused = true;
1056
+ this.emit();
1057
+ return outcome;
1058
+ }
1059
+ this.status.paused = false;
1060
+ for (const cfg of this.resources) {
1061
+ const state = await this.syncState.get(cfg.resource);
1062
+ let since = state?.last_pulled ?? "";
1063
+ let cursor = state?.cursor ?? null;
1064
+ for (; ; ) {
1065
+ let delta;
1066
+ try {
1067
+ delta = await this.transport.pullDelta(cfg.resource, since, cursor, {
1068
+ token: this.getAuthToken()
1069
+ });
1070
+ } catch (err) {
1071
+ this.status.online = false;
1072
+ this.status.lastError = errMsg(err);
1073
+ this.emit();
1074
+ throw new SyncNetworkError(errMsg(err), err);
1075
+ }
1076
+ await this.applyDelta(cfg, delta);
1077
+ outcome.upserted += delta.changed.length;
1078
+ outcome.deleted += delta.deleted.length;
1079
+ const pulledAt = this.isoNow();
1080
+ await this.syncState.set(cfg.resource, pulledAt, delta.cursor);
1081
+ since = pulledAt;
1082
+ cursor = delta.cursor;
1083
+ if (!delta.hasMore) break;
1084
+ }
1085
+ }
1086
+ this.status.online = true;
1087
+ this.status.lastError = null;
1088
+ this.status.lastPullAt = this.isoNow();
1089
+ this.emit();
1090
+ return outcome;
1091
+ }
1092
+ /** كتابة دفعة delta في transaction واحدة: upsert للمتغيّر + حذف الـ tombstones. */
1093
+ async applyDelta(cfg, delta) {
1094
+ if (delta.changed.length === 0 && delta.deleted.length === 0) return;
1095
+ const idColumn = cfg.idColumn ?? "id";
1096
+ await this.adapter.transaction(async (tx) => {
1097
+ for (const raw of delta.changed) {
1098
+ const row = cfg.toRow(raw);
1099
+ const cols = Object.keys(row);
1100
+ const placeholders = cols.map(() => "?").join(",");
1101
+ await tx.execute(
1102
+ `INSERT OR REPLACE INTO ${cfg.table} (${cols.join(",")}) VALUES (${placeholders})`,
1103
+ cols.map((c) => row[c])
1104
+ );
1105
+ }
1106
+ if (delta.deleted.length > 0) {
1107
+ const placeholders = delta.deleted.map(() => "?").join(",");
1108
+ const guard = cfg.guardLocal ? " AND is_local = 0" : "";
1109
+ await tx.execute(
1110
+ `DELETE FROM ${cfg.table} WHERE ${idColumn} IN (${placeholders})${guard}`,
1111
+ delta.deleted
1112
+ );
1113
+ }
1114
+ });
1115
+ }
1116
+ // -------------------------------------------------------------------------
1117
+ // التنسيق العام
1118
+ // -------------------------------------------------------------------------
1119
+ /** رفع ثم تنزيل — للزر اليدوي وبعد إعادة الاتصال. أخطاء الـ pull الشبكية بتتبلع. */
1120
+ async syncNow() {
1121
+ await this.push();
1122
+ try {
1123
+ await this.pull();
1124
+ } catch (err) {
1125
+ if (!(err instanceof SyncNetworkError)) throw err;
1126
+ this.onError(err, "pull");
1127
+ }
1128
+ }
1129
+ /** بدء التشغيل: استرجاع + مزامنة فورية + pull دوري كل fiveMin. */
1130
+ start() {
1131
+ this.stopped = false;
1132
+ this.status.running = true;
1133
+ this.emit();
1134
+ void this.recover().then(() => this.syncNow()).catch((err) => this.onError(err, "start"));
1135
+ this.pullTimer = setInterval(() => {
1136
+ void this.pull().catch((err) => {
1137
+ if (!(err instanceof SyncNetworkError)) this.onError(err, "pull-interval");
1138
+ });
1139
+ }, this.pullIntervalMs);
1140
+ }
1141
+ /** إيقاف: بيوقف الـ timer وبيخلّي أي push/backoff جاري يخرج عند أقرب نقطة. */
1142
+ stop() {
1143
+ this.stopped = true;
1144
+ this.status.running = false;
1145
+ if (this.pullTimer) {
1146
+ clearInterval(this.pullTimer);
1147
+ this.pullTimer = null;
1148
+ }
1149
+ this.emit();
1150
+ }
1151
+ };
1152
+
1153
+ // src/conformance/assert.ts
1154
+ function assert(cond, msg) {
1155
+ if (!cond) throw new Error(`conformance: ${msg}`);
1156
+ }
1157
+ function assertEqual(actual, expected, msg) {
1158
+ const a = JSON.stringify(actual);
1159
+ const e = JSON.stringify(expected);
1160
+ if (a !== e) {
1161
+ throw new Error(`conformance: ${msg} \u2014 \u0627\u0644\u0645\u062A\u0648\u0642\u0639 ${e} \u0644\u0643\u0646 \u062C\u0647 ${a}`);
1162
+ }
1163
+ }
1164
+ function bytesEqual(a, b) {
1165
+ if (a.length !== b.length) return false;
1166
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
1167
+ return true;
1168
+ }
1169
+ async function assertThrows(fn, msg) {
1170
+ let threw = false;
1171
+ try {
1172
+ await fn();
1173
+ } catch {
1174
+ threw = true;
1175
+ }
1176
+ assert(threw, msg);
1177
+ }
1178
+
1179
+ // src/conformance/cases.ts
1180
+ async function withAdapter(make, body) {
1181
+ const adapter = await make();
1182
+ try {
1183
+ await body(adapter);
1184
+ } finally {
1185
+ await adapter.close();
1186
+ }
1187
+ }
1188
+ var CONFORMANCE_CASES = [
1189
+ {
1190
+ name: "execute + query: \u0625\u0646\u0634\u0627\u0621 \u0648\u0625\u062F\u0631\u0627\u062C \u0648\u0642\u0631\u0627\u0621\u0629",
1191
+ run: (make) => withAdapter(make, async (a) => {
1192
+ await a.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
1193
+ await a.execute("INSERT INTO t (id, name) VALUES (?, ?)", [1, "\u0642\u0647\u0648\u0629"]);
1194
+ const rows = await a.query(
1195
+ "SELECT id, name FROM t"
1196
+ );
1197
+ assertEqual(rows, [{ id: 1, name: "\u0642\u0647\u0648\u0629" }], "\u0635\u0641 \u0648\u0627\u062D\u062F \u0628\u0627\u0644\u0642\u064A\u0645 \u0627\u0644\u0635\u062D");
1198
+ })
1199
+ },
1200
+ {
1201
+ name: "\u0627\u0644\u0645\u0639\u0627\u0645\u0644\u0627\u062A: string / number / null / blob \u062A\u062A\u0631\u0628\u0637 \u0648\u062A\u0631\u062C\u0639 \u0635\u062D",
1202
+ run: (make) => withAdapter(make, async (a) => {
1203
+ await a.execute(
1204
+ "CREATE TABLE t (id INTEGER, s TEXT, n REAL, z TEXT, b BLOB)"
1205
+ );
1206
+ const blob = new Uint8Array([0, 255, 16, 200]);
1207
+ await a.execute("INSERT INTO t (id, s, n, z, b) VALUES (?, ?, ?, ?, ?)", [
1208
+ 7,
1209
+ "\u0646\u0635",
1210
+ 12.5,
1211
+ null,
1212
+ blob
1213
+ ]);
1214
+ const rows = await a.query("SELECT id, s, n, z, b FROM t WHERE id = ?", [7]);
1215
+ assert(rows.length === 1, "\u0635\u0641 \u0648\u0627\u062D\u062F");
1216
+ const r = rows[0];
1217
+ assertEqual(
1218
+ { id: r.id, s: r.s, n: r.n, z: r.z },
1219
+ { id: 7, s: "\u0646\u0635", n: 12.5, z: null },
1220
+ "\u0627\u0644\u0642\u064A\u0645 \u063A\u064A\u0631 \u0627\u0644\u0640 blob"
1221
+ );
1222
+ assert(
1223
+ bytesEqual(new Uint8Array(r.b), blob),
1224
+ "\u0627\u0644\u0640 blob \u064A\u0631\u062C\u0639 \u0628\u0646\u0641\u0633 \u0627\u0644\u0628\u0627\u064A\u062A\u0627\u062A"
1225
+ );
1226
+ })
1227
+ },
1228
+ {
1229
+ name: "execute \u0645\u0646 \u063A\u064A\u0631 params \u064A\u0646\u0641\u0651\u0630 \u0623\u0643\u062A\u0631 \u0645\u0646 statement (DDL/migrations)",
1230
+ run: (make) => withAdapter(make, async (a) => {
1231
+ await a.execute(`
1232
+ CREATE TABLE a (id INTEGER);
1233
+ CREATE TABLE b (id INTEGER);
1234
+ INSERT INTO a (id) VALUES (1);
1235
+ INSERT INTO b (id) VALUES (2);
1236
+ `);
1237
+ const ra = await a.query("SELECT id FROM a");
1238
+ const rb = await a.query("SELECT id FROM b");
1239
+ assertEqual([ra, rb], [[{ id: 1 }], [{ id: 2 }]], "\u0627\u0644\u062C\u062F\u0648\u0644\u064A\u0646 \u0627\u062A\u0639\u0645\u0644\u0648\u0627");
1240
+ })
1241
+ },
1242
+ {
1243
+ name: "transaction \u0628\u062A\u0639\u0645\u0644 commit \u0639\u0646\u062F \u0627\u0644\u0646\u062C\u0627\u062D",
1244
+ run: (make) => withAdapter(make, async (a) => {
1245
+ await a.execute("CREATE TABLE t (id INTEGER)");
1246
+ await a.transaction(async (tx) => {
1247
+ await tx.execute("INSERT INTO t (id) VALUES (1)");
1248
+ await tx.execute("INSERT INTO t (id) VALUES (2)");
1249
+ });
1250
+ const rows = await a.query("SELECT id FROM t ORDER BY id");
1251
+ assertEqual(rows, [{ id: 1 }, { id: 2 }], "\u0627\u0644\u0635\u0641\u064A\u0646 \u0627\u062A\u062B\u0628\u062A\u0648\u0627 \u0628\u0639\u062F \u0627\u0644\u0640 commit");
1252
+ })
1253
+ },
1254
+ {
1255
+ name: "transaction \u0628\u062A\u0639\u0645\u0644 rollback \u0639\u0646\u062F \u0623\u064A throw (atomic) \u0648\u062A\u0639\u064A\u062F \u0627\u0644\u0631\u0645\u064A",
1256
+ run: (make) => withAdapter(make, async (a) => {
1257
+ await a.execute("CREATE TABLE t (id INTEGER)");
1258
+ await assertThrows(
1259
+ () => a.transaction(async (tx) => {
1260
+ await tx.execute("INSERT INTO t (id) VALUES (1)");
1261
+ throw new Error("\u0641\u0634\u0644 \u0645\u0642\u0635\u0648\u062F \u0648\u0633\u0637 \u0627\u0644\u0640 transaction");
1262
+ }),
1263
+ "\u0627\u0644\u0640 transaction \u0628\u062A\u0631\u0645\u064A \u0627\u0644\u062E\u0637\u0623 \u0628\u0631\u0647"
1264
+ );
1265
+ const rows = await a.query("SELECT id FROM t");
1266
+ assertEqual(rows, [], "\u0645\u0641\u064A\u0634 \u0623\u064A \u0635\u0641 \u0627\u062A\u0643\u062A\u0628 \u2014 \u0627\u062A\u0639\u0645\u0644 rollback \u0644\u0644\u0643\u0644");
1267
+ })
1268
+ },
1269
+ {
1270
+ name: "transaction \u0628\u062A\u0631\u062C\u0651\u0639 \u0642\u064A\u0645\u0629 \u0627\u0644\u0640 callback",
1271
+ run: (make) => withAdapter(make, async (a) => {
1272
+ await a.execute("CREATE TABLE t (id INTEGER)");
1273
+ const value = await a.transaction(async (tx) => {
1274
+ await tx.execute("INSERT INTO t (id) VALUES (42)");
1275
+ const rows = await tx.query("SELECT id FROM t");
1276
+ return rows[0].id;
1277
+ });
1278
+ assertEqual(value, 42, "\u0627\u0644\u0642\u064A\u0645\u0629 \u0627\u0644\u0631\u0627\u062C\u0639\u0629 \u0645\u0646 \u0627\u0644\u0640 callback");
1279
+ })
1280
+ },
1281
+ {
1282
+ name: "read-your-writes: \u0627\u0644\u0640 tx \u0628\u064A\u0634\u0648\u0641 \u0643\u062A\u0627\u0628\u0627\u062A\u0647 \u0642\u0628\u0644 \u0627\u0644\u0640 commit",
1283
+ run: (make) => withAdapter(make, async (a) => {
1284
+ await a.execute("CREATE TABLE t (id INTEGER)");
1285
+ await a.transaction(async (tx) => {
1286
+ await tx.execute("INSERT INTO t (id) VALUES (5)");
1287
+ const rows = await tx.query("SELECT id FROM t");
1288
+ assertEqual(rows, [{ id: 5 }], "\u0627\u0644\u0627\u0633\u062A\u0639\u0644\u0627\u0645 \u062C\u0648\u0647 \u0627\u0644\u0640 tx \u0628\u064A\u0634\u0648\u0641 \u0627\u0644\u0625\u062F\u0631\u0627\u062C");
1289
+ });
1290
+ })
1291
+ },
1292
+ {
1293
+ name: "INSERT OR REPLACE: upsert (\u064A\u0633\u062A\u062E\u062F\u0645\u0647 \u0627\u0644\u0640 pull)",
1294
+ run: (make) => withAdapter(make, async (a) => {
1295
+ await a.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)");
1296
+ await a.execute("INSERT OR REPLACE INTO t (id, v) VALUES (?, ?)", [1, "\u0623\u0648\u0644"]);
1297
+ await a.execute("INSERT OR REPLACE INTO t (id, v) VALUES (?, ?)", [1, "\u062A\u0627\u0646\u064A"]);
1298
+ const rows = await a.query("SELECT id, v FROM t");
1299
+ assertEqual(rows, [{ id: 1, v: "\u062A\u0627\u0646\u064A" }], "\u0627\u0644\u0635\u0641 \u0627\u062A\u0633\u062A\u0628\u062F\u0644 \u0645\u0634 \u0627\u062A\u0643\u0631\u0651\u0631");
1300
+ })
1301
+ }
1302
+ ];
1303
+
1304
+ // src/conformance/index.ts
1305
+ function runAdapterConformance(label, make, harness) {
1306
+ harness.describe(`StorageAdapter conformance \u2014 ${label}`, () => {
1307
+ for (const c of CONFORMANCE_CASES) {
1308
+ harness.it(c.name, () => c.run(make));
1309
+ }
1310
+ });
1311
+ }
1312
+
1313
+ exports.CASHIERS_RESOURCE = CASHIERS_RESOURCE;
1314
+ exports.CONFORMANCE_CASES = CONFORMANCE_CASES;
1315
+ exports.DEFAULT_BACKOFF = DEFAULT_BACKOFF;
1316
+ exports.DEFAULT_RESOURCES = DEFAULT_RESOURCES;
1317
+ exports.MIGRATIONS = MIGRATIONS;
1318
+ exports.OrdersRepository = OrdersRepository;
1319
+ exports.OutboxRepository = OutboxRepository;
1320
+ exports.PRODUCTS_RESOURCE = PRODUCTS_RESOURCE;
1321
+ exports.ProductsRepository = ProductsRepository;
1322
+ exports.ReportsRepository = ReportsRepository;
1323
+ exports.SETTINGS_RESOURCE = SETTINGS_RESOURCE;
1324
+ exports.ShiftsRepository = ShiftsRepository;
1325
+ exports.SyncEngine = SyncEngine;
1326
+ exports.SyncNetworkError = SyncNetworkError;
1327
+ exports.SyncStateRepository = SyncStateRepository;
1328
+ exports.computeBackoff = computeBackoff;
1329
+ exports.createStorage = createStorage;
1330
+ exports.newId = newId;
1331
+ exports.nowISO = nowISO;
1332
+ exports.runAdapterConformance = runAdapterConformance;
1333
+ exports.runMigrations = runMigrations;
1334
+ //# sourceMappingURL=index.cjs.map
1335
+ //# sourceMappingURL=index.cjs.map