@neetru/sdk 1.2.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +336 -244
- package/README.md +194 -194
- package/dist/auth.cjs +3762 -345
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +5 -1
- package/dist/auth.d.ts +5 -1
- package/dist/auth.mjs +3762 -345
- package/dist/auth.mjs.map +1 -1
- package/dist/catalog.cjs.map +1 -1
- package/dist/catalog.d.cts +5 -1
- package/dist/catalog.d.ts +5 -1
- package/dist/catalog.mjs.map +1 -1
- package/dist/checkout.cjs.map +1 -1
- package/dist/checkout.d.cts +5 -1
- package/dist/checkout.d.ts +5 -1
- package/dist/checkout.mjs.map +1 -1
- package/dist/collection-ref-DqAAhuhX.d.cts +472 -0
- package/dist/collection-ref-DqAAhuhX.d.ts +472 -0
- package/dist/db-react.cjs +136 -0
- package/dist/db-react.cjs.map +1 -0
- package/dist/db-react.d.cts +99 -0
- package/dist/db-react.d.ts +99 -0
- package/dist/db-react.mjs +112 -0
- package/dist/db-react.mjs.map +1 -0
- package/dist/db.cjs +3621 -131
- package/dist/db.cjs.map +1 -1
- package/dist/db.d.cts +5 -8
- package/dist/db.d.ts +5 -8
- package/dist/db.mjs +3618 -131
- package/dist/db.mjs.map +1 -1
- package/dist/entitlements.cjs.map +1 -1
- package/dist/entitlements.d.cts +5 -1
- package/dist/entitlements.d.ts +5 -1
- package/dist/entitlements.mjs.map +1 -1
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.mjs.map +1 -1
- package/dist/index.cjs +3984 -341
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -6
- package/dist/index.d.ts +13 -6
- package/dist/index.mjs +3904 -262
- package/dist/index.mjs.map +1 -1
- package/dist/mocks.cjs +189 -7
- package/dist/mocks.cjs.map +1 -1
- package/dist/mocks.d.cts +18 -5
- package/dist/mocks.d.ts +18 -5
- package/dist/mocks.mjs +189 -7
- package/dist/mocks.mjs.map +1 -1
- package/dist/notifications.cjs.map +1 -1
- package/dist/notifications.d.cts +5 -1
- package/dist/notifications.d.ts +5 -1
- package/dist/notifications.mjs.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +5 -1
- package/dist/react.d.ts +5 -1
- package/dist/react.mjs.map +1 -1
- package/dist/support.cjs.map +1 -1
- package/dist/support.d.cts +5 -1
- package/dist/support.d.ts +5 -1
- package/dist/support.mjs.map +1 -1
- package/dist/telemetry.cjs.map +1 -1
- package/dist/telemetry.d.cts +5 -1
- package/dist/telemetry.d.ts +5 -1
- package/dist/telemetry.mjs.map +1 -1
- package/dist/types-Cfb-qeDg.d.cts +1364 -0
- package/dist/types-V1EfjR1p.d.ts +1364 -0
- package/dist/usage.cjs.map +1 -1
- package/dist/usage.d.cts +5 -1
- package/dist/usage.d.ts +5 -1
- package/dist/usage.mjs.map +1 -1
- package/dist/webhooks.cjs.map +1 -1
- package/dist/webhooks.d.cts +5 -1
- package/dist/webhooks.d.ts +5 -1
- package/dist/webhooks.mjs.map +1 -1
- package/package.json +133 -111
- package/dist/types-CQAfwqUS.d.cts +0 -654
- package/dist/types-CQAfwqUS.d.ts +0 -654
package/dist/index.cjs
CHANGED
|
@@ -1,30 +1,48 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
var idb = require('idb');
|
|
4
|
+
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
8
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
9
|
+
}) : x)(function(x) {
|
|
10
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
11
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
12
|
+
});
|
|
13
|
+
var __esm = (fn, res) => function __init() {
|
|
14
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
15
|
+
};
|
|
16
|
+
var __export = (target, all) => {
|
|
17
|
+
for (var name in all)
|
|
18
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
16
19
|
};
|
|
17
20
|
|
|
18
|
-
// src/
|
|
19
|
-
|
|
21
|
+
// src/errors.ts
|
|
22
|
+
exports.NeetruError = void 0;
|
|
23
|
+
var init_errors = __esm({
|
|
24
|
+
"src/errors.ts"() {
|
|
25
|
+
exports.NeetruError = class _NeetruError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
status;
|
|
28
|
+
requestId;
|
|
29
|
+
constructor(code, message, status, requestId) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "NeetruError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.requestId = requestId;
|
|
35
|
+
Object.setPrototypeOf(this, _NeetruError.prototype);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
});
|
|
20
40
|
|
|
21
41
|
// src/http.ts
|
|
22
|
-
var
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"network_error"
|
|
27
|
-
]);
|
|
42
|
+
var http_exports = {};
|
|
43
|
+
__export(http_exports, {
|
|
44
|
+
httpRequest: () => httpRequest
|
|
45
|
+
});
|
|
28
46
|
function backoffMs(attempt) {
|
|
29
47
|
const base = 200 * Math.pow(4, attempt);
|
|
30
48
|
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
@@ -84,7 +102,7 @@ async function httpRequest(config, opts) {
|
|
|
84
102
|
};
|
|
85
103
|
if (opts.requireAuth) {
|
|
86
104
|
if (!config.apiKey) {
|
|
87
|
-
throw new NeetruError(
|
|
105
|
+
throw new exports.NeetruError(
|
|
88
106
|
"missing_api_key",
|
|
89
107
|
"This operation requires an apiKey. Pass it to createNeetruClient({ apiKey }) or set NEETRU_API_KEY env var."
|
|
90
108
|
);
|
|
@@ -105,7 +123,7 @@ async function httpRequest(config, opts) {
|
|
|
105
123
|
res = await config.fetch(url, init);
|
|
106
124
|
} catch (err2) {
|
|
107
125
|
const message2 = err2 instanceof DOMException && err2.name === "TimeoutError" ? "Network error: timeout after 30s" : `Network error: ${err2 instanceof Error ? err2.message : "fetch failed"}`;
|
|
108
|
-
lastError = new NeetruError("network_error", message2);
|
|
126
|
+
lastError = new exports.NeetruError("network_error", message2);
|
|
109
127
|
if (attempt < maxRetries) {
|
|
110
128
|
await sleep(backoffMs(attempt));
|
|
111
129
|
continue;
|
|
@@ -129,7 +147,7 @@ async function httpRequest(config, opts) {
|
|
|
129
147
|
if (typeof errField.message === "string") message = errField.message;
|
|
130
148
|
}
|
|
131
149
|
}
|
|
132
|
-
const err = new NeetruError(code, message, res.status, requestId);
|
|
150
|
+
const err = new exports.NeetruError(code, message, res.status, requestId);
|
|
133
151
|
lastError = err;
|
|
134
152
|
const isRetryable = RETRYABLE_CODES.has(code);
|
|
135
153
|
if (isRetryable && attempt < maxRetries) {
|
|
@@ -140,20 +158,40 @@ async function httpRequest(config, opts) {
|
|
|
140
158
|
}
|
|
141
159
|
throw err;
|
|
142
160
|
}
|
|
143
|
-
throw lastError ?? new NeetruError("unknown", "unexpected httpRequest exit");
|
|
161
|
+
throw lastError ?? new exports.NeetruError("unknown", "unexpected httpRequest exit");
|
|
144
162
|
}
|
|
163
|
+
var DEFAULT_RETRIES, RETRYABLE_CODES;
|
|
164
|
+
var init_http = __esm({
|
|
165
|
+
"src/http.ts"() {
|
|
166
|
+
init_errors();
|
|
167
|
+
DEFAULT_RETRIES = 2;
|
|
168
|
+
RETRYABLE_CODES = /* @__PURE__ */ new Set([
|
|
169
|
+
"rate_limited",
|
|
170
|
+
"server_error",
|
|
171
|
+
"network_error"
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// src/auth.ts
|
|
177
|
+
init_errors();
|
|
178
|
+
|
|
179
|
+
// src/types.ts
|
|
180
|
+
var DEFAULT_BASE_URL = "https://api.neetru.com";
|
|
145
181
|
|
|
146
182
|
// src/catalog.ts
|
|
183
|
+
init_errors();
|
|
184
|
+
init_http();
|
|
147
185
|
function toProduct(raw) {
|
|
148
186
|
if (!raw || typeof raw !== "object") {
|
|
149
|
-
throw new NeetruError("invalid_response", "Catalog response item is not an object");
|
|
187
|
+
throw new exports.NeetruError("invalid_response", "Catalog response item is not an object");
|
|
150
188
|
}
|
|
151
189
|
const r = raw;
|
|
152
190
|
if (typeof r.slug !== "string" || !r.slug) {
|
|
153
|
-
throw new NeetruError("invalid_response", "Catalog product missing slug");
|
|
191
|
+
throw new exports.NeetruError("invalid_response", "Catalog product missing slug");
|
|
154
192
|
}
|
|
155
193
|
if (typeof r.name !== "string" || !r.name) {
|
|
156
|
-
throw new NeetruError("invalid_response", "Catalog product missing name");
|
|
194
|
+
throw new exports.NeetruError("invalid_response", "Catalog product missing name");
|
|
157
195
|
}
|
|
158
196
|
const product = {
|
|
159
197
|
slug: r.slug,
|
|
@@ -186,7 +224,7 @@ function createCatalogNamespace(config) {
|
|
|
186
224
|
path: "/api/sdk/v1/catalog"
|
|
187
225
|
});
|
|
188
226
|
if (!raw || !Array.isArray(raw.products)) {
|
|
189
|
-
throw new NeetruError(
|
|
227
|
+
throw new exports.NeetruError(
|
|
190
228
|
"invalid_response",
|
|
191
229
|
"Catalog list response missing products array"
|
|
192
230
|
);
|
|
@@ -203,14 +241,14 @@ function createCatalogNamespace(config) {
|
|
|
203
241
|
*/
|
|
204
242
|
async get(slug) {
|
|
205
243
|
if (!slug || typeof slug !== "string") {
|
|
206
|
-
throw new NeetruError("validation_failed", "slug is required");
|
|
244
|
+
throw new exports.NeetruError("validation_failed", "slug is required");
|
|
207
245
|
}
|
|
208
246
|
const raw = await httpRequest(config, {
|
|
209
247
|
method: "GET",
|
|
210
248
|
path: `/api/sdk/v1/catalog/${encodeURIComponent(slug)}`
|
|
211
249
|
});
|
|
212
250
|
if (!raw || !raw.product) {
|
|
213
|
-
throw new NeetruError(
|
|
251
|
+
throw new exports.NeetruError(
|
|
214
252
|
"invalid_response",
|
|
215
253
|
"Catalog get response missing product"
|
|
216
254
|
);
|
|
@@ -221,13 +259,15 @@ function createCatalogNamespace(config) {
|
|
|
221
259
|
}
|
|
222
260
|
|
|
223
261
|
// src/entitlements.ts
|
|
262
|
+
init_errors();
|
|
263
|
+
init_http();
|
|
224
264
|
function toEntitlementCheck(raw) {
|
|
225
265
|
if (!raw || typeof raw !== "object") {
|
|
226
|
-
throw new NeetruError("invalid_response", "Entitlement response is not an object");
|
|
266
|
+
throw new exports.NeetruError("invalid_response", "Entitlement response is not an object");
|
|
227
267
|
}
|
|
228
268
|
const r = raw;
|
|
229
269
|
if (typeof r.allowed !== "boolean") {
|
|
230
|
-
throw new NeetruError("invalid_response", "Entitlement response missing `allowed` boolean");
|
|
270
|
+
throw new exports.NeetruError("invalid_response", "Entitlement response missing `allowed` boolean");
|
|
231
271
|
}
|
|
232
272
|
return {
|
|
233
273
|
allowed: r.allowed,
|
|
@@ -262,8 +302,8 @@ function createEntitlementsNamespace(config) {
|
|
|
262
302
|
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
263
303
|
}
|
|
264
304
|
async function checkDetailed(productSlug, feature, opts = {}) {
|
|
265
|
-
if (!productSlug) throw new NeetruError("validation_failed", "productSlug is required");
|
|
266
|
-
if (!feature) throw new NeetruError("validation_failed", "feature is required");
|
|
305
|
+
if (!productSlug) throw new exports.NeetruError("validation_failed", "productSlug is required");
|
|
306
|
+
if (!feature) throw new exports.NeetruError("validation_failed", "feature is required");
|
|
267
307
|
const key = cacheKey(productSlug, feature);
|
|
268
308
|
if (!opts.cacheBust) {
|
|
269
309
|
const cached = readCache(key);
|
|
@@ -299,6 +339,8 @@ function createEntitlementsNamespace(config) {
|
|
|
299
339
|
}
|
|
300
340
|
|
|
301
341
|
// src/telemetry.ts
|
|
342
|
+
init_errors();
|
|
343
|
+
init_http();
|
|
302
344
|
var VALID_LOG_LEVELS = ["debug", "info", "warn", "error", "fatal"];
|
|
303
345
|
function consoleFor(level) {
|
|
304
346
|
switch (level) {
|
|
@@ -369,13 +411,13 @@ function createTelemetryNamespace(config) {
|
|
|
369
411
|
*/
|
|
370
412
|
async event(input) {
|
|
371
413
|
if (!input || typeof input !== "object") {
|
|
372
|
-
throw new NeetruError("validation_failed", "event input is required");
|
|
414
|
+
throw new exports.NeetruError("validation_failed", "event input is required");
|
|
373
415
|
}
|
|
374
416
|
if (typeof input.name !== "string" || input.name.length === 0) {
|
|
375
|
-
throw new NeetruError("validation_failed", "event.name is required");
|
|
417
|
+
throw new exports.NeetruError("validation_failed", "event.name is required");
|
|
376
418
|
}
|
|
377
419
|
if (input.name.length > 128) {
|
|
378
|
-
throw new NeetruError("validation_failed", "event.name max 128 chars");
|
|
420
|
+
throw new exports.NeetruError("validation_failed", "event.name max 128 chars");
|
|
379
421
|
}
|
|
380
422
|
const body = { name: input.name };
|
|
381
423
|
if (input.properties && typeof input.properties === "object") {
|
|
@@ -389,7 +431,7 @@ function createTelemetryNamespace(config) {
|
|
|
389
431
|
requireAuth: true
|
|
390
432
|
});
|
|
391
433
|
if (!raw || raw.ok !== true || typeof raw.eventId !== "string") {
|
|
392
|
-
throw new NeetruError("invalid_response", "Telemetry response missing eventId");
|
|
434
|
+
throw new exports.NeetruError("invalid_response", "Telemetry response missing eventId");
|
|
393
435
|
}
|
|
394
436
|
return { ok: true, eventId: raw.eventId };
|
|
395
437
|
},
|
|
@@ -442,16 +484,16 @@ function createTelemetryNamespace(config) {
|
|
|
442
484
|
*/
|
|
443
485
|
async log(input) {
|
|
444
486
|
if (!input || typeof input !== "object") {
|
|
445
|
-
throw new NeetruError("validation_failed", "log input is required");
|
|
487
|
+
throw new exports.NeetruError("validation_failed", "log input is required");
|
|
446
488
|
}
|
|
447
489
|
if (!VALID_LOG_LEVELS.includes(input.level)) {
|
|
448
|
-
throw new NeetruError("validation_failed", `level must be one of ${VALID_LOG_LEVELS.join(", ")}`);
|
|
490
|
+
throw new exports.NeetruError("validation_failed", `level must be one of ${VALID_LOG_LEVELS.join(", ")}`);
|
|
449
491
|
}
|
|
450
492
|
if (typeof input.message !== "string" || input.message.length === 0) {
|
|
451
|
-
throw new NeetruError("validation_failed", "message is required");
|
|
493
|
+
throw new exports.NeetruError("validation_failed", "message is required");
|
|
452
494
|
}
|
|
453
495
|
if (input.message.length > 4e3) {
|
|
454
|
-
throw new NeetruError("validation_failed", "message max 4000 chars");
|
|
496
|
+
throw new exports.NeetruError("validation_failed", "message max 4000 chars");
|
|
455
497
|
}
|
|
456
498
|
if (config.env === "dev") {
|
|
457
499
|
const fn = consoleFor(input.level);
|
|
@@ -482,7 +524,7 @@ function createTelemetryNamespace(config) {
|
|
|
482
524
|
headers
|
|
483
525
|
});
|
|
484
526
|
if (!raw || raw.ok !== true) {
|
|
485
|
-
throw new NeetruError("invalid_response", "Telemetry log response missing ok");
|
|
527
|
+
throw new exports.NeetruError("invalid_response", "Telemetry log response missing ok");
|
|
486
528
|
}
|
|
487
529
|
return {
|
|
488
530
|
ok: true,
|
|
@@ -494,13 +536,15 @@ function createTelemetryNamespace(config) {
|
|
|
494
536
|
}
|
|
495
537
|
|
|
496
538
|
// src/usage.ts
|
|
539
|
+
init_errors();
|
|
540
|
+
init_http();
|
|
497
541
|
function toQuota(metric, raw) {
|
|
498
542
|
if (!raw || typeof raw !== "object") {
|
|
499
|
-
throw new NeetruError("invalid_response", "Quota response is not an object");
|
|
543
|
+
throw new exports.NeetruError("invalid_response", "Quota response is not an object");
|
|
500
544
|
}
|
|
501
545
|
const r = raw;
|
|
502
546
|
if (typeof r.used !== "number" || typeof r.limit !== "number") {
|
|
503
|
-
throw new NeetruError("invalid_response", "Quota response missing used/limit numbers");
|
|
547
|
+
throw new exports.NeetruError("invalid_response", "Quota response missing used/limit numbers");
|
|
504
548
|
}
|
|
505
549
|
return {
|
|
506
550
|
metric: typeof r.metric === "string" ? r.metric : metric,
|
|
@@ -518,10 +562,10 @@ function createUsageNamespace(config) {
|
|
|
518
562
|
*/
|
|
519
563
|
async track(event, properties) {
|
|
520
564
|
if (!event || typeof event !== "string") {
|
|
521
|
-
throw new NeetruError("validation_failed", "event name is required");
|
|
565
|
+
throw new exports.NeetruError("validation_failed", "event name is required");
|
|
522
566
|
}
|
|
523
567
|
if (event.length > 128) {
|
|
524
|
-
throw new NeetruError("validation_failed", "event name max 128 chars");
|
|
568
|
+
throw new exports.NeetruError("validation_failed", "event name max 128 chars");
|
|
525
569
|
}
|
|
526
570
|
const body = { event };
|
|
527
571
|
if (properties && typeof properties === "object") body.properties = properties;
|
|
@@ -532,7 +576,7 @@ function createUsageNamespace(config) {
|
|
|
532
576
|
requireAuth: true
|
|
533
577
|
});
|
|
534
578
|
if (!raw || raw.ok !== true) {
|
|
535
|
-
throw new NeetruError("invalid_response", "Usage record response missing ok");
|
|
579
|
+
throw new exports.NeetruError("invalid_response", "Usage record response missing ok");
|
|
536
580
|
}
|
|
537
581
|
return { ok: true };
|
|
538
582
|
},
|
|
@@ -541,7 +585,7 @@ function createUsageNamespace(config) {
|
|
|
541
585
|
*/
|
|
542
586
|
async getQuota(metric) {
|
|
543
587
|
if (!metric || typeof metric !== "string") {
|
|
544
|
-
throw new NeetruError("validation_failed", "metric is required");
|
|
588
|
+
throw new exports.NeetruError("validation_failed", "metric is required");
|
|
545
589
|
}
|
|
546
590
|
const raw = await httpRequest(config, {
|
|
547
591
|
method: "GET",
|
|
@@ -556,282 +600,3735 @@ function createUsageNamespace(config) {
|
|
|
556
600
|
*/
|
|
557
601
|
async report(resource, qty = 1, options) {
|
|
558
602
|
if (!resource || typeof resource !== "string") {
|
|
559
|
-
throw new NeetruError("validation_failed", "resource is required");
|
|
603
|
+
throw new exports.NeetruError("validation_failed", "resource is required");
|
|
560
604
|
}
|
|
561
605
|
if (!Number.isFinite(qty) || qty <= 0) {
|
|
562
|
-
throw new NeetruError("validation_failed", "qty must be positive integer");
|
|
606
|
+
throw new exports.NeetruError("validation_failed", "qty must be positive integer");
|
|
563
607
|
}
|
|
564
608
|
const productId = options?.productId ?? config.productId;
|
|
565
609
|
const tenantId = options?.tenantId ?? config.tenantId;
|
|
566
610
|
if (!productId) {
|
|
567
|
-
throw new NeetruError(
|
|
611
|
+
throw new exports.NeetruError(
|
|
568
612
|
"validation_failed",
|
|
569
613
|
"productId required (pass to options or set on createNeetruClient)"
|
|
570
614
|
);
|
|
571
615
|
}
|
|
572
|
-
if (!tenantId) {
|
|
573
|
-
throw new NeetruError(
|
|
574
|
-
"validation_failed",
|
|
575
|
-
"tenantId required (pass to options or set on createNeetruClient)"
|
|
616
|
+
if (!tenantId) {
|
|
617
|
+
throw new exports.NeetruError(
|
|
618
|
+
"validation_failed",
|
|
619
|
+
"tenantId required (pass to options or set on createNeetruClient)"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const raw = await httpRequest(config, {
|
|
623
|
+
method: "POST",
|
|
624
|
+
path: "/sdk/v1/usage/record",
|
|
625
|
+
body: { productId, tenantId, resource, qty: Math.floor(qty) },
|
|
626
|
+
requireAuth: true
|
|
627
|
+
});
|
|
628
|
+
if (!raw || raw.ok !== true) {
|
|
629
|
+
throw new exports.NeetruError("invalid_response", "usage.report response missing ok");
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
ok: true,
|
|
633
|
+
counterId: raw.counterId,
|
|
634
|
+
value: raw.value,
|
|
635
|
+
limit: raw.limit,
|
|
636
|
+
remaining: raw.remaining,
|
|
637
|
+
status: raw.status
|
|
638
|
+
};
|
|
639
|
+
},
|
|
640
|
+
/**
|
|
641
|
+
* v0.3 — Verifica entitlement de um resource via GET /sdk/v1/entitlements.
|
|
642
|
+
*/
|
|
643
|
+
async check(resource, options) {
|
|
644
|
+
if (!resource || typeof resource !== "string") {
|
|
645
|
+
throw new exports.NeetruError("validation_failed", "resource is required");
|
|
646
|
+
}
|
|
647
|
+
const productId = options?.productId ?? config.productId;
|
|
648
|
+
const tenantId = options?.tenantId ?? config.tenantId;
|
|
649
|
+
if (!productId || !tenantId) {
|
|
650
|
+
throw new exports.NeetruError(
|
|
651
|
+
"validation_failed",
|
|
652
|
+
"productId and tenantId required"
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const raw = await httpRequest(config, {
|
|
656
|
+
method: "GET",
|
|
657
|
+
path: "/sdk/v1/entitlements",
|
|
658
|
+
query: { productId, tenantId, feature: resource },
|
|
659
|
+
requireAuth: true
|
|
660
|
+
});
|
|
661
|
+
if (!raw || typeof raw.allowed !== "boolean") {
|
|
662
|
+
throw new exports.NeetruError("invalid_response", "usage.check response missing allowed");
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
allowed: raw.allowed,
|
|
666
|
+
reason: raw.reason,
|
|
667
|
+
remaining: raw.remaining,
|
|
668
|
+
limit: raw.limit,
|
|
669
|
+
planId: raw.planId,
|
|
670
|
+
planFeatures: raw.planFeatures
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// src/support.ts
|
|
677
|
+
init_errors();
|
|
678
|
+
init_http();
|
|
679
|
+
var VALID_SEVERITIES = ["low", "normal", "high", "urgent"];
|
|
680
|
+
var VALID_STATUSES = ["open", "pending", "resolved", "closed"];
|
|
681
|
+
function toTicket(raw) {
|
|
682
|
+
if (!raw || typeof raw !== "object") {
|
|
683
|
+
throw new exports.NeetruError("invalid_response", "Ticket response is not an object");
|
|
684
|
+
}
|
|
685
|
+
const r = raw;
|
|
686
|
+
if (typeof r.id !== "string") {
|
|
687
|
+
throw new exports.NeetruError("invalid_response", "Ticket missing id");
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
id: r.id,
|
|
691
|
+
subject: typeof r.subject === "string" ? r.subject : "",
|
|
692
|
+
message: typeof r.message === "string" ? r.message : "",
|
|
693
|
+
severity: VALID_SEVERITIES.includes(r.severity) ? r.severity : "normal",
|
|
694
|
+
status: VALID_STATUSES.includes(r.status) ? r.status : "open",
|
|
695
|
+
createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
696
|
+
updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : void 0,
|
|
697
|
+
productSlug: typeof r.productSlug === "string" ? r.productSlug : void 0
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function createSupportNamespace(config) {
|
|
701
|
+
return {
|
|
702
|
+
/**
|
|
703
|
+
* Cria um ticket de suporte. Requer Bearer auth. Se `productSlug` não é
|
|
704
|
+
* passado, o backend infere do escopo do token.
|
|
705
|
+
*/
|
|
706
|
+
async createTicket(input) {
|
|
707
|
+
if (!input || typeof input !== "object") {
|
|
708
|
+
throw new exports.NeetruError("validation_failed", "input is required");
|
|
709
|
+
}
|
|
710
|
+
if (!input.subject || typeof input.subject !== "string") {
|
|
711
|
+
throw new exports.NeetruError("validation_failed", "subject is required");
|
|
712
|
+
}
|
|
713
|
+
if (input.subject.length > 200) {
|
|
714
|
+
throw new exports.NeetruError("validation_failed", "subject max 200 chars");
|
|
715
|
+
}
|
|
716
|
+
if (!input.message || typeof input.message !== "string") {
|
|
717
|
+
throw new exports.NeetruError("validation_failed", "message is required");
|
|
718
|
+
}
|
|
719
|
+
if (input.message.length > 1e4) {
|
|
720
|
+
throw new exports.NeetruError("validation_failed", "message max 10000 chars");
|
|
721
|
+
}
|
|
722
|
+
if (input.severity && !VALID_SEVERITIES.includes(input.severity)) {
|
|
723
|
+
throw new exports.NeetruError("validation_failed", `severity must be one of ${VALID_SEVERITIES.join(", ")}`);
|
|
724
|
+
}
|
|
725
|
+
const slug = input.productSlug ?? "_default";
|
|
726
|
+
const body = {
|
|
727
|
+
subject: input.subject,
|
|
728
|
+
message: input.message,
|
|
729
|
+
severity: input.severity ?? "normal"
|
|
730
|
+
};
|
|
731
|
+
const raw = await httpRequest(config, {
|
|
732
|
+
method: "POST",
|
|
733
|
+
path: `/api/v1/products/${encodeURIComponent(slug)}/tickets`,
|
|
734
|
+
body,
|
|
735
|
+
requireAuth: true
|
|
736
|
+
});
|
|
737
|
+
const candidate = raw && typeof raw === "object" && "ticket" in raw ? raw.ticket : raw;
|
|
738
|
+
return toTicket(candidate);
|
|
739
|
+
},
|
|
740
|
+
/**
|
|
741
|
+
* Lista tickets do customer no produto atual (escopo do token).
|
|
742
|
+
*/
|
|
743
|
+
async listMyTickets() {
|
|
744
|
+
const raw = await httpRequest(config, {
|
|
745
|
+
method: "GET",
|
|
746
|
+
path: "/api/v1/products/_default/tickets",
|
|
747
|
+
requireAuth: true
|
|
748
|
+
});
|
|
749
|
+
const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && "tickets" in raw ? raw.tickets ?? [] : [];
|
|
750
|
+
return list.map(toTicket);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/db-errors.ts
|
|
756
|
+
init_errors();
|
|
757
|
+
var RETRYABLE_CODES2 = /* @__PURE__ */ new Set([
|
|
758
|
+
"db_unavailable",
|
|
759
|
+
"db_conflict",
|
|
760
|
+
"db_timeout"
|
|
761
|
+
]);
|
|
762
|
+
var NeetruDbError = class _NeetruDbError extends exports.NeetruError {
|
|
763
|
+
/** Código de erro fechado — específico de DB. */
|
|
764
|
+
code;
|
|
765
|
+
/**
|
|
766
|
+
* `true` para erros transientes que o produto pode tentar novamente.
|
|
767
|
+
* São retryable: `db_unavailable`, `db_conflict`, `db_timeout`.
|
|
768
|
+
*/
|
|
769
|
+
retryable;
|
|
770
|
+
/**
|
|
771
|
+
* ID opaco do banco lógico — só para correlação com logs do Core.
|
|
772
|
+
* Nunca deve ser exibido ao usuário final.
|
|
773
|
+
*/
|
|
774
|
+
dbId;
|
|
775
|
+
constructor(code, message, dbId) {
|
|
776
|
+
super(code, message);
|
|
777
|
+
this.name = "NeetruDbError";
|
|
778
|
+
this.code = code;
|
|
779
|
+
this.retryable = RETRYABLE_CODES2.has(code);
|
|
780
|
+
this.dbId = dbId;
|
|
781
|
+
Object.setPrototypeOf(this, _NeetruDbError.prototype);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
// src/db/offline/query-engine.ts
|
|
786
|
+
function typeRank(v) {
|
|
787
|
+
if (v === null || v === void 0) return 0;
|
|
788
|
+
if (typeof v === "number") return 1;
|
|
789
|
+
if (typeof v === "string") return 2;
|
|
790
|
+
if (typeof v === "boolean") return 3;
|
|
791
|
+
return 4;
|
|
792
|
+
}
|
|
793
|
+
function compareValues(a, b) {
|
|
794
|
+
const ra = typeRank(a);
|
|
795
|
+
const rb = typeRank(b);
|
|
796
|
+
if (ra !== rb) return ra - rb;
|
|
797
|
+
if (a === null || a === void 0) return 0;
|
|
798
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
799
|
+
if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0;
|
|
800
|
+
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
|
|
801
|
+
const sa = String(a);
|
|
802
|
+
const sb = String(b);
|
|
803
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
804
|
+
}
|
|
805
|
+
function getField(data, field) {
|
|
806
|
+
const parts = field.split(".");
|
|
807
|
+
let current = data;
|
|
808
|
+
for (const part of parts) {
|
|
809
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
810
|
+
return void 0;
|
|
811
|
+
}
|
|
812
|
+
current = current[part];
|
|
813
|
+
}
|
|
814
|
+
return current;
|
|
815
|
+
}
|
|
816
|
+
function evaluateFilter(data, filter) {
|
|
817
|
+
const fieldValue = getField(data, filter.field);
|
|
818
|
+
if (fieldValue === void 0) return false;
|
|
819
|
+
const { op, value } = filter;
|
|
820
|
+
switch (op) {
|
|
821
|
+
case "==":
|
|
822
|
+
return fieldValue === value;
|
|
823
|
+
case "!=":
|
|
824
|
+
return fieldValue !== value;
|
|
825
|
+
case "<":
|
|
826
|
+
return compareValues(fieldValue, value) < 0;
|
|
827
|
+
case "<=":
|
|
828
|
+
return compareValues(fieldValue, value) <= 0;
|
|
829
|
+
case ">":
|
|
830
|
+
return compareValues(fieldValue, value) > 0;
|
|
831
|
+
case ">=":
|
|
832
|
+
return compareValues(fieldValue, value) >= 0;
|
|
833
|
+
case "array-contains":
|
|
834
|
+
return Array.isArray(fieldValue) && fieldValue.includes(value);
|
|
835
|
+
case "in":
|
|
836
|
+
if (!Array.isArray(value)) return false;
|
|
837
|
+
return value.includes(fieldValue);
|
|
838
|
+
case "not-in":
|
|
839
|
+
if (!Array.isArray(value)) return true;
|
|
840
|
+
return !value.includes(fieldValue);
|
|
841
|
+
default:
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function matchesAllFilters(data, filters) {
|
|
846
|
+
return filters.every((f) => evaluateFilter(data, f));
|
|
847
|
+
}
|
|
848
|
+
function buildComparator(orderBy) {
|
|
849
|
+
return (a, b) => {
|
|
850
|
+
if (orderBy) {
|
|
851
|
+
const aVal = getField(a.data, orderBy.field);
|
|
852
|
+
const bVal = getField(b.data, orderBy.field);
|
|
853
|
+
const cmp = compareValues(aVal, bVal);
|
|
854
|
+
if (cmp !== 0) {
|
|
855
|
+
return orderBy.direction === "desc" ? -cmp : cmp;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function applyCursor(sorted, cursor) {
|
|
862
|
+
const idx = sorted.findIndex((d) => d.id === cursor.docId);
|
|
863
|
+
if (idx === -1) {
|
|
864
|
+
return sorted;
|
|
865
|
+
}
|
|
866
|
+
if (cursor.type === "startAfter") {
|
|
867
|
+
return sorted.slice(idx + 1);
|
|
868
|
+
} else {
|
|
869
|
+
return sorted.slice(0, idx);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
var QueryEngine = class _QueryEngine {
|
|
873
|
+
/**
|
|
874
|
+
* Avalia um `QueryDescriptor` contra um array de `OfflineDoc`.
|
|
875
|
+
*
|
|
876
|
+
* Pipeline (I3 §5.3):
|
|
877
|
+
* 1. Filtra docs com `deleted:false`.
|
|
878
|
+
* 2. Aplica `where` (AND de todos os filtros).
|
|
879
|
+
* 3. Ordena por `orderBy` + tie-break por docId.
|
|
880
|
+
* 4. Aplica cursor (`startAfter` / `endBefore`).
|
|
881
|
+
* 5. Corta em `limit`.
|
|
882
|
+
*/
|
|
883
|
+
evaluate(docs, query) {
|
|
884
|
+
let filtered = docs.filter((d) => !d.meta.deleted);
|
|
885
|
+
const filters = query.where ?? [];
|
|
886
|
+
if (filters.length > 0) {
|
|
887
|
+
filtered = filtered.filter(
|
|
888
|
+
(d) => matchesAllFilters(d.data, filters)
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
const comparator = buildComparator(query.orderBy);
|
|
892
|
+
const sorted = [...filtered].sort(comparator);
|
|
893
|
+
const afterCursor = query.cursor ? applyCursor(sorted, query.cursor) : sorted;
|
|
894
|
+
const limitN = Math.min(query.limit ?? 20, 500);
|
|
895
|
+
const limited = afterCursor.slice(0, limitN);
|
|
896
|
+
return {
|
|
897
|
+
docs: limited.map((d) => ({ id: d.id, data: d.data })),
|
|
898
|
+
// `incomplete` é sempre true aqui — o QueryEngine não sabe se o cache
|
|
899
|
+
// tem todos os docs da coleção. É responsabilidade do chamador (LocalStore)
|
|
900
|
+
// injetar o flag de completude baseado nos metadados do query_cache.
|
|
901
|
+
incomplete: true
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
// ─── Static helper ─────────────────────────────────────────────────────────
|
|
905
|
+
/**
|
|
906
|
+
* Helper estático para uso sem instanciar a classe.
|
|
907
|
+
* Equivalente a `new QueryEngine().evaluate(docs, query)`.
|
|
908
|
+
*/
|
|
909
|
+
static evaluate(docs, query) {
|
|
910
|
+
return new _QueryEngine().evaluate(docs, query);
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// src/db/offline/local-store.ts
|
|
915
|
+
var SCHEMA_VERSION = 1;
|
|
916
|
+
var STORE_DOCUMENTS = "documents";
|
|
917
|
+
var STORE_MUTATIONS = "mutations";
|
|
918
|
+
var STORE_QUERY_CACHE = "query_cache";
|
|
919
|
+
var STORE_SYNC_META = "sync_meta";
|
|
920
|
+
var STORE_CONFLICT_LOG = "conflict_log";
|
|
921
|
+
var LocalStore = class {
|
|
922
|
+
db = null;
|
|
923
|
+
dbName;
|
|
924
|
+
constructor(dbName) {
|
|
925
|
+
this.dbName = dbName;
|
|
926
|
+
}
|
|
927
|
+
// ─── Ciclo de vida ──────────────────────────────────────────────────────────
|
|
928
|
+
/**
|
|
929
|
+
* Abre (ou reabre) o banco IndexedDB e executa o upgrade de schema se necessário.
|
|
930
|
+
* Idempotente — chamadas subsequentes são no-ops se o banco já está aberto.
|
|
931
|
+
*/
|
|
932
|
+
async open() {
|
|
933
|
+
if (this.db !== null) return;
|
|
934
|
+
this.db = await idb.openDB(this.dbName, SCHEMA_VERSION, {
|
|
935
|
+
upgrade(db) {
|
|
936
|
+
if (!db.objectStoreNames.contains(STORE_DOCUMENTS)) {
|
|
937
|
+
const docsStore = db.createObjectStore(STORE_DOCUMENTS, {
|
|
938
|
+
keyPath: ["collection", "id"]
|
|
939
|
+
});
|
|
940
|
+
docsStore.createIndex("by_collection", "collection");
|
|
941
|
+
docsStore.createIndex("by_collection_state", ["collection", "meta.state"]);
|
|
942
|
+
docsStore.createIndex("by_updatedAtServer", "meta.updatedAtServer");
|
|
943
|
+
}
|
|
944
|
+
if (!db.objectStoreNames.contains(STORE_MUTATIONS)) {
|
|
945
|
+
const mutStore = db.createObjectStore(STORE_MUTATIONS, {
|
|
946
|
+
keyPath: "mutationId"
|
|
947
|
+
});
|
|
948
|
+
mutStore.createIndex("by_seq", "seq");
|
|
949
|
+
mutStore.createIndex("by_status", "status");
|
|
950
|
+
mutStore.createIndex("by_doc", ["collection", "docId"]);
|
|
951
|
+
mutStore.createIndex("by_batch", "batchId");
|
|
952
|
+
}
|
|
953
|
+
if (!db.objectStoreNames.contains(STORE_QUERY_CACHE)) {
|
|
954
|
+
db.createObjectStore(STORE_QUERY_CACHE, { keyPath: "queryHash" });
|
|
955
|
+
}
|
|
956
|
+
if (!db.objectStoreNames.contains(STORE_SYNC_META)) {
|
|
957
|
+
db.createObjectStore(STORE_SYNC_META, { keyPath: "key" });
|
|
958
|
+
}
|
|
959
|
+
if (!db.objectStoreNames.contains(STORE_CONFLICT_LOG)) {
|
|
960
|
+
const conflictStore = db.createObjectStore(STORE_CONFLICT_LOG, {
|
|
961
|
+
keyPath: "id",
|
|
962
|
+
autoIncrement: true
|
|
963
|
+
});
|
|
964
|
+
conflictStore.createIndex("by_delivered", "delivered");
|
|
965
|
+
conflictStore.createIndex("by_doc", ["collection", "docId"]);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
/** Fecha o banco IndexedDB. */
|
|
971
|
+
async close() {
|
|
972
|
+
this.db?.close();
|
|
973
|
+
this.db = null;
|
|
974
|
+
}
|
|
975
|
+
assertOpen() {
|
|
976
|
+
if (this.db === null) {
|
|
977
|
+
throw new Error("LocalStore: banco n\xE3o est\xE1 aberto. Chame open() primeiro.");
|
|
978
|
+
}
|
|
979
|
+
return this.db;
|
|
980
|
+
}
|
|
981
|
+
// ─── Documents ──────────────────────────────────────────────────────────────
|
|
982
|
+
/**
|
|
983
|
+
* Retorna um documento pelo [collection, id], ou `null` se não existir.
|
|
984
|
+
* Retorna tombstones (deleted: true) — o chamador decide se deve mostrá-los.
|
|
985
|
+
*/
|
|
986
|
+
async getDoc(collection, id) {
|
|
987
|
+
const db = this.assertOpen();
|
|
988
|
+
const result = await db.get(STORE_DOCUMENTS, [collection, id]);
|
|
989
|
+
return result ?? null;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Insere ou atualiza um documento na store `documents`.
|
|
993
|
+
* O documento é identificado pelo keyPath composto `[collection, id]`.
|
|
994
|
+
*/
|
|
995
|
+
async putDoc(doc) {
|
|
996
|
+
const db = this.assertOpen();
|
|
997
|
+
await db.put(STORE_DOCUMENTS, doc);
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Marca um documento como tombstone (`deleted: true`).
|
|
1001
|
+
* Não remove fisicamente — o tombstone é necessário para reconciliação.
|
|
1002
|
+
* No-op se o documento não existir.
|
|
1003
|
+
*/
|
|
1004
|
+
async deleteDoc(collection, id) {
|
|
1005
|
+
const db = this.assertOpen();
|
|
1006
|
+
const existing = await db.get(STORE_DOCUMENTS, [collection, id]);
|
|
1007
|
+
if (!existing) return;
|
|
1008
|
+
await db.put(STORE_DOCUMENTS, {
|
|
1009
|
+
...existing,
|
|
1010
|
+
meta: {
|
|
1011
|
+
...existing.meta,
|
|
1012
|
+
deleted: true,
|
|
1013
|
+
updatedAtLocal: Date.now()
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Lista documentos de uma coleção, aplicando o QueryDescriptor via QueryEngine.
|
|
1019
|
+
*
|
|
1020
|
+
* I3 §5.3: a listagem lê todos os docs da coleção pelo índice `by_collection`
|
|
1021
|
+
* e delega a filtragem/ordenação/paginação ao QueryEngine (que opera em memória).
|
|
1022
|
+
*/
|
|
1023
|
+
async listDocs(collection, query) {
|
|
1024
|
+
const db = this.assertOpen();
|
|
1025
|
+
const rawDocs = await db.getAllFromIndex(STORE_DOCUMENTS, "by_collection", collection);
|
|
1026
|
+
return QueryEngine.evaluate(rawDocs, query);
|
|
1027
|
+
}
|
|
1028
|
+
// ─── Sync meta (key-value) ──────────────────────────────────────────────────
|
|
1029
|
+
/**
|
|
1030
|
+
* Retorna o valor de uma chave da store `sync_meta`, ou `null` se não existir.
|
|
1031
|
+
*
|
|
1032
|
+
* Chaves conhecidas (I3 §3.3):
|
|
1033
|
+
* 'lastSyncWatermark', 'resumeToken:<col>', 'schemaVersion',
|
|
1034
|
+
* 'lastFullResyncAt', 'leaderTabId'.
|
|
1035
|
+
*/
|
|
1036
|
+
async getMeta(key) {
|
|
1037
|
+
const db = this.assertOpen();
|
|
1038
|
+
const entry = await db.get(STORE_SYNC_META, key);
|
|
1039
|
+
if (!entry) return null;
|
|
1040
|
+
return entry.value;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Armazena ou atualiza um valor na store `sync_meta`.
|
|
1044
|
+
*/
|
|
1045
|
+
async setMeta(key, value) {
|
|
1046
|
+
const db = this.assertOpen();
|
|
1047
|
+
await db.put(STORE_SYNC_META, { key, value });
|
|
1048
|
+
}
|
|
1049
|
+
// ─── Conflict log ───────────────────────────────────────────────────────────
|
|
1050
|
+
/**
|
|
1051
|
+
* Adiciona um registro de conflito ao `conflict_log`.
|
|
1052
|
+
* O `id` é autoIncrement — não deve ser fornecido pelo chamador.
|
|
1053
|
+
*/
|
|
1054
|
+
async appendConflict(record) {
|
|
1055
|
+
const db = this.assertOpen();
|
|
1056
|
+
const id = await db.add(STORE_CONFLICT_LOG, record);
|
|
1057
|
+
return { ...record, id };
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Lista registros do `conflict_log`.
|
|
1061
|
+
* Filtra por `delivered` se a opção for fornecida.
|
|
1062
|
+
*/
|
|
1063
|
+
async listConflicts(options) {
|
|
1064
|
+
const db = this.assertOpen();
|
|
1065
|
+
const all = await db.getAll(STORE_CONFLICT_LOG);
|
|
1066
|
+
if (options?.delivered !== void 0) {
|
|
1067
|
+
return all.filter((r) => r.delivered === options.delivered);
|
|
1068
|
+
}
|
|
1069
|
+
return all;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Marca um registro do `conflict_log` como entregue ao produto.
|
|
1073
|
+
*/
|
|
1074
|
+
async markConflictDelivered(id) {
|
|
1075
|
+
const db = this.assertOpen();
|
|
1076
|
+
const existing = await db.get(STORE_CONFLICT_LOG, id);
|
|
1077
|
+
if (!existing) return;
|
|
1078
|
+
await db.put(STORE_CONFLICT_LOG, { ...existing, delivered: true });
|
|
1079
|
+
}
|
|
1080
|
+
// ─── Mutations (acessores usados pelo MutationQueue) ────────────────────────
|
|
1081
|
+
/**
|
|
1082
|
+
* Insere ou atualiza uma mutação na store `mutations`.
|
|
1083
|
+
* Key: `mutationId`.
|
|
1084
|
+
*/
|
|
1085
|
+
async putMutation(mutation) {
|
|
1086
|
+
const db = this.assertOpen();
|
|
1087
|
+
await db.put(STORE_MUTATIONS, mutation);
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Retorna uma mutação pelo `mutationId`, ou `null` se não existir.
|
|
1091
|
+
*/
|
|
1092
|
+
async getMutation(mutationId) {
|
|
1093
|
+
const db = this.assertOpen();
|
|
1094
|
+
const result = await db.get(STORE_MUTATIONS, mutationId);
|
|
1095
|
+
return result ?? null;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Lista mutações com filtros opcionais.
|
|
1099
|
+
* Resultado ordenado por `seq` crescente.
|
|
1100
|
+
*/
|
|
1101
|
+
async listMutations(options) {
|
|
1102
|
+
const db = this.assertOpen();
|
|
1103
|
+
let mutations;
|
|
1104
|
+
if (options?.status !== void 0) {
|
|
1105
|
+
mutations = await db.getAllFromIndex(STORE_MUTATIONS, "by_status", options.status);
|
|
1106
|
+
} else if (options?.collection !== void 0 && options.docId !== void 0) {
|
|
1107
|
+
mutations = await db.getAllFromIndex(STORE_MUTATIONS, "by_doc", [options.collection, options.docId]);
|
|
1108
|
+
} else {
|
|
1109
|
+
mutations = await db.getAllFromIndex(STORE_MUTATIONS, "by_seq");
|
|
1110
|
+
}
|
|
1111
|
+
return mutations.sort((a, b) => a.seq - b.seq);
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Remove uma mutação pelo `mutationId`.
|
|
1115
|
+
* No-op se não existir.
|
|
1116
|
+
*/
|
|
1117
|
+
async deleteMutation(mutationId) {
|
|
1118
|
+
const db = this.assertOpen();
|
|
1119
|
+
await db.delete(STORE_MUTATIONS, mutationId);
|
|
1120
|
+
}
|
|
1121
|
+
// ─── Collection discovery ────────────────────────────────────────────────────
|
|
1122
|
+
/**
|
|
1123
|
+
* Retorna a lista de coleções únicas presentes na store `documents`.
|
|
1124
|
+
*
|
|
1125
|
+
* Usado pelo SyncEngine no full resync para descobrir coleções cujos docs
|
|
1126
|
+
* precisam ser verificados contra a resposta do servidor (tombstone detection).
|
|
1127
|
+
*
|
|
1128
|
+
* Implementação: itera o índice `by_collection` com `openKeyCursor` para
|
|
1129
|
+
* coletar os valores únicos de forma eficiente (sem carregar os docs completos).
|
|
1130
|
+
*/
|
|
1131
|
+
async listCollections() {
|
|
1132
|
+
const db = this.assertOpen();
|
|
1133
|
+
const allDocs = await db.getAllFromIndex(STORE_DOCUMENTS, "by_collection");
|
|
1134
|
+
const collections = /* @__PURE__ */ new Set();
|
|
1135
|
+
for (const doc of allDocs) {
|
|
1136
|
+
collections.add(doc.collection);
|
|
1137
|
+
}
|
|
1138
|
+
return Array.from(collections);
|
|
1139
|
+
}
|
|
1140
|
+
// ─── Query cache ─────────────────────────────────────────────────────────────
|
|
1141
|
+
/**
|
|
1142
|
+
* Retorna a entrada de `query_cache` para um hash de query, ou `null`.
|
|
1143
|
+
*/
|
|
1144
|
+
async getQueryCache(queryHash) {
|
|
1145
|
+
const db = this.assertOpen();
|
|
1146
|
+
const result = await db.get(STORE_QUERY_CACHE, queryHash);
|
|
1147
|
+
return result ?? null;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Insere ou atualiza uma entrada de `query_cache`.
|
|
1151
|
+
*/
|
|
1152
|
+
async putQueryCache(entry) {
|
|
1153
|
+
const db = this.assertOpen();
|
|
1154
|
+
await db.put(STORE_QUERY_CACHE, entry);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Remove uma entrada de `query_cache` pelo queryHash.
|
|
1158
|
+
*/
|
|
1159
|
+
async deleteQueryCache(queryHash) {
|
|
1160
|
+
const db = this.assertOpen();
|
|
1161
|
+
await db.delete(STORE_QUERY_CACHE, queryHash);
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// src/db/offline/mutation-queue.ts
|
|
1166
|
+
function generateUUIDv4() {
|
|
1167
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
1168
|
+
return crypto.randomUUID();
|
|
1169
|
+
}
|
|
1170
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
1171
|
+
const r = Math.random() * 16 | 0;
|
|
1172
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
1173
|
+
return v.toString(16);
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
var MutationQueue = class {
|
|
1177
|
+
store;
|
|
1178
|
+
/**
|
|
1179
|
+
* Ponteiro de sequência local.
|
|
1180
|
+
* Inicializado com 0 — o primeiro enqueue sincroniza o valor real do banco
|
|
1181
|
+
* (`_syncSeq`), garantindo que nunca sobreponha um seq existente.
|
|
1182
|
+
*/
|
|
1183
|
+
seqCounter = 0;
|
|
1184
|
+
seqSynced = false;
|
|
1185
|
+
constructor(store) {
|
|
1186
|
+
this.store = store;
|
|
1187
|
+
}
|
|
1188
|
+
// ─── Seq management ─────────────────────────────────────────────────────────
|
|
1189
|
+
/**
|
|
1190
|
+
* Sincroniza o ponteiro de seq com o maior seq existente no banco.
|
|
1191
|
+
* Chamado lazy no primeiro enqueue.
|
|
1192
|
+
*/
|
|
1193
|
+
async syncSeq() {
|
|
1194
|
+
if (this.seqSynced) return;
|
|
1195
|
+
const all = await this.store.listMutations();
|
|
1196
|
+
if (all.length > 0) {
|
|
1197
|
+
const maxSeq = Math.max(...all.map((m) => m.seq));
|
|
1198
|
+
this.seqCounter = maxSeq;
|
|
1199
|
+
}
|
|
1200
|
+
this.seqSynced = true;
|
|
1201
|
+
}
|
|
1202
|
+
nextSeq() {
|
|
1203
|
+
this.seqCounter += 1;
|
|
1204
|
+
return this.seqCounter;
|
|
1205
|
+
}
|
|
1206
|
+
// ─── Coalescing ─────────────────────────────────────────────────────────────
|
|
1207
|
+
/**
|
|
1208
|
+
* Tenta coalescir `newOp`/`newPayload` com a mutação existente `existing`.
|
|
1209
|
+
*
|
|
1210
|
+
* Regras de coalescing (I3 §4.4):
|
|
1211
|
+
* - update + update → update com merge dos campos (segundo vence nos conflitos)
|
|
1212
|
+
* - add + update → add com campos mesclados
|
|
1213
|
+
* - add + remove → nada (remove a mutação existente, retorna null para remove)
|
|
1214
|
+
* - set + update → set com campos mesclados
|
|
1215
|
+
* - any + remove (base servidor) → remove
|
|
1216
|
+
*
|
|
1217
|
+
* Retorna:
|
|
1218
|
+
* - `{ coalesced: Mutation }` — substitui a mutação existente (update in-place)
|
|
1219
|
+
* - `{ removed: true }` — a mutação existente deve ser deletada (add+remove)
|
|
1220
|
+
* - `null` — não é possível coalescir
|
|
1221
|
+
*/
|
|
1222
|
+
tryCoalesce(existing, newOp, newPayload, newBatchId) {
|
|
1223
|
+
if (existing.batchId !== newBatchId) return null;
|
|
1224
|
+
if (existing.status === "inflight") return null;
|
|
1225
|
+
const existingOp = existing.op;
|
|
1226
|
+
if (existingOp === "add" && newOp === "remove") {
|
|
1227
|
+
return { removed: true };
|
|
1228
|
+
}
|
|
1229
|
+
if (existingOp === "update" && newOp === "update") {
|
|
1230
|
+
return {
|
|
1231
|
+
coalesced: {
|
|
1232
|
+
...existing,
|
|
1233
|
+
payload: { ...existing.payload ?? {}, ...newPayload ?? {} }
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
if (existingOp === "add" && newOp === "update") {
|
|
1238
|
+
return {
|
|
1239
|
+
coalesced: {
|
|
1240
|
+
...existing,
|
|
1241
|
+
op: "add",
|
|
1242
|
+
payload: { ...existing.payload ?? {}, ...newPayload ?? {} }
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
if (existingOp === "set" && newOp === "update") {
|
|
1247
|
+
return {
|
|
1248
|
+
coalesced: {
|
|
1249
|
+
...existing,
|
|
1250
|
+
op: "set",
|
|
1251
|
+
payload: { ...existing.payload ?? {}, ...newPayload ?? {} }
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
if (newOp === "remove") {
|
|
1256
|
+
return {
|
|
1257
|
+
coalesced: {
|
|
1258
|
+
...existing,
|
|
1259
|
+
op: "remove",
|
|
1260
|
+
payload: null
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
// ─── Enqueue ─────────────────────────────────────────────────────────────────
|
|
1267
|
+
/**
|
|
1268
|
+
* Enfileira uma nova mutação.
|
|
1269
|
+
*
|
|
1270
|
+
* Processo (I3 §4.1):
|
|
1271
|
+
* 1. Gera docId se op=add e não fornecido.
|
|
1272
|
+
* 2. Sincroniza seq (lazy).
|
|
1273
|
+
* 3. Tenta coalescir com a última mutação queued do mesmo [collection, docId].
|
|
1274
|
+
* 4. Se coalescing:
|
|
1275
|
+
* - `removed` → deleta mutação existente; retorna sem enfileirar nova.
|
|
1276
|
+
* - `coalesced` → substitui a mutação existente (mesmo seq, mesmo mutationId).
|
|
1277
|
+
* 5. Se não coalesce → enfileira nova mutação (novo seq, novo mutationId).
|
|
1278
|
+
*
|
|
1279
|
+
* Atômico: a persistência final é uma única operação putMutation.
|
|
1280
|
+
*/
|
|
1281
|
+
async enqueue(params) {
|
|
1282
|
+
const { collection, op, payload, baseVersion, batchId } = params;
|
|
1283
|
+
const docId = params.docId ?? (op === "add" ? generateUUIDv4() : "");
|
|
1284
|
+
await this.syncSeq();
|
|
1285
|
+
const existingMutations = await this.store.listMutations({ collection, docId });
|
|
1286
|
+
const lastQueued = existingMutations.filter((m) => m.status === "queued").sort((a, b) => b.seq - a.seq)[0];
|
|
1287
|
+
if (lastQueued !== void 0) {
|
|
1288
|
+
const coalesceResult = this.tryCoalesce(lastQueued, op, payload, batchId);
|
|
1289
|
+
if (coalesceResult !== null) {
|
|
1290
|
+
if ("removed" in coalesceResult) {
|
|
1291
|
+
await this.store.deleteMutation(lastQueued.mutationId);
|
|
1292
|
+
const phantomMut = {
|
|
1293
|
+
seq: lastQueued.seq,
|
|
1294
|
+
mutationId: generateUUIDv4(),
|
|
1295
|
+
collection,
|
|
1296
|
+
docId,
|
|
1297
|
+
op: "remove",
|
|
1298
|
+
payload: null,
|
|
1299
|
+
baseVersion,
|
|
1300
|
+
enqueuedAt: Date.now(),
|
|
1301
|
+
attempts: 0,
|
|
1302
|
+
lastError: null,
|
|
1303
|
+
status: "queued",
|
|
1304
|
+
batchId
|
|
1305
|
+
};
|
|
1306
|
+
return phantomMut;
|
|
1307
|
+
}
|
|
1308
|
+
const { coalesced } = coalesceResult;
|
|
1309
|
+
await this.store.putMutation(coalesced);
|
|
1310
|
+
return coalesced;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
const seq = this.nextSeq();
|
|
1314
|
+
const mutation = {
|
|
1315
|
+
seq,
|
|
1316
|
+
mutationId: generateUUIDv4(),
|
|
1317
|
+
collection,
|
|
1318
|
+
docId,
|
|
1319
|
+
op,
|
|
1320
|
+
payload,
|
|
1321
|
+
baseVersion,
|
|
1322
|
+
enqueuedAt: Date.now(),
|
|
1323
|
+
attempts: 0,
|
|
1324
|
+
lastError: null,
|
|
1325
|
+
status: "queued",
|
|
1326
|
+
batchId
|
|
1327
|
+
};
|
|
1328
|
+
await this.store.putMutation(mutation);
|
|
1329
|
+
return mutation;
|
|
1330
|
+
}
|
|
1331
|
+
// ─── Leitura da fila ─────────────────────────────────────────────────────────
|
|
1332
|
+
/**
|
|
1333
|
+
* Retorna a primeira mutação com status `queued` (menor seq), ou `null`.
|
|
1334
|
+
*/
|
|
1335
|
+
async peek() {
|
|
1336
|
+
const pending = await this.listPending();
|
|
1337
|
+
return pending[0] ?? null;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Retorna todas as mutações com status `queued`, ordenadas por seq crescente.
|
|
1341
|
+
*/
|
|
1342
|
+
async listPending() {
|
|
1343
|
+
return this.store.listMutations({ status: "queued" });
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Retorna TODAS as mutações (qualquer status), ordenadas por seq crescente.
|
|
1347
|
+
* Útil para inspeção e testes.
|
|
1348
|
+
*/
|
|
1349
|
+
async listAll() {
|
|
1350
|
+
return this.store.listMutations();
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Conta o número de mutações com status `queued`.
|
|
1354
|
+
*/
|
|
1355
|
+
async countPending() {
|
|
1356
|
+
const pending = await this.listPending();
|
|
1357
|
+
return pending.length;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Retorna um lote de mutações `queued` para drenagem (I3 §6.2 FASE 1).
|
|
1361
|
+
* Ordena por seq crescente. O SyncEngine chama drain(), itera, e marca
|
|
1362
|
+
* cada mutação como applied/failed.
|
|
1363
|
+
*/
|
|
1364
|
+
async drain(options) {
|
|
1365
|
+
const pending = await this.listPending();
|
|
1366
|
+
if (options?.maxBatch !== void 0 && options.maxBatch > 0) {
|
|
1367
|
+
return pending.slice(0, options.maxBatch);
|
|
1368
|
+
}
|
|
1369
|
+
return pending;
|
|
1370
|
+
}
|
|
1371
|
+
// ─── Ciclo de vida de mutações ────────────────────────────────────────────────
|
|
1372
|
+
/**
|
|
1373
|
+
* Marca uma mutação como `inflight` (está sendo enviada ao Core).
|
|
1374
|
+
* Chamado pelo SyncEngine antes de enviar o request.
|
|
1375
|
+
*/
|
|
1376
|
+
async markInflight(mutationId) {
|
|
1377
|
+
const mut = await this.store.getMutation(mutationId);
|
|
1378
|
+
if (!mut) return;
|
|
1379
|
+
await this.store.putMutation({ ...mut, status: "inflight" });
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Remove a mutação da fila (sucesso de replay).
|
|
1383
|
+
* Chamado pelo SyncEngine ao receber confirmação do Core.
|
|
1384
|
+
*/
|
|
1385
|
+
async markApplied(mutationId) {
|
|
1386
|
+
await this.store.deleteMutation(mutationId);
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Marca uma mutação como `failed` e registra o erro.
|
|
1390
|
+
* Incrementa `attempts`. Chamado pelo SyncEngine em falha permanente.
|
|
1391
|
+
*/
|
|
1392
|
+
async markFailed(mutationId, error) {
|
|
1393
|
+
const mut = await this.store.getMutation(mutationId);
|
|
1394
|
+
if (!mut) return;
|
|
1395
|
+
await this.store.putMutation({
|
|
1396
|
+
...mut,
|
|
1397
|
+
status: "failed",
|
|
1398
|
+
lastError: error,
|
|
1399
|
+
attempts: mut.attempts + 1
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Incrementa `attempts` e volta para `queued` (falha transiente com backoff).
|
|
1404
|
+
* Chamado pelo SyncEngine em falha transiente (5xx, timeout).
|
|
1405
|
+
*/
|
|
1406
|
+
async markRetry(mutationId, error) {
|
|
1407
|
+
const mut = await this.store.getMutation(mutationId);
|
|
1408
|
+
if (!mut) return;
|
|
1409
|
+
await this.store.putMutation({
|
|
1410
|
+
...mut,
|
|
1411
|
+
status: "queued",
|
|
1412
|
+
lastError: error,
|
|
1413
|
+
attempts: mut.attempts + 1
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
// src/db/offline/sync-engine.ts
|
|
1419
|
+
var SyncEngine = class {
|
|
1420
|
+
_store;
|
|
1421
|
+
_queue;
|
|
1422
|
+
_resolver;
|
|
1423
|
+
_bus;
|
|
1424
|
+
_transport;
|
|
1425
|
+
_tabCoordinator;
|
|
1426
|
+
_connectivity;
|
|
1427
|
+
/** `true` se um sync está atualmente em progresso — guarda re-entrância. */
|
|
1428
|
+
_syncing = false;
|
|
1429
|
+
/** `true` se destroy() foi chamado. */
|
|
1430
|
+
_destroyed = false;
|
|
1431
|
+
/**
|
|
1432
|
+
* Conjunto de coleções "conhecidas" pelo engine.
|
|
1433
|
+
* Populado ao aplicar docs do servidor (Fase 1 e 2) e ao ler do cache.
|
|
1434
|
+
* Persiste em sync_meta['activeCollections'] para sobreviver a reloads.
|
|
1435
|
+
*/
|
|
1436
|
+
_activeCollections = /* @__PURE__ */ new Set();
|
|
1437
|
+
/** Estado de sync atual (fonte de verdade em RAM). */
|
|
1438
|
+
_state;
|
|
1439
|
+
/** Listeners de mudança de SyncState. */
|
|
1440
|
+
_stateListeners = /* @__PURE__ */ new Set();
|
|
1441
|
+
/** Cleanup functions dos listeners externos. */
|
|
1442
|
+
_cleanups = [];
|
|
1443
|
+
/** Timer de resync periódico. */
|
|
1444
|
+
_periodicTimer = null;
|
|
1445
|
+
constructor(opts) {
|
|
1446
|
+
this._store = opts.store;
|
|
1447
|
+
this._queue = opts.queue;
|
|
1448
|
+
this._resolver = opts.resolver;
|
|
1449
|
+
this._bus = opts.bus;
|
|
1450
|
+
this._transport = opts.transport;
|
|
1451
|
+
this._tabCoordinator = opts.tabCoordinator;
|
|
1452
|
+
this._connectivity = opts.connectivity;
|
|
1453
|
+
this._state = this._buildInitialState();
|
|
1454
|
+
this._wireListeners();
|
|
1455
|
+
const intervalMs = opts.periodicSyncIntervalMs ?? 5 * 60 * 1e3;
|
|
1456
|
+
if (intervalMs > 0) {
|
|
1457
|
+
this._periodicTimer = setInterval(() => {
|
|
1458
|
+
this._triggerSync("periodic");
|
|
1459
|
+
}, intervalMs);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
// ─── API pública ─────────────────────────────────────────────────────────────
|
|
1463
|
+
/**
|
|
1464
|
+
* Expõe o transporte de sync para acesso direto por `DbCollectionRefImpl`.
|
|
1465
|
+
* Necessário para que `onSnapshot` possa chamar `subscribeCollection` no
|
|
1466
|
+
* transport nosql-vm (HIGH-1 fix — realtime deltas).
|
|
1467
|
+
*/
|
|
1468
|
+
get transport() {
|
|
1469
|
+
return this._transport;
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Retorna o estado de sync atual (snapshot síncrono).
|
|
1473
|
+
* Equivale a `client.db.syncState` na API pública do SDK (I3 §10.3).
|
|
1474
|
+
*
|
|
1475
|
+
* `pendingWrites` reflete o valor mais recente calculado de forma assíncrona.
|
|
1476
|
+
* Para garantir o valor mais atualizado, aguarde `refreshPendingWrites()` antes.
|
|
1477
|
+
*/
|
|
1478
|
+
getSyncState() {
|
|
1479
|
+
return { ...this._state };
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Força a atualização de `pendingWrites` e retorna o estado atualizado.
|
|
1483
|
+
* Útil quando o chamador precisa do pendingWrites fresco sem aguardar um sync.
|
|
1484
|
+
*/
|
|
1485
|
+
async refreshPendingWrites() {
|
|
1486
|
+
await this._refreshPendingWrites();
|
|
1487
|
+
return { ...this._state };
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Registra um listener de mudanças no SyncState.
|
|
1491
|
+
* Retorna uma função de unsubscribe.
|
|
1492
|
+
*/
|
|
1493
|
+
onSyncStateChange(cb) {
|
|
1494
|
+
this._stateListeners.add(cb);
|
|
1495
|
+
return () => {
|
|
1496
|
+
this._stateListeners.delete(cb);
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Força um sync imediato (I3 §10.3 `flush()`).
|
|
1501
|
+
* Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
|
|
1502
|
+
*/
|
|
1503
|
+
async flush() {
|
|
1504
|
+
if (this._destroyed) return;
|
|
1505
|
+
await this.sync();
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Executa as 3 fases de sincronização.
|
|
1509
|
+
*
|
|
1510
|
+
* Guarda re-entrância: se já está em progresso, retorna imediatamente.
|
|
1511
|
+
* NÃO executa se esta aba não é líder ou se está offline.
|
|
1512
|
+
*/
|
|
1513
|
+
async sync() {
|
|
1514
|
+
if (this._destroyed) return;
|
|
1515
|
+
if (!this._tabCoordinator.isLeader()) return;
|
|
1516
|
+
if (!this._connectivity.isOnline) return;
|
|
1517
|
+
if (this._syncing) return;
|
|
1518
|
+
this._syncing = true;
|
|
1519
|
+
this._updateState({ status: "syncing" });
|
|
1520
|
+
try {
|
|
1521
|
+
const phase1Ok = await this._phase1Push();
|
|
1522
|
+
if (!phase1Ok) {
|
|
1523
|
+
this._updateState({ status: "idle" });
|
|
1524
|
+
this._syncing = false;
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
await this._phase2Pull();
|
|
1528
|
+
this._phase3Realtime();
|
|
1529
|
+
this._updateState({ status: "idle", lastSyncedAt: Date.now() });
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
this._updateState({ status: "idle" });
|
|
1532
|
+
} finally {
|
|
1533
|
+
this._syncing = false;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Teardown: para timers, remove listeners, marca como destroyed.
|
|
1538
|
+
*/
|
|
1539
|
+
destroy() {
|
|
1540
|
+
if (this._destroyed) return;
|
|
1541
|
+
this._destroyed = true;
|
|
1542
|
+
if (this._periodicTimer !== null) {
|
|
1543
|
+
clearInterval(this._periodicTimer);
|
|
1544
|
+
this._periodicTimer = null;
|
|
1545
|
+
}
|
|
1546
|
+
for (const cleanup of this._cleanups) {
|
|
1547
|
+
try {
|
|
1548
|
+
cleanup();
|
|
1549
|
+
} catch {
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
this._cleanups.length = 0;
|
|
1553
|
+
this._stateListeners.clear();
|
|
1554
|
+
}
|
|
1555
|
+
// ─── FASE 1 — push (drenar a fila) ───────────────────────────────────────────
|
|
1556
|
+
/**
|
|
1557
|
+
* Drena a MutationQueue, enviando mutações em ordem de seq.
|
|
1558
|
+
*
|
|
1559
|
+
* Retorna `true` se a fase completou sem falhas transientes.
|
|
1560
|
+
* Retorna `false` se houve falha transiente (ciclo deve ser abortado).
|
|
1561
|
+
*/
|
|
1562
|
+
async _phase1Push() {
|
|
1563
|
+
const mutations = await this._queue.drain();
|
|
1564
|
+
if (mutations.length === 0) return true;
|
|
1565
|
+
for (const mut of mutations) {
|
|
1566
|
+
await this._queue.markInflight(mut.mutationId);
|
|
1567
|
+
}
|
|
1568
|
+
let result;
|
|
1569
|
+
try {
|
|
1570
|
+
result = await this._transport.pushMutations(mutations);
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
for (const mut of mutations) {
|
|
1573
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1574
|
+
await this._queue.markRetry(mut.mutationId, errMsg);
|
|
1575
|
+
}
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
const busChanges = [];
|
|
1579
|
+
for (const res of result.results) {
|
|
1580
|
+
const mut = mutations.find((m) => m.mutationId === res.mutationId);
|
|
1581
|
+
if (!mut) continue;
|
|
1582
|
+
if (res.outcome === "confirmed") {
|
|
1583
|
+
await this._queue.markApplied(mut.mutationId);
|
|
1584
|
+
await this._applySyncedDoc(
|
|
1585
|
+
mut.collection,
|
|
1586
|
+
mut.docId,
|
|
1587
|
+
res.serverVersion,
|
|
1588
|
+
res.serverTimestamp,
|
|
1589
|
+
mut,
|
|
1590
|
+
busChanges
|
|
1591
|
+
);
|
|
1592
|
+
} else if (res.outcome === "superseded") {
|
|
1593
|
+
await this._queue.markApplied(mut.mutationId);
|
|
1594
|
+
await this._applySuperseded(mut, res, busChanges);
|
|
1595
|
+
} else if (res.outcome === "rejected") {
|
|
1596
|
+
await this._queue.markApplied(mut.mutationId);
|
|
1597
|
+
await this._applyRejected(mut, res, busChanges);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (busChanges.length > 0) {
|
|
1601
|
+
this._bus.emit(busChanges);
|
|
1602
|
+
}
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
/** Atualiza o cache após confirmação de escrita (outcome=confirmed). */
|
|
1606
|
+
async _applySyncedDoc(collection, docId, serverVersion, serverTimestamp, mut, busChanges) {
|
|
1607
|
+
const existing = await this._store.getDoc(collection, docId);
|
|
1608
|
+
if (mut.op === "remove") {
|
|
1609
|
+
if (existing) {
|
|
1610
|
+
await this._store.putDoc({
|
|
1611
|
+
...existing,
|
|
1612
|
+
meta: {
|
|
1613
|
+
...existing.meta,
|
|
1614
|
+
serverVersion,
|
|
1615
|
+
updatedAtServer: serverTimestamp,
|
|
1616
|
+
updatedAtLocal: Date.now(),
|
|
1617
|
+
state: "synced",
|
|
1618
|
+
deleted: true,
|
|
1619
|
+
pendingMutationIds: []
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
busChanges.push({ type: "removed", collection, doc: { id: docId, data: existing.data } });
|
|
1623
|
+
}
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const newData = mut.payload ?? (existing?.data ?? {});
|
|
1627
|
+
const updatedDoc = {
|
|
1628
|
+
collection,
|
|
1629
|
+
id: docId,
|
|
1630
|
+
data: mut.op === "update" ? { ...existing?.data ?? {}, ...newData } : newData,
|
|
1631
|
+
meta: {
|
|
1632
|
+
serverVersion,
|
|
1633
|
+
updatedAtServer: serverTimestamp,
|
|
1634
|
+
updatedAtLocal: Date.now(),
|
|
1635
|
+
state: "synced",
|
|
1636
|
+
pendingMutationIds: [],
|
|
1637
|
+
deleted: false,
|
|
1638
|
+
ownerId: existing?.meta.ownerId ?? null
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
await this._store.putDoc(updatedDoc);
|
|
1642
|
+
busChanges.push({
|
|
1643
|
+
type: existing ? "modified" : "added",
|
|
1644
|
+
collection,
|
|
1645
|
+
doc: { id: docId, data: updatedDoc.data }
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
/** Processa resultado 'superseded' (LWW: servidor venceu). */
|
|
1649
|
+
async _applySuperseded(mut, res, busChanges) {
|
|
1650
|
+
const localDoc = await this._store.getDoc(mut.collection, mut.docId);
|
|
1651
|
+
const serverDocEnvelope = {
|
|
1652
|
+
collection: mut.collection,
|
|
1653
|
+
id: mut.docId,
|
|
1654
|
+
data: res.serverData,
|
|
1655
|
+
meta: {
|
|
1656
|
+
serverVersion: res.serverVersion,
|
|
1657
|
+
updatedAtServer: res.serverTimestamp,
|
|
1658
|
+
updatedAtLocal: Date.now(),
|
|
1659
|
+
state: "synced",
|
|
1660
|
+
pendingMutationIds: [],
|
|
1661
|
+
deleted: false,
|
|
1662
|
+
ownerId: localDoc?.meta.ownerId ?? null
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
const localDocForResolver = localDoc ?? {
|
|
1666
|
+
collection: mut.collection,
|
|
1667
|
+
id: mut.docId,
|
|
1668
|
+
data: mut.payload ?? {},
|
|
1669
|
+
meta: {
|
|
1670
|
+
serverVersion: mut.baseVersion,
|
|
1671
|
+
updatedAtServer: res.serverTimestamp - 1,
|
|
1672
|
+
// garante server é mais novo
|
|
1673
|
+
updatedAtLocal: mut.enqueuedAt,
|
|
1674
|
+
state: "pending",
|
|
1675
|
+
pendingMutationIds: [mut.mutationId],
|
|
1676
|
+
deleted: false,
|
|
1677
|
+
ownerId: null
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
const resolveResult = this._resolver.resolve(
|
|
1681
|
+
localDocForResolver,
|
|
1682
|
+
serverDocEnvelope,
|
|
1683
|
+
mut
|
|
1684
|
+
);
|
|
1685
|
+
const conflictRecord = resolveResult.conflictRecord ?? {
|
|
1686
|
+
collection: mut.collection,
|
|
1687
|
+
docId: mut.docId,
|
|
1688
|
+
mutationId: mut.mutationId,
|
|
1689
|
+
losingData: mut.payload ?? {},
|
|
1690
|
+
winningData: res.serverData,
|
|
1691
|
+
reason: "lww_server_newer",
|
|
1692
|
+
detectedAt: Date.now(),
|
|
1693
|
+
delivered: false
|
|
1694
|
+
};
|
|
1695
|
+
await this._store.appendConflict(conflictRecord);
|
|
1696
|
+
const updatedDoc = {
|
|
1697
|
+
collection: mut.collection,
|
|
1698
|
+
id: mut.docId,
|
|
1699
|
+
data: res.serverData,
|
|
1700
|
+
meta: {
|
|
1701
|
+
serverVersion: res.serverVersion,
|
|
1702
|
+
updatedAtServer: res.serverTimestamp,
|
|
1703
|
+
updatedAtLocal: Date.now(),
|
|
1704
|
+
state: "synced",
|
|
1705
|
+
pendingMutationIds: [],
|
|
1706
|
+
deleted: false,
|
|
1707
|
+
ownerId: localDoc?.meta.ownerId ?? null
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
await this._store.putDoc(updatedDoc);
|
|
1711
|
+
busChanges.push({
|
|
1712
|
+
type: localDoc ? "modified" : "added",
|
|
1713
|
+
collection: mut.collection,
|
|
1714
|
+
doc: { id: mut.docId, data: res.serverData }
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
/** Processa resultado 'rejected' (permissão/validação negada). */
|
|
1718
|
+
async _applyRejected(mut, res, busChanges) {
|
|
1719
|
+
const storedDoc = await this._store.getDoc(mut.collection, mut.docId);
|
|
1720
|
+
const localDoc = storedDoc ?? {
|
|
1721
|
+
collection: mut.collection,
|
|
1722
|
+
id: mut.docId,
|
|
1723
|
+
data: mut.payload ?? {},
|
|
1724
|
+
meta: {
|
|
1725
|
+
serverVersion: mut.baseVersion,
|
|
1726
|
+
updatedAtServer: null,
|
|
1727
|
+
updatedAtLocal: mut.enqueuedAt,
|
|
1728
|
+
state: "pending",
|
|
1729
|
+
pendingMutationIds: [mut.mutationId],
|
|
1730
|
+
deleted: false,
|
|
1731
|
+
ownerId: null
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
const serverDocEnvelope = res.serverData ? {
|
|
1735
|
+
...localDoc,
|
|
1736
|
+
data: res.serverData,
|
|
1737
|
+
meta: {
|
|
1738
|
+
...localDoc.meta,
|
|
1739
|
+
serverVersion: res.serverVersion ?? localDoc.meta.serverVersion
|
|
1740
|
+
}
|
|
1741
|
+
} : null;
|
|
1742
|
+
const resolveResult = this._resolver.resolveRejected(
|
|
1743
|
+
localDoc,
|
|
1744
|
+
serverDocEnvelope,
|
|
1745
|
+
mut,
|
|
1746
|
+
res.reason
|
|
1747
|
+
);
|
|
1748
|
+
const conflictRecord = resolveResult.conflictRecord ?? {
|
|
1749
|
+
collection: mut.collection,
|
|
1750
|
+
docId: mut.docId,
|
|
1751
|
+
mutationId: mut.mutationId,
|
|
1752
|
+
losingData: mut.payload ?? {},
|
|
1753
|
+
winningData: res.serverData ?? {},
|
|
1754
|
+
reason: res.reason,
|
|
1755
|
+
detectedAt: Date.now(),
|
|
1756
|
+
delivered: false
|
|
1757
|
+
};
|
|
1758
|
+
await this._store.appendConflict(conflictRecord);
|
|
1759
|
+
if (res.serverData) {
|
|
1760
|
+
await this._store.putDoc({
|
|
1761
|
+
...localDoc,
|
|
1762
|
+
data: res.serverData,
|
|
1763
|
+
meta: {
|
|
1764
|
+
...localDoc.meta,
|
|
1765
|
+
state: "synced",
|
|
1766
|
+
pendingMutationIds: []
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
busChanges.push({
|
|
1770
|
+
type: "modified",
|
|
1771
|
+
collection: mut.collection,
|
|
1772
|
+
doc: { id: mut.docId, data: res.serverData }
|
|
1773
|
+
});
|
|
1774
|
+
} else {
|
|
1775
|
+
await this._store.putDoc({
|
|
1776
|
+
...localDoc,
|
|
1777
|
+
meta: {
|
|
1778
|
+
...localDoc.meta,
|
|
1779
|
+
state: "synced",
|
|
1780
|
+
pendingMutationIds: []
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
// ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
|
|
1786
|
+
/**
|
|
1787
|
+
* Busca mudanças do servidor e aplica ao cache via ConflictResolver.
|
|
1788
|
+
*
|
|
1789
|
+
* Se `resyncRequired === true`, faz full resync em vez de pull incremental.
|
|
1790
|
+
*/
|
|
1791
|
+
async _phase2Pull() {
|
|
1792
|
+
const watermark = await this._store.getMeta("lastSyncWatermark");
|
|
1793
|
+
const resumeToken = await this._store.getMeta("lastResumeToken");
|
|
1794
|
+
let pullResult;
|
|
1795
|
+
try {
|
|
1796
|
+
pullResult = await this._transport.pullChanges(watermark, resumeToken);
|
|
1797
|
+
} catch {
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
if (pullResult.resyncRequired) {
|
|
1801
|
+
await this._phase2FullResync();
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const busChanges = [];
|
|
1805
|
+
await this._applyServerDocs(pullResult.docs, busChanges, false);
|
|
1806
|
+
if (pullResult.newWatermark !== null) {
|
|
1807
|
+
await this._store.setMeta("lastSyncWatermark", pullResult.newWatermark);
|
|
1808
|
+
}
|
|
1809
|
+
if (busChanges.length > 0) {
|
|
1810
|
+
this._bus.emit(busChanges);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
/** Full resync: lista completa + detecta deleções por ausência. */
|
|
1814
|
+
async _phase2FullResync() {
|
|
1815
|
+
const collections = await this._getActiveCollections();
|
|
1816
|
+
let resyncResult;
|
|
1817
|
+
try {
|
|
1818
|
+
resyncResult = await this._transport.fullResync(collections);
|
|
1819
|
+
} catch {
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const busChanges = [];
|
|
1823
|
+
await this._applyServerDocs(resyncResult.docs, busChanges, true);
|
|
1824
|
+
const returnedKeys = new Set(
|
|
1825
|
+
resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
|
|
1826
|
+
);
|
|
1827
|
+
for (const col of collections) {
|
|
1828
|
+
const queryResult = await this._store.listDocs(col, {});
|
|
1829
|
+
for (const { id } of queryResult.docs) {
|
|
1830
|
+
const key = `${col}::${id}`;
|
|
1831
|
+
if (!returnedKeys.has(key)) {
|
|
1832
|
+
const fullDoc = await this._store.getDoc(col, id);
|
|
1833
|
+
if (fullDoc && !fullDoc.meta.deleted) {
|
|
1834
|
+
await this._store.putDoc({
|
|
1835
|
+
...fullDoc,
|
|
1836
|
+
meta: {
|
|
1837
|
+
...fullDoc.meta,
|
|
1838
|
+
deleted: true,
|
|
1839
|
+
updatedAtLocal: Date.now(),
|
|
1840
|
+
state: "synced"
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
busChanges.push({
|
|
1844
|
+
type: "removed",
|
|
1845
|
+
collection: col,
|
|
1846
|
+
doc: { id, data: fullDoc.data }
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (resyncResult.newWatermark !== null) {
|
|
1853
|
+
await this._store.setMeta("lastSyncWatermark", resyncResult.newWatermark);
|
|
1854
|
+
}
|
|
1855
|
+
await this._store.setMeta("lastFullResyncAt", Date.now());
|
|
1856
|
+
if (busChanges.length > 0) {
|
|
1857
|
+
this._bus.emit(busChanges);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Aplica uma lista de ServerDoc ao cache local via ConflictResolver.
|
|
1862
|
+
*
|
|
1863
|
+
* `isFullResync = true` indica que o conjunto é completo — usamos isso para
|
|
1864
|
+
* otimizar a path (sempre sobrescreve docs synced; pending passam pelo LWW).
|
|
1865
|
+
*/
|
|
1866
|
+
async _applyServerDocs(docs, busChanges, _isFullResync) {
|
|
1867
|
+
for (const serverDoc of docs) {
|
|
1868
|
+
const localDoc = await this._store.getDoc(serverDoc.collection, serverDoc.id);
|
|
1869
|
+
if (serverDoc.deleted) {
|
|
1870
|
+
if (localDoc && !localDoc.meta.deleted) {
|
|
1871
|
+
await this._store.putDoc({
|
|
1872
|
+
...localDoc,
|
|
1873
|
+
meta: {
|
|
1874
|
+
...localDoc.meta,
|
|
1875
|
+
deleted: true,
|
|
1876
|
+
serverVersion: serverDoc.serverVersion,
|
|
1877
|
+
updatedAtServer: serverDoc.serverTimestamp,
|
|
1878
|
+
updatedAtLocal: Date.now(),
|
|
1879
|
+
state: "synced"
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1882
|
+
busChanges.push({
|
|
1883
|
+
type: "removed",
|
|
1884
|
+
collection: serverDoc.collection,
|
|
1885
|
+
doc: { id: serverDoc.id, data: localDoc.data }
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
if (localDoc && localDoc.meta.state === "pending") {
|
|
1891
|
+
const serverEnvelope = {
|
|
1892
|
+
collection: serverDoc.collection,
|
|
1893
|
+
id: serverDoc.id,
|
|
1894
|
+
data: serverDoc.data,
|
|
1895
|
+
meta: {
|
|
1896
|
+
serverVersion: serverDoc.serverVersion,
|
|
1897
|
+
updatedAtServer: serverDoc.serverTimestamp,
|
|
1898
|
+
updatedAtLocal: Date.now(),
|
|
1899
|
+
state: "synced",
|
|
1900
|
+
pendingMutationIds: [],
|
|
1901
|
+
deleted: false,
|
|
1902
|
+
ownerId: localDoc.meta.ownerId
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
const pendingMuts = await this._queue.drain();
|
|
1906
|
+
const docMut = pendingMuts.find(
|
|
1907
|
+
(m) => m.collection === serverDoc.collection && m.docId === serverDoc.id
|
|
1908
|
+
);
|
|
1909
|
+
if (docMut) {
|
|
1910
|
+
const result = this._resolver.resolve(localDoc, serverEnvelope, docMut);
|
|
1911
|
+
if (result.conflictRecord) {
|
|
1912
|
+
await this._store.appendConflict(result.conflictRecord);
|
|
1913
|
+
}
|
|
1914
|
+
await this._store.putDoc({
|
|
1915
|
+
...localDoc,
|
|
1916
|
+
meta: { ...localDoc.meta, state: "conflict" }
|
|
1917
|
+
});
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
const isNew = !localDoc;
|
|
1922
|
+
const newDoc = {
|
|
1923
|
+
collection: serverDoc.collection,
|
|
1924
|
+
id: serverDoc.id,
|
|
1925
|
+
data: serverDoc.data,
|
|
1926
|
+
meta: {
|
|
1927
|
+
serverVersion: serverDoc.serverVersion,
|
|
1928
|
+
updatedAtServer: serverDoc.serverTimestamp,
|
|
1929
|
+
updatedAtLocal: Date.now(),
|
|
1930
|
+
state: "synced",
|
|
1931
|
+
pendingMutationIds: [],
|
|
1932
|
+
deleted: false,
|
|
1933
|
+
ownerId: localDoc?.meta.ownerId ?? null
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
await this._store.putDoc(newDoc);
|
|
1937
|
+
busChanges.push({
|
|
1938
|
+
type: isNew ? "added" : "modified",
|
|
1939
|
+
collection: serverDoc.collection,
|
|
1940
|
+
doc: { id: serverDoc.id, data: serverDoc.data }
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
// ─── FASE 3 — reabre listeners (realtime) ─────────────────────────────────────
|
|
1945
|
+
/**
|
|
1946
|
+
* Fase 3: sinaliza que o cache está reconciliado.
|
|
1947
|
+
*
|
|
1948
|
+
* O transporte de tempo real (Firestore onSnapshot / WebSocket) é gerido
|
|
1949
|
+
* pela camada superior — aqui apenas garantimos que o SyncState reflita
|
|
1950
|
+
* o término do sync, o que notifica os listeners `onSyncStateChange` e
|
|
1951
|
+
* transitivamente os `onSnapshot` da UI.
|
|
1952
|
+
*/
|
|
1953
|
+
_phase3Realtime() {
|
|
1954
|
+
}
|
|
1955
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
1956
|
+
/**
|
|
1957
|
+
* Retorna a lista de coleções com documentos no cache local.
|
|
1958
|
+
* Usado pelo full resync para saber quais coleções checar.
|
|
1959
|
+
*
|
|
1960
|
+
* Combina:
|
|
1961
|
+
* 1. Set em memória `_activeCollections` (populado ao aplicar docs)
|
|
1962
|
+
* 2. Coleções de mutações pendentes
|
|
1963
|
+
* 3. Coleções de conflict_log
|
|
1964
|
+
* 4. Coleções persistas em sync_meta['activeCollections']
|
|
1965
|
+
*/
|
|
1966
|
+
async _getActiveCollections() {
|
|
1967
|
+
const collectionSet = new Set(this._activeCollections);
|
|
1968
|
+
const mutations = await this._queue.listAll();
|
|
1969
|
+
for (const m of mutations) collectionSet.add(m.collection);
|
|
1970
|
+
const conflicts = await this._store.listConflicts();
|
|
1971
|
+
for (const c of conflicts) collectionSet.add(c.collection);
|
|
1972
|
+
const persistedRaw = await this._store.getMeta("activeCollections");
|
|
1973
|
+
if (persistedRaw) {
|
|
1974
|
+
for (const col of persistedRaw) collectionSet.add(col);
|
|
1975
|
+
}
|
|
1976
|
+
const storedCollections = await this._store.listCollections();
|
|
1977
|
+
for (const col of storedCollections) collectionSet.add(col);
|
|
1978
|
+
return Array.from(collectionSet);
|
|
1979
|
+
}
|
|
1980
|
+
/** Constrói o estado inicial com base no estado atual dos colaboradores. */
|
|
1981
|
+
_buildInitialState() {
|
|
1982
|
+
const isOnline = this._connectivity.isOnline;
|
|
1983
|
+
return {
|
|
1984
|
+
status: isOnline ? "idle" : "offline",
|
|
1985
|
+
pendingWrites: 0,
|
|
1986
|
+
lastSyncedAt: null,
|
|
1987
|
+
isLeaderTab: this._tabCoordinator.isLeader()
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* Atualiza o SyncState em RAM e notifica os listeners.
|
|
1992
|
+
* Só emite se algo realmente mudou.
|
|
1993
|
+
*/
|
|
1994
|
+
_updateState(partial) {
|
|
1995
|
+
const prev = this._state;
|
|
1996
|
+
const next = { ...prev, ...partial };
|
|
1997
|
+
const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab;
|
|
1998
|
+
this._state = next;
|
|
1999
|
+
if (changed) {
|
|
2000
|
+
this._emitState(next);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
_emitState(state) {
|
|
2004
|
+
for (const listener of this._stateListeners) {
|
|
2005
|
+
try {
|
|
2006
|
+
listener({ ...state });
|
|
2007
|
+
} catch {
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Atualiza `pendingWrites` no estado.
|
|
2013
|
+
* Chamado de forma assíncrona quando necessário.
|
|
2014
|
+
*/
|
|
2015
|
+
async _refreshPendingWrites() {
|
|
2016
|
+
if (this._destroyed) return;
|
|
2017
|
+
try {
|
|
2018
|
+
const count = await this._queue.countPending();
|
|
2019
|
+
if (count !== this._state.pendingWrites) {
|
|
2020
|
+
this._updateState({ pendingWrites: count });
|
|
2021
|
+
}
|
|
2022
|
+
} catch {
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
/** Dispara um sync de forma fire-and-forget (para gatilhos externos). */
|
|
2026
|
+
_triggerSync(_reason) {
|
|
2027
|
+
if (this._destroyed) return;
|
|
2028
|
+
this._refreshPendingWrites().catch(() => {
|
|
2029
|
+
});
|
|
2030
|
+
this.sync().catch(() => {
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
/** Carrega activeCollections do sync_meta e popula o Set em memória. */
|
|
2034
|
+
_loadActiveCollections() {
|
|
2035
|
+
this._store.getMeta("activeCollections").then((raw) => {
|
|
2036
|
+
const cols = raw;
|
|
2037
|
+
if (cols) {
|
|
2038
|
+
for (const c of cols) this._activeCollections.add(c);
|
|
2039
|
+
}
|
|
2040
|
+
}).catch(() => {
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
/** Conecta os listeners de ConnectivityMonitor e TabCoordinator. */
|
|
2044
|
+
_wireListeners() {
|
|
2045
|
+
this._loadActiveCollections();
|
|
2046
|
+
const unsubConn = this._connectivity.subscribe((state) => {
|
|
2047
|
+
if (state === "offline") {
|
|
2048
|
+
this._updateState({ status: "offline" });
|
|
2049
|
+
} else {
|
|
2050
|
+
if (this._state.status === "offline") {
|
|
2051
|
+
this._updateState({ status: "idle" });
|
|
2052
|
+
}
|
|
2053
|
+
this._triggerSync("connectivity:online");
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
this._cleanups.push(unsubConn);
|
|
2057
|
+
const unsubRole = this._tabCoordinator.onRoleChange((role) => {
|
|
2058
|
+
const isLeader = role === "leader";
|
|
2059
|
+
this._updateState({ isLeaderTab: isLeader });
|
|
2060
|
+
if (isLeader) {
|
|
2061
|
+
this._triggerSync("role:leader");
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
this._cleanups.push(unsubRole);
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
// src/db/offline/conflict-resolver.ts
|
|
2069
|
+
function isServerNewer(localUpdatedAtServer, remoteUpdatedAtServer) {
|
|
2070
|
+
if (remoteUpdatedAtServer === null) return false;
|
|
2071
|
+
if (localUpdatedAtServer === null) return true;
|
|
2072
|
+
return remoteUpdatedAtServer > localUpdatedAtServer;
|
|
2073
|
+
}
|
|
2074
|
+
var ConflictResolver = class _ConflictResolver {
|
|
2075
|
+
/**
|
|
2076
|
+
* Resolve um conflito com base na operação da mutação (auto-dispatch).
|
|
2077
|
+
*
|
|
2078
|
+
* - `add` / `set` → `resolveDocLevel`
|
|
2079
|
+
* - `update` → `resolveFieldRestricted`
|
|
2080
|
+
* - `remove` → a remoção sempre vence (LWW: operação mais recente é a destruição)
|
|
2081
|
+
*/
|
|
2082
|
+
resolve(localDoc, serverDoc, pendingMutation) {
|
|
2083
|
+
switch (pendingMutation.op) {
|
|
2084
|
+
case "remove":
|
|
2085
|
+
return this.resolveRemove(localDoc, serverDoc, pendingMutation);
|
|
2086
|
+
case "update":
|
|
2087
|
+
return this.resolveFieldRestricted(localDoc, serverDoc, pendingMutation);
|
|
2088
|
+
case "add":
|
|
2089
|
+
case "set":
|
|
2090
|
+
default:
|
|
2091
|
+
return this.resolveDocLevel(localDoc, serverDoc, pendingMutation);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* LWW documento-nível (para `set` e `add`).
|
|
2096
|
+
*
|
|
2097
|
+
* O documento inteiro com o timestamp de servidor mais recente vence.
|
|
2098
|
+
* Se o servidor for mais novo, a escrita local é descartada e um
|
|
2099
|
+
* `ConflictRecord` é emitido.
|
|
2100
|
+
*/
|
|
2101
|
+
resolveDocLevel(localDoc, serverDoc, pendingMutation) {
|
|
2102
|
+
if (serverDoc === null) {
|
|
2103
|
+
const data = pendingMutation.payload ?? {};
|
|
2104
|
+
return { outcome: "no_conflict", resolvedData: data, conflictRecord: null };
|
|
2105
|
+
}
|
|
2106
|
+
if (localDoc === null) {
|
|
2107
|
+
return {
|
|
2108
|
+
outcome: "server_wins",
|
|
2109
|
+
resolvedData: serverDoc.data,
|
|
2110
|
+
conflictRecord: null
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
const serverNewer = isServerNewer(
|
|
2114
|
+
localDoc.meta.updatedAtServer,
|
|
2115
|
+
serverDoc.meta.updatedAtServer
|
|
2116
|
+
);
|
|
2117
|
+
if (!serverNewer) {
|
|
2118
|
+
return {
|
|
2119
|
+
outcome: "local_wins",
|
|
2120
|
+
resolvedData: localDoc.data,
|
|
2121
|
+
conflictRecord: null
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
const conflictRecord = {
|
|
2125
|
+
collection: pendingMutation.collection,
|
|
2126
|
+
docId: pendingMutation.docId,
|
|
2127
|
+
mutationId: pendingMutation.mutationId,
|
|
2128
|
+
losingData: localDoc.data,
|
|
2129
|
+
winningData: serverDoc.data,
|
|
2130
|
+
reason: "lww_server_newer",
|
|
2131
|
+
detectedAt: Date.now(),
|
|
2132
|
+
delivered: false
|
|
2133
|
+
};
|
|
2134
|
+
return {
|
|
2135
|
+
outcome: "server_wins",
|
|
2136
|
+
resolvedData: serverDoc.data,
|
|
2137
|
+
conflictRecord
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* LWW campo-restrito (para `update`).
|
|
2142
|
+
*
|
|
2143
|
+
* Apenas os campos declarados no payload da mutação são comparados.
|
|
2144
|
+
* Campos não tocados pela mutação são preservados do estado do servidor
|
|
2145
|
+
* (se disponível) ou do estado local (I3 §7.2).
|
|
2146
|
+
*
|
|
2147
|
+
* Resultado:
|
|
2148
|
+
* - Se o servidor for mais novo nos campos conflitantes → `server_wins`
|
|
2149
|
+
* para esses campos; o doc resultante é uma mescla de server (campos
|
|
2150
|
+
* conflitantes) + estado antes da mutação para os demais.
|
|
2151
|
+
* - Se o local for mais recente → `local_wins`; os campos do update são
|
|
2152
|
+
* aplicados sobre o server.
|
|
2153
|
+
* - Campos do server não tocados pelo update sempre preservados → `merged`.
|
|
2154
|
+
*/
|
|
2155
|
+
resolveFieldRestricted(localDoc, serverDoc, pendingMutation) {
|
|
2156
|
+
const mutatedFields = Object.keys(pendingMutation.payload ?? {});
|
|
2157
|
+
if (serverDoc === null) {
|
|
2158
|
+
const base = localDoc?.data ?? {};
|
|
2159
|
+
const resolved = { ...base, ...pendingMutation.payload ?? {} };
|
|
2160
|
+
return { outcome: "no_conflict", resolvedData: resolved, conflictRecord: null };
|
|
2161
|
+
}
|
|
2162
|
+
const serverData = serverDoc.data;
|
|
2163
|
+
if (localDoc === null) {
|
|
2164
|
+
const resolved = { ...serverData, ...pendingMutation.payload ?? {} };
|
|
2165
|
+
return { outcome: "no_conflict", resolvedData: resolved, conflictRecord: null };
|
|
2166
|
+
}
|
|
2167
|
+
const serverNewer = isServerNewer(
|
|
2168
|
+
localDoc.meta.updatedAtServer,
|
|
2169
|
+
serverDoc.meta.updatedAtServer
|
|
2170
|
+
);
|
|
2171
|
+
if (!serverNewer) {
|
|
2172
|
+
const resolved = { ...serverData, ...pendingMutation.payload ?? {} };
|
|
2173
|
+
return { outcome: "local_wins", resolvedData: resolved, conflictRecord: null };
|
|
2174
|
+
}
|
|
2175
|
+
const losingFields = {};
|
|
2176
|
+
for (const field of mutatedFields) {
|
|
2177
|
+
losingFields[field] = localDoc.data[field];
|
|
2178
|
+
}
|
|
2179
|
+
const conflictRecord = {
|
|
2180
|
+
collection: pendingMutation.collection,
|
|
2181
|
+
docId: pendingMutation.docId,
|
|
2182
|
+
mutationId: pendingMutation.mutationId,
|
|
2183
|
+
losingData: losingFields,
|
|
2184
|
+
winningData: serverData,
|
|
2185
|
+
reason: "lww_server_newer",
|
|
2186
|
+
detectedAt: Date.now(),
|
|
2187
|
+
delivered: false
|
|
2188
|
+
};
|
|
2189
|
+
return {
|
|
2190
|
+
outcome: "server_wins",
|
|
2191
|
+
resolvedData: serverData,
|
|
2192
|
+
conflictRecord
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* Resolve uma operação `remove`.
|
|
2197
|
+
*
|
|
2198
|
+
* A remoção sempre vence no LWW (I3 §7.4: "a última operação é a destruição").
|
|
2199
|
+
* Se o servidor modificou o doc depois que o cliente enfileirou o remove, a
|
|
2200
|
+
* remoção ainda prevalece — comportamento documentado.
|
|
2201
|
+
*/
|
|
2202
|
+
resolveRemove(_localDoc, serverDoc, _pendingMutation) {
|
|
2203
|
+
const emptyDoc = {};
|
|
2204
|
+
if (serverDoc !== null && serverDoc.meta.updatedAtServer !== null) {
|
|
2205
|
+
const conflictRecord = {
|
|
2206
|
+
collection: _pendingMutation.collection,
|
|
2207
|
+
docId: _pendingMutation.docId,
|
|
2208
|
+
mutationId: _pendingMutation.mutationId,
|
|
2209
|
+
losingData: serverDoc.data,
|
|
2210
|
+
winningData: {},
|
|
2211
|
+
reason: "lww_server_newer",
|
|
2212
|
+
detectedAt: Date.now(),
|
|
2213
|
+
delivered: false
|
|
2214
|
+
};
|
|
2215
|
+
return { outcome: "local_wins", resolvedData: emptyDoc, conflictRecord };
|
|
2216
|
+
}
|
|
2217
|
+
return { outcome: "local_wins", resolvedData: emptyDoc, conflictRecord: null };
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Resolve conflito com razão de rejeição explícita (servidor retornou
|
|
2221
|
+
* `rejected_permission` ou `rejected_validation`).
|
|
2222
|
+
*
|
|
2223
|
+
* Usado pelo SyncEngine quando o Core rejeita o replay por motivo não-LWW.
|
|
2224
|
+
*/
|
|
2225
|
+
resolveRejected(localDoc, serverDoc, pendingMutation, reason) {
|
|
2226
|
+
const winningData = serverDoc?.data ?? {};
|
|
2227
|
+
const conflictRecord = {
|
|
2228
|
+
collection: pendingMutation.collection,
|
|
2229
|
+
docId: pendingMutation.docId,
|
|
2230
|
+
mutationId: pendingMutation.mutationId,
|
|
2231
|
+
losingData: localDoc.data,
|
|
2232
|
+
winningData,
|
|
2233
|
+
reason,
|
|
2234
|
+
detectedAt: Date.now(),
|
|
2235
|
+
delivered: false
|
|
2236
|
+
};
|
|
2237
|
+
return {
|
|
2238
|
+
outcome: "server_wins",
|
|
2239
|
+
resolvedData: serverDoc?.data ?? localDoc.data,
|
|
2240
|
+
conflictRecord
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
// ─── Static helpers ─────────────────────────────────────────────────────────
|
|
2244
|
+
static resolve(localDoc, serverDoc, pendingMutation) {
|
|
2245
|
+
return new _ConflictResolver().resolve(localDoc, serverDoc, pendingMutation);
|
|
2246
|
+
}
|
|
2247
|
+
};
|
|
2248
|
+
|
|
2249
|
+
// src/db/offline/change-bus.ts
|
|
2250
|
+
function dedupeKey(c) {
|
|
2251
|
+
return `${c.collection}::${c.doc.id}`;
|
|
2252
|
+
}
|
|
2253
|
+
var ChangeBus = class {
|
|
2254
|
+
/** Listeners filtrados por coleção. */
|
|
2255
|
+
_collectionListeners = /* @__PURE__ */ new Map();
|
|
2256
|
+
/** Listeners globais (todas as coleções). */
|
|
2257
|
+
_globalListeners = /* @__PURE__ */ new Set();
|
|
2258
|
+
/**
|
|
2259
|
+
* Subscreve a eventos de uma coleção específica.
|
|
2260
|
+
*
|
|
2261
|
+
* @param collection - Nome da coleção a filtrar.
|
|
2262
|
+
* @param listener - Callback que recebe os changes da coleção.
|
|
2263
|
+
* @returns Função de unsubscribe.
|
|
2264
|
+
*/
|
|
2265
|
+
subscribe(collection, listener) {
|
|
2266
|
+
if (!this._collectionListeners.has(collection)) {
|
|
2267
|
+
this._collectionListeners.set(collection, /* @__PURE__ */ new Set());
|
|
2268
|
+
}
|
|
2269
|
+
this._collectionListeners.get(collection).add(listener);
|
|
2270
|
+
return () => {
|
|
2271
|
+
const set = this._collectionListeners.get(collection);
|
|
2272
|
+
if (set) {
|
|
2273
|
+
set.delete(listener);
|
|
2274
|
+
if (set.size === 0) {
|
|
2275
|
+
this._collectionListeners.delete(collection);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Subscreve a eventos de TODAS as coleções.
|
|
2282
|
+
*
|
|
2283
|
+
* @param listener - Callback que recebe todos os changes, de qualquer coleção.
|
|
2284
|
+
* @returns Função de unsubscribe.
|
|
2285
|
+
*/
|
|
2286
|
+
subscribeAll(listener) {
|
|
2287
|
+
this._globalListeners.add(listener);
|
|
2288
|
+
return () => {
|
|
2289
|
+
this._globalListeners.delete(listener);
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Emite um array de `Change` events para todos os listeners relevantes.
|
|
2294
|
+
*
|
|
2295
|
+
* Agrupa os changes por coleção antes de despachar, para que cada listener
|
|
2296
|
+
* receba apenas os events da sua coleção.
|
|
2297
|
+
*
|
|
2298
|
+
* Deduplicação: events com o mesmo `[collection, id]` dentro do mesmo `emit()`
|
|
2299
|
+
* são compactados: apenas o último do array é mantido (o mais recente vence).
|
|
2300
|
+
* Isso cobre o caso Fase 1 + Fase 2 do SyncEngine emitindo o mesmo doc.
|
|
2301
|
+
*
|
|
2302
|
+
* Erros em listeners individuais são capturados e não interrompem os demais.
|
|
2303
|
+
*/
|
|
2304
|
+
emit(changes) {
|
|
2305
|
+
if (changes.length === 0) return;
|
|
2306
|
+
const deduped = /* @__PURE__ */ new Map();
|
|
2307
|
+
for (const c of changes) {
|
|
2308
|
+
deduped.set(dedupeKey(c), c);
|
|
2309
|
+
}
|
|
2310
|
+
const uniqueChanges = Array.from(deduped.values());
|
|
2311
|
+
const byCollection = /* @__PURE__ */ new Map();
|
|
2312
|
+
for (const c of uniqueChanges) {
|
|
2313
|
+
if (!byCollection.has(c.collection)) {
|
|
2314
|
+
byCollection.set(c.collection, []);
|
|
2315
|
+
}
|
|
2316
|
+
byCollection.get(c.collection).push(c);
|
|
2317
|
+
}
|
|
2318
|
+
for (const [collection, collChanges] of byCollection) {
|
|
2319
|
+
const listeners = this._collectionListeners.get(collection);
|
|
2320
|
+
if (listeners) {
|
|
2321
|
+
for (const listener of listeners) {
|
|
2322
|
+
this._safeCall(listener, collChanges);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
for (const listener of this._globalListeners) {
|
|
2327
|
+
this._safeCall(listener, uniqueChanges);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Número de listeners ativos (útil em testes).
|
|
2332
|
+
*/
|
|
2333
|
+
get listenerCount() {
|
|
2334
|
+
let count = this._globalListeners.size;
|
|
2335
|
+
for (const set of this._collectionListeners.values()) {
|
|
2336
|
+
count += set.size;
|
|
2337
|
+
}
|
|
2338
|
+
return count;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Remove todos os listeners registrados.
|
|
2342
|
+
* Útil para teardown em testes ou no shutdown do SDK.
|
|
2343
|
+
*/
|
|
2344
|
+
clear() {
|
|
2345
|
+
this._collectionListeners.clear();
|
|
2346
|
+
this._globalListeners.clear();
|
|
2347
|
+
}
|
|
2348
|
+
// ─── Privado ────────────────────────────────────────────────────────────────
|
|
2349
|
+
_safeCall(listener, changes) {
|
|
2350
|
+
try {
|
|
2351
|
+
listener(changes);
|
|
2352
|
+
} catch {
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
|
|
2357
|
+
// src/db/offline/connectivity-monitor.ts
|
|
2358
|
+
var ConnectivityMonitor = class {
|
|
2359
|
+
_state;
|
|
2360
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
2361
|
+
_nav;
|
|
2362
|
+
_target;
|
|
2363
|
+
_debounceMs;
|
|
2364
|
+
_debounceTimer = null;
|
|
2365
|
+
_started = false;
|
|
2366
|
+
// Handlers bound para remoção correta em removeEventListener
|
|
2367
|
+
_onOnline;
|
|
2368
|
+
_onOffline;
|
|
2369
|
+
constructor(options = {}) {
|
|
2370
|
+
this._nav = options.navigator ?? (typeof navigator !== "undefined" ? navigator : void 0);
|
|
2371
|
+
this._target = options.eventTarget ?? (typeof window !== "undefined" ? window : void 0);
|
|
2372
|
+
this._debounceMs = options.debounceMs ?? 300;
|
|
2373
|
+
this._state = this._nav?.onLine === false ? "offline" : "online";
|
|
2374
|
+
this._onOnline = () => this._handleNativeEvent("online");
|
|
2375
|
+
this._onOffline = () => this._handleNativeEvent("offline");
|
|
2376
|
+
}
|
|
2377
|
+
// ─── Ciclo de vida ──────────────────────────────────────────────────────────
|
|
2378
|
+
/**
|
|
2379
|
+
* Inicia a escuta de eventos. Deve ser chamado após instanciar.
|
|
2380
|
+
* Sem efeito se já foi iniciado.
|
|
2381
|
+
*/
|
|
2382
|
+
start() {
|
|
2383
|
+
if (this._started) return;
|
|
2384
|
+
this._started = true;
|
|
2385
|
+
this._target?.addEventListener("online", this._onOnline);
|
|
2386
|
+
this._target?.addEventListener("offline", this._onOffline);
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Para a escuta e remove todos os listeners.
|
|
2390
|
+
* Deve ser chamado no shutdown para evitar memory leaks.
|
|
2391
|
+
*/
|
|
2392
|
+
destroy() {
|
|
2393
|
+
this._started = false;
|
|
2394
|
+
this._target?.removeEventListener("online", this._onOnline);
|
|
2395
|
+
this._target?.removeEventListener("offline", this._onOffline);
|
|
2396
|
+
if (this._debounceTimer !== null) {
|
|
2397
|
+
clearTimeout(this._debounceTimer);
|
|
2398
|
+
this._debounceTimer = null;
|
|
2399
|
+
}
|
|
2400
|
+
this._listeners.clear();
|
|
2401
|
+
}
|
|
2402
|
+
// ─── Estado atual ───────────────────────────────────────────────────────────
|
|
2403
|
+
/**
|
|
2404
|
+
* Retorna o estado de conectividade atual.
|
|
2405
|
+
*/
|
|
2406
|
+
getState() {
|
|
2407
|
+
return this._state;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* True se o estado atual é `'online'`.
|
|
2411
|
+
*/
|
|
2412
|
+
get isOnline() {
|
|
2413
|
+
return this._state === "online";
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* True se o estado atual é `'offline'`.
|
|
2417
|
+
*/
|
|
2418
|
+
get isOffline() {
|
|
2419
|
+
return this._state === "offline";
|
|
2420
|
+
}
|
|
2421
|
+
// ─── Subscrições ────────────────────────────────────────────────────────────
|
|
2422
|
+
/**
|
|
2423
|
+
* Subscreve a mudanças de estado de conectividade.
|
|
2424
|
+
*
|
|
2425
|
+
* @param listener - Callback chamado ao mudar o estado.
|
|
2426
|
+
* @returns Função de unsubscribe (sem memory leak).
|
|
2427
|
+
*/
|
|
2428
|
+
subscribe(listener) {
|
|
2429
|
+
this._listeners.add(listener);
|
|
2430
|
+
return () => {
|
|
2431
|
+
this._listeners.delete(listener);
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Número de listeners ativos (útil em testes).
|
|
2436
|
+
*/
|
|
2437
|
+
get listenerCount() {
|
|
2438
|
+
return this._listeners.size;
|
|
2439
|
+
}
|
|
2440
|
+
// ─── Força de estado (para testes e heartbeat externo) ─────────────────────
|
|
2441
|
+
/**
|
|
2442
|
+
* Força o estado para um valor específico, ignorando eventos nativos.
|
|
2443
|
+
* Útil para o SyncEngine injetar o resultado de um heartbeat real ao Core.
|
|
2444
|
+
* Respeita o debounce.
|
|
2445
|
+
*/
|
|
2446
|
+
forceState(state) {
|
|
2447
|
+
this._scheduleTransition(state);
|
|
2448
|
+
}
|
|
2449
|
+
// ─── Privado ────────────────────────────────────────────────────────────────
|
|
2450
|
+
_handleNativeEvent(type) {
|
|
2451
|
+
const newState = type === "online" ? "online" : "offline";
|
|
2452
|
+
this._scheduleTransition(newState);
|
|
2453
|
+
}
|
|
2454
|
+
_scheduleTransition(newState) {
|
|
2455
|
+
if (this._debounceTimer !== null) {
|
|
2456
|
+
clearTimeout(this._debounceTimer);
|
|
2457
|
+
this._debounceTimer = null;
|
|
2458
|
+
}
|
|
2459
|
+
if (this._debounceMs <= 0) {
|
|
2460
|
+
this._applyTransition(newState);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
this._debounceTimer = setTimeout(() => {
|
|
2464
|
+
this._debounceTimer = null;
|
|
2465
|
+
this._applyTransition(newState);
|
|
2466
|
+
}, this._debounceMs);
|
|
2467
|
+
}
|
|
2468
|
+
_applyTransition(newState) {
|
|
2469
|
+
if (newState === this._state) return;
|
|
2470
|
+
this._state = newState;
|
|
2471
|
+
this._notifyListeners(newState);
|
|
2472
|
+
}
|
|
2473
|
+
_notifyListeners(state) {
|
|
2474
|
+
for (const listener of this._listeners) {
|
|
2475
|
+
try {
|
|
2476
|
+
listener(state);
|
|
2477
|
+
} catch {
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
};
|
|
2482
|
+
|
|
2483
|
+
// src/db/offline/tab-coordinator.ts
|
|
2484
|
+
var TabCoordinator = class {
|
|
2485
|
+
_lockName;
|
|
2486
|
+
_channelName;
|
|
2487
|
+
_lockManager;
|
|
2488
|
+
_channel;
|
|
2489
|
+
_channelFactory;
|
|
2490
|
+
/** ID único desta aba (para debug e `sync_state`). */
|
|
2491
|
+
_tabId;
|
|
2492
|
+
/** Estado de papel atual. */
|
|
2493
|
+
_role = "follower";
|
|
2494
|
+
/** Promise resolve que libera o lock perpétuo. */
|
|
2495
|
+
_releaseLock = null;
|
|
2496
|
+
/** `true` se destroy() já foi chamado. */
|
|
2497
|
+
_destroyed = false;
|
|
2498
|
+
/** `true` se start() já foi chamado. */
|
|
2499
|
+
_started = false;
|
|
2500
|
+
/** Listeners de mudança de papel. */
|
|
2501
|
+
_roleListeners = /* @__PURE__ */ new Set();
|
|
2502
|
+
/** Listeners de mensagem recebida. */
|
|
2503
|
+
_messageListeners = /* @__PURE__ */ new Set();
|
|
2504
|
+
constructor(options) {
|
|
2505
|
+
this._lockName = options.lockName;
|
|
2506
|
+
this._channelName = options.channelName;
|
|
2507
|
+
this._tabId = `tab-${Math.random().toString(36).slice(2, 10)}-${Date.now()}`;
|
|
2508
|
+
this._lockManager = options.lockManager !== void 0 ? options.lockManager : typeof navigator !== "undefined" && "locks" in navigator && navigator.locks != null ? navigator.locks : void 0;
|
|
2509
|
+
if (options.broadcastChannel !== void 0) {
|
|
2510
|
+
this._channel = options.broadcastChannel;
|
|
2511
|
+
} else if (typeof BroadcastChannel !== "undefined") {
|
|
2512
|
+
const channelName = this._channelName;
|
|
2513
|
+
this._channelFactory = () => {
|
|
2514
|
+
const bc = new BroadcastChannel(channelName);
|
|
2515
|
+
const wrapper = {
|
|
2516
|
+
get onmessage() {
|
|
2517
|
+
return bc.onmessage;
|
|
2518
|
+
},
|
|
2519
|
+
set onmessage(cb) {
|
|
2520
|
+
bc.onmessage = cb ? (event) => cb(event.data) : null;
|
|
2521
|
+
},
|
|
2522
|
+
postMessage: (msg) => bc.postMessage(msg),
|
|
2523
|
+
close: () => bc.close()
|
|
2524
|
+
};
|
|
2525
|
+
return wrapper;
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
// ─── Ciclo de vida ───────────────────────────────────────────────────────────
|
|
2530
|
+
/**
|
|
2531
|
+
* Inicia o processo de eleição de líder.
|
|
2532
|
+
* Idempotente: sem efeito se já foi chamado.
|
|
2533
|
+
*/
|
|
2534
|
+
start() {
|
|
2535
|
+
if (this._started || this._destroyed) return;
|
|
2536
|
+
this._started = true;
|
|
2537
|
+
if (!this._channel && this._channelFactory) {
|
|
2538
|
+
this._channel = this._channelFactory();
|
|
2539
|
+
}
|
|
2540
|
+
if (this._channel) {
|
|
2541
|
+
this._channel.onmessage = (msg) => {
|
|
2542
|
+
this._handleIncomingMessage(msg);
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
if (this._lockManager) {
|
|
2546
|
+
this._electWithLocks();
|
|
2547
|
+
} else {
|
|
2548
|
+
this._becomeLeader();
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Para o TabCoordinator: libera o lock, fecha o canal.
|
|
2553
|
+
* Idempotente: pode ser chamado múltiplas vezes.
|
|
2554
|
+
*/
|
|
2555
|
+
destroy() {
|
|
2556
|
+
if (this._destroyed) return;
|
|
2557
|
+
this._destroyed = true;
|
|
2558
|
+
this._started = false;
|
|
2559
|
+
if (this._releaseLock) {
|
|
2560
|
+
this._releaseLock();
|
|
2561
|
+
this._releaseLock = null;
|
|
2562
|
+
}
|
|
2563
|
+
if (this._channel) {
|
|
2564
|
+
this._channel.onmessage = null;
|
|
2565
|
+
this._channel.close();
|
|
2566
|
+
this._channel = void 0;
|
|
2567
|
+
}
|
|
2568
|
+
this._role = "follower";
|
|
2569
|
+
this._roleListeners.clear();
|
|
2570
|
+
this._messageListeners.clear();
|
|
2571
|
+
}
|
|
2572
|
+
// ─── Estado ──────────────────────────────────────────────────────────────────
|
|
2573
|
+
/**
|
|
2574
|
+
* Retorna `true` se esta aba é atualmente a líder.
|
|
2575
|
+
*/
|
|
2576
|
+
isLeader() {
|
|
2577
|
+
return !this._destroyed && this._role === "leader";
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Retorna o papel atual desta aba.
|
|
2581
|
+
*/
|
|
2582
|
+
getRole() {
|
|
2583
|
+
return this._destroyed ? "follower" : this._role;
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* ID único desta aba (para diagnóstico).
|
|
2587
|
+
*/
|
|
2588
|
+
get tabId() {
|
|
2589
|
+
return this._tabId;
|
|
2590
|
+
}
|
|
2591
|
+
// ─── Subscrições ─────────────────────────────────────────────────────────────
|
|
2592
|
+
/**
|
|
2593
|
+
* Registra um listener para mudanças de papel (`'leader'` | `'follower'`).
|
|
2594
|
+
*
|
|
2595
|
+
* @returns Função de unsubscribe.
|
|
2596
|
+
*/
|
|
2597
|
+
onRoleChange(listener) {
|
|
2598
|
+
this._roleListeners.add(listener);
|
|
2599
|
+
return () => {
|
|
2600
|
+
this._roleListeners.delete(listener);
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Registra um listener para mensagens recebidas de outras abas via
|
|
2605
|
+
* BroadcastChannel. O remetente NÃO recebe suas próprias mensagens.
|
|
2606
|
+
*
|
|
2607
|
+
* @returns Função de unsubscribe.
|
|
2608
|
+
*/
|
|
2609
|
+
onMessage(listener) {
|
|
2610
|
+
this._messageListeners.add(listener);
|
|
2611
|
+
return () => {
|
|
2612
|
+
this._messageListeners.delete(listener);
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
// ─── Comunicação ─────────────────────────────────────────────────────────────
|
|
2616
|
+
/**
|
|
2617
|
+
* Transmite uma mensagem para todas as outras abas via BroadcastChannel.
|
|
2618
|
+
* Sem efeito se o canal não estiver disponível ou o coordinator foi destroyed.
|
|
2619
|
+
*/
|
|
2620
|
+
broadcast(msg) {
|
|
2621
|
+
if (this._destroyed) return;
|
|
2622
|
+
try {
|
|
2623
|
+
this._channel?.postMessage(msg);
|
|
2624
|
+
} catch {
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
// ─── Privado — eleição ───────────────────────────────────────────────────────
|
|
2628
|
+
/**
|
|
2629
|
+
* Inicia a disputa pelo lock Web Locks.
|
|
2630
|
+
*
|
|
2631
|
+
* `navigator.locks.request` com uma Promise-interna que nunca resolve:
|
|
2632
|
+
* enquanto a aba vive e o lock está ativo, a callback não retorna.
|
|
2633
|
+
* Ao chamar `destroy()` a Promise interna resolve → lock liberado.
|
|
2634
|
+
*/
|
|
2635
|
+
_electWithLocks() {
|
|
2636
|
+
const lockPromise = new Promise((resolve) => {
|
|
2637
|
+
this._releaseLock = resolve;
|
|
2638
|
+
});
|
|
2639
|
+
Promise.resolve().then(() => {
|
|
2640
|
+
if (!this._destroyed && this._role === "follower") {
|
|
2641
|
+
this._notifyRoleChange("follower");
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
this._lockManager.request(this._lockName, (_lock) => {
|
|
2645
|
+
if (this._destroyed) {
|
|
2646
|
+
return Promise.resolve();
|
|
2647
|
+
}
|
|
2648
|
+
this._becomeLeader();
|
|
2649
|
+
return lockPromise;
|
|
2650
|
+
}).catch(() => {
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Transição para o papel de líder.
|
|
2655
|
+
*/
|
|
2656
|
+
_becomeLeader() {
|
|
2657
|
+
if (this._destroyed) return;
|
|
2658
|
+
this._setRole("leader");
|
|
2659
|
+
Promise.resolve().then(() => {
|
|
2660
|
+
if (!this._destroyed) this._broadcastSyncState();
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Muda o papel e notifica os listeners.
|
|
2665
|
+
*/
|
|
2666
|
+
_setRole(role) {
|
|
2667
|
+
const previous = this._role;
|
|
2668
|
+
this._role = role;
|
|
2669
|
+
if (previous !== role) {
|
|
2670
|
+
this._notifyRoleChange(role);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Transmite o estado de sync atual (chamado ao se tornar líder).
|
|
2675
|
+
*/
|
|
2676
|
+
_broadcastSyncState() {
|
|
2677
|
+
this.broadcast({
|
|
2678
|
+
type: "sync_state",
|
|
2679
|
+
leaderTabId: this._tabId,
|
|
2680
|
+
syncing: false,
|
|
2681
|
+
pendingWrites: 0
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
// ─── Privado — mensagens ─────────────────────────────────────────────────────
|
|
2685
|
+
/**
|
|
2686
|
+
* Processa mensagem recebida de outra aba.
|
|
2687
|
+
* Mensagens de tipo desconhecido são ignoradas silenciosamente.
|
|
2688
|
+
*/
|
|
2689
|
+
_handleIncomingMessage(msg) {
|
|
2690
|
+
if (this._destroyed) return;
|
|
2691
|
+
if (!msg || typeof msg.type !== "string") return;
|
|
2692
|
+
for (const listener of this._messageListeners) {
|
|
2693
|
+
try {
|
|
2694
|
+
listener(msg);
|
|
2695
|
+
} catch {
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
// ─── Privado — notificações ───────────────────────────────────────────────────
|
|
2700
|
+
_notifyRoleChange(role) {
|
|
2701
|
+
for (const listener of this._roleListeners) {
|
|
2702
|
+
try {
|
|
2703
|
+
listener(role);
|
|
2704
|
+
} catch {
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
};
|
|
2709
|
+
|
|
2710
|
+
// src/db/collection-ref.ts
|
|
2711
|
+
var COLL_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
2712
|
+
function assertValidCollection(name) {
|
|
2713
|
+
if (!COLL_RE.test(name)) {
|
|
2714
|
+
throw new NeetruDbError(
|
|
2715
|
+
"db_invalid_query",
|
|
2716
|
+
`Nome de cole\xE7\xE3o inv\xE1lido: "${name}". Deve seguir o padr\xE3o ${COLL_RE}.`
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
function assertValidId(id, label = "id") {
|
|
2721
|
+
if (!id || typeof id !== "string" || id.trim() === "") {
|
|
2722
|
+
throw new NeetruDbError(
|
|
2723
|
+
"db_invalid_query",
|
|
2724
|
+
`${label} \xE9 obrigat\xF3rio e deve ser uma string n\xE3o-vazia.`
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
function toQueryDescriptor(query) {
|
|
2729
|
+
const desc = {};
|
|
2730
|
+
if (query?.where) {
|
|
2731
|
+
desc.where = query.where.map((f) => ({
|
|
2732
|
+
field: f.field,
|
|
2733
|
+
op: f.op,
|
|
2734
|
+
value: f.value
|
|
2735
|
+
}));
|
|
2736
|
+
}
|
|
2737
|
+
if (query?.orderBy) {
|
|
2738
|
+
desc.orderBy = {
|
|
2739
|
+
field: query.orderBy.field,
|
|
2740
|
+
direction: query.orderBy.direction
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
if (query?.limit !== void 0) {
|
|
2744
|
+
desc.limit = query.limit;
|
|
2745
|
+
}
|
|
2746
|
+
if (query?.cursor) {
|
|
2747
|
+
try {
|
|
2748
|
+
const cursorObj = JSON.parse(atob(query.cursor));
|
|
2749
|
+
desc.cursor = { type: cursorObj.type ?? "startAfter", docId: cursorObj.docId };
|
|
2750
|
+
} catch {
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
return desc;
|
|
2754
|
+
}
|
|
2755
|
+
function buildNextCursor(docs, limit) {
|
|
2756
|
+
if (docs.length < limit) return null;
|
|
2757
|
+
const lastDoc = docs[docs.length - 1];
|
|
2758
|
+
if (!lastDoc) return null;
|
|
2759
|
+
return btoa(JSON.stringify({ type: "startAfter", docId: lastDoc.id }));
|
|
2760
|
+
}
|
|
2761
|
+
var DbCollectionRefImpl = class {
|
|
2762
|
+
constructor(_collection, _store, _queue, _bus, _engine, _connectivity) {
|
|
2763
|
+
this._collection = _collection;
|
|
2764
|
+
this._store = _store;
|
|
2765
|
+
this._queue = _queue;
|
|
2766
|
+
this._bus = _bus;
|
|
2767
|
+
this._engine = _engine;
|
|
2768
|
+
this._connectivity = _connectivity;
|
|
2769
|
+
}
|
|
2770
|
+
_collection;
|
|
2771
|
+
_store;
|
|
2772
|
+
_queue;
|
|
2773
|
+
_bus;
|
|
2774
|
+
_engine;
|
|
2775
|
+
_connectivity;
|
|
2776
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
2777
|
+
/** Verifica se há mutações pendentes para esta coleção. */
|
|
2778
|
+
async _hasPendingWrites() {
|
|
2779
|
+
const pending = await this._queue.listPending();
|
|
2780
|
+
return pending.some((m) => m.collection === this._collection);
|
|
2781
|
+
}
|
|
2782
|
+
/** Verifica se há mutações pendentes para um doc específico. */
|
|
2783
|
+
async _docHasPendingWrites(id) {
|
|
2784
|
+
const pending = await this._queue.listPending();
|
|
2785
|
+
return pending.some((m) => m.collection === this._collection && m.docId === id);
|
|
2786
|
+
}
|
|
2787
|
+
_isOnline() {
|
|
2788
|
+
return this._connectivity.isOnline;
|
|
2789
|
+
}
|
|
2790
|
+
/** Constrói um DbGetResult para um doc específico, ou null se tombstoned/ausente. */
|
|
2791
|
+
async _buildGetResult(id) {
|
|
2792
|
+
const doc = await this._store.getDoc(this._collection, id);
|
|
2793
|
+
if (!doc || doc.meta.deleted) return null;
|
|
2794
|
+
const hasPending = await this._docHasPendingWrites(id);
|
|
2795
|
+
const isOnline = this._isOnline();
|
|
2796
|
+
const syncState = this._engine.getSyncState();
|
|
2797
|
+
const staleGet = !isOnline || syncState.status === "offline";
|
|
2798
|
+
const fromCacheGet = hasPending || syncState.lastSyncedAt === null || staleGet;
|
|
2799
|
+
return {
|
|
2800
|
+
docs: [{ id: doc.id, data: doc.data }],
|
|
2801
|
+
fromCache: fromCacheGet,
|
|
2802
|
+
stale: staleGet,
|
|
2803
|
+
hasPendingWrites: hasPending,
|
|
2804
|
+
changes: []
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
/** Constrói um DbListResult para a coleção com query opcional. */
|
|
2808
|
+
async _buildListResult(q) {
|
|
2809
|
+
const desc = toQueryDescriptor(q);
|
|
2810
|
+
const effectiveLimit = desc.limit ?? 20;
|
|
2811
|
+
const result = await this._store.listDocs(this._collection, desc);
|
|
2812
|
+
const hasPending = await this._hasPendingWrites();
|
|
2813
|
+
const isOnline = this._isOnline();
|
|
2814
|
+
const syncState = this._engine.getSyncState();
|
|
2815
|
+
const nextCursor = buildNextCursor(result.docs, effectiveLimit);
|
|
2816
|
+
const stale = !isOnline || syncState.status === "offline";
|
|
2817
|
+
const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
|
|
2818
|
+
return {
|
|
2819
|
+
docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
|
|
2820
|
+
nextCursor,
|
|
2821
|
+
fromCache,
|
|
2822
|
+
stale,
|
|
2823
|
+
hasPendingWrites: hasPending,
|
|
2824
|
+
changes: []
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
2828
|
+
async get(id) {
|
|
2829
|
+
assertValidId(id);
|
|
2830
|
+
return this._buildGetResult(id);
|
|
2831
|
+
}
|
|
2832
|
+
async list(q) {
|
|
2833
|
+
return this._buildListResult(q);
|
|
2834
|
+
}
|
|
2835
|
+
async add(data) {
|
|
2836
|
+
const mutation = await this._queue.enqueue({
|
|
2837
|
+
collection: this._collection,
|
|
2838
|
+
op: "add",
|
|
2839
|
+
payload: data,
|
|
2840
|
+
baseVersion: null,
|
|
2841
|
+
batchId: null
|
|
2842
|
+
});
|
|
2843
|
+
const docId = mutation.docId;
|
|
2844
|
+
await this._store.putDoc({
|
|
2845
|
+
collection: this._collection,
|
|
2846
|
+
id: docId,
|
|
2847
|
+
data,
|
|
2848
|
+
meta: {
|
|
2849
|
+
serverVersion: null,
|
|
2850
|
+
updatedAtServer: null,
|
|
2851
|
+
updatedAtLocal: Date.now(),
|
|
2852
|
+
state: "pending",
|
|
2853
|
+
pendingMutationIds: [mutation.mutationId],
|
|
2854
|
+
deleted: false,
|
|
2855
|
+
ownerId: null
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
this._bus.emit([{
|
|
2859
|
+
type: "added",
|
|
2860
|
+
collection: this._collection,
|
|
2861
|
+
doc: { id: docId, data }
|
|
2862
|
+
}]);
|
|
2863
|
+
return { ok: true, id: docId };
|
|
2864
|
+
}
|
|
2865
|
+
async set(id, data) {
|
|
2866
|
+
assertValidId(id);
|
|
2867
|
+
const existing = await this._store.getDoc(this._collection, id);
|
|
2868
|
+
const mutation = await this._queue.enqueue({
|
|
2869
|
+
collection: this._collection,
|
|
2870
|
+
docId: id,
|
|
2871
|
+
op: "set",
|
|
2872
|
+
payload: data,
|
|
2873
|
+
baseVersion: existing?.meta.serverVersion ?? null,
|
|
2874
|
+
batchId: null
|
|
2875
|
+
});
|
|
2876
|
+
await this._store.putDoc({
|
|
2877
|
+
collection: this._collection,
|
|
2878
|
+
id,
|
|
2879
|
+
data,
|
|
2880
|
+
meta: {
|
|
2881
|
+
serverVersion: existing?.meta.serverVersion ?? null,
|
|
2882
|
+
updatedAtServer: existing?.meta.updatedAtServer ?? null,
|
|
2883
|
+
updatedAtLocal: Date.now(),
|
|
2884
|
+
state: "pending",
|
|
2885
|
+
pendingMutationIds: [mutation.mutationId],
|
|
2886
|
+
deleted: false,
|
|
2887
|
+
ownerId: null
|
|
2888
|
+
}
|
|
2889
|
+
});
|
|
2890
|
+
this._bus.emit([{
|
|
2891
|
+
type: existing && !existing.meta.deleted ? "modified" : "added",
|
|
2892
|
+
collection: this._collection,
|
|
2893
|
+
doc: { id, data }
|
|
2894
|
+
}]);
|
|
2895
|
+
return { ok: true };
|
|
2896
|
+
}
|
|
2897
|
+
async update(id, data) {
|
|
2898
|
+
assertValidId(id);
|
|
2899
|
+
const existing = await this._store.getDoc(this._collection, id);
|
|
2900
|
+
const mergedData = existing?.meta.deleted ? { ...data } : { ...existing?.data ?? {}, ...data };
|
|
2901
|
+
const mutation = await this._queue.enqueue({
|
|
2902
|
+
collection: this._collection,
|
|
2903
|
+
docId: id,
|
|
2904
|
+
op: "update",
|
|
2905
|
+
payload: data,
|
|
2906
|
+
baseVersion: existing?.meta.serverVersion ?? null,
|
|
2907
|
+
batchId: null
|
|
2908
|
+
});
|
|
2909
|
+
await this._store.putDoc({
|
|
2910
|
+
collection: this._collection,
|
|
2911
|
+
id,
|
|
2912
|
+
data: mergedData,
|
|
2913
|
+
meta: {
|
|
2914
|
+
serverVersion: existing?.meta.serverVersion ?? null,
|
|
2915
|
+
updatedAtServer: existing?.meta.updatedAtServer ?? null,
|
|
2916
|
+
updatedAtLocal: Date.now(),
|
|
2917
|
+
state: "pending",
|
|
2918
|
+
pendingMutationIds: [
|
|
2919
|
+
...existing?.meta.pendingMutationIds ?? [],
|
|
2920
|
+
mutation.mutationId
|
|
2921
|
+
],
|
|
2922
|
+
deleted: false,
|
|
2923
|
+
ownerId: existing?.meta.ownerId ?? null
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
this._bus.emit([{
|
|
2927
|
+
type: "modified",
|
|
2928
|
+
collection: this._collection,
|
|
2929
|
+
doc: { id, data: mergedData }
|
|
2930
|
+
}]);
|
|
2931
|
+
return { ok: true };
|
|
2932
|
+
}
|
|
2933
|
+
async remove(id) {
|
|
2934
|
+
assertValidId(id);
|
|
2935
|
+
const existing = await this._store.getDoc(this._collection, id);
|
|
2936
|
+
if (!existing || existing.meta.deleted) ;
|
|
2937
|
+
await this._queue.enqueue({
|
|
2938
|
+
collection: this._collection,
|
|
2939
|
+
docId: id,
|
|
2940
|
+
op: "remove",
|
|
2941
|
+
payload: null,
|
|
2942
|
+
baseVersion: existing?.meta.serverVersion ?? null,
|
|
2943
|
+
batchId: null
|
|
2944
|
+
});
|
|
2945
|
+
await this._store.deleteDoc(this._collection, id);
|
|
2946
|
+
if (existing && !existing.meta.deleted) {
|
|
2947
|
+
this._bus.emit([{
|
|
2948
|
+
type: "removed",
|
|
2949
|
+
collection: this._collection,
|
|
2950
|
+
doc: { id, data: existing.data }
|
|
2951
|
+
}]);
|
|
2952
|
+
}
|
|
2953
|
+
return { ok: true };
|
|
2954
|
+
}
|
|
2955
|
+
async batch(ops) {
|
|
2956
|
+
const batchId = `batch-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2957
|
+
const busChanges = [];
|
|
2958
|
+
for (const op of ops) {
|
|
2959
|
+
const collection = this._collection;
|
|
2960
|
+
if (op.kind === "add") {
|
|
2961
|
+
const data = op.data;
|
|
2962
|
+
const mutation = await this._queue.enqueue({
|
|
2963
|
+
collection,
|
|
2964
|
+
op: "add",
|
|
2965
|
+
payload: data,
|
|
2966
|
+
baseVersion: null,
|
|
2967
|
+
batchId
|
|
2968
|
+
});
|
|
2969
|
+
const docId = mutation.docId;
|
|
2970
|
+
await this._store.putDoc({
|
|
2971
|
+
collection,
|
|
2972
|
+
id: docId,
|
|
2973
|
+
data,
|
|
2974
|
+
meta: {
|
|
2975
|
+
serverVersion: null,
|
|
2976
|
+
updatedAtServer: null,
|
|
2977
|
+
updatedAtLocal: Date.now(),
|
|
2978
|
+
state: "pending",
|
|
2979
|
+
pendingMutationIds: [mutation.mutationId],
|
|
2980
|
+
deleted: false,
|
|
2981
|
+
ownerId: null
|
|
2982
|
+
}
|
|
2983
|
+
});
|
|
2984
|
+
busChanges.push({ type: "added", collection, doc: { id: docId, data } });
|
|
2985
|
+
} else if (op.kind === "set") {
|
|
2986
|
+
const id = op.id;
|
|
2987
|
+
const data = op.data;
|
|
2988
|
+
const existing = await this._store.getDoc(collection, id);
|
|
2989
|
+
const mutation = await this._queue.enqueue({
|
|
2990
|
+
collection,
|
|
2991
|
+
docId: id,
|
|
2992
|
+
op: "set",
|
|
2993
|
+
payload: data,
|
|
2994
|
+
baseVersion: existing?.meta.serverVersion ?? null,
|
|
2995
|
+
batchId
|
|
2996
|
+
});
|
|
2997
|
+
await this._store.putDoc({
|
|
2998
|
+
collection,
|
|
2999
|
+
id,
|
|
3000
|
+
data,
|
|
3001
|
+
meta: {
|
|
3002
|
+
serverVersion: existing?.meta.serverVersion ?? null,
|
|
3003
|
+
updatedAtServer: existing?.meta.updatedAtServer ?? null,
|
|
3004
|
+
updatedAtLocal: Date.now(),
|
|
3005
|
+
state: "pending",
|
|
3006
|
+
pendingMutationIds: [mutation.mutationId],
|
|
3007
|
+
deleted: false,
|
|
3008
|
+
ownerId: null
|
|
3009
|
+
}
|
|
3010
|
+
});
|
|
3011
|
+
busChanges.push({
|
|
3012
|
+
type: existing && !existing.meta.deleted ? "modified" : "added",
|
|
3013
|
+
collection,
|
|
3014
|
+
doc: { id, data }
|
|
3015
|
+
});
|
|
3016
|
+
} else if (op.kind === "update") {
|
|
3017
|
+
const id = op.id;
|
|
3018
|
+
const data = op.data;
|
|
3019
|
+
const existing = await this._store.getDoc(collection, id);
|
|
3020
|
+
const merged = { ...existing?.data ?? {}, ...data };
|
|
3021
|
+
const mutation = await this._queue.enqueue({
|
|
3022
|
+
collection,
|
|
3023
|
+
docId: id,
|
|
3024
|
+
op: "update",
|
|
3025
|
+
payload: data,
|
|
3026
|
+
baseVersion: existing?.meta.serverVersion ?? null,
|
|
3027
|
+
batchId
|
|
3028
|
+
});
|
|
3029
|
+
await this._store.putDoc({
|
|
3030
|
+
collection,
|
|
3031
|
+
id,
|
|
3032
|
+
data: merged,
|
|
3033
|
+
meta: {
|
|
3034
|
+
serverVersion: existing?.meta.serverVersion ?? null,
|
|
3035
|
+
updatedAtServer: existing?.meta.updatedAtServer ?? null,
|
|
3036
|
+
updatedAtLocal: Date.now(),
|
|
3037
|
+
state: "pending",
|
|
3038
|
+
pendingMutationIds: [
|
|
3039
|
+
...existing?.meta.pendingMutationIds ?? [],
|
|
3040
|
+
mutation.mutationId
|
|
3041
|
+
],
|
|
3042
|
+
deleted: false,
|
|
3043
|
+
ownerId: existing?.meta.ownerId ?? null
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
busChanges.push({ type: "modified", collection, doc: { id, data: merged } });
|
|
3047
|
+
} else if (op.kind === "remove") {
|
|
3048
|
+
const id = op.id;
|
|
3049
|
+
const existing = await this._store.getDoc(collection, id);
|
|
3050
|
+
await this._queue.enqueue({
|
|
3051
|
+
collection,
|
|
3052
|
+
docId: id,
|
|
3053
|
+
op: "remove",
|
|
3054
|
+
payload: null,
|
|
3055
|
+
baseVersion: existing?.meta.serverVersion ?? null,
|
|
3056
|
+
batchId
|
|
3057
|
+
});
|
|
3058
|
+
await this._store.deleteDoc(collection, id);
|
|
3059
|
+
if (existing && !existing.meta.deleted) {
|
|
3060
|
+
busChanges.push({ type: "removed", collection, doc: { id, data: existing.data } });
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
if (busChanges.length > 0) {
|
|
3065
|
+
this._bus.emit(busChanges);
|
|
3066
|
+
}
|
|
3067
|
+
return { ok: true };
|
|
3068
|
+
}
|
|
3069
|
+
// ── Realtime ─────────────────────────────────────────────────────────────────
|
|
3070
|
+
onDoc(id, cb) {
|
|
3071
|
+
assertValidId(id);
|
|
3072
|
+
this._buildGetResult(id).then((r) => {
|
|
3073
|
+
cb(r ? r.docs[0]?.data ?? null : null);
|
|
3074
|
+
}).catch(() => cb(null));
|
|
3075
|
+
return this._bus.subscribe(this._collection, (changes) => {
|
|
3076
|
+
const relevant = changes.find((c) => c.doc.id === id);
|
|
3077
|
+
if (!relevant) return;
|
|
3078
|
+
if (relevant.type === "removed") {
|
|
3079
|
+
cb(null);
|
|
3080
|
+
} else {
|
|
3081
|
+
cb(relevant.doc.data);
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
onSnapshot(q, cb) {
|
|
3086
|
+
this._buildListResult(q).then((snap) => cb(snap)).catch(() => {
|
|
3087
|
+
});
|
|
3088
|
+
const unsubBus = this._bus.subscribe(this._collection, async (_changes) => {
|
|
3089
|
+
try {
|
|
3090
|
+
const snap = await this._buildListResult(q);
|
|
3091
|
+
const delta = _changes.map((c) => ({
|
|
3092
|
+
type: c.type,
|
|
3093
|
+
doc: { id: c.doc.id, data: c.doc.data }
|
|
3094
|
+
}));
|
|
3095
|
+
cb({ ...snap, changes: delta });
|
|
3096
|
+
} catch {
|
|
3097
|
+
}
|
|
3098
|
+
});
|
|
3099
|
+
const unsubSync = this._engine.onSyncStateChange(async () => {
|
|
3100
|
+
try {
|
|
3101
|
+
const snap = await this._buildListResult(q);
|
|
3102
|
+
cb(snap);
|
|
3103
|
+
} catch {
|
|
3104
|
+
}
|
|
3105
|
+
});
|
|
3106
|
+
let unsubRealtime;
|
|
3107
|
+
if (typeof this._engine.transport.subscribeCollection === "function") {
|
|
3108
|
+
unsubRealtime = this._engine.transport.subscribeCollection(
|
|
3109
|
+
this._collection,
|
|
3110
|
+
async (remoteChanges, needsResync) => {
|
|
3111
|
+
if (needsResync) {
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
for (const rc of remoteChanges) {
|
|
3115
|
+
if (rc.type === "removed" || rc.data === null) {
|
|
3116
|
+
await this._store.deleteDoc(this._collection, rc.docId);
|
|
3117
|
+
this._bus.emit([{
|
|
3118
|
+
type: "removed",
|
|
3119
|
+
collection: this._collection,
|
|
3120
|
+
doc: { id: rc.docId, data: {} }
|
|
3121
|
+
}]);
|
|
3122
|
+
} else {
|
|
3123
|
+
await this._store.putDoc({
|
|
3124
|
+
collection: this._collection,
|
|
3125
|
+
id: rc.docId,
|
|
3126
|
+
data: rc.data,
|
|
3127
|
+
meta: {
|
|
3128
|
+
serverVersion: `rt_${Date.now()}`,
|
|
3129
|
+
updatedAtServer: Date.now(),
|
|
3130
|
+
updatedAtLocal: Date.now(),
|
|
3131
|
+
state: "synced",
|
|
3132
|
+
pendingMutationIds: [],
|
|
3133
|
+
deleted: false,
|
|
3134
|
+
ownerId: null
|
|
3135
|
+
}
|
|
3136
|
+
});
|
|
3137
|
+
this._bus.emit([{
|
|
3138
|
+
type: rc.type,
|
|
3139
|
+
collection: this._collection,
|
|
3140
|
+
doc: { id: rc.docId, data: rc.data }
|
|
3141
|
+
}]);
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
return () => {
|
|
3148
|
+
unsubBus();
|
|
3149
|
+
unsubSync();
|
|
3150
|
+
unsubRealtime?.();
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
doc(id) {
|
|
3154
|
+
assertValidId(id);
|
|
3155
|
+
const self = this;
|
|
3156
|
+
return {
|
|
3157
|
+
get id() {
|
|
3158
|
+
return id;
|
|
3159
|
+
},
|
|
3160
|
+
get path() {
|
|
3161
|
+
return `${self._collection}/${id}`;
|
|
3162
|
+
},
|
|
3163
|
+
get collection() {
|
|
3164
|
+
return self._collection;
|
|
3165
|
+
},
|
|
3166
|
+
async get() {
|
|
3167
|
+
return self._buildGetResult(id);
|
|
3168
|
+
},
|
|
3169
|
+
async set(data) {
|
|
3170
|
+
return self.set(id, data);
|
|
3171
|
+
},
|
|
3172
|
+
async update(data) {
|
|
3173
|
+
return self.update(id, data);
|
|
3174
|
+
},
|
|
3175
|
+
async remove() {
|
|
3176
|
+
return self.remove(id);
|
|
3177
|
+
},
|
|
3178
|
+
onSnapshot(cb) {
|
|
3179
|
+
self._buildGetResult(id).then((r) => cb(r)).catch(() => cb(null));
|
|
3180
|
+
const unsubBus = self._bus.subscribe(self._collection, async (changes) => {
|
|
3181
|
+
const relevant = changes.find((c) => c.doc.id === id);
|
|
3182
|
+
if (!relevant) return;
|
|
3183
|
+
try {
|
|
3184
|
+
if (relevant.type === "removed") {
|
|
3185
|
+
cb(null);
|
|
3186
|
+
} else {
|
|
3187
|
+
const result = await self._buildGetResult(id);
|
|
3188
|
+
cb(result);
|
|
3189
|
+
}
|
|
3190
|
+
} catch {
|
|
3191
|
+
cb(null);
|
|
3192
|
+
}
|
|
3193
|
+
});
|
|
3194
|
+
const unsubSync = self._engine.onSyncStateChange(async () => {
|
|
3195
|
+
try {
|
|
3196
|
+
const result = await self._buildGetResult(id);
|
|
3197
|
+
cb(result);
|
|
3198
|
+
} catch {
|
|
3199
|
+
cb(null);
|
|
3200
|
+
}
|
|
3201
|
+
});
|
|
3202
|
+
return () => {
|
|
3203
|
+
unsubBus();
|
|
3204
|
+
unsubSync();
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
};
|
|
3210
|
+
var NeetruDbDocumentsImpl = class {
|
|
3211
|
+
_store;
|
|
3212
|
+
_queue;
|
|
3213
|
+
_bus;
|
|
3214
|
+
_engine;
|
|
3215
|
+
_connectivity;
|
|
3216
|
+
constructor(opts) {
|
|
3217
|
+
this._store = opts.store;
|
|
3218
|
+
this._queue = opts.queue;
|
|
3219
|
+
this._bus = opts.bus;
|
|
3220
|
+
this._engine = opts.engine;
|
|
3221
|
+
this._connectivity = opts.connectivity;
|
|
3222
|
+
}
|
|
3223
|
+
collection(name) {
|
|
3224
|
+
assertValidCollection(name);
|
|
3225
|
+
return new DbCollectionRefImpl(
|
|
3226
|
+
name,
|
|
3227
|
+
this._store,
|
|
3228
|
+
this._queue,
|
|
3229
|
+
this._bus,
|
|
3230
|
+
this._engine,
|
|
3231
|
+
this._connectivity
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
get syncState() {
|
|
3235
|
+
return this._engine.getSyncState();
|
|
3236
|
+
}
|
|
3237
|
+
onSyncStateChanged(cb) {
|
|
3238
|
+
return this._engine.onSyncStateChange(cb);
|
|
3239
|
+
}
|
|
3240
|
+
async flush() {
|
|
3241
|
+
return this._engine.flush();
|
|
3242
|
+
}
|
|
3243
|
+
async clearCache() {
|
|
3244
|
+
await this._store.close();
|
|
3245
|
+
await this._store.open();
|
|
3246
|
+
}
|
|
3247
|
+
async getConflicts() {
|
|
3248
|
+
return this._store.listConflicts();
|
|
3249
|
+
}
|
|
3250
|
+
};
|
|
3251
|
+
async function createOfflineDocumentsNamespace(opts) {
|
|
3252
|
+
const store = new LocalStore(opts.dbName);
|
|
3253
|
+
await store.open();
|
|
3254
|
+
const queue = new MutationQueue(store);
|
|
3255
|
+
const resolver = new ConflictResolver();
|
|
3256
|
+
const bus = new ChangeBus();
|
|
3257
|
+
const connectivity = new ConnectivityMonitor({
|
|
3258
|
+
// Se startOnline=false, injeta um navigator falso
|
|
3259
|
+
navigator: opts.startOnline === false ? { onLine: false } : typeof navigator !== "undefined" ? navigator : void 0
|
|
3260
|
+
});
|
|
3261
|
+
const tabCoordinatorLike = opts.singleTab ? (
|
|
3262
|
+
// Fake tab coordinator para single-tab mode (sempre líder)
|
|
3263
|
+
{
|
|
3264
|
+
isLeader: () => true,
|
|
3265
|
+
onRoleChange: (_cb) => {
|
|
3266
|
+
return () => {
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
) : new TabCoordinator({
|
|
3271
|
+
lockName: `neetru-offline:${opts.dbName}`,
|
|
3272
|
+
channelName: `neetru-offline:${opts.dbName}`
|
|
3273
|
+
});
|
|
3274
|
+
const engine = new SyncEngine({
|
|
3275
|
+
store,
|
|
3276
|
+
queue,
|
|
3277
|
+
resolver,
|
|
3278
|
+
bus,
|
|
3279
|
+
transport: opts.transport,
|
|
3280
|
+
tabCoordinator: tabCoordinatorLike,
|
|
3281
|
+
connectivity,
|
|
3282
|
+
periodicSyncIntervalMs: opts.periodicSyncIntervalMs ?? 5 * 60 * 1e3
|
|
3283
|
+
});
|
|
3284
|
+
return new NeetruDbDocumentsImpl({
|
|
3285
|
+
store,
|
|
3286
|
+
queue,
|
|
3287
|
+
bus,
|
|
3288
|
+
engine,
|
|
3289
|
+
connectivity
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/db/sql/lease.ts
|
|
3294
|
+
var RENEWAL_THRESHOLD = 0.8;
|
|
3295
|
+
var MIN_RENEWAL_DELAY_MS = 3e4;
|
|
3296
|
+
var SqlLeaseManager = class {
|
|
3297
|
+
_lease;
|
|
3298
|
+
_pool;
|
|
3299
|
+
_orm;
|
|
3300
|
+
_renewalTimer = null;
|
|
3301
|
+
_closed = false;
|
|
3302
|
+
_deps;
|
|
3303
|
+
constructor(lease, pool, orm, deps) {
|
|
3304
|
+
this._lease = lease;
|
|
3305
|
+
this._pool = pool;
|
|
3306
|
+
this._orm = orm;
|
|
3307
|
+
this._deps = {
|
|
3308
|
+
...deps,
|
|
3309
|
+
now: deps.now ?? (() => Date.now())
|
|
3310
|
+
};
|
|
3311
|
+
this._scheduleRenewal();
|
|
3312
|
+
}
|
|
3313
|
+
// ─── Superfície pública (NeetruSqlClient) ──────────────────────────────────
|
|
3314
|
+
get orm() {
|
|
3315
|
+
this._assertOpen();
|
|
3316
|
+
return this._orm;
|
|
3317
|
+
}
|
|
3318
|
+
async transaction(fn, opts) {
|
|
3319
|
+
this._assertOpen();
|
|
3320
|
+
try {
|
|
3321
|
+
if (opts?.isolationLevel) {
|
|
3322
|
+
return await this._orm.transaction(fn, {
|
|
3323
|
+
isolationLevel: opts.isolationLevel
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
return await this._orm.transaction(fn);
|
|
3327
|
+
} catch (err) {
|
|
3328
|
+
throw mapPoolError(err);
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
async close() {
|
|
3332
|
+
if (this._closed) return;
|
|
3333
|
+
this._closed = true;
|
|
3334
|
+
this._cancelRenewal();
|
|
3335
|
+
await this._pool.end().catch(() => {
|
|
3336
|
+
});
|
|
3337
|
+
}
|
|
3338
|
+
// ─── Renovação proativa ────────────────────────────────────────────────────
|
|
3339
|
+
/**
|
|
3340
|
+
* Calcula o delay de renovação: momento de ~80% do TTL restante.
|
|
3341
|
+
*
|
|
3342
|
+
* Fórmula: `renewAt = issuedAt + TTL * RENEWAL_THRESHOLD`
|
|
3343
|
+
* = `expiresAt - TTL * (1 - RENEWAL_THRESHOLD)`
|
|
3344
|
+
*
|
|
3345
|
+
* Se o lease já está além do ponto de renovação (ou TTL não calculável),
|
|
3346
|
+
* usa `MIN_RENEWAL_DELAY_MS` como fallback seguro.
|
|
3347
|
+
*/
|
|
3348
|
+
_calcRenewalDelayMs() {
|
|
3349
|
+
const expiresAtMs = Date.parse(this._lease.expiresAt);
|
|
3350
|
+
if (!Number.isFinite(expiresAtMs)) return MIN_RENEWAL_DELAY_MS;
|
|
3351
|
+
const now = this._deps.now();
|
|
3352
|
+
const remainingMs = expiresAtMs - now;
|
|
3353
|
+
if (remainingMs <= 0) return MIN_RENEWAL_DELAY_MS;
|
|
3354
|
+
const renewInMs = remainingMs * (1 - RENEWAL_THRESHOLD);
|
|
3355
|
+
return Math.max(MIN_RENEWAL_DELAY_MS, Math.round(renewInMs));
|
|
3356
|
+
}
|
|
3357
|
+
_scheduleRenewal() {
|
|
3358
|
+
if (this._closed) return;
|
|
3359
|
+
const delayMs = this._calcRenewalDelayMs();
|
|
3360
|
+
this._renewalTimer = setTimeout(() => {
|
|
3361
|
+
void this._doRenew();
|
|
3362
|
+
}, delayMs);
|
|
3363
|
+
}
|
|
3364
|
+
_cancelRenewal() {
|
|
3365
|
+
if (this._renewalTimer !== null) {
|
|
3366
|
+
clearTimeout(this._renewalTimer);
|
|
3367
|
+
this._renewalTimer = null;
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
async _doRenew() {
|
|
3371
|
+
if (this._closed) return;
|
|
3372
|
+
try {
|
|
3373
|
+
const renewOpts = {
|
|
3374
|
+
leaseId: this._lease.leaseId
|
|
3375
|
+
};
|
|
3376
|
+
if (this._deps.database !== void 0) {
|
|
3377
|
+
renewOpts.database = this._deps.database;
|
|
3378
|
+
}
|
|
3379
|
+
const newLease = await this._deps.fetchLease(renewOpts);
|
|
3380
|
+
await this._swapPool(newLease);
|
|
3381
|
+
} catch (err) {
|
|
3382
|
+
this._renewalTimer = setTimeout(() => {
|
|
3383
|
+
void this._doRenew();
|
|
3384
|
+
}, MIN_RENEWAL_DELAY_MS);
|
|
3385
|
+
if (process.env["NODE_ENV"] !== "production") {
|
|
3386
|
+
console.warn("[SqlLeaseManager] Lease renewal failed, will retry:", err);
|
|
3387
|
+
}
|
|
3388
|
+
return;
|
|
3389
|
+
}
|
|
3390
|
+
this._scheduleRenewal();
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Hot-swap do pool: cria pool novo, atualiza ponteiros, drena pool antigo.
|
|
3394
|
+
*
|
|
3395
|
+
* Exposto como método protegido para testes.
|
|
3396
|
+
*/
|
|
3397
|
+
async _swapPool(newLease) {
|
|
3398
|
+
const credRotated = newLease.credentialVersion !== this._lease.credentialVersion;
|
|
3399
|
+
if (!credRotated && newLease.leaseId === this._lease.leaseId) {
|
|
3400
|
+
this._lease = newLease;
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
const newPool = this._deps.createPool(newLease);
|
|
3404
|
+
const newOrm = this._deps.createDrizzle(newPool, this._deps.schema);
|
|
3405
|
+
const oldPool = this._pool;
|
|
3406
|
+
this._lease = newLease;
|
|
3407
|
+
this._pool = newPool;
|
|
3408
|
+
this._orm = newOrm;
|
|
3409
|
+
void oldPool.end().catch(() => {
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
3413
|
+
_assertOpen() {
|
|
3414
|
+
if (this._closed) {
|
|
3415
|
+
throw new NeetruDbError(
|
|
3416
|
+
"db_unavailable",
|
|
3417
|
+
"SqlLeaseManager foi fechado \u2014 chame close() apenas no shutdown."
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
function mapPoolError(err) {
|
|
3423
|
+
if (err instanceof NeetruDbError) return err;
|
|
3424
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3425
|
+
const code = err instanceof Error ? err.code : void 0;
|
|
3426
|
+
if (typeof code === "string" && (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT")) {
|
|
3427
|
+
return new NeetruDbError(
|
|
3428
|
+
"db_unavailable",
|
|
3429
|
+
`Pool connection failed (${code}): ${message}`
|
|
3430
|
+
);
|
|
3431
|
+
}
|
|
3432
|
+
if (message.includes("statement timeout") || message.includes("lock timeout")) {
|
|
3433
|
+
return new NeetruDbError("db_timeout", `Query timeout: ${message}`);
|
|
3434
|
+
}
|
|
3435
|
+
if (message.includes("40001") || message.includes("40P01") || message.includes("deadlock")) {
|
|
3436
|
+
return new NeetruDbError("db_conflict", `Transaction conflict: ${message}`);
|
|
3437
|
+
}
|
|
3438
|
+
if (message.includes("42501") || message.toLowerCase().includes("permission denied")) {
|
|
3439
|
+
return new NeetruDbError("db_permission_denied", `Permission denied: ${message}`);
|
|
3440
|
+
}
|
|
3441
|
+
return new NeetruDbError("db_unavailable", `Database error: ${message}`);
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// src/db/sql/sql-client.ts
|
|
3445
|
+
var LEASE_ENDPOINT = "/api/sdk/v1/db/lease";
|
|
3446
|
+
var LEASE_RENEW_ENDPOINT = "/api/sdk/v1/db/lease/renew";
|
|
3447
|
+
function mapEnvToCore(sdkEnv) {
|
|
3448
|
+
if (sdkEnv === "workspace") return "staging";
|
|
3449
|
+
if (sdkEnv === "prod") return "production";
|
|
3450
|
+
return "dev";
|
|
3451
|
+
}
|
|
3452
|
+
function createHttpLeaseFetcher(config) {
|
|
3453
|
+
return async (opts) => {
|
|
3454
|
+
const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
|
|
3455
|
+
const isRenewal = Boolean(opts?.leaseId);
|
|
3456
|
+
const path = isRenewal ? LEASE_RENEW_ENDPOINT : LEASE_ENDPOINT;
|
|
3457
|
+
let body;
|
|
3458
|
+
if (isRenewal) {
|
|
3459
|
+
body = { leaseId: opts.leaseId };
|
|
3460
|
+
} else {
|
|
3461
|
+
body = {
|
|
3462
|
+
productId: config.productId ?? "",
|
|
3463
|
+
environment: mapEnvToCore(config.env ?? "prod")
|
|
3464
|
+
};
|
|
3465
|
+
if (opts?.database !== void 0) {
|
|
3466
|
+
body["database"] = opts.database;
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
let raw;
|
|
3470
|
+
try {
|
|
3471
|
+
raw = await httpRequest2(config, {
|
|
3472
|
+
method: "POST",
|
|
3473
|
+
path,
|
|
3474
|
+
body,
|
|
3475
|
+
requireAuth: true,
|
|
3476
|
+
// Lease fetch é idempotente do ponto de vista de segurança — retries OK.
|
|
3477
|
+
retries: 2
|
|
3478
|
+
});
|
|
3479
|
+
} catch (err) {
|
|
3480
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3481
|
+
throw new NeetruDbError(
|
|
3482
|
+
"db_unavailable",
|
|
3483
|
+
`Falha ao ${isRenewal ? "renovar" : "obter"} lease SQL: ${msg}`
|
|
3484
|
+
);
|
|
3485
|
+
}
|
|
3486
|
+
return parseLease(raw);
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
function parseLease(raw) {
|
|
3490
|
+
if (!raw || typeof raw !== "object") {
|
|
3491
|
+
throw new NeetruDbError("db_unavailable", "Resposta de lease inv\xE1lida (n\xE3o \xE9 objeto).");
|
|
3492
|
+
}
|
|
3493
|
+
const envelope = raw;
|
|
3494
|
+
const leaseObj = envelope["lease"];
|
|
3495
|
+
if (!leaseObj || typeof leaseObj !== "object") {
|
|
3496
|
+
throw new NeetruDbError(
|
|
3497
|
+
"db_unavailable",
|
|
3498
|
+
'Resposta de lease inv\xE1lida: campo "lease" ausente ou n\xE3o \xE9 objeto. O Core retorna { kind, lease: { leaseId, host, ... } }.'
|
|
3499
|
+
);
|
|
3500
|
+
}
|
|
3501
|
+
const r = leaseObj;
|
|
3502
|
+
function req(field, type) {
|
|
3503
|
+
if (typeof r[field] !== type) {
|
|
3504
|
+
throw new NeetruDbError(
|
|
3505
|
+
"db_unavailable",
|
|
3506
|
+
`Campo obrigat\xF3rio "${field}" ausente ou tipo inv\xE1lido na resposta de lease.`
|
|
3507
|
+
);
|
|
3508
|
+
}
|
|
3509
|
+
return r[field];
|
|
3510
|
+
}
|
|
3511
|
+
return {
|
|
3512
|
+
leaseId: req("leaseId", "string"),
|
|
3513
|
+
host: req("host", "string"),
|
|
3514
|
+
port: typeof r["port"] === "number" ? r["port"] : 5433,
|
|
3515
|
+
dbName: req("dbName", "string"),
|
|
3516
|
+
user: req("user", "string"),
|
|
3517
|
+
password: req("password", "string"),
|
|
3518
|
+
sslca: typeof r["sslca"] === "string" ? r["sslca"] : null,
|
|
3519
|
+
clientCert: typeof r["clientCert"] === "string" ? r["clientCert"] : null,
|
|
3520
|
+
clientKey: typeof r["clientKey"] === "string" ? r["clientKey"] : null,
|
|
3521
|
+
credentialVersion: typeof r["credentialVersion"] === "number" ? r["credentialVersion"] : 1,
|
|
3522
|
+
expiresAt: req("expiresAt", "string")
|
|
3523
|
+
};
|
|
3524
|
+
}
|
|
3525
|
+
async function createSqlClientWithDeps(deps, options) {
|
|
3526
|
+
const depsWithDb = options?.database !== void 0 ? { ...deps, database: options.database } : deps;
|
|
3527
|
+
let lease;
|
|
3528
|
+
try {
|
|
3529
|
+
if (options?.database !== void 0) {
|
|
3530
|
+
lease = await depsWithDb.fetchLease({ database: options.database });
|
|
3531
|
+
} else {
|
|
3532
|
+
lease = await depsWithDb.fetchLease();
|
|
3533
|
+
}
|
|
3534
|
+
} catch (err) {
|
|
3535
|
+
if (err instanceof NeetruDbError) throw err;
|
|
3536
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3537
|
+
throw new NeetruDbError("db_unavailable", `Falha ao obter lease inicial: ${msg}`);
|
|
3538
|
+
}
|
|
3539
|
+
let pool;
|
|
3540
|
+
try {
|
|
3541
|
+
pool = depsWithDb.createPool(lease);
|
|
3542
|
+
} catch (err) {
|
|
3543
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3544
|
+
throw new NeetruDbError("db_unavailable", `Falha ao abrir pool: ${msg}`);
|
|
3545
|
+
}
|
|
3546
|
+
let orm;
|
|
3547
|
+
try {
|
|
3548
|
+
orm = depsWithDb.createDrizzle(pool, depsWithDb.schema);
|
|
3549
|
+
} catch (err) {
|
|
3550
|
+
await pool.end().catch(() => {
|
|
3551
|
+
});
|
|
3552
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3553
|
+
throw new NeetruDbError("db_unavailable", `Falha ao criar handle Drizzle: ${msg}`);
|
|
3554
|
+
}
|
|
3555
|
+
return new SqlLeaseManager(lease, pool, orm, depsWithDb);
|
|
3556
|
+
}
|
|
3557
|
+
async function createSqlClientFromConfig(config, schema, options) {
|
|
3558
|
+
const fetchLease = createHttpLeaseFetcher(config);
|
|
3559
|
+
const createPool = (lease) => {
|
|
3560
|
+
const { Pool } = __require("pg");
|
|
3561
|
+
const ssl = lease.sslca ? {
|
|
3562
|
+
ca: lease.sslca,
|
|
3563
|
+
cert: lease.clientCert ?? void 0,
|
|
3564
|
+
key: lease.clientKey ?? void 0,
|
|
3565
|
+
rejectUnauthorized: true
|
|
3566
|
+
} : false;
|
|
3567
|
+
return new Pool({
|
|
3568
|
+
host: lease.host,
|
|
3569
|
+
port: lease.port,
|
|
3570
|
+
database: lease.dbName,
|
|
3571
|
+
user: lease.user,
|
|
3572
|
+
password: lease.password,
|
|
3573
|
+
ssl,
|
|
3574
|
+
max: 3,
|
|
3575
|
+
idleTimeoutMillis: 3e4,
|
|
3576
|
+
connectionTimeoutMillis: 1e4
|
|
3577
|
+
});
|
|
3578
|
+
};
|
|
3579
|
+
const createDrizzle = (pool, s) => {
|
|
3580
|
+
const { drizzle } = __require("drizzle-orm/node-postgres");
|
|
3581
|
+
return drizzle(pool, { schema: s });
|
|
3582
|
+
};
|
|
3583
|
+
return createSqlClientWithDeps(
|
|
3584
|
+
{
|
|
3585
|
+
fetchLease,
|
|
3586
|
+
createPool,
|
|
3587
|
+
createDrizzle,
|
|
3588
|
+
schema
|
|
3589
|
+
},
|
|
3590
|
+
options
|
|
3591
|
+
);
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
// src/db/realtime/realtime-client.ts
|
|
3595
|
+
var WS_OPEN = 1;
|
|
3596
|
+
var WS_CLOSED = 3;
|
|
3597
|
+
var JITTER_MIN = 0.5;
|
|
3598
|
+
var JITTER_MAX = 1.5;
|
|
3599
|
+
var DEFAULT_BACKOFF_BASE_MS = 1e3;
|
|
3600
|
+
var DEFAULT_BACKOFF_MAX_MS = 3e4;
|
|
3601
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 25e3;
|
|
3602
|
+
var _idCounter = 0;
|
|
3603
|
+
function generateId() {
|
|
3604
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
3605
|
+
return globalThis.crypto.randomUUID();
|
|
3606
|
+
}
|
|
3607
|
+
return `nrt-sub-${Date.now()}-${++_idCounter}-${Math.random().toString(36).slice(2)}`;
|
|
3608
|
+
}
|
|
3609
|
+
var NeetruRealtimeClient = class {
|
|
3610
|
+
// ── configuração ──────────────────────────────────────────────────────────
|
|
3611
|
+
_gatewayUrl;
|
|
3612
|
+
_wsFactory;
|
|
3613
|
+
_setTimeout;
|
|
3614
|
+
_clearTimeout;
|
|
3615
|
+
_backoffBaseMs;
|
|
3616
|
+
_backoffMaxMs;
|
|
3617
|
+
_heartbeatIntervalMs;
|
|
3618
|
+
_ticketProvider;
|
|
3619
|
+
/** ID do banco lógico (BL-8) — embutido em query.filter._dbId de cada subscribe. */
|
|
3620
|
+
_dbId;
|
|
3621
|
+
/** Ticket atual — obtido antes de cada abertura de WS e embutido nos frames. */
|
|
3622
|
+
_currentTicket = null;
|
|
3623
|
+
// ── estado interno ────────────────────────────────────────────────────────
|
|
3624
|
+
/** Subscriptions ativas: id → entry */
|
|
3625
|
+
_subscriptions = /* @__PURE__ */ new Map();
|
|
3626
|
+
/** Listeners do estado da conexão. */
|
|
3627
|
+
_stateListeners = [];
|
|
3628
|
+
/** Estado atual da conexão. */
|
|
3629
|
+
_connectionState = "connecting";
|
|
3630
|
+
/** Socket atual (pode ser null entre tentativas). */
|
|
3631
|
+
_ws = null;
|
|
3632
|
+
/** Número da tentativa de reconexão corrente (reseta após conexão bem-sucedida). */
|
|
3633
|
+
_reconnectAttempt = 0;
|
|
3634
|
+
/** Handle do timer de backoff pendente. */
|
|
3635
|
+
_reconnectTimer = null;
|
|
3636
|
+
/** Handle do timer de heartbeat. */
|
|
3637
|
+
_heartbeatTimer = null;
|
|
3638
|
+
/** Indica que o cliente foi encerrado via `close()`. Não reconecta mais. */
|
|
3639
|
+
_closed = false;
|
|
3640
|
+
/**
|
|
3641
|
+
* Frames de subscribe que precisam ser enviados mas a socket ainda não
|
|
3642
|
+
* está aberta (estado CONNECTING). Drenados no onopen.
|
|
3643
|
+
*/
|
|
3644
|
+
_pendingFrames = [];
|
|
3645
|
+
// ── construtor ────────────────────────────────────────────────────────────
|
|
3646
|
+
constructor(options) {
|
|
3647
|
+
this._gatewayUrl = options.gatewayUrl;
|
|
3648
|
+
this._wsFactory = options.webSocketFactory ?? defaultWebSocketFactory;
|
|
3649
|
+
this._setTimeout = options.setTimeoutFn ?? globalThis.setTimeout.bind(globalThis);
|
|
3650
|
+
this._clearTimeout = options.clearTimeoutFn ?? globalThis.clearTimeout.bind(globalThis);
|
|
3651
|
+
this._backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
3652
|
+
this._backoffMaxMs = options.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
|
|
3653
|
+
this._heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
3654
|
+
this._ticketProvider = options.ticketProvider;
|
|
3655
|
+
this._dbId = options.dbId;
|
|
3656
|
+
this._connect();
|
|
3657
|
+
}
|
|
3658
|
+
// ── API pública ───────────────────────────────────────────────────────────
|
|
3659
|
+
/**
|
|
3660
|
+
* Registra uma subscription em `collection` com `query` opcional.
|
|
3661
|
+
*
|
|
3662
|
+
* Envia um frame `subscribe` ao gateway (ou enfileira se o socket ainda
|
|
3663
|
+
* não está aberto). O `callback` é invocado com cada frame `delta`,
|
|
3664
|
+
* `resync`, `stale` ou `error` roteado para esta subscription.
|
|
3665
|
+
*
|
|
3666
|
+
* @returns O `subscriptionId` opaco — usar para cancelar via `unsubscribe()`.
|
|
3667
|
+
*/
|
|
3668
|
+
subscribe(collection, query, callback) {
|
|
3669
|
+
if (this._closed) {
|
|
3670
|
+
return generateId();
|
|
3671
|
+
}
|
|
3672
|
+
const subscriptionId = generateId();
|
|
3673
|
+
this._subscriptions.set(subscriptionId, {
|
|
3674
|
+
subscriptionId,
|
|
3675
|
+
collection,
|
|
3676
|
+
query: Object.keys(query).length > 0 ? query : void 0,
|
|
3677
|
+
callback
|
|
3678
|
+
});
|
|
3679
|
+
const frame = {
|
|
3680
|
+
op: "subscribe",
|
|
3681
|
+
subscriptionId,
|
|
3682
|
+
collection,
|
|
3683
|
+
query: this._buildSubscribeQuery(query)
|
|
3684
|
+
};
|
|
3685
|
+
this._sendOrBuffer(frame);
|
|
3686
|
+
return subscriptionId;
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* Cancela uma subscription.
|
|
3690
|
+
*
|
|
3691
|
+
* Envia um frame `unsubscribe` ao gateway e remove o listener local.
|
|
3692
|
+
* Frames subsequentes com este `subscriptionId` são silenciosamente descartados.
|
|
3693
|
+
*/
|
|
3694
|
+
unsubscribe(subscriptionId) {
|
|
3695
|
+
if (!this._subscriptions.has(subscriptionId)) {
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
this._subscriptions.delete(subscriptionId);
|
|
3699
|
+
const frame = { op: "unsubscribe", subscriptionId };
|
|
3700
|
+
this._sendOrBuffer(frame);
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Registra um listener de mudanças no estado da conexão.
|
|
3704
|
+
*
|
|
3705
|
+
* O listener é invocado imediatamente com o estado atual e depois a cada
|
|
3706
|
+
* transição. Retorna uma função `unsubscribe`.
|
|
3707
|
+
*/
|
|
3708
|
+
onConnectionState(listener) {
|
|
3709
|
+
this._stateListeners.push(listener);
|
|
3710
|
+
listener(this._connectionState);
|
|
3711
|
+
return () => {
|
|
3712
|
+
this._stateListeners = this._stateListeners.filter((l) => l !== listener);
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* Encerra o cliente definitivamente.
|
|
3717
|
+
*
|
|
3718
|
+
* Fecha o socket ativo, cancela timers pendentes e marca o cliente como
|
|
3719
|
+
* encerrado (sem mais reconexões).
|
|
3720
|
+
*/
|
|
3721
|
+
close() {
|
|
3722
|
+
this._closed = true;
|
|
3723
|
+
this._cancelReconnectTimer();
|
|
3724
|
+
this._cancelHeartbeatTimer();
|
|
3725
|
+
if (this._ws) {
|
|
3726
|
+
this._detachHandlers(this._ws);
|
|
3727
|
+
if (this._ws.readyState !== WS_CLOSED) {
|
|
3728
|
+
this._ws.close(1e3, "client closed");
|
|
3729
|
+
}
|
|
3730
|
+
this._ws = null;
|
|
3731
|
+
}
|
|
3732
|
+
this._setState("disconnected");
|
|
3733
|
+
}
|
|
3734
|
+
// ── Conexão ───────────────────────────────────────────────────────────────
|
|
3735
|
+
/**
|
|
3736
|
+
* Busca um ticket de autenticação e, em seguida, cria um novo WebSocket e
|
|
3737
|
+
* instala os handlers de evento.
|
|
3738
|
+
*
|
|
3739
|
+
* A busca de ticket é obrigatória antes de abrir qualquer WS (primeira
|
|
3740
|
+
* conexão e reconexões). Se a busca falhar, a conexão é abortada com
|
|
3741
|
+
* estado `'connecting'` e o backoff reconectará em seguida.
|
|
3742
|
+
*
|
|
3743
|
+
* Fail-closed: o WebSocket NUNCA é aberto sem um ticket válido.
|
|
3744
|
+
*/
|
|
3745
|
+
_connect() {
|
|
3746
|
+
if (this._closed) return;
|
|
3747
|
+
this._setState("connecting");
|
|
3748
|
+
void this._connectWithTicket();
|
|
3749
|
+
}
|
|
3750
|
+
/** Implementação assíncrona de _connect — busca ticket e abre WS. */
|
|
3751
|
+
async _connectWithTicket() {
|
|
3752
|
+
if (this._closed) return;
|
|
3753
|
+
let ticket;
|
|
3754
|
+
try {
|
|
3755
|
+
ticket = await this._ticketProvider();
|
|
3756
|
+
} catch (err) {
|
|
3757
|
+
if (this._closed) return;
|
|
3758
|
+
const delay = this._computeBackoffDelay(this._reconnectAttempt);
|
|
3759
|
+
this._reconnectAttempt++;
|
|
3760
|
+
this._reconnectTimer = this._setTimeout(() => {
|
|
3761
|
+
this._reconnectTimer = null;
|
|
3762
|
+
if (!this._closed) this._connect();
|
|
3763
|
+
}, delay);
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
if (this._closed) return;
|
|
3767
|
+
this._currentTicket = ticket;
|
|
3768
|
+
const ws = this._wsFactory(this._gatewayUrl);
|
|
3769
|
+
this._ws = ws;
|
|
3770
|
+
ws.onopen = this._handleOpen.bind(this);
|
|
3771
|
+
ws.onmessage = this._handleMessage.bind(this);
|
|
3772
|
+
ws.onclose = this._handleClose.bind(this);
|
|
3773
|
+
ws.onerror = this._handleError.bind(this);
|
|
3774
|
+
}
|
|
3775
|
+
// ── Handlers de WebSocket ─────────────────────────────────────────────────
|
|
3776
|
+
_handleOpen(_event) {
|
|
3777
|
+
if (this._closed) return;
|
|
3778
|
+
this._reconnectAttempt = 0;
|
|
3779
|
+
this._setState("connected");
|
|
3780
|
+
for (const frame of this._pendingFrames) {
|
|
3781
|
+
this._sendNow(frame);
|
|
3782
|
+
}
|
|
3783
|
+
this._pendingFrames = [];
|
|
3784
|
+
this._resubscribeAll();
|
|
3785
|
+
this._scheduleHeartbeat();
|
|
3786
|
+
}
|
|
3787
|
+
_handleMessage(event) {
|
|
3788
|
+
if (this._closed) return;
|
|
3789
|
+
let frame;
|
|
3790
|
+
try {
|
|
3791
|
+
frame = JSON.parse(event.data);
|
|
3792
|
+
} catch {
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
switch (frame.op) {
|
|
3796
|
+
case "delta":
|
|
3797
|
+
case "resync":
|
|
3798
|
+
case "stale":
|
|
3799
|
+
case "error":
|
|
3800
|
+
this._routeToSubscription(frame);
|
|
3801
|
+
break;
|
|
3802
|
+
case "pong":
|
|
3803
|
+
break;
|
|
3804
|
+
case "drain":
|
|
3805
|
+
this._handleDrain();
|
|
3806
|
+
break;
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
_handleClose(_event) {
|
|
3810
|
+
if (this._closed) return;
|
|
3811
|
+
this._cancelHeartbeatTimer();
|
|
3812
|
+
this._ws = null;
|
|
3813
|
+
this._scheduleReconnect();
|
|
3814
|
+
}
|
|
3815
|
+
_handleError(_event) {
|
|
3816
|
+
if (this._closed) return;
|
|
3817
|
+
this._setState("connecting");
|
|
3818
|
+
}
|
|
3819
|
+
// ── Drain (graceful shutdown do gateway) ─────────────────────────────────
|
|
3820
|
+
_handleDrain() {
|
|
3821
|
+
if (this._closed) return;
|
|
3822
|
+
this._setState("draining");
|
|
3823
|
+
this._cancelHeartbeatTimer();
|
|
3824
|
+
if (this._ws) {
|
|
3825
|
+
this._detachHandlers(this._ws);
|
|
3826
|
+
if (this._ws.readyState !== WS_CLOSED) {
|
|
3827
|
+
this._ws.close(1001, "drain");
|
|
3828
|
+
}
|
|
3829
|
+
this._ws = null;
|
|
3830
|
+
}
|
|
3831
|
+
this._scheduleReconnect();
|
|
3832
|
+
}
|
|
3833
|
+
// ── Roteamento de frames ──────────────────────────────────────────────────
|
|
3834
|
+
_routeToSubscription(frame) {
|
|
3835
|
+
const entry = this._subscriptions.get(frame.subscriptionId);
|
|
3836
|
+
if (!entry) {
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
try {
|
|
3840
|
+
entry.callback(frame);
|
|
3841
|
+
} catch {
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
// ── Resubscrição ──────────────────────────────────────────────────────────
|
|
3845
|
+
/**
|
|
3846
|
+
* Reenvía frames `subscribe` para todas as subscriptions ativas.
|
|
3847
|
+
* Chamado no `onopen` de reconexões para restaurar o estado remoto.
|
|
3848
|
+
*
|
|
3849
|
+
* Durante a PRIMEIRA conexão: _pendingFrames já foi drenado com os
|
|
3850
|
+
* subscribes iniciais acima. ResubscribeAll envia novamente — como a
|
|
3851
|
+
* lista de subscriptions foi populada pelos `subscribe()` calls anteriores
|
|
3852
|
+
* ao open, e os frames drenados também os incluem, teríamos duplicatas.
|
|
3853
|
+
*
|
|
3854
|
+
* Solução: resubscribeAll só é invocado após drenar _pendingFrames, e
|
|
3855
|
+
* como o gateway é idempotente para o mesmo subscriptionId (segundo o
|
|
3856
|
+
* design do componente #19), a duplicata é inócua. Em reconexões, onde
|
|
3857
|
+
* _pendingFrames está vazio, os frames são os únicos enviados.
|
|
3858
|
+
*
|
|
3859
|
+
* Para o caso do primeiro open com subscribes anteriores ao open, os
|
|
3860
|
+
* frames já foram drenados de _pendingFrames — não chamamos resubscribeAll
|
|
3861
|
+
* se era a primeira conexão (_reconnectAttempt era 0 antes de resetar).
|
|
3862
|
+
* Rastreamos isso com _hasConnectedOnce.
|
|
3863
|
+
*/
|
|
3864
|
+
_hasConnectedOnce = false;
|
|
3865
|
+
_resubscribeAll() {
|
|
3866
|
+
if (!this._hasConnectedOnce) {
|
|
3867
|
+
this._hasConnectedOnce = true;
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
for (const entry of this._subscriptions.values()) {
|
|
3871
|
+
const frame = {
|
|
3872
|
+
op: "subscribe",
|
|
3873
|
+
subscriptionId: entry.subscriptionId,
|
|
3874
|
+
collection: entry.collection,
|
|
3875
|
+
query: this._buildSubscribeQuery(entry.query ?? {})
|
|
3876
|
+
};
|
|
3877
|
+
this._sendNow(frame);
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
// ── Ticket embedding ──────────────────────────────────────────────────────
|
|
3881
|
+
/**
|
|
3882
|
+
* Constrói o descriptor `query` para um frame `subscribe`, embutindo o
|
|
3883
|
+
* `token` do ticket corrente em `query.filter._ticket` e o `_dbId` do
|
|
3884
|
+
* banco lógico em `query.filter._dbId` (BL-8 fix).
|
|
3885
|
+
*
|
|
3886
|
+
* O gateway extrai `filter._ticket` do primeiro frame `subscribe` e valida
|
|
3887
|
+
* contra `POST /api/sdk/v1/db/realtime/validate`. `filter._dbId` é exigido
|
|
3888
|
+
* pelo `@neetru/realtime-changestream._extractDbId` para rotear a
|
|
3889
|
+
* subscription ao banco correto — sem ele a subscription é rejeitada.
|
|
3890
|
+
*
|
|
3891
|
+
* Retorna `undefined` se a query seria vazia E não há ticket (estado
|
|
3892
|
+
* transitório antes do primeiro ticket — não deve ocorrer normalmente pois
|
|
3893
|
+
* `_connect` só abre o WS após obter o ticket).
|
|
3894
|
+
*/
|
|
3895
|
+
_buildSubscribeQuery(query) {
|
|
3896
|
+
const ticket = this._currentTicket;
|
|
3897
|
+
const hasUserQuery = Object.keys(query).length > 0;
|
|
3898
|
+
if (!ticket) {
|
|
3899
|
+
return hasUserQuery ? query : void 0;
|
|
3900
|
+
}
|
|
3901
|
+
const mergedFilter = {
|
|
3902
|
+
...query.filter ?? {},
|
|
3903
|
+
_ticket: ticket.token
|
|
3904
|
+
};
|
|
3905
|
+
if (this._dbId !== void 0) {
|
|
3906
|
+
mergedFilter["_dbId"] = this._dbId;
|
|
3907
|
+
}
|
|
3908
|
+
return {
|
|
3909
|
+
...query,
|
|
3910
|
+
filter: mergedFilter
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
// ── Backoff e reconexão ───────────────────────────────────────────────────
|
|
3914
|
+
/** Agenda uma tentativa de reconexão com backoff exponencial + jitter. */
|
|
3915
|
+
_scheduleReconnect() {
|
|
3916
|
+
if (this._closed) return;
|
|
3917
|
+
this._cancelReconnectTimer();
|
|
3918
|
+
this._setState("connecting");
|
|
3919
|
+
this._currentTicket = null;
|
|
3920
|
+
const delayMs = this._computeBackoffDelay(this._reconnectAttempt);
|
|
3921
|
+
this._reconnectAttempt++;
|
|
3922
|
+
this._reconnectTimer = this._setTimeout(() => {
|
|
3923
|
+
this._reconnectTimer = null;
|
|
3924
|
+
if (!this._closed) {
|
|
3925
|
+
this._connect();
|
|
3926
|
+
}
|
|
3927
|
+
}, delayMs);
|
|
3928
|
+
}
|
|
3929
|
+
/**
|
|
3930
|
+
* Calcula o delay de backoff para o attempt N.
|
|
3931
|
+
*
|
|
3932
|
+
* Fórmula: `min(baseMs * 2^N, maxMs) * jitter`
|
|
3933
|
+
* onde `jitter ∈ [JITTER_MIN, JITTER_MAX]` (±50%).
|
|
3934
|
+
*/
|
|
3935
|
+
_computeBackoffDelay(attempt) {
|
|
3936
|
+
const exponential = this._backoffBaseMs * Math.pow(2, attempt);
|
|
3937
|
+
const capped = Math.min(exponential, this._backoffMaxMs);
|
|
3938
|
+
const jitter = JITTER_MIN + Math.random() * (JITTER_MAX - JITTER_MIN);
|
|
3939
|
+
return Math.round(capped * jitter);
|
|
3940
|
+
}
|
|
3941
|
+
_cancelReconnectTimer() {
|
|
3942
|
+
if (this._reconnectTimer !== null) {
|
|
3943
|
+
this._clearTimeout(this._reconnectTimer);
|
|
3944
|
+
this._reconnectTimer = null;
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
// ── Heartbeat ─────────────────────────────────────────────────────────────
|
|
3948
|
+
/** Agenda o próximo ping. Não-operacional se heartbeatIntervalMs === 0. */
|
|
3949
|
+
_scheduleHeartbeat() {
|
|
3950
|
+
if (this._heartbeatIntervalMs === 0) return;
|
|
3951
|
+
this._cancelHeartbeatTimer();
|
|
3952
|
+
this._heartbeatTimer = this._setTimeout(() => {
|
|
3953
|
+
this._heartbeatTimer = null;
|
|
3954
|
+
this._sendPing();
|
|
3955
|
+
this._scheduleHeartbeat();
|
|
3956
|
+
}, this._heartbeatIntervalMs);
|
|
3957
|
+
}
|
|
3958
|
+
_cancelHeartbeatTimer() {
|
|
3959
|
+
if (this._heartbeatTimer !== null) {
|
|
3960
|
+
this._clearTimeout(this._heartbeatTimer);
|
|
3961
|
+
this._heartbeatTimer = null;
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
_sendPing() {
|
|
3965
|
+
const frame = { op: "ping", subscriptionId: "" };
|
|
3966
|
+
this._sendOrBuffer(frame);
|
|
3967
|
+
}
|
|
3968
|
+
// ── Envio de frames ───────────────────────────────────────────────────────
|
|
3969
|
+
/**
|
|
3970
|
+
* Envia o frame imediatamente se o socket está aberto; caso contrário,
|
|
3971
|
+
* enfileira em `_pendingFrames` para envio no próximo `onopen`.
|
|
3972
|
+
*/
|
|
3973
|
+
_sendOrBuffer(frame) {
|
|
3974
|
+
if (this._ws !== null && this._ws.readyState === WS_OPEN) {
|
|
3975
|
+
this._sendNow(frame);
|
|
3976
|
+
} else {
|
|
3977
|
+
if (frame.op !== "unsubscribe") {
|
|
3978
|
+
this._pendingFrames.push(frame);
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
/** Serializa e envia o frame imediatamente via WebSocket. */
|
|
3983
|
+
_sendNow(frame) {
|
|
3984
|
+
if (!this._ws || this._ws.readyState !== WS_OPEN) return;
|
|
3985
|
+
try {
|
|
3986
|
+
this._ws.send(JSON.stringify(frame));
|
|
3987
|
+
} catch {
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
// ── Estado da conexão ─────────────────────────────────────────────────────
|
|
3991
|
+
_setState(state) {
|
|
3992
|
+
if (this._connectionState === state) return;
|
|
3993
|
+
this._connectionState = state;
|
|
3994
|
+
for (const listener of this._stateListeners) {
|
|
3995
|
+
try {
|
|
3996
|
+
listener(state);
|
|
3997
|
+
} catch {
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
// ── Desvinculação de handlers ─────────────────────────────────────────────
|
|
4002
|
+
/** Remove todos os handlers de um WebSocket (evita disparo após close()). */
|
|
4003
|
+
_detachHandlers(ws) {
|
|
4004
|
+
ws.onopen = null;
|
|
4005
|
+
ws.onmessage = null;
|
|
4006
|
+
ws.onclose = null;
|
|
4007
|
+
ws.onerror = null;
|
|
4008
|
+
}
|
|
4009
|
+
};
|
|
4010
|
+
var defaultWebSocketFactory = (url) => {
|
|
4011
|
+
if (typeof WebSocket === "undefined") {
|
|
4012
|
+
throw new Error(
|
|
4013
|
+
"[NeetruRealtimeClient] WebSocket n\xE3o dispon\xEDvel neste ambiente. Injete uma implementa\xE7\xE3o via `webSocketFactory` nas op\xE7\xF5es."
|
|
4014
|
+
);
|
|
4015
|
+
}
|
|
4016
|
+
return new WebSocket(url);
|
|
4017
|
+
};
|
|
4018
|
+
|
|
4019
|
+
// src/db/client-db.ts
|
|
4020
|
+
var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
|
|
4021
|
+
var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
|
|
4022
|
+
function resolveTransport(engine, opts, config) {
|
|
4023
|
+
if (opts._transport) {
|
|
4024
|
+
return opts._transport;
|
|
4025
|
+
}
|
|
4026
|
+
if (engine === "firestore") {
|
|
4027
|
+
if (opts.firestoreTransport) {
|
|
4028
|
+
return opts.firestoreTransport;
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
if (engine === "nosql-vm" && opts.realtimeGatewayUrl) {
|
|
4032
|
+
return createWebSocketSyncTransport(opts.realtimeGatewayUrl, config, opts);
|
|
4033
|
+
}
|
|
4034
|
+
return createRestSyncTransport(config);
|
|
4035
|
+
}
|
|
4036
|
+
function createRestSyncTransport(config) {
|
|
4037
|
+
return {
|
|
4038
|
+
async pushMutations(mutations) {
|
|
4039
|
+
if (!config) {
|
|
4040
|
+
throw new NeetruDbError(
|
|
4041
|
+
"db_unavailable",
|
|
4042
|
+
"[RestSyncTransport] config n\xE3o dispon\xEDvel \u2014 n\xE3o \xE9 poss\xEDvel enviar ao Core. Inicialize o transporte via createNeetruDb."
|
|
576
4043
|
);
|
|
577
4044
|
}
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
4045
|
+
const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
|
|
4046
|
+
const results = [];
|
|
4047
|
+
const tenantScopeId = config.tenantId ?? config.productId;
|
|
4048
|
+
const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
|
|
4049
|
+
for (const m of mutations) {
|
|
4050
|
+
try {
|
|
4051
|
+
let raw;
|
|
4052
|
+
if (m.op === "add") {
|
|
4053
|
+
raw = await httpRequest2(config, {
|
|
4054
|
+
method: "POST",
|
|
4055
|
+
path: DATASTORE_COLLECTION_ENDPOINT(m.collection),
|
|
4056
|
+
body: { data: m.payload ?? {} },
|
|
4057
|
+
requireAuth: true,
|
|
4058
|
+
retries: 0,
|
|
4059
|
+
headers: tenantHeaders
|
|
4060
|
+
});
|
|
4061
|
+
} else if (m.op === "set") {
|
|
4062
|
+
raw = await httpRequest2(config, {
|
|
4063
|
+
method: "PUT",
|
|
4064
|
+
path: DATASTORE_DOC_ENDPOINT(m.collection, m.docId),
|
|
4065
|
+
body: { data: m.payload ?? {} },
|
|
4066
|
+
requireAuth: true,
|
|
4067
|
+
retries: 2,
|
|
4068
|
+
headers: tenantHeaders
|
|
4069
|
+
});
|
|
4070
|
+
} else if (m.op === "update") {
|
|
4071
|
+
raw = await httpRequest2(config, {
|
|
4072
|
+
method: "PATCH",
|
|
4073
|
+
path: DATASTORE_DOC_ENDPOINT(m.collection, m.docId),
|
|
4074
|
+
body: { data: m.payload ?? {} },
|
|
4075
|
+
requireAuth: true,
|
|
4076
|
+
retries: 2,
|
|
4077
|
+
headers: tenantHeaders
|
|
4078
|
+
});
|
|
4079
|
+
} else {
|
|
4080
|
+
raw = await httpRequest2(config, {
|
|
4081
|
+
method: "DELETE",
|
|
4082
|
+
path: DATASTORE_DOC_ENDPOINT(m.collection, m.docId),
|
|
4083
|
+
requireAuth: true,
|
|
4084
|
+
retries: 2,
|
|
4085
|
+
headers: tenantHeaders
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
const resp = raw;
|
|
4089
|
+
results.push({
|
|
4090
|
+
mutationId: m.mutationId,
|
|
4091
|
+
outcome: "confirmed",
|
|
4092
|
+
serverVersion: resp?.serverVersion ?? resp?.id ?? `rest_${Date.now()}`,
|
|
4093
|
+
serverTimestamp: Date.now()
|
|
4094
|
+
});
|
|
4095
|
+
} catch (err) {
|
|
4096
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4097
|
+
throw new NeetruDbError("db_unavailable", `pushMutations falhou: ${msg}`);
|
|
4098
|
+
}
|
|
586
4099
|
}
|
|
587
|
-
return {
|
|
588
|
-
ok: true,
|
|
589
|
-
counterId: raw.counterId,
|
|
590
|
-
value: raw.value,
|
|
591
|
-
limit: raw.limit,
|
|
592
|
-
remaining: raw.remaining,
|
|
593
|
-
status: raw.status
|
|
594
|
-
};
|
|
4100
|
+
return { results };
|
|
595
4101
|
},
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
async check(resource, options) {
|
|
600
|
-
if (!resource || typeof resource !== "string") {
|
|
601
|
-
throw new NeetruError("validation_failed", "resource is required");
|
|
4102
|
+
async pullChanges(_watermark, _resumeToken) {
|
|
4103
|
+
if (!config) {
|
|
4104
|
+
return { docs: [], newWatermark: Date.now(), resyncRequired: false };
|
|
602
4105
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
"productId and tenantId required"
|
|
609
|
-
);
|
|
4106
|
+
return { docs: [], newWatermark: Date.now(), resyncRequired: false };
|
|
4107
|
+
},
|
|
4108
|
+
async fullResync(_collections) {
|
|
4109
|
+
if (!config) {
|
|
4110
|
+
return { docs: [], newWatermark: Date.now() };
|
|
610
4111
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
4112
|
+
return { docs: [], newWatermark: Date.now() };
|
|
4113
|
+
}
|
|
4114
|
+
};
|
|
4115
|
+
}
|
|
4116
|
+
function createWebSocketSyncTransport(gatewayUrl, config, opts) {
|
|
4117
|
+
const restTransport = createRestSyncTransport(config);
|
|
4118
|
+
const realtimeClient = new NeetruRealtimeClient({
|
|
4119
|
+
gatewayUrl,
|
|
4120
|
+
ticketProvider: buildTicketProvider(config, opts),
|
|
4121
|
+
dbId: opts.dbId,
|
|
4122
|
+
webSocketFactory: opts._wsFactory
|
|
4123
|
+
});
|
|
4124
|
+
return {
|
|
4125
|
+
pushMutations: restTransport.pushMutations.bind(restTransport),
|
|
4126
|
+
pullChanges: restTransport.pullChanges.bind(restTransport),
|
|
4127
|
+
fullResync: restTransport.fullResync.bind(restTransport),
|
|
4128
|
+
// HIGH-1: subscribeCollection — chamado por DbCollectionRefImpl.onSnapshot
|
|
4129
|
+
// quando o engine é nosql-vm. Registra a subscription no NeetruRealtimeClient
|
|
4130
|
+
// e traduz os frames inbound para o contrato de `onChange`.
|
|
4131
|
+
subscribeCollection(collection, onChange) {
|
|
4132
|
+
const subId = realtimeClient.subscribe(collection, {}, (frame) => {
|
|
4133
|
+
if (frame.op === "resync") {
|
|
4134
|
+
onChange([], true);
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
if (frame.op === "stale") {
|
|
4138
|
+
onChange([], true);
|
|
4139
|
+
return;
|
|
4140
|
+
}
|
|
4141
|
+
if (frame.op === "delta" && frame.changes) {
|
|
4142
|
+
const changes = frame.changes.map((c) => ({
|
|
4143
|
+
type: c.type === "insert" ? "added" : c.type === "delete" ? "removed" : "modified",
|
|
4144
|
+
docId: c.documentId,
|
|
4145
|
+
data: c.data ?? null
|
|
4146
|
+
}));
|
|
4147
|
+
if (changes.length > 0) {
|
|
4148
|
+
onChange(changes, false);
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
616
4151
|
});
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
return {
|
|
621
|
-
allowed: raw.allowed,
|
|
622
|
-
reason: raw.reason,
|
|
623
|
-
remaining: raw.remaining,
|
|
624
|
-
limit: raw.limit,
|
|
625
|
-
planId: raw.planId,
|
|
626
|
-
planFeatures: raw.planFeatures
|
|
4152
|
+
return () => {
|
|
4153
|
+
realtimeClient.unsubscribe(subId);
|
|
627
4154
|
};
|
|
628
4155
|
}
|
|
629
4156
|
};
|
|
630
4157
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
subject: typeof r.subject === "string" ? r.subject : "",
|
|
646
|
-
message: typeof r.message === "string" ? r.message : "",
|
|
647
|
-
severity: VALID_SEVERITIES.includes(r.severity) ? r.severity : "normal",
|
|
648
|
-
status: VALID_STATUSES.includes(r.status) ? r.status : "open",
|
|
649
|
-
createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
650
|
-
updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : void 0,
|
|
651
|
-
productSlug: typeof r.productSlug === "string" ? r.productSlug : void 0
|
|
4158
|
+
function buildTicketProvider(config, opts) {
|
|
4159
|
+
return async () => {
|
|
4160
|
+
const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
|
|
4161
|
+
const ticket = await httpRequest2(config, {
|
|
4162
|
+
method: "POST",
|
|
4163
|
+
path: "/api/sdk/v1/db/realtime/ticket",
|
|
4164
|
+
body: {
|
|
4165
|
+
productId: config.productId ?? "",
|
|
4166
|
+
collections: opts.collections ?? ["*"]
|
|
4167
|
+
},
|
|
4168
|
+
requireAuth: true,
|
|
4169
|
+
retries: 1
|
|
4170
|
+
});
|
|
4171
|
+
return ticket;
|
|
652
4172
|
};
|
|
653
4173
|
}
|
|
654
|
-
function
|
|
4174
|
+
function createNeetruDb(config, dbOpts = {}) {
|
|
4175
|
+
const engine = dbOpts.engine ?? "rest";
|
|
4176
|
+
const transport = resolveTransport(engine, dbOpts, config);
|
|
4177
|
+
const dbId = dbOpts.dbId ?? "default";
|
|
4178
|
+
const dbName = dbOpts.dbName ?? `neetru-db__${config.productId ?? "sdk"}__${dbId}__${config.env}`;
|
|
4179
|
+
let _docsPromise = null;
|
|
4180
|
+
let _docsResolved = null;
|
|
4181
|
+
function getDocsPromise() {
|
|
4182
|
+
if (!_docsPromise) {
|
|
4183
|
+
_docsPromise = createOfflineDocumentsNamespace({
|
|
4184
|
+
dbName,
|
|
4185
|
+
transport,
|
|
4186
|
+
singleTab: dbOpts.singleTab ?? config.env !== "prod"
|
|
4187
|
+
}).then((docs) => {
|
|
4188
|
+
_docsResolved = docs;
|
|
4189
|
+
return docs;
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
return _docsPromise;
|
|
4193
|
+
}
|
|
655
4194
|
return {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
async
|
|
661
|
-
if (
|
|
662
|
-
throw new
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
if (input.subject.length > 200) {
|
|
668
|
-
throw new NeetruError("validation_failed", "subject max 200 chars");
|
|
669
|
-
}
|
|
670
|
-
if (!input.message || typeof input.message !== "string") {
|
|
671
|
-
throw new NeetruError("validation_failed", "message is required");
|
|
672
|
-
}
|
|
673
|
-
if (input.message.length > 1e4) {
|
|
674
|
-
throw new NeetruError("validation_failed", "message max 10000 chars");
|
|
675
|
-
}
|
|
676
|
-
if (input.severity && !VALID_SEVERITIES.includes(input.severity)) {
|
|
677
|
-
throw new NeetruError("validation_failed", `severity must be one of ${VALID_SEVERITIES.join(", ")}`);
|
|
4195
|
+
collection(name) {
|
|
4196
|
+
if (_docsResolved) return _docsResolved.collection(name);
|
|
4197
|
+
return createLazyCollectionRef(name, getDocsPromise);
|
|
4198
|
+
},
|
|
4199
|
+
async sql(schema, options) {
|
|
4200
|
+
if (config.env === "dev") {
|
|
4201
|
+
throw new NeetruDbError(
|
|
4202
|
+
"db_unavailable",
|
|
4203
|
+
"[SDK] db.sql() n\xE3o dispon\xEDvel em NEETRU_ENV=dev. Use `neetru dev` para subir o container Postgres local."
|
|
4204
|
+
);
|
|
678
4205
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
4206
|
+
return createSqlClientFromConfig(config, schema, options);
|
|
4207
|
+
},
|
|
4208
|
+
get syncState() {
|
|
4209
|
+
return _docsResolved?.syncState ?? {
|
|
4210
|
+
status: "idle",
|
|
4211
|
+
pendingWrites: 0,
|
|
4212
|
+
lastSyncedAt: null,
|
|
4213
|
+
isLeaderTab: false
|
|
684
4214
|
};
|
|
685
|
-
const raw = await httpRequest(config, {
|
|
686
|
-
method: "POST",
|
|
687
|
-
path: `/api/v1/products/${encodeURIComponent(slug)}/tickets`,
|
|
688
|
-
body,
|
|
689
|
-
requireAuth: true
|
|
690
|
-
});
|
|
691
|
-
const candidate = raw && typeof raw === "object" && "ticket" in raw ? raw.ticket : raw;
|
|
692
|
-
return toTicket(candidate);
|
|
693
4215
|
},
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
4216
|
+
onSyncStateChanged(cb) {
|
|
4217
|
+
if (_docsResolved) return _docsResolved.onSyncStateChanged(cb);
|
|
4218
|
+
let unsub = null;
|
|
4219
|
+
let cancelled = false;
|
|
4220
|
+
getDocsPromise().then((docs) => {
|
|
4221
|
+
if (!cancelled) {
|
|
4222
|
+
unsub = docs.onSyncStateChanged(cb);
|
|
4223
|
+
}
|
|
702
4224
|
});
|
|
703
|
-
|
|
704
|
-
|
|
4225
|
+
return () => {
|
|
4226
|
+
cancelled = true;
|
|
4227
|
+
unsub?.();
|
|
4228
|
+
};
|
|
4229
|
+
},
|
|
4230
|
+
async flush() {
|
|
4231
|
+
const docs = await getDocsPromise();
|
|
4232
|
+
return docs.flush();
|
|
4233
|
+
},
|
|
4234
|
+
async clearCache() {
|
|
4235
|
+
const docs = await getDocsPromise();
|
|
4236
|
+
return docs.clearCache();
|
|
4237
|
+
},
|
|
4238
|
+
async getConflicts() {
|
|
4239
|
+
const docs = await getDocsPromise();
|
|
4240
|
+
return docs.getConflicts();
|
|
705
4241
|
}
|
|
706
4242
|
};
|
|
707
4243
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
if (!COLL_RE.test(name)) {
|
|
713
|
-
throw new NeetruError(
|
|
714
|
-
"validation_failed",
|
|
715
|
-
`Invalid collection name: "${name}". Must match ${COLL_RE}.`
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
function serializeWhere(filter) {
|
|
720
|
-
const { field, op, value } = filter;
|
|
721
|
-
if (op === "in") {
|
|
722
|
-
if (!Array.isArray(value)) {
|
|
723
|
-
throw new NeetruError(
|
|
724
|
-
"validation_failed",
|
|
725
|
-
`where op="in" requer value array (recebido: ${typeof value})`
|
|
726
|
-
);
|
|
727
|
-
}
|
|
728
|
-
return `${field}:in:${value.map((v) => String(v)).join(",")}`;
|
|
4244
|
+
function createLazyCollectionRef(name, getDocsPromise) {
|
|
4245
|
+
async function getRef() {
|
|
4246
|
+
const docs = await getDocsPromise();
|
|
4247
|
+
return docs.collection(name);
|
|
729
4248
|
}
|
|
730
|
-
return `${field}:${op}:${String(value)}`;
|
|
731
|
-
}
|
|
732
|
-
function createDbNamespace(config) {
|
|
733
4249
|
return {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
4250
|
+
async get(id) {
|
|
4251
|
+
return (await getRef()).get(id);
|
|
4252
|
+
},
|
|
4253
|
+
async list(q) {
|
|
4254
|
+
return (await getRef()).list(q);
|
|
4255
|
+
},
|
|
4256
|
+
async add(data) {
|
|
4257
|
+
return (await getRef()).add(data);
|
|
4258
|
+
},
|
|
4259
|
+
async set(id, data) {
|
|
4260
|
+
return (await getRef()).set(id, data);
|
|
4261
|
+
},
|
|
4262
|
+
async update(id, data) {
|
|
4263
|
+
return (await getRef()).update(id, data);
|
|
4264
|
+
},
|
|
4265
|
+
async remove(id) {
|
|
4266
|
+
return (await getRef()).remove(id);
|
|
4267
|
+
},
|
|
4268
|
+
async batch(ops) {
|
|
4269
|
+
return (await getRef()).batch(ops);
|
|
4270
|
+
},
|
|
4271
|
+
onDoc(id, cb) {
|
|
4272
|
+
let unsub = null;
|
|
4273
|
+
let cancelled = false;
|
|
4274
|
+
getRef().then((ref) => {
|
|
4275
|
+
if (!cancelled) {
|
|
4276
|
+
unsub = ref.onDoc(id, cb);
|
|
4277
|
+
}
|
|
4278
|
+
});
|
|
4279
|
+
return () => {
|
|
4280
|
+
cancelled = true;
|
|
4281
|
+
unsub?.();
|
|
4282
|
+
};
|
|
4283
|
+
},
|
|
4284
|
+
onSnapshot(q, cb) {
|
|
4285
|
+
let unsub = null;
|
|
4286
|
+
let cancelled = false;
|
|
4287
|
+
getRef().then((ref) => {
|
|
4288
|
+
if (!cancelled) {
|
|
4289
|
+
unsub = ref.onSnapshot(q, cb);
|
|
4290
|
+
}
|
|
4291
|
+
});
|
|
4292
|
+
return () => {
|
|
4293
|
+
cancelled = true;
|
|
4294
|
+
unsub?.();
|
|
4295
|
+
};
|
|
4296
|
+
},
|
|
4297
|
+
doc(id) {
|
|
738
4298
|
return {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const params = new URLSearchParams();
|
|
742
|
-
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
743
|
-
if (opts?.where && opts.where.length > 0) {
|
|
744
|
-
for (const f of opts.where) {
|
|
745
|
-
params.append("where", serializeWhere(f));
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
if (config.tenantId) params.set("tenantId", config.tenantId);
|
|
749
|
-
const qs = params.toString();
|
|
750
|
-
if (qs) path += `?${qs}`;
|
|
751
|
-
const raw = await httpRequest(config, {
|
|
752
|
-
method: "GET",
|
|
753
|
-
path,
|
|
754
|
-
requireAuth: true,
|
|
755
|
-
headers
|
|
756
|
-
});
|
|
757
|
-
if (!raw || !Array.isArray(raw.items)) {
|
|
758
|
-
throw new NeetruError(
|
|
759
|
-
"invalid_response",
|
|
760
|
-
"datastore.list missing items[]"
|
|
761
|
-
);
|
|
762
|
-
}
|
|
763
|
-
return raw.items;
|
|
4299
|
+
get id() {
|
|
4300
|
+
return id;
|
|
764
4301
|
},
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
throw new NeetruError("validation_failed", "id required");
|
|
768
|
-
}
|
|
769
|
-
try {
|
|
770
|
-
const raw = await httpRequest(config, {
|
|
771
|
-
method: "GET",
|
|
772
|
-
path: `/api/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
|
|
773
|
-
requireAuth: true,
|
|
774
|
-
headers
|
|
775
|
-
});
|
|
776
|
-
return raw?.item ?? null;
|
|
777
|
-
} catch (err) {
|
|
778
|
-
if (err instanceof NeetruError && err.code === "not_found") return null;
|
|
779
|
-
throw err;
|
|
780
|
-
}
|
|
4302
|
+
get path() {
|
|
4303
|
+
return `${name}/${id}`;
|
|
781
4304
|
},
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
throw new NeetruError("validation_failed", "data object required");
|
|
785
|
-
}
|
|
786
|
-
const raw = await httpRequest(config, {
|
|
787
|
-
method: "POST",
|
|
788
|
-
path: `/api/sdk/v1/datastore/${name}`,
|
|
789
|
-
body: { data },
|
|
790
|
-
requireAuth: true,
|
|
791
|
-
headers
|
|
792
|
-
});
|
|
793
|
-
if (!raw || typeof raw.id !== "string") {
|
|
794
|
-
throw new NeetruError("invalid_response", "datastore.add missing id");
|
|
795
|
-
}
|
|
796
|
-
return { ok: true, id: raw.id };
|
|
4305
|
+
get collection() {
|
|
4306
|
+
return name;
|
|
797
4307
|
},
|
|
798
|
-
async
|
|
799
|
-
|
|
800
|
-
throw new NeetruError("validation_failed", "id required");
|
|
801
|
-
}
|
|
802
|
-
await httpRequest(config, {
|
|
803
|
-
method: "PUT",
|
|
804
|
-
path: `/api/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
|
|
805
|
-
body: { data },
|
|
806
|
-
requireAuth: true,
|
|
807
|
-
headers
|
|
808
|
-
});
|
|
809
|
-
return { ok: true };
|
|
4308
|
+
async get() {
|
|
4309
|
+
return (await getRef()).doc(id).get();
|
|
810
4310
|
},
|
|
811
|
-
async
|
|
812
|
-
|
|
813
|
-
throw new NeetruError("validation_failed", "id required");
|
|
814
|
-
}
|
|
815
|
-
await httpRequest(config, {
|
|
816
|
-
method: "PATCH",
|
|
817
|
-
path: `/api/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
|
|
818
|
-
body: { data },
|
|
819
|
-
requireAuth: true,
|
|
820
|
-
headers
|
|
821
|
-
});
|
|
822
|
-
return { ok: true };
|
|
4311
|
+
async set(data) {
|
|
4312
|
+
return (await getRef()).doc(id).set(data);
|
|
823
4313
|
},
|
|
824
|
-
async
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
await
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
4314
|
+
async update(data) {
|
|
4315
|
+
return (await getRef()).doc(id).update(data);
|
|
4316
|
+
},
|
|
4317
|
+
async remove() {
|
|
4318
|
+
return (await getRef()).doc(id).remove();
|
|
4319
|
+
},
|
|
4320
|
+
onSnapshot(cb) {
|
|
4321
|
+
let unsub = null;
|
|
4322
|
+
let cancelled = false;
|
|
4323
|
+
getRef().then((ref) => {
|
|
4324
|
+
if (!cancelled) {
|
|
4325
|
+
unsub = ref.doc(id).onSnapshot(cb);
|
|
4326
|
+
}
|
|
833
4327
|
});
|
|
834
|
-
return
|
|
4328
|
+
return () => {
|
|
4329
|
+
cancelled = true;
|
|
4330
|
+
unsub?.();
|
|
4331
|
+
};
|
|
835
4332
|
}
|
|
836
4333
|
};
|
|
837
4334
|
}
|
|
@@ -839,16 +4336,18 @@ function createDbNamespace(config) {
|
|
|
839
4336
|
}
|
|
840
4337
|
|
|
841
4338
|
// src/checkout.ts
|
|
4339
|
+
init_errors();
|
|
4340
|
+
init_http();
|
|
842
4341
|
function parseStartResponse(raw) {
|
|
843
4342
|
if (!raw || typeof raw !== "object") {
|
|
844
|
-
throw new NeetruError("invalid_response", "checkout.start response is not an object");
|
|
4343
|
+
throw new exports.NeetruError("invalid_response", "checkout.start response is not an object");
|
|
845
4344
|
}
|
|
846
4345
|
const r = raw;
|
|
847
4346
|
if (typeof r.intentId !== "string" || !r.intentId) {
|
|
848
|
-
throw new NeetruError("invalid_response", "checkout.start response missing intentId");
|
|
4347
|
+
throw new exports.NeetruError("invalid_response", "checkout.start response missing intentId");
|
|
849
4348
|
}
|
|
850
4349
|
if (typeof r.redirectUrl !== "string" || !r.redirectUrl) {
|
|
851
|
-
throw new NeetruError("invalid_response", "checkout.start response missing redirectUrl");
|
|
4350
|
+
throw new exports.NeetruError("invalid_response", "checkout.start response missing redirectUrl");
|
|
852
4351
|
}
|
|
853
4352
|
return {
|
|
854
4353
|
intentId: r.intentId,
|
|
@@ -860,15 +4359,15 @@ function parseStartResponse(raw) {
|
|
|
860
4359
|
}
|
|
861
4360
|
function parseGetResponse(raw) {
|
|
862
4361
|
if (!raw || typeof raw !== "object") {
|
|
863
|
-
throw new NeetruError("invalid_response", "checkout.get response is not an object");
|
|
4362
|
+
throw new exports.NeetruError("invalid_response", "checkout.get response is not an object");
|
|
864
4363
|
}
|
|
865
4364
|
const r = raw;
|
|
866
4365
|
const intent = r.intent;
|
|
867
4366
|
if (!intent || typeof intent !== "object") {
|
|
868
|
-
throw new NeetruError("invalid_response", "checkout.get response missing intent");
|
|
4367
|
+
throw new exports.NeetruError("invalid_response", "checkout.get response missing intent");
|
|
869
4368
|
}
|
|
870
4369
|
if (typeof intent.intentId !== "string") {
|
|
871
|
-
throw new NeetruError("invalid_response", "checkout.get response missing intentId");
|
|
4370
|
+
throw new exports.NeetruError("invalid_response", "checkout.get response missing intentId");
|
|
872
4371
|
}
|
|
873
4372
|
return {
|
|
874
4373
|
intentId: intent.intentId,
|
|
@@ -901,13 +4400,13 @@ function createHttpCheckoutNamespace(config) {
|
|
|
901
4400
|
return {
|
|
902
4401
|
async start(input) {
|
|
903
4402
|
if (!input?.productId) {
|
|
904
|
-
throw new NeetruError("validation_failed", "checkout.start: productId is required");
|
|
4403
|
+
throw new exports.NeetruError("validation_failed", "checkout.start: productId is required");
|
|
905
4404
|
}
|
|
906
4405
|
if (!input?.planId) {
|
|
907
|
-
throw new NeetruError("validation_failed", "checkout.start: planId is required");
|
|
4406
|
+
throw new exports.NeetruError("validation_failed", "checkout.start: planId is required");
|
|
908
4407
|
}
|
|
909
4408
|
if (!input?.callbackUrl) {
|
|
910
|
-
throw new NeetruError("validation_failed", "checkout.start: callbackUrl is required");
|
|
4409
|
+
throw new exports.NeetruError("validation_failed", "checkout.start: callbackUrl is required");
|
|
911
4410
|
}
|
|
912
4411
|
const body = {
|
|
913
4412
|
productId: input.productId,
|
|
@@ -931,7 +4430,7 @@ function createHttpCheckoutNamespace(config) {
|
|
|
931
4430
|
},
|
|
932
4431
|
async get(intentId) {
|
|
933
4432
|
if (!intentId || typeof intentId !== "string") {
|
|
934
|
-
throw new NeetruError("validation_failed", "checkout.get: intentId is required");
|
|
4433
|
+
throw new exports.NeetruError("validation_failed", "checkout.get: intentId is required");
|
|
935
4434
|
}
|
|
936
4435
|
const raw = await httpRequest(config, {
|
|
937
4436
|
method: "GET",
|
|
@@ -942,7 +4441,7 @@ function createHttpCheckoutNamespace(config) {
|
|
|
942
4441
|
},
|
|
943
4442
|
async cancel(intentId) {
|
|
944
4443
|
if (!intentId || typeof intentId !== "string") {
|
|
945
|
-
throw new NeetruError("validation_failed", "checkout.cancel: intentId is required");
|
|
4444
|
+
throw new exports.NeetruError("validation_failed", "checkout.cancel: intentId is required");
|
|
946
4445
|
}
|
|
947
4446
|
const raw = await httpRequest(config, {
|
|
948
4447
|
method: "DELETE",
|
|
@@ -960,13 +4459,13 @@ var MockCheckout = class {
|
|
|
960
4459
|
intents = /* @__PURE__ */ new Map();
|
|
961
4460
|
async start(input) {
|
|
962
4461
|
if (!input?.productId) {
|
|
963
|
-
throw new NeetruError("validation_failed", "checkout.start: productId is required");
|
|
4462
|
+
throw new exports.NeetruError("validation_failed", "checkout.start: productId is required");
|
|
964
4463
|
}
|
|
965
4464
|
if (!input?.planId) {
|
|
966
|
-
throw new NeetruError("validation_failed", "checkout.start: planId is required");
|
|
4465
|
+
throw new exports.NeetruError("validation_failed", "checkout.start: planId is required");
|
|
967
4466
|
}
|
|
968
4467
|
if (!input?.callbackUrl) {
|
|
969
|
-
throw new NeetruError("validation_failed", "checkout.start: callbackUrl is required");
|
|
4468
|
+
throw new exports.NeetruError("validation_failed", "checkout.start: callbackUrl is required");
|
|
970
4469
|
}
|
|
971
4470
|
const intentId = `chk_mock_${Math.random().toString(36).slice(2, 10)}`;
|
|
972
4471
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1e3).toISOString();
|
|
@@ -994,14 +4493,14 @@ var MockCheckout = class {
|
|
|
994
4493
|
async get(intentId) {
|
|
995
4494
|
const found = this.intents.get(intentId);
|
|
996
4495
|
if (!found) {
|
|
997
|
-
throw new NeetruError("not_found", `Mock intent ${intentId} not found`);
|
|
4496
|
+
throw new exports.NeetruError("not_found", `Mock intent ${intentId} not found`);
|
|
998
4497
|
}
|
|
999
4498
|
return { ...found };
|
|
1000
4499
|
}
|
|
1001
4500
|
async cancel(intentId) {
|
|
1002
4501
|
const found = this.intents.get(intentId);
|
|
1003
4502
|
if (!found) {
|
|
1004
|
-
throw new NeetruError("not_found", `Mock intent ${intentId} not found`);
|
|
4503
|
+
throw new exports.NeetruError("not_found", `Mock intent ${intentId} not found`);
|
|
1005
4504
|
}
|
|
1006
4505
|
const alreadyCancelled = found.status === "cancelled";
|
|
1007
4506
|
this.intents.set(intentId, { ...found, status: "cancelled" });
|
|
@@ -1014,6 +4513,8 @@ function createCheckoutNamespace(config) {
|
|
|
1014
4513
|
}
|
|
1015
4514
|
|
|
1016
4515
|
// src/webhooks.ts
|
|
4516
|
+
init_errors();
|
|
4517
|
+
init_http();
|
|
1017
4518
|
var VALID_EVENTS = [
|
|
1018
4519
|
"subscription.activated",
|
|
1019
4520
|
"subscription.cancelled",
|
|
@@ -1026,11 +4527,11 @@ var VALID_EVENTS = [
|
|
|
1026
4527
|
];
|
|
1027
4528
|
function toEndpoint(raw) {
|
|
1028
4529
|
if (!raw || typeof raw !== "object") {
|
|
1029
|
-
throw new NeetruError("invalid_response", "Webhook response is not an object");
|
|
4530
|
+
throw new exports.NeetruError("invalid_response", "Webhook response is not an object");
|
|
1030
4531
|
}
|
|
1031
4532
|
const r = raw;
|
|
1032
4533
|
if (typeof r.id !== "string") {
|
|
1033
|
-
throw new NeetruError("invalid_response", "Webhook missing id");
|
|
4534
|
+
throw new exports.NeetruError("invalid_response", "Webhook missing id");
|
|
1034
4535
|
}
|
|
1035
4536
|
return {
|
|
1036
4537
|
id: r.id,
|
|
@@ -1047,7 +4548,7 @@ function toEndpoint(raw) {
|
|
|
1047
4548
|
}
|
|
1048
4549
|
function validateInput(input) {
|
|
1049
4550
|
if (!input.url || typeof input.url !== "string") {
|
|
1050
|
-
throw new NeetruError("validation_failed", "url \xE9 obrigat\xF3ria");
|
|
4551
|
+
throw new exports.NeetruError("validation_failed", "url \xE9 obrigat\xF3ria");
|
|
1051
4552
|
}
|
|
1052
4553
|
try {
|
|
1053
4554
|
const parsed = new URL(input.url);
|
|
@@ -1055,18 +4556,18 @@ function validateInput(input) {
|
|
|
1055
4556
|
throw new Error("invalid protocol");
|
|
1056
4557
|
}
|
|
1057
4558
|
} catch {
|
|
1058
|
-
throw new NeetruError("validation_failed", `url inv\xE1lida: ${input.url}`);
|
|
4559
|
+
throw new exports.NeetruError("validation_failed", `url inv\xE1lida: ${input.url}`);
|
|
1059
4560
|
}
|
|
1060
4561
|
if (!Array.isArray(input.events) || input.events.length === 0) {
|
|
1061
|
-
throw new NeetruError("validation_failed", "events deve ter pelo menos 1 evento");
|
|
4562
|
+
throw new exports.NeetruError("validation_failed", "events deve ter pelo menos 1 evento");
|
|
1062
4563
|
}
|
|
1063
4564
|
for (const ev of input.events) {
|
|
1064
4565
|
if (!VALID_EVENTS.includes(ev)) {
|
|
1065
|
-
throw new NeetruError("validation_failed", `evento desconhecido: ${ev}`);
|
|
4566
|
+
throw new exports.NeetruError("validation_failed", `evento desconhecido: ${ev}`);
|
|
1066
4567
|
}
|
|
1067
4568
|
}
|
|
1068
4569
|
if (input.secret !== void 0 && input.secret.length < 16) {
|
|
1069
|
-
throw new NeetruError("validation_failed", "secret deve ter \u226516 chars (recomendado 32+)");
|
|
4570
|
+
throw new exports.NeetruError("validation_failed", "secret deve ter \u226516 chars (recomendado 32+)");
|
|
1070
4571
|
}
|
|
1071
4572
|
}
|
|
1072
4573
|
function createWebhooksNamespace(config) {
|
|
@@ -1091,7 +4592,7 @@ function createWebhooksNamespace(config) {
|
|
|
1091
4592
|
return list.map(toEndpoint);
|
|
1092
4593
|
},
|
|
1093
4594
|
async unregister(id) {
|
|
1094
|
-
if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
4595
|
+
if (!id) throw new exports.NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
1095
4596
|
await httpRequest(config, {
|
|
1096
4597
|
method: "DELETE",
|
|
1097
4598
|
path: `/api/sdk/v1/webhooks/${encodeURIComponent(id)}`,
|
|
@@ -1100,7 +4601,7 @@ function createWebhooksNamespace(config) {
|
|
|
1100
4601
|
return { ok: true };
|
|
1101
4602
|
},
|
|
1102
4603
|
async test(id) {
|
|
1103
|
-
if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
4604
|
+
if (!id) throw new exports.NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
1104
4605
|
const raw = await httpRequest(config, {
|
|
1105
4606
|
method: "POST",
|
|
1106
4607
|
path: `/api/sdk/v1/webhooks/${encodeURIComponent(id)}/test`,
|
|
@@ -1141,20 +4642,22 @@ var MockWebhooks = class {
|
|
|
1141
4642
|
}
|
|
1142
4643
|
async unregister(id) {
|
|
1143
4644
|
if (!this.endpoints.has(id)) {
|
|
1144
|
-
throw new NeetruError("not_found", `Webhook ${id} n\xE3o encontrado`);
|
|
4645
|
+
throw new exports.NeetruError("not_found", `Webhook ${id} n\xE3o encontrado`);
|
|
1145
4646
|
}
|
|
1146
4647
|
this.endpoints.delete(id);
|
|
1147
4648
|
return { ok: true };
|
|
1148
4649
|
}
|
|
1149
4650
|
async test(id) {
|
|
1150
4651
|
if (!this.endpoints.has(id)) {
|
|
1151
|
-
throw new NeetruError("not_found", `Webhook ${id} n\xE3o encontrado`);
|
|
4652
|
+
throw new exports.NeetruError("not_found", `Webhook ${id} n\xE3o encontrado`);
|
|
1152
4653
|
}
|
|
1153
4654
|
return { ok: true, statusCode: 200, durationMs: 42 };
|
|
1154
4655
|
}
|
|
1155
4656
|
};
|
|
1156
4657
|
|
|
1157
4658
|
// src/notifications.ts
|
|
4659
|
+
init_errors();
|
|
4660
|
+
init_http();
|
|
1158
4661
|
var VALID_SEVERITIES2 = [
|
|
1159
4662
|
"info",
|
|
1160
4663
|
"success",
|
|
@@ -1163,11 +4666,11 @@ var VALID_SEVERITIES2 = [
|
|
|
1163
4666
|
];
|
|
1164
4667
|
function toNotification(raw) {
|
|
1165
4668
|
if (!raw || typeof raw !== "object") {
|
|
1166
|
-
throw new NeetruError("invalid_response", "Notification response is not an object");
|
|
4669
|
+
throw new exports.NeetruError("invalid_response", "Notification response is not an object");
|
|
1167
4670
|
}
|
|
1168
4671
|
const r = raw;
|
|
1169
4672
|
if (typeof r.id !== "string") {
|
|
1170
|
-
throw new NeetruError("invalid_response", "Notification missing id");
|
|
4673
|
+
throw new exports.NeetruError("invalid_response", "Notification missing id");
|
|
1171
4674
|
}
|
|
1172
4675
|
const sev = VALID_SEVERITIES2.includes(r.severity) ? r.severity : "info";
|
|
1173
4676
|
return {
|
|
@@ -1185,20 +4688,20 @@ function toNotification(raw) {
|
|
|
1185
4688
|
};
|
|
1186
4689
|
}
|
|
1187
4690
|
function validateInput2(input) {
|
|
1188
|
-
if (!input.userId) throw new NeetruError("validation_failed", "userId obrigat\xF3rio");
|
|
1189
|
-
if (!input.kind) throw new NeetruError("validation_failed", "kind obrigat\xF3rio");
|
|
1190
|
-
if (!input.title) throw new NeetruError("validation_failed", "title obrigat\xF3rio");
|
|
4691
|
+
if (!input.userId) throw new exports.NeetruError("validation_failed", "userId obrigat\xF3rio");
|
|
4692
|
+
if (!input.kind) throw new exports.NeetruError("validation_failed", "kind obrigat\xF3rio");
|
|
4693
|
+
if (!input.title) throw new exports.NeetruError("validation_failed", "title obrigat\xF3rio");
|
|
1191
4694
|
if (input.severity && !VALID_SEVERITIES2.includes(input.severity)) {
|
|
1192
|
-
throw new NeetruError(
|
|
4695
|
+
throw new exports.NeetruError(
|
|
1193
4696
|
"validation_failed",
|
|
1194
4697
|
`severity inv\xE1lida: ${input.severity} (use ${VALID_SEVERITIES2.join("|")})`
|
|
1195
4698
|
);
|
|
1196
4699
|
}
|
|
1197
4700
|
if (input.title.length > 200) {
|
|
1198
|
-
throw new NeetruError("validation_failed", "title m\xE1x 200 chars");
|
|
4701
|
+
throw new exports.NeetruError("validation_failed", "title m\xE1x 200 chars");
|
|
1199
4702
|
}
|
|
1200
4703
|
if (input.body && input.body.length > 2e3) {
|
|
1201
|
-
throw new NeetruError("validation_failed", "body m\xE1x 2000 chars");
|
|
4704
|
+
throw new exports.NeetruError("validation_failed", "body m\xE1x 2000 chars");
|
|
1202
4705
|
}
|
|
1203
4706
|
}
|
|
1204
4707
|
function createNotificationsNamespace(config) {
|
|
@@ -1214,7 +4717,7 @@ function createNotificationsNamespace(config) {
|
|
|
1214
4717
|
return toNotification(raw);
|
|
1215
4718
|
},
|
|
1216
4719
|
async list(userId, options) {
|
|
1217
|
-
if (!userId) throw new NeetruError("validation_failed", "userId obrigat\xF3rio");
|
|
4720
|
+
if (!userId) throw new exports.NeetruError("validation_failed", "userId obrigat\xF3rio");
|
|
1218
4721
|
const params = new URLSearchParams();
|
|
1219
4722
|
if (options?.includeDismissed) params.set("includeDismissed", "true");
|
|
1220
4723
|
if (options?.onlyUnread) params.set("onlyUnread", "true");
|
|
@@ -1231,7 +4734,7 @@ function createNotificationsNamespace(config) {
|
|
|
1231
4734
|
return list.map(toNotification);
|
|
1232
4735
|
},
|
|
1233
4736
|
async markRead(id) {
|
|
1234
|
-
if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
4737
|
+
if (!id) throw new exports.NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
1235
4738
|
await httpRequest(config, {
|
|
1236
4739
|
method: "POST",
|
|
1237
4740
|
path: `/api/sdk/v1/notifications/${encodeURIComponent(id)}/read`,
|
|
@@ -1240,7 +4743,7 @@ function createNotificationsNamespace(config) {
|
|
|
1240
4743
|
return { ok: true };
|
|
1241
4744
|
},
|
|
1242
4745
|
async dismiss(id) {
|
|
1243
|
-
if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
4746
|
+
if (!id) throw new exports.NeetruError("validation_failed", "id obrigat\xF3rio");
|
|
1244
4747
|
await httpRequest(config, {
|
|
1245
4748
|
method: "POST",
|
|
1246
4749
|
path: `/api/sdk/v1/notifications/${encodeURIComponent(id)}/dismiss`,
|
|
@@ -1280,13 +4783,13 @@ var MockNotifications = class {
|
|
|
1280
4783
|
return notif;
|
|
1281
4784
|
}
|
|
1282
4785
|
async list(userId, options) {
|
|
1283
|
-
if (!userId) throw new NeetruError("validation_failed", "userId obrigat\xF3rio");
|
|
4786
|
+
if (!userId) throw new exports.NeetruError("validation_failed", "userId obrigat\xF3rio");
|
|
1284
4787
|
const limit = Math.min(Math.max(1, options?.limit ?? 50), 200);
|
|
1285
4788
|
return [...this.notifications.values()].filter((n) => n.userId === userId).filter((n) => options?.includeDismissed || !n.dismissedAt).filter((n) => !options?.onlyUnread || !n.readAt).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
|
|
1286
4789
|
}
|
|
1287
4790
|
async markRead(id) {
|
|
1288
4791
|
const n = this.notifications.get(id);
|
|
1289
|
-
if (!n) throw new NeetruError("not_found", `Notification ${id} n\xE3o encontrada`);
|
|
4792
|
+
if (!n) throw new exports.NeetruError("not_found", `Notification ${id} n\xE3o encontrada`);
|
|
1290
4793
|
if (!n.readAt) {
|
|
1291
4794
|
n.readAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1292
4795
|
this.notifications.set(id, n);
|
|
@@ -1295,7 +4798,7 @@ var MockNotifications = class {
|
|
|
1295
4798
|
}
|
|
1296
4799
|
async dismiss(id) {
|
|
1297
4800
|
const n = this.notifications.get(id);
|
|
1298
|
-
if (!n) throw new NeetruError("not_found", `Notification ${id} n\xE3o encontrada`);
|
|
4801
|
+
if (!n) throw new exports.NeetruError("not_found", `Notification ${id} n\xE3o encontrada`);
|
|
1299
4802
|
if (!n.dismissedAt) {
|
|
1300
4803
|
n.dismissedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1301
4804
|
this.notifications.set(id, n);
|
|
@@ -1523,24 +5026,61 @@ var MockDb = class {
|
|
|
1523
5026
|
return typeof v === "number" && typeof f.value === "number" && v >= f.value;
|
|
1524
5027
|
case "in":
|
|
1525
5028
|
return Array.isArray(f.value) && f.value.includes(v);
|
|
5029
|
+
case "array-contains":
|
|
5030
|
+
return Array.isArray(v) && v.includes(f.value);
|
|
5031
|
+
case "not-in":
|
|
5032
|
+
return Array.isArray(f.value) && !f.value.includes(v);
|
|
1526
5033
|
default:
|
|
1527
5034
|
return true;
|
|
1528
5035
|
}
|
|
1529
5036
|
};
|
|
1530
5037
|
let autoSeq = 0;
|
|
1531
|
-
|
|
1532
|
-
|
|
5038
|
+
function buildListResult(items) {
|
|
5039
|
+
return {
|
|
5040
|
+
docs: items.map((d, i) => ({ id: d.id ?? String(i), data: d })),
|
|
5041
|
+
nextCursor: null,
|
|
5042
|
+
fromCache: false,
|
|
5043
|
+
stale: false,
|
|
5044
|
+
hasPendingWrites: false,
|
|
5045
|
+
changes: []
|
|
5046
|
+
};
|
|
5047
|
+
}
|
|
5048
|
+
function buildGetResult(data, id) {
|
|
5049
|
+
return {
|
|
5050
|
+
docs: [{ id, data }],
|
|
5051
|
+
fromCache: false,
|
|
5052
|
+
stale: false,
|
|
5053
|
+
hasPendingWrites: false,
|
|
5054
|
+
changes: []
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
const ref = {
|
|
5058
|
+
async list(q) {
|
|
1533
5059
|
let items = Array.from(coll.values());
|
|
1534
|
-
if (
|
|
5060
|
+
if (q?.where && q.where.length > 0) {
|
|
1535
5061
|
items = items.filter(
|
|
1536
|
-
(doc) =>
|
|
5062
|
+
(doc) => q.where.every((f) => matchesFilter(doc, f))
|
|
1537
5063
|
);
|
|
1538
5064
|
}
|
|
1539
|
-
if (
|
|
1540
|
-
|
|
5065
|
+
if (q?.orderBy) {
|
|
5066
|
+
const { field, direction } = q.orderBy;
|
|
5067
|
+
items = items.slice().sort((a, b) => {
|
|
5068
|
+
const av = a[field];
|
|
5069
|
+
const bv = b[field];
|
|
5070
|
+
const avs = av ?? "";
|
|
5071
|
+
const bvs = bv ?? "";
|
|
5072
|
+
const cmp = avs < bvs ? -1 : avs > bvs ? 1 : 0;
|
|
5073
|
+
return direction === "desc" ? -cmp : cmp;
|
|
5074
|
+
});
|
|
5075
|
+
}
|
|
5076
|
+
const limit = q?.limit ?? 20;
|
|
5077
|
+
items = items.slice(0, limit);
|
|
5078
|
+
return buildListResult(items);
|
|
1541
5079
|
},
|
|
1542
5080
|
async get(id) {
|
|
1543
|
-
|
|
5081
|
+
const data = coll.get(id);
|
|
5082
|
+
if (!data) return null;
|
|
5083
|
+
return buildGetResult(data, id);
|
|
1544
5084
|
},
|
|
1545
5085
|
async add(data) {
|
|
1546
5086
|
const id = `mock-${++autoSeq}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -1563,8 +5103,109 @@ var MockDb = class {
|
|
|
1563
5103
|
async remove(id) {
|
|
1564
5104
|
coll.delete(id);
|
|
1565
5105
|
return { ok: true };
|
|
5106
|
+
},
|
|
5107
|
+
async batch(ops) {
|
|
5108
|
+
for (const op of ops) {
|
|
5109
|
+
const collMap = coll;
|
|
5110
|
+
if (op.kind === "add") {
|
|
5111
|
+
const id = `mock-batch-${++autoSeq}`;
|
|
5112
|
+
collMap.set(id, { ...op.data, id });
|
|
5113
|
+
} else if (op.kind === "set") {
|
|
5114
|
+
collMap.set(op.id, { ...op.data, id: op.id });
|
|
5115
|
+
} else if (op.kind === "update") {
|
|
5116
|
+
const cur = collMap.get(op.id);
|
|
5117
|
+
collMap.set(op.id, { ...cur ?? {}, ...op.data, id: op.id });
|
|
5118
|
+
} else if (op.kind === "remove") {
|
|
5119
|
+
collMap.delete(op.id);
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
return { ok: true };
|
|
5123
|
+
},
|
|
5124
|
+
onDoc(id, cb) {
|
|
5125
|
+
const data = coll.get(id);
|
|
5126
|
+
Promise.resolve().then(() => cb(data ? data : null));
|
|
5127
|
+
return () => {
|
|
5128
|
+
};
|
|
5129
|
+
},
|
|
5130
|
+
onSnapshot(q, cb) {
|
|
5131
|
+
Promise.resolve().then(async () => {
|
|
5132
|
+
const snap = await ref.list(q);
|
|
5133
|
+
cb(snap);
|
|
5134
|
+
});
|
|
5135
|
+
return () => {
|
|
5136
|
+
};
|
|
5137
|
+
},
|
|
5138
|
+
doc(id) {
|
|
5139
|
+
return {
|
|
5140
|
+
get id() {
|
|
5141
|
+
return id;
|
|
5142
|
+
},
|
|
5143
|
+
get path() {
|
|
5144
|
+
return `${name}/${id}`;
|
|
5145
|
+
},
|
|
5146
|
+
get collection() {
|
|
5147
|
+
return name;
|
|
5148
|
+
},
|
|
5149
|
+
async get() {
|
|
5150
|
+
const data = coll.get(id);
|
|
5151
|
+
if (!data) return null;
|
|
5152
|
+
return buildGetResult(data, id);
|
|
5153
|
+
},
|
|
5154
|
+
async set(data) {
|
|
5155
|
+
coll.set(id, { ...data, id });
|
|
5156
|
+
return { ok: true };
|
|
5157
|
+
},
|
|
5158
|
+
async update(data) {
|
|
5159
|
+
const cur = coll.get(id);
|
|
5160
|
+
if (!cur) {
|
|
5161
|
+
coll.set(id, { ...data, id });
|
|
5162
|
+
} else {
|
|
5163
|
+
coll.set(id, { ...cur, ...data });
|
|
5164
|
+
}
|
|
5165
|
+
return { ok: true };
|
|
5166
|
+
},
|
|
5167
|
+
async remove() {
|
|
5168
|
+
coll.delete(id);
|
|
5169
|
+
return { ok: true };
|
|
5170
|
+
},
|
|
5171
|
+
onSnapshot(cb) {
|
|
5172
|
+
const data = coll.get(id);
|
|
5173
|
+
Promise.resolve().then(
|
|
5174
|
+
() => cb(data ? buildGetResult(data, id) : null)
|
|
5175
|
+
);
|
|
5176
|
+
return () => {
|
|
5177
|
+
};
|
|
5178
|
+
}
|
|
5179
|
+
};
|
|
1566
5180
|
}
|
|
1567
5181
|
};
|
|
5182
|
+
return ref;
|
|
5183
|
+
}
|
|
5184
|
+
async sql(_schema) {
|
|
5185
|
+
throw new NeetruDbError(
|
|
5186
|
+
"db_unavailable",
|
|
5187
|
+
'[MockDb] sql() n\xE3o dispon\xEDvel no mock. Use createNeetruClient({ env: "dev" }) e `neetru dev` para o banco local.'
|
|
5188
|
+
);
|
|
5189
|
+
}
|
|
5190
|
+
get syncState() {
|
|
5191
|
+
return {
|
|
5192
|
+
status: "idle",
|
|
5193
|
+
pendingWrites: 0,
|
|
5194
|
+
lastSyncedAt: null,
|
|
5195
|
+
isLeaderTab: true
|
|
5196
|
+
};
|
|
5197
|
+
}
|
|
5198
|
+
onSyncStateChanged(_cb) {
|
|
5199
|
+
return () => {
|
|
5200
|
+
};
|
|
5201
|
+
}
|
|
5202
|
+
async flush() {
|
|
5203
|
+
}
|
|
5204
|
+
async clearCache() {
|
|
5205
|
+
this._store.clear();
|
|
5206
|
+
}
|
|
5207
|
+
async getConflicts() {
|
|
5208
|
+
return [];
|
|
1568
5209
|
}
|
|
1569
5210
|
/** Test helper — substitui fixture inteira de uma collection. */
|
|
1570
5211
|
__setFixture(name, items) {
|
|
@@ -1676,7 +5317,7 @@ function createOidcAuthNamespace(config) {
|
|
|
1676
5317
|
globalThis.location.assign(url.toString());
|
|
1677
5318
|
return;
|
|
1678
5319
|
}
|
|
1679
|
-
throw new NeetruError(
|
|
5320
|
+
throw new exports.NeetruError(
|
|
1680
5321
|
"invalid_config",
|
|
1681
5322
|
"auth.signIn requires a browser context or mocks. Use NEETRU_ENV=dev or pass mocks.auth."
|
|
1682
5323
|
);
|
|
@@ -1714,7 +5355,7 @@ function createOidcAuthNamespace(config) {
|
|
|
1714
5355
|
function createNeetruClient(config = {}) {
|
|
1715
5356
|
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
1716
5357
|
if (typeof fetchImpl !== "function") {
|
|
1717
|
-
throw new NeetruError(
|
|
5358
|
+
throw new exports.NeetruError(
|
|
1718
5359
|
"invalid_config",
|
|
1719
5360
|
"fetch is not available in this runtime. Pass `fetch` explicitly to createNeetruClient."
|
|
1720
5361
|
);
|
|
@@ -1735,7 +5376,7 @@ function createNeetruClient(config = {}) {
|
|
|
1735
5376
|
const usage = config.mocks?.usage ?? (isDev ? new MockUsage() : createUsageNamespace(resolved));
|
|
1736
5377
|
const support = config.mocks?.support ?? (isDev ? new MockSupport() : createSupportNamespace(resolved));
|
|
1737
5378
|
const entitlements = config.mocks?.entitlements ?? (isDev ? new MockEntitlements() : createEntitlementsNamespace(resolved));
|
|
1738
|
-
const db = config.mocks?.db ?? (
|
|
5379
|
+
const db = config.mocks?.db ?? createNeetruDb(resolved, config.db);
|
|
1739
5380
|
const webhooks = config.mocks?.webhooks ?? (isDev ? new MockWebhooks() : createWebhooksNamespace(resolved));
|
|
1740
5381
|
const notifications = config.mocks?.notifications ?? (isDev ? new MockNotifications() : createNotificationsNamespace(resolved));
|
|
1741
5382
|
const client = Object.freeze({
|
|
@@ -1755,10 +5396,11 @@ function createNeetruClient(config = {}) {
|
|
|
1755
5396
|
}
|
|
1756
5397
|
|
|
1757
5398
|
// src/index.ts
|
|
1758
|
-
|
|
5399
|
+
init_errors();
|
|
5400
|
+
var VERSION = "2.0.0";
|
|
1759
5401
|
function initNeetru(config) {
|
|
1760
|
-
const { apiUrl, baseUrl, ...rest } = config;
|
|
1761
|
-
return createNeetruClient({ ...rest, baseUrl
|
|
5402
|
+
const { apiUrl: _apiUrl, baseUrl, ...rest } = config;
|
|
5403
|
+
return createNeetruClient({ ...rest, baseUrl });
|
|
1762
5404
|
}
|
|
1763
5405
|
|
|
1764
5406
|
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
@@ -1771,10 +5413,11 @@ exports.MockNotifications = MockNotifications;
|
|
|
1771
5413
|
exports.MockSupport = MockSupport;
|
|
1772
5414
|
exports.MockUsage = MockUsage;
|
|
1773
5415
|
exports.MockWebhooks = MockWebhooks;
|
|
1774
|
-
exports.
|
|
5416
|
+
exports.NeetruDbError = NeetruDbError;
|
|
1775
5417
|
exports.VERSION = VERSION;
|
|
1776
5418
|
exports.createCheckoutNamespace = createCheckoutNamespace;
|
|
1777
5419
|
exports.createNeetruClient = createNeetruClient;
|
|
5420
|
+
exports.createNeetruDb = createNeetruDb;
|
|
1778
5421
|
exports.createNotificationsNamespace = createNotificationsNamespace;
|
|
1779
5422
|
exports.createWebhooksNamespace = createWebhooksNamespace;
|
|
1780
5423
|
exports.initNeetru = initNeetru;
|