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/README.md +128 -0
- package/dist/StorageAdapter-B2IgHcUF.d.cts +27 -0
- package/dist/StorageAdapter-B2IgHcUF.d.ts +27 -0
- package/dist/better-sqlite3.cjs +96 -0
- package/dist/better-sqlite3.cjs.map +1 -0
- package/dist/better-sqlite3.d.cts +94 -0
- package/dist/better-sqlite3.d.ts +94 -0
- package/dist/better-sqlite3.js +90 -0
- package/dist/better-sqlite3.js.map +1 -0
- package/dist/chunk-CELALQHK.js +38 -0
- package/dist/chunk-CELALQHK.js.map +1 -0
- package/dist/chunk-HXRUTEU7.js +163 -0
- package/dist/chunk-HXRUTEU7.js.map +1 -0
- package/dist/conformance.cjs +170 -0
- package/dist/conformance.cjs.map +1 -0
- package/dist/conformance.d.cts +42 -0
- package/dist/conformance.d.ts +42 -0
- package/dist/conformance.js +3 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.cjs +1335 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +518 -0
- package/dist/index.d.ts +518 -0
- package/dist/index.js +1154 -0
- package/dist/index.js.map +1 -0
- package/dist/op-sqlite.cjs +73 -0
- package/dist/op-sqlite.cjs.map +1 -0
- package/dist/op-sqlite.d.cts +23 -0
- package/dist/op-sqlite.d.ts +23 -0
- package/dist/op-sqlite.js +38 -0
- package/dist/op-sqlite.js.map +1 -0
- package/dist/wa-sqlite.cjs +68 -0
- package/dist/wa-sqlite.cjs.map +1 -0
- package/dist/wa-sqlite.d.cts +39 -0
- package/dist/wa-sqlite.d.ts +39 -0
- package/dist/wa-sqlite.js +32 -0
- package/dist/wa-sqlite.js.map +1 -0
- package/package.json +84 -0
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
|