@neetru/sdk 1.1.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +284 -214
  2. package/README.md +194 -218
  3. package/dist/auth.cjs +4181 -346
  4. package/dist/auth.cjs.map +1 -1
  5. package/dist/auth.d.cts +5 -1
  6. package/dist/auth.d.ts +5 -1
  7. package/dist/auth.mjs +4181 -346
  8. package/dist/auth.mjs.map +1 -1
  9. package/dist/catalog.cjs +63 -24
  10. package/dist/catalog.cjs.map +1 -1
  11. package/dist/catalog.d.cts +6 -2
  12. package/dist/catalog.d.ts +6 -2
  13. package/dist/catalog.mjs +63 -24
  14. package/dist/catalog.mjs.map +1 -1
  15. package/dist/checkout.cjs +60 -18
  16. package/dist/checkout.cjs.map +1 -1
  17. package/dist/checkout.d.cts +5 -1
  18. package/dist/checkout.d.ts +5 -1
  19. package/dist/checkout.mjs +60 -18
  20. package/dist/checkout.mjs.map +1 -1
  21. package/dist/collection-ref-BBvTTXoG.d.cts +423 -0
  22. package/dist/collection-ref-BBvTTXoG.d.ts +423 -0
  23. package/dist/db-react.cjs +136 -0
  24. package/dist/db-react.cjs.map +1 -0
  25. package/dist/db-react.d.cts +99 -0
  26. package/dist/db-react.d.ts +99 -0
  27. package/dist/db-react.mjs +112 -0
  28. package/dist/db-react.mjs.map +1 -0
  29. package/dist/db.cjs +3652 -143
  30. package/dist/db.cjs.map +1 -1
  31. package/dist/db.d.cts +5 -8
  32. package/dist/db.d.ts +5 -8
  33. package/dist/db.mjs +3649 -143
  34. package/dist/db.mjs.map +1 -1
  35. package/dist/entitlements.cjs +101 -24
  36. package/dist/entitlements.cjs.map +1 -1
  37. package/dist/entitlements.d.cts +15 -5
  38. package/dist/entitlements.d.ts +15 -5
  39. package/dist/entitlements.mjs +101 -24
  40. package/dist/entitlements.mjs.map +1 -1
  41. package/dist/errors.cjs.map +1 -1
  42. package/dist/errors.mjs.map +1 -1
  43. package/dist/index.cjs +4341 -282
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.d.cts +13 -6
  46. package/dist/index.d.ts +13 -6
  47. package/dist/index.mjs +4243 -189
  48. package/dist/index.mjs.map +1 -1
  49. package/dist/mocks.cjs +186 -9
  50. package/dist/mocks.cjs.map +1 -1
  51. package/dist/mocks.d.cts +21 -6
  52. package/dist/mocks.d.ts +21 -6
  53. package/dist/mocks.mjs +186 -9
  54. package/dist/mocks.mjs.map +1 -1
  55. package/dist/notifications.cjs +296 -0
  56. package/dist/notifications.cjs.map +1 -0
  57. package/dist/notifications.d.cts +5 -0
  58. package/dist/notifications.d.ts +5 -0
  59. package/dist/notifications.mjs +293 -0
  60. package/dist/notifications.mjs.map +1 -0
  61. package/dist/react.cjs +7 -3
  62. package/dist/react.cjs.map +1 -1
  63. package/dist/react.d.cts +5 -1
  64. package/dist/react.d.ts +5 -1
  65. package/dist/react.mjs +7 -3
  66. package/dist/react.mjs.map +1 -1
  67. package/dist/support.cjs +60 -18
  68. package/dist/support.cjs.map +1 -1
  69. package/dist/support.d.cts +5 -1
  70. package/dist/support.d.ts +5 -1
  71. package/dist/support.mjs +60 -18
  72. package/dist/support.mjs.map +1 -1
  73. package/dist/telemetry.cjs +130 -19
  74. package/dist/telemetry.cjs.map +1 -1
  75. package/dist/telemetry.d.cts +21 -1
  76. package/dist/telemetry.d.ts +21 -1
  77. package/dist/telemetry.mjs +130 -19
  78. package/dist/telemetry.mjs.map +1 -1
  79. package/dist/types-B1jylbMC.d.ts +1364 -0
  80. package/dist/types-Kmt4y1FQ.d.cts +1364 -0
  81. package/dist/usage.cjs +60 -18
  82. package/dist/usage.cjs.map +1 -1
  83. package/dist/usage.d.cts +5 -1
  84. package/dist/usage.d.ts +5 -1
  85. package/dist/usage.mjs +60 -18
  86. package/dist/usage.mjs.map +1 -1
  87. package/dist/webhooks.cjs +316 -0
  88. package/dist/webhooks.cjs.map +1 -0
  89. package/dist/webhooks.d.cts +5 -0
  90. package/dist/webhooks.d.ts +5 -0
  91. package/dist/webhooks.mjs +312 -0
  92. package/dist/webhooks.mjs.map +1 -0
  93. package/package.json +133 -101
  94. package/dist/types-BA53dd8S.d.cts +0 -490
  95. package/dist/types-BA53dd8S.d.ts +0 -490
package/dist/auth.cjs CHANGED
@@ -1,24 +1,67 @@
1
1
  'use strict';
2
2
 
3
- // src/errors.ts
4
- var NeetruError = class _NeetruError extends Error {
5
- code;
6
- status;
7
- requestId;
8
- constructor(code, message, status, requestId) {
9
- super(message);
10
- this.name = "NeetruError";
11
- this.code = code;
12
- this.status = status;
13
- this.requestId = requestId;
14
- Object.setPrototypeOf(this, _NeetruError.prototype);
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/types.ts
19
- var DEFAULT_BASE_URL = "https://api.neetru.com";
21
+ // src/errors.ts
22
+ var NeetruError;
23
+ var init_errors = __esm({
24
+ "src/errors.ts"() {
25
+ 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
42
+ var http_exports = {};
43
+ __export(http_exports, {
44
+ httpRequest: () => httpRequest
45
+ });
46
+ function backoffMs(attempt) {
47
+ const base = 200 * Math.pow(4, attempt);
48
+ const jitter = base * 0.2 * (Math.random() * 2 - 1);
49
+ return Math.max(50, Math.round(base + jitter));
50
+ }
51
+ function parseRetryAfter(value) {
52
+ if (!value) return null;
53
+ const secs = Number(value);
54
+ if (Number.isFinite(secs) && secs >= 0) return Math.round(secs * 1e3);
55
+ const dateMs = Date.parse(value);
56
+ if (Number.isFinite(dateMs)) {
57
+ const delta = dateMs - Date.now();
58
+ if (delta > 0) return delta;
59
+ }
60
+ return null;
61
+ }
62
+ function sleep(ms) {
63
+ return new Promise((resolve) => setTimeout(resolve, ms));
64
+ }
22
65
  function statusToCode(status) {
23
66
  if (status === 401) return "unauthorized";
24
67
  if (status === 403) return "forbidden";
@@ -52,6 +95,7 @@ async function safeJson(res) {
52
95
  async function httpRequest(config, opts) {
53
96
  const method = opts.method ?? "GET";
54
97
  const url = buildUrl(config.baseUrl, opts.path, opts.query);
98
+ const maxRetries = opts.retries ?? DEFAULT_RETRIES;
55
99
  const headers = {
56
100
  accept: "application/json",
57
101
  ...opts.headers
@@ -65,24 +109,32 @@ async function httpRequest(config, opts) {
65
109
  }
66
110
  headers.authorization = `Bearer ${config.apiKey}`;
67
111
  }
68
- const init = { method, headers };
69
- if (opts.body !== void 0 && method !== "GET" && method !== "DELETE") {
112
+ const bodyString = opts.body !== void 0 && method !== "GET" && method !== "DELETE" ? JSON.stringify(opts.body) : void 0;
113
+ if (bodyString !== void 0) {
70
114
  headers["content-type"] = "application/json";
71
- init.body = JSON.stringify(opts.body);
72
115
  }
73
- init.signal = AbortSignal.timeout(3e4);
74
- let res;
75
- try {
76
- res = await config.fetch(url, init);
77
- } catch (err) {
78
- if (err instanceof DOMException && err.name === "TimeoutError") {
79
- throw new NeetruError("network_error", "Network error: timeout after 30s");
116
+ let lastError = null;
117
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
118
+ const init = { method, headers };
119
+ if (bodyString !== void 0) init.body = bodyString;
120
+ init.signal = AbortSignal.timeout(3e4);
121
+ let res;
122
+ try {
123
+ res = await config.fetch(url, init);
124
+ } catch (err2) {
125
+ const message2 = err2 instanceof DOMException && err2.name === "TimeoutError" ? "Network error: timeout after 30s" : `Network error: ${err2 instanceof Error ? err2.message : "fetch failed"}`;
126
+ lastError = new NeetruError("network_error", message2);
127
+ if (attempt < maxRetries) {
128
+ await sleep(backoffMs(attempt));
129
+ continue;
130
+ }
131
+ throw lastError;
132
+ }
133
+ const requestId = res.headers.get("x-request-id") ?? res.headers.get("x-correlation-id") ?? void 0;
134
+ if (res.ok) {
135
+ const parsed = await safeJson(res);
136
+ return parsed;
80
137
  }
81
- const message = err instanceof Error ? err.message : "fetch failed";
82
- throw new NeetruError("network_error", `Network error: ${message}`);
83
- }
84
- const requestId = res.headers.get("x-request-id") ?? res.headers.get("x-correlation-id") ?? void 0;
85
- if (!res.ok) {
86
138
  const body = await safeJson(res);
87
139
  let code = statusToCode(res.status);
88
140
  let message = `HTTP ${res.status}`;
@@ -95,13 +147,41 @@ async function httpRequest(config, opts) {
95
147
  if (typeof errField.message === "string") message = errField.message;
96
148
  }
97
149
  }
98
- throw new NeetruError(code, message, res.status, requestId);
150
+ const err = new NeetruError(code, message, res.status, requestId);
151
+ lastError = err;
152
+ const isRetryable = RETRYABLE_CODES.has(code);
153
+ if (isRetryable && attempt < maxRetries) {
154
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
155
+ const delay = retryAfter ?? backoffMs(attempt);
156
+ await sleep(delay);
157
+ continue;
158
+ }
159
+ throw err;
99
160
  }
100
- const parsed = await safeJson(res);
101
- return parsed;
161
+ throw lastError ?? new NeetruError("unknown", "unexpected httpRequest exit");
102
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";
103
181
 
104
182
  // src/catalog.ts
183
+ init_errors();
184
+ init_http();
105
185
  function toProduct(raw) {
106
186
  if (!raw || typeof raw !== "object") {
107
187
  throw new NeetruError("invalid_response", "Catalog response item is not an object");
@@ -138,12 +218,10 @@ function createCatalogNamespace(config) {
138
218
  * Lista produtos publicados. Por default só `published=true`; staff
139
219
  * pode passar `includeDrafts: true` (requer Bearer com role admin/operator).
140
220
  */
141
- async list(opts = {}) {
221
+ async list(_opts = {}) {
142
222
  const raw = await httpRequest(config, {
143
223
  method: "GET",
144
- path: "/api/v1/cli/catalog",
145
- query: opts.includeDrafts ? { drafts: "true" } : void 0,
146
- requireAuth: true
224
+ path: "/api/sdk/v1/catalog"
147
225
  });
148
226
  if (!raw || !Array.isArray(raw.products)) {
149
227
  throw new NeetruError(
@@ -167,8 +245,7 @@ function createCatalogNamespace(config) {
167
245
  }
168
246
  const raw = await httpRequest(config, {
169
247
  method: "GET",
170
- path: `/api/v1/cli/catalog/${encodeURIComponent(slug)}`,
171
- requireAuth: true
248
+ path: `/api/sdk/v1/catalog/${encodeURIComponent(slug)}`
172
249
  });
173
250
  if (!raw || !raw.product) {
174
251
  throw new NeetruError(
@@ -182,6 +259,8 @@ function createCatalogNamespace(config) {
182
259
  }
183
260
 
184
261
  // src/entitlements.ts
262
+ init_errors();
263
+ init_http();
185
264
  function toEntitlementCheck(raw) {
186
265
  if (!raw || typeof raw !== "object") {
187
266
  throw new NeetruError("invalid_response", "Entitlement response is not an object");
@@ -197,34 +276,71 @@ function toEntitlementCheck(raw) {
197
276
  reason: typeof r.reason === "string" ? r.reason : void 0
198
277
  };
199
278
  }
279
+ var CACHE_TTL_MS = 6e4;
280
+ var CACHE_MAX = 100;
200
281
  function createEntitlementsNamespace(config) {
201
- async function checkDetailed(productSlug, feature) {
282
+ const cache = /* @__PURE__ */ new Map();
283
+ function cacheKey(productSlug, feature) {
284
+ return `${productSlug}::${feature}`;
285
+ }
286
+ function readCache(key) {
287
+ const entry = cache.get(key);
288
+ if (!entry) return null;
289
+ if (entry.expiresAt < Date.now()) {
290
+ cache.delete(key);
291
+ return null;
292
+ }
293
+ cache.delete(key);
294
+ cache.set(key, entry);
295
+ return entry.value;
296
+ }
297
+ function writeCache(key, value) {
298
+ if (cache.size >= CACHE_MAX) {
299
+ const oldest = cache.keys().next().value;
300
+ if (oldest !== void 0) cache.delete(oldest);
301
+ }
302
+ cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
303
+ }
304
+ async function checkDetailed(productSlug, feature, opts = {}) {
202
305
  if (!productSlug) throw new NeetruError("validation_failed", "productSlug is required");
203
306
  if (!feature) throw new NeetruError("validation_failed", "feature is required");
307
+ const key = cacheKey(productSlug, feature);
308
+ if (!opts.cacheBust) {
309
+ const cached = readCache(key);
310
+ if (cached) return cached;
311
+ }
204
312
  const raw = await httpRequest(config, {
205
313
  method: "GET",
206
314
  path: "/api/v1/sdk/entitlements/check",
207
315
  query: { slug: productSlug, feature },
208
316
  requireAuth: true
209
317
  });
210
- return toEntitlementCheck(raw);
318
+ const result = toEntitlementCheck(raw);
319
+ writeCache(key, result);
320
+ return result;
211
321
  }
212
322
  return {
213
323
  /**
214
324
  * Verifica se o caller pode usar `feature` no produto `productSlug`.
215
- * Retorno simples: `true` libera, `false` bloqueia.
325
+ * Retorno simples: `true` libera, `false` bloqueia. Cache 60s automático.
216
326
  *
217
327
  * Use `checkDetailed` se precisar do `reason` pra mensagem de upgrade.
218
328
  */
219
- async check(productSlug, feature) {
220
- const result = await checkDetailed(productSlug, feature);
329
+ async check(productSlug, feature, opts) {
330
+ const result = await checkDetailed(productSlug, feature, opts);
221
331
  return result.allowed;
222
332
  },
223
- checkDetailed
333
+ checkDetailed,
334
+ /** Test helper: limpa o cache LRU. */
335
+ __resetCache() {
336
+ cache.clear();
337
+ }
224
338
  };
225
339
  }
226
340
 
227
341
  // src/telemetry.ts
342
+ init_errors();
343
+ init_http();
228
344
  var VALID_LOG_LEVELS = ["debug", "info", "warn", "error", "fatal"];
229
345
  function consoleFor(level) {
230
346
  switch (level) {
@@ -242,7 +358,44 @@ function consoleFor(level) {
242
358
  return console.log.bind(console);
243
359
  }
244
360
  }
361
+ var TRACK_FLUSH_MS = 500;
362
+ var TRACK_MAX_QUEUE = 50;
245
363
  function createTelemetryNamespace(config) {
364
+ const queue = [];
365
+ let flushTimer = null;
366
+ async function drainQueue() {
367
+ if (queue.length === 0) return;
368
+ const batch = queue.splice(0, queue.length);
369
+ flushTimer = null;
370
+ await Promise.allSettled(
371
+ batch.map(async (ev) => {
372
+ try {
373
+ const body = { name: ev.name };
374
+ if (ev.properties && typeof ev.properties === "object") {
375
+ body.properties = ev.properties;
376
+ }
377
+ if (ev.timestamp) body.timestamp = ev.timestamp;
378
+ await httpRequest(config, {
379
+ method: "POST",
380
+ path: "/api/v1/sdk/telemetry/event",
381
+ body,
382
+ requireAuth: true,
383
+ retries: 1
384
+ });
385
+ } catch (err) {
386
+ if (typeof console !== "undefined") {
387
+ console.warn("[neetru-sdk] telemetry.track flush failed for event", ev.name, err);
388
+ }
389
+ }
390
+ })
391
+ );
392
+ }
393
+ function scheduleFlush() {
394
+ if (flushTimer) return;
395
+ flushTimer = setTimeout(() => {
396
+ void drainQueue();
397
+ }, TRACK_FLUSH_MS);
398
+ }
246
399
  return {
247
400
  /**
248
401
  * Persiste um evento de uso. Lança `NeetruError` em qualquer falha
@@ -282,6 +435,38 @@ function createTelemetryNamespace(config) {
282
435
  }
283
436
  return { ok: true, eventId: raw.eventId };
284
437
  },
438
+ /**
439
+ * Fire-and-forget: enqueue + flush 500ms debounce. Não retorna `eventId`
440
+ * — falhas são logadas como warning. Ideal pra alta frequência.
441
+ *
442
+ * @example
443
+ * ```ts
444
+ * client.telemetry.track({ name: 'page_view', properties: { path: '/' } });
445
+ * // segue execução; flush async em background
446
+ * ```
447
+ */
448
+ track(input) {
449
+ if (!input || typeof input !== "object") return;
450
+ if (typeof input.name !== "string" || input.name.length === 0) return;
451
+ if (input.name.length > 128) return;
452
+ queue.push(input);
453
+ if (queue.length >= TRACK_MAX_QUEUE) {
454
+ void drainQueue();
455
+ } else {
456
+ scheduleFlush();
457
+ }
458
+ },
459
+ /**
460
+ * Força flush imediato da queue de `track()`. Resolva antes de
461
+ * page unload / SSR boundary pra não perder eventos.
462
+ */
463
+ async flush() {
464
+ if (flushTimer) {
465
+ clearTimeout(flushTimer);
466
+ flushTimer = null;
467
+ }
468
+ await drainQueue();
469
+ },
285
470
  /**
286
471
  * Registra um log estruturado per-product (Sprint 6).
287
472
  *
@@ -333,7 +518,7 @@ function createTelemetryNamespace(config) {
333
518
  if (cid) headers["x-correlation-id"] = cid;
334
519
  const raw = await httpRequest(config, {
335
520
  method: "POST",
336
- path: "/sdk/v1/telemetry/log",
521
+ path: "/api/sdk/v1/telemetry/log",
337
522
  body,
338
523
  requireAuth: true,
339
524
  headers
@@ -351,6 +536,8 @@ function createTelemetryNamespace(config) {
351
536
  }
352
537
 
353
538
  // src/usage.ts
539
+ init_errors();
540
+ init_http();
354
541
  function toQuota(metric, raw) {
355
542
  if (!raw || typeof raw !== "object") {
356
543
  throw new NeetruError("invalid_response", "Quota response is not an object");
@@ -487,6 +674,8 @@ function createUsageNamespace(config) {
487
674
  }
488
675
 
489
676
  // src/support.ts
677
+ init_errors();
678
+ init_http();
490
679
  var VALID_SEVERITIES = ["low", "normal", "high", "urgent"];
491
680
  var VALID_STATUSES = ["open", "pending", "resolved", "closed"];
492
681
  function toTicket(raw) {
@@ -563,235 +752,3665 @@ function createSupportNamespace(config) {
563
752
  };
564
753
  }
565
754
 
566
- // src/db.ts
567
- var COLL_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
568
- function assertValidCollection(name) {
569
- if (!COLL_RE.test(name)) {
570
- throw new NeetruError(
571
- "validation_failed",
572
- `Invalid collection name: "${name}". Must match ${COLL_RE}.`
573
- );
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 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);
574
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;
575
792
  }
576
- function serializeWhere(filter) {
577
- const { field, op, value } = filter;
578
- if (op === "in") {
579
- if (!Array.isArray(value)) {
580
- throw new NeetruError(
581
- "validation_failed",
582
- `where op="in" requer value array (recebido: ${typeof value})`
583
- );
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;
584
811
  }
585
- return `${field}:in:${value.map((v) => String(v)).join(",")}`;
812
+ current = current[part];
586
813
  }
587
- return `${field}:${op}:${String(value)}`;
814
+ return current;
588
815
  }
589
- function createDbNamespace(config) {
590
- return {
591
- collection(name) {
592
- assertValidCollection(name);
593
- const headers = {};
594
- if (config.tenantId) headers["x-neetru-tenant"] = config.tenantId;
595
- return {
596
- async list(opts) {
597
- if (opts?.limit !== void 0) opts.limit;
598
- let path = `/sdk/v1/datastore/${name}`;
599
- const params = new URLSearchParams();
600
- if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
601
- if (opts?.where && opts.where.length > 0) {
602
- for (const f of opts.where) {
603
- params.append("where", serializeWhere(f));
604
- }
605
- }
606
- if (config.tenantId) params.set("tenantId", config.tenantId);
607
- const qs = params.toString();
608
- if (qs) path += `?${qs}`;
609
- const raw = await httpRequest(config, {
610
- method: "GET",
611
- path,
612
- requireAuth: true,
613
- headers
614
- });
615
- if (!raw || !Array.isArray(raw.items)) {
616
- throw new NeetruError(
617
- "invalid_response",
618
- "datastore.list missing items[]"
619
- );
620
- }
621
- return raw.items;
622
- },
623
- async get(id) {
624
- if (!id || typeof id !== "string") {
625
- throw new NeetruError("validation_failed", "id required");
626
- }
627
- try {
628
- const raw = await httpRequest(config, {
629
- method: "GET",
630
- path: `/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
631
- requireAuth: true,
632
- headers
633
- });
634
- return raw?.item ?? null;
635
- } catch (err) {
636
- if (err instanceof NeetruError && err.code === "not_found") return null;
637
- throw err;
638
- }
639
- },
640
- async add(data) {
641
- if (!data || typeof data !== "object") {
642
- throw new NeetruError("validation_failed", "data object required");
643
- }
644
- const raw = await httpRequest(config, {
645
- method: "POST",
646
- path: `/sdk/v1/datastore/${name}`,
647
- body: { data },
648
- requireAuth: true,
649
- headers
650
- });
651
- if (!raw || typeof raw.id !== "string") {
652
- throw new NeetruError("invalid_response", "datastore.add missing id");
653
- }
654
- return { ok: true, id: raw.id };
655
- },
656
- async set(id, data) {
657
- if (!id || typeof id !== "string") {
658
- throw new NeetruError("validation_failed", "id required");
659
- }
660
- await httpRequest(config, {
661
- method: "PUT",
662
- path: `/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
663
- body: { data },
664
- requireAuth: true,
665
- headers
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"]
666
939
  });
667
- return { ok: true };
668
- },
669
- async update(id, data) {
670
- if (!id || typeof id !== "string") {
671
- throw new NeetruError("validation_failed", "id required");
672
- }
673
- await httpRequest(config, {
674
- method: "PATCH",
675
- path: `/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
676
- body: { data },
677
- requireAuth: true,
678
- headers
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"
679
947
  });
680
- return { ok: true };
681
- },
682
- async remove(id) {
683
- if (!id || typeof id !== "string") {
684
- throw new NeetruError("validation_failed", "id required");
685
- }
686
- await httpRequest(config, {
687
- method: "DELETE",
688
- path: `/sdk/v1/datastore/${name}/${encodeURIComponent(id)}`,
689
- requireAuth: true,
690
- headers
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
691
963
  });
692
- return { ok: true };
964
+ conflictStore.createIndex("by_delivered", "delivered");
965
+ conflictStore.createIndex("by_doc", ["collection", "docId"]);
693
966
  }
694
- };
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.");
695
978
  }
696
- };
697
- }
698
-
699
- // src/checkout.ts
700
- function parseStartResponse(raw) {
701
- if (!raw || typeof raw !== "object") {
702
- throw new NeetruError("invalid_response", "checkout.start response is not an object");
979
+ return this.db;
703
980
  }
704
- const r = raw;
705
- if (typeof r.intentId !== "string" || !r.intentId) {
706
- throw new NeetruError("invalid_response", "checkout.start response missing intentId");
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;
707
990
  }
708
- if (typeof r.redirectUrl !== "string" || !r.redirectUrl) {
709
- throw new NeetruError("invalid_response", "checkout.start response missing redirectUrl");
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);
710
998
  }
711
- return {
712
- intentId: r.intentId,
713
- redirectUrl: r.redirectUrl,
714
- status: r.status ?? "pending",
715
- expiresAt: typeof r.expiresAt === "string" ? r.expiresAt : (/* @__PURE__ */ new Date()).toISOString(),
716
- requiresKyc: r.requiresKyc === true
717
- };
718
- }
719
- function parseGetResponse(raw) {
720
- if (!raw || typeof raw !== "object") {
721
- throw new NeetruError("invalid_response", "checkout.get response is not an object");
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
+ });
722
1016
  }
723
- const r = raw;
724
- const intent = r.intent;
725
- if (!intent || typeof intent !== "object") {
726
- throw new NeetruError("invalid_response", "checkout.get response missing intent");
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);
727
1027
  }
728
- if (typeof intent.intentId !== "string") {
729
- throw new NeetruError("invalid_response", "checkout.get response missing intentId");
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;
730
1041
  }
731
- return {
732
- intentId: intent.intentId,
733
- uid: intent.uid ?? "",
734
- targetTenantId: intent.targetTenantId ?? "",
735
- targetTenantType: intent.targetTenantType ?? "pf",
736
- productId: intent.productId ?? "",
737
- planId: intent.planId ?? "",
738
- callbackUrl: intent.callbackUrl ?? "",
739
- status: intent.status ?? "pending",
740
- stripeSessionId: intent.stripeSessionId ?? null,
741
- expiresAt: intent.expiresAt ?? (/* @__PURE__ */ new Date()).toISOString(),
742
- isStale: r.isStale === true
743
- };
744
- }
745
- function inBrowser() {
746
- try {
747
- return typeof globalThis !== "undefined" && typeof globalThis.window !== "undefined" && typeof globalThis.location !== "undefined" && typeof globalThis.location.assign === "function";
748
- } catch {
749
- return false;
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 });
750
1048
  }
751
- }
752
- function performRedirect(url) {
753
- try {
754
- globalThis.location.assign(url);
755
- } catch {
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);
756
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
+ });
757
1175
  }
758
- function createHttpCheckoutNamespace(config) {
759
- return {
760
- async start(input) {
761
- if (!input?.productId) {
762
- throw new NeetruError("validation_failed", "checkout.start: productId is required");
763
- }
764
- if (!input?.planId) {
765
- throw new NeetruError("validation_failed", "checkout.start: planId is required");
766
- }
767
- if (!input?.callbackUrl) {
768
- throw new NeetruError("validation_failed", "checkout.start: callbackUrl is required");
769
- }
770
- const body = {
771
- productId: input.productId,
772
- planId: input.planId,
773
- callbackUrl: input.callbackUrl
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
+ }
774
1235
  };
775
- if (input.tenantType) body.targetTenantType = input.tenantType;
776
- if (input.tenantId) body.targetTenantId = input.tenantId;
777
- const raw = await httpRequest(config, {
778
- method: "POST",
779
- path: "/api/v1/checkout/intents",
780
- body,
781
- requireAuth: true
782
- });
783
- const result = parseStartResponse(raw);
784
- const shouldRedirect = input.autoRedirect !== false;
785
- if (shouldRedirect && inBrowser()) {
786
- performRedirect(result.redirectUrl);
787
- }
788
- return result;
789
- },
790
- async get(intentId) {
791
- if (!intentId || typeof intentId !== "string") {
792
- throw new NeetruError("validation_failed", "checkout.get: intentId is required");
793
- }
794
- const raw = await httpRequest(config, {
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 id = op.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
2960
+ const data = op.data ?? {};
2961
+ const collection = op.collection;
2962
+ if (op.op === "add") {
2963
+ const mutation = await this._queue.enqueue({
2964
+ collection,
2965
+ op: "add",
2966
+ payload: data,
2967
+ baseVersion: null,
2968
+ batchId
2969
+ });
2970
+ const docId = mutation.docId;
2971
+ await this._store.putDoc({
2972
+ collection,
2973
+ id: docId,
2974
+ data,
2975
+ meta: {
2976
+ serverVersion: null,
2977
+ updatedAtServer: null,
2978
+ updatedAtLocal: Date.now(),
2979
+ state: "pending",
2980
+ pendingMutationIds: [mutation.mutationId],
2981
+ deleted: false,
2982
+ ownerId: null
2983
+ }
2984
+ });
2985
+ busChanges.push({ type: "added", collection, doc: { id: docId, data } });
2986
+ } else if (op.op === "set") {
2987
+ const existing = await this._store.getDoc(collection, id);
2988
+ const mutation = await this._queue.enqueue({
2989
+ collection,
2990
+ docId: id,
2991
+ op: "set",
2992
+ payload: data,
2993
+ baseVersion: existing?.meta.serverVersion ?? null,
2994
+ batchId
2995
+ });
2996
+ await this._store.putDoc({
2997
+ collection,
2998
+ id,
2999
+ data,
3000
+ meta: {
3001
+ serverVersion: existing?.meta.serverVersion ?? null,
3002
+ updatedAtServer: existing?.meta.updatedAtServer ?? null,
3003
+ updatedAtLocal: Date.now(),
3004
+ state: "pending",
3005
+ pendingMutationIds: [mutation.mutationId],
3006
+ deleted: false,
3007
+ ownerId: null
3008
+ }
3009
+ });
3010
+ busChanges.push({
3011
+ type: existing && !existing.meta.deleted ? "modified" : "added",
3012
+ collection,
3013
+ doc: { id, data }
3014
+ });
3015
+ } else if (op.op === "update") {
3016
+ const existing = await this._store.getDoc(collection, id);
3017
+ const merged = { ...existing?.data ?? {}, ...data };
3018
+ const mutation = await this._queue.enqueue({
3019
+ collection,
3020
+ docId: id,
3021
+ op: "update",
3022
+ payload: data,
3023
+ baseVersion: existing?.meta.serverVersion ?? null,
3024
+ batchId
3025
+ });
3026
+ await this._store.putDoc({
3027
+ collection,
3028
+ id,
3029
+ data: merged,
3030
+ meta: {
3031
+ serverVersion: existing?.meta.serverVersion ?? null,
3032
+ updatedAtServer: existing?.meta.updatedAtServer ?? null,
3033
+ updatedAtLocal: Date.now(),
3034
+ state: "pending",
3035
+ pendingMutationIds: [
3036
+ ...existing?.meta.pendingMutationIds ?? [],
3037
+ mutation.mutationId
3038
+ ],
3039
+ deleted: false,
3040
+ ownerId: existing?.meta.ownerId ?? null
3041
+ }
3042
+ });
3043
+ busChanges.push({ type: "modified", collection, doc: { id, data: merged } });
3044
+ } else if (op.op === "remove") {
3045
+ const existing = await this._store.getDoc(collection, id);
3046
+ await this._queue.enqueue({
3047
+ collection,
3048
+ docId: id,
3049
+ op: "remove",
3050
+ payload: null,
3051
+ baseVersion: existing?.meta.serverVersion ?? null,
3052
+ batchId
3053
+ });
3054
+ await this._store.deleteDoc(collection, id);
3055
+ if (existing && !existing.meta.deleted) {
3056
+ busChanges.push({ type: "removed", collection, doc: { id, data: existing.data } });
3057
+ }
3058
+ }
3059
+ }
3060
+ if (busChanges.length > 0) {
3061
+ this._bus.emit(busChanges);
3062
+ }
3063
+ return { ok: true };
3064
+ }
3065
+ // ── Realtime ─────────────────────────────────────────────────────────────────
3066
+ onDoc(id, cb) {
3067
+ assertValidId(id);
3068
+ this._buildGetResult(id).then((r) => {
3069
+ cb(r ? r.docs[0]?.data ?? null : null);
3070
+ }).catch(() => cb(null));
3071
+ return this._bus.subscribe(this._collection, (changes) => {
3072
+ const relevant = changes.find((c) => c.doc.id === id);
3073
+ if (!relevant) return;
3074
+ if (relevant.type === "removed") {
3075
+ cb(null);
3076
+ } else {
3077
+ cb(relevant.doc.data);
3078
+ }
3079
+ });
3080
+ }
3081
+ onSnapshot(q, cb) {
3082
+ this._buildListResult(q).then((snap) => cb(snap)).catch(() => {
3083
+ });
3084
+ const unsubBus = this._bus.subscribe(this._collection, async (_changes) => {
3085
+ try {
3086
+ const snap = await this._buildListResult(q);
3087
+ const delta = _changes.map((c) => ({
3088
+ type: c.type,
3089
+ doc: { id: c.doc.id, data: c.doc.data }
3090
+ }));
3091
+ cb({ ...snap, changes: delta });
3092
+ } catch {
3093
+ }
3094
+ });
3095
+ const unsubSync = this._engine.onSyncStateChange(async () => {
3096
+ try {
3097
+ const snap = await this._buildListResult(q);
3098
+ cb(snap);
3099
+ } catch {
3100
+ }
3101
+ });
3102
+ let unsubRealtime;
3103
+ if (typeof this._engine.transport.subscribeCollection === "function") {
3104
+ unsubRealtime = this._engine.transport.subscribeCollection(
3105
+ this._collection,
3106
+ async (remoteChanges, needsResync) => {
3107
+ if (needsResync) {
3108
+ return;
3109
+ }
3110
+ for (const rc of remoteChanges) {
3111
+ if (rc.type === "removed" || rc.data === null) {
3112
+ await this._store.deleteDoc(this._collection, rc.docId);
3113
+ this._bus.emit([{
3114
+ type: "removed",
3115
+ collection: this._collection,
3116
+ doc: { id: rc.docId, data: {} }
3117
+ }]);
3118
+ } else {
3119
+ await this._store.putDoc({
3120
+ collection: this._collection,
3121
+ id: rc.docId,
3122
+ data: rc.data,
3123
+ meta: {
3124
+ serverVersion: `rt_${Date.now()}`,
3125
+ updatedAtServer: Date.now(),
3126
+ updatedAtLocal: Date.now(),
3127
+ state: "synced",
3128
+ pendingMutationIds: [],
3129
+ deleted: false,
3130
+ ownerId: null
3131
+ }
3132
+ });
3133
+ this._bus.emit([{
3134
+ type: rc.type,
3135
+ collection: this._collection,
3136
+ doc: { id: rc.docId, data: rc.data }
3137
+ }]);
3138
+ }
3139
+ }
3140
+ }
3141
+ );
3142
+ }
3143
+ return () => {
3144
+ unsubBus();
3145
+ unsubSync();
3146
+ unsubRealtime?.();
3147
+ };
3148
+ }
3149
+ doc(id) {
3150
+ assertValidId(id);
3151
+ const self = this;
3152
+ return {
3153
+ async get() {
3154
+ return self._buildGetResult(id);
3155
+ },
3156
+ async set(data) {
3157
+ return self.set(id, data);
3158
+ },
3159
+ async update(data) {
3160
+ return self.update(id, data);
3161
+ },
3162
+ async remove() {
3163
+ return self.remove(id);
3164
+ },
3165
+ onSnapshot(cb) {
3166
+ self._buildGetResult(id).then((r) => cb(r)).catch(() => cb(null));
3167
+ const unsubBus = self._bus.subscribe(self._collection, async (changes) => {
3168
+ const relevant = changes.find((c) => c.doc.id === id);
3169
+ if (!relevant) return;
3170
+ try {
3171
+ if (relevant.type === "removed") {
3172
+ cb(null);
3173
+ } else {
3174
+ const result = await self._buildGetResult(id);
3175
+ cb(result);
3176
+ }
3177
+ } catch {
3178
+ cb(null);
3179
+ }
3180
+ });
3181
+ const unsubSync = self._engine.onSyncStateChange(async () => {
3182
+ try {
3183
+ const result = await self._buildGetResult(id);
3184
+ cb(result);
3185
+ } catch {
3186
+ cb(null);
3187
+ }
3188
+ });
3189
+ return () => {
3190
+ unsubBus();
3191
+ unsubSync();
3192
+ };
3193
+ }
3194
+ };
3195
+ }
3196
+ };
3197
+ var NeetruDbDocumentsImpl = class {
3198
+ _store;
3199
+ _queue;
3200
+ _bus;
3201
+ _engine;
3202
+ _connectivity;
3203
+ constructor(opts) {
3204
+ this._store = opts.store;
3205
+ this._queue = opts.queue;
3206
+ this._bus = opts.bus;
3207
+ this._engine = opts.engine;
3208
+ this._connectivity = opts.connectivity;
3209
+ }
3210
+ collection(name) {
3211
+ assertValidCollection(name);
3212
+ return new DbCollectionRefImpl(
3213
+ name,
3214
+ this._store,
3215
+ this._queue,
3216
+ this._bus,
3217
+ this._engine,
3218
+ this._connectivity
3219
+ );
3220
+ }
3221
+ get syncState() {
3222
+ return this._engine.getSyncState();
3223
+ }
3224
+ onSyncStateChanged(cb) {
3225
+ return this._engine.onSyncStateChange(cb);
3226
+ }
3227
+ async flush() {
3228
+ return this._engine.flush();
3229
+ }
3230
+ async clearCache() {
3231
+ await this._store.close();
3232
+ await this._store.open();
3233
+ }
3234
+ async getConflicts() {
3235
+ return this._store.listConflicts();
3236
+ }
3237
+ };
3238
+ async function createOfflineDocumentsNamespace(opts) {
3239
+ const store = new LocalStore(opts.dbName);
3240
+ await store.open();
3241
+ const queue = new MutationQueue(store);
3242
+ const resolver = new ConflictResolver();
3243
+ const bus = new ChangeBus();
3244
+ const connectivity = new ConnectivityMonitor({
3245
+ // Se startOnline=false, injeta um navigator falso
3246
+ navigator: opts.startOnline === false ? { onLine: false } : typeof navigator !== "undefined" ? navigator : void 0
3247
+ });
3248
+ const tabCoordinatorLike = opts.singleTab ? (
3249
+ // Fake tab coordinator para single-tab mode (sempre líder)
3250
+ {
3251
+ isLeader: () => true,
3252
+ onRoleChange: (_cb) => {
3253
+ return () => {
3254
+ };
3255
+ }
3256
+ }
3257
+ ) : new TabCoordinator({
3258
+ lockName: `neetru-offline:${opts.dbName}`,
3259
+ channelName: `neetru-offline:${opts.dbName}`
3260
+ });
3261
+ const engine = new SyncEngine({
3262
+ store,
3263
+ queue,
3264
+ resolver,
3265
+ bus,
3266
+ transport: opts.transport,
3267
+ tabCoordinator: tabCoordinatorLike,
3268
+ connectivity,
3269
+ periodicSyncIntervalMs: opts.periodicSyncIntervalMs ?? 5 * 60 * 1e3
3270
+ });
3271
+ return new NeetruDbDocumentsImpl({
3272
+ store,
3273
+ queue,
3274
+ bus,
3275
+ engine,
3276
+ connectivity
3277
+ });
3278
+ }
3279
+
3280
+ // src/db/sql/lease.ts
3281
+ var RENEWAL_THRESHOLD = 0.8;
3282
+ var MIN_RENEWAL_DELAY_MS = 3e4;
3283
+ var SqlLeaseManager = class {
3284
+ _lease;
3285
+ _pool;
3286
+ _orm;
3287
+ _renewalTimer = null;
3288
+ _closed = false;
3289
+ _deps;
3290
+ constructor(lease, pool, orm, deps) {
3291
+ this._lease = lease;
3292
+ this._pool = pool;
3293
+ this._orm = orm;
3294
+ this._deps = {
3295
+ ...deps,
3296
+ now: deps.now ?? (() => Date.now())
3297
+ };
3298
+ this._scheduleRenewal();
3299
+ }
3300
+ // ─── Superfície pública (NeetruSqlClient) ──────────────────────────────────
3301
+ get orm() {
3302
+ this._assertOpen();
3303
+ return this._orm;
3304
+ }
3305
+ async transaction(fn, opts) {
3306
+ this._assertOpen();
3307
+ try {
3308
+ if (opts?.isolationLevel) {
3309
+ return await this._orm.transaction(fn, {
3310
+ isolationLevel: opts.isolationLevel
3311
+ });
3312
+ }
3313
+ return await this._orm.transaction(fn);
3314
+ } catch (err) {
3315
+ throw mapPoolError(err);
3316
+ }
3317
+ }
3318
+ async close() {
3319
+ if (this._closed) return;
3320
+ this._closed = true;
3321
+ this._cancelRenewal();
3322
+ await this._pool.end().catch(() => {
3323
+ });
3324
+ }
3325
+ // ─── Renovação proativa ────────────────────────────────────────────────────
3326
+ /**
3327
+ * Calcula o delay de renovação: momento de ~80% do TTL restante.
3328
+ *
3329
+ * Fórmula: `renewAt = issuedAt + TTL * RENEWAL_THRESHOLD`
3330
+ * = `expiresAt - TTL * (1 - RENEWAL_THRESHOLD)`
3331
+ *
3332
+ * Se o lease já está além do ponto de renovação (ou TTL não calculável),
3333
+ * usa `MIN_RENEWAL_DELAY_MS` como fallback seguro.
3334
+ */
3335
+ _calcRenewalDelayMs() {
3336
+ const expiresAtMs = Date.parse(this._lease.expiresAt);
3337
+ if (!Number.isFinite(expiresAtMs)) return MIN_RENEWAL_DELAY_MS;
3338
+ const now = this._deps.now();
3339
+ const remainingMs = expiresAtMs - now;
3340
+ if (remainingMs <= 0) return MIN_RENEWAL_DELAY_MS;
3341
+ const renewInMs = remainingMs * (1 - RENEWAL_THRESHOLD);
3342
+ return Math.max(MIN_RENEWAL_DELAY_MS, Math.round(renewInMs));
3343
+ }
3344
+ _scheduleRenewal() {
3345
+ if (this._closed) return;
3346
+ const delayMs = this._calcRenewalDelayMs();
3347
+ this._renewalTimer = setTimeout(() => {
3348
+ void this._doRenew();
3349
+ }, delayMs);
3350
+ }
3351
+ _cancelRenewal() {
3352
+ if (this._renewalTimer !== null) {
3353
+ clearTimeout(this._renewalTimer);
3354
+ this._renewalTimer = null;
3355
+ }
3356
+ }
3357
+ async _doRenew() {
3358
+ if (this._closed) return;
3359
+ try {
3360
+ const renewOpts = {
3361
+ leaseId: this._lease.leaseId
3362
+ };
3363
+ if (this._deps.database !== void 0) {
3364
+ renewOpts.database = this._deps.database;
3365
+ }
3366
+ const newLease = await this._deps.fetchLease(renewOpts);
3367
+ await this._swapPool(newLease);
3368
+ } catch (err) {
3369
+ this._renewalTimer = setTimeout(() => {
3370
+ void this._doRenew();
3371
+ }, MIN_RENEWAL_DELAY_MS);
3372
+ if (process.env["NODE_ENV"] !== "production") {
3373
+ console.warn("[SqlLeaseManager] Lease renewal failed, will retry:", err);
3374
+ }
3375
+ return;
3376
+ }
3377
+ this._scheduleRenewal();
3378
+ }
3379
+ /**
3380
+ * Hot-swap do pool: cria pool novo, atualiza ponteiros, drena pool antigo.
3381
+ *
3382
+ * Exposto como método protegido para testes.
3383
+ */
3384
+ async _swapPool(newLease) {
3385
+ const credRotated = newLease.credentialVersion !== this._lease.credentialVersion;
3386
+ if (!credRotated && newLease.leaseId === this._lease.leaseId) {
3387
+ this._lease = newLease;
3388
+ return;
3389
+ }
3390
+ const newPool = this._deps.createPool(newLease);
3391
+ const newOrm = this._deps.createDrizzle(newPool, this._deps.schema);
3392
+ const oldPool = this._pool;
3393
+ this._lease = newLease;
3394
+ this._pool = newPool;
3395
+ this._orm = newOrm;
3396
+ void oldPool.end().catch(() => {
3397
+ });
3398
+ }
3399
+ // ─── Helpers ───────────────────────────────────────────────────────────────
3400
+ _assertOpen() {
3401
+ if (this._closed) {
3402
+ throw new NeetruDbError(
3403
+ "db_unavailable",
3404
+ "SqlLeaseManager foi fechado \u2014 chame close() apenas no shutdown."
3405
+ );
3406
+ }
3407
+ }
3408
+ };
3409
+ function mapPoolError(err) {
3410
+ if (err instanceof NeetruDbError) return err;
3411
+ const message = err instanceof Error ? err.message : String(err);
3412
+ const code = err instanceof Error ? err.code : void 0;
3413
+ if (typeof code === "string" && (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT")) {
3414
+ return new NeetruDbError(
3415
+ "db_unavailable",
3416
+ `Pool connection failed (${code}): ${message}`
3417
+ );
3418
+ }
3419
+ if (message.includes("statement timeout") || message.includes("lock timeout")) {
3420
+ return new NeetruDbError("db_timeout", `Query timeout: ${message}`);
3421
+ }
3422
+ if (message.includes("40001") || message.includes("40P01") || message.includes("deadlock")) {
3423
+ return new NeetruDbError("db_conflict", `Transaction conflict: ${message}`);
3424
+ }
3425
+ if (message.includes("42501") || message.toLowerCase().includes("permission denied")) {
3426
+ return new NeetruDbError("db_permission_denied", `Permission denied: ${message}`);
3427
+ }
3428
+ return new NeetruDbError("db_unavailable", `Database error: ${message}`);
3429
+ }
3430
+
3431
+ // src/db/sql/sql-client.ts
3432
+ var LEASE_ENDPOINT = "/api/sdk/v1/db/lease";
3433
+ var LEASE_RENEW_ENDPOINT = "/api/sdk/v1/db/lease/renew";
3434
+ function mapEnvToCore(sdkEnv) {
3435
+ if (sdkEnv === "workspace") return "staging";
3436
+ if (sdkEnv === "prod") return "production";
3437
+ return "dev";
3438
+ }
3439
+ function createHttpLeaseFetcher(config) {
3440
+ return async (opts) => {
3441
+ const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
3442
+ const isRenewal = Boolean(opts?.leaseId);
3443
+ const path = isRenewal ? LEASE_RENEW_ENDPOINT : LEASE_ENDPOINT;
3444
+ let body;
3445
+ if (isRenewal) {
3446
+ body = { leaseId: opts.leaseId };
3447
+ } else {
3448
+ body = {
3449
+ productId: config.productId ?? "",
3450
+ environment: mapEnvToCore(config.env ?? "prod")
3451
+ };
3452
+ if (opts?.database !== void 0) {
3453
+ body["database"] = opts.database;
3454
+ }
3455
+ }
3456
+ let raw;
3457
+ try {
3458
+ raw = await httpRequest2(config, {
3459
+ method: "POST",
3460
+ path,
3461
+ body,
3462
+ requireAuth: true,
3463
+ // Lease fetch é idempotente do ponto de vista de segurança — retries OK.
3464
+ retries: 2
3465
+ });
3466
+ } catch (err) {
3467
+ const msg = err instanceof Error ? err.message : String(err);
3468
+ throw new NeetruDbError(
3469
+ "db_unavailable",
3470
+ `Falha ao ${isRenewal ? "renovar" : "obter"} lease SQL: ${msg}`
3471
+ );
3472
+ }
3473
+ return parseLease(raw);
3474
+ };
3475
+ }
3476
+ function parseLease(raw) {
3477
+ if (!raw || typeof raw !== "object") {
3478
+ throw new NeetruDbError("db_unavailable", "Resposta de lease inv\xE1lida (n\xE3o \xE9 objeto).");
3479
+ }
3480
+ const envelope = raw;
3481
+ const leaseObj = envelope["lease"];
3482
+ if (!leaseObj || typeof leaseObj !== "object") {
3483
+ throw new NeetruDbError(
3484
+ "db_unavailable",
3485
+ 'Resposta de lease inv\xE1lida: campo "lease" ausente ou n\xE3o \xE9 objeto. O Core retorna { kind, lease: { leaseId, host, ... } }.'
3486
+ );
3487
+ }
3488
+ const r = leaseObj;
3489
+ function req(field, type) {
3490
+ if (typeof r[field] !== type) {
3491
+ throw new NeetruDbError(
3492
+ "db_unavailable",
3493
+ `Campo obrigat\xF3rio "${field}" ausente ou tipo inv\xE1lido na resposta de lease.`
3494
+ );
3495
+ }
3496
+ return r[field];
3497
+ }
3498
+ return {
3499
+ leaseId: req("leaseId", "string"),
3500
+ host: req("host", "string"),
3501
+ port: typeof r["port"] === "number" ? r["port"] : 5433,
3502
+ dbName: req("dbName", "string"),
3503
+ user: req("user", "string"),
3504
+ password: req("password", "string"),
3505
+ sslca: typeof r["sslca"] === "string" ? r["sslca"] : null,
3506
+ clientCert: typeof r["clientCert"] === "string" ? r["clientCert"] : null,
3507
+ clientKey: typeof r["clientKey"] === "string" ? r["clientKey"] : null,
3508
+ credentialVersion: typeof r["credentialVersion"] === "number" ? r["credentialVersion"] : 1,
3509
+ expiresAt: req("expiresAt", "string")
3510
+ };
3511
+ }
3512
+ async function createSqlClientWithDeps(deps, options) {
3513
+ const depsWithDb = options?.database !== void 0 ? { ...deps, database: options.database } : deps;
3514
+ let lease;
3515
+ try {
3516
+ if (options?.database !== void 0) {
3517
+ lease = await depsWithDb.fetchLease({ database: options.database });
3518
+ } else {
3519
+ lease = await depsWithDb.fetchLease();
3520
+ }
3521
+ } catch (err) {
3522
+ if (err instanceof NeetruDbError) throw err;
3523
+ const msg = err instanceof Error ? err.message : String(err);
3524
+ throw new NeetruDbError("db_unavailable", `Falha ao obter lease inicial: ${msg}`);
3525
+ }
3526
+ let pool;
3527
+ try {
3528
+ pool = depsWithDb.createPool(lease);
3529
+ } catch (err) {
3530
+ const msg = err instanceof Error ? err.message : String(err);
3531
+ throw new NeetruDbError("db_unavailable", `Falha ao abrir pool: ${msg}`);
3532
+ }
3533
+ let orm;
3534
+ try {
3535
+ orm = depsWithDb.createDrizzle(pool, depsWithDb.schema);
3536
+ } catch (err) {
3537
+ await pool.end().catch(() => {
3538
+ });
3539
+ const msg = err instanceof Error ? err.message : String(err);
3540
+ throw new NeetruDbError("db_unavailable", `Falha ao criar handle Drizzle: ${msg}`);
3541
+ }
3542
+ return new SqlLeaseManager(lease, pool, orm, depsWithDb);
3543
+ }
3544
+ async function createSqlClientFromConfig(config, schema, options) {
3545
+ const fetchLease = createHttpLeaseFetcher(config);
3546
+ const createPool = (lease) => {
3547
+ const { Pool } = __require("pg");
3548
+ const ssl = lease.sslca ? {
3549
+ ca: lease.sslca,
3550
+ cert: lease.clientCert ?? void 0,
3551
+ key: lease.clientKey ?? void 0,
3552
+ rejectUnauthorized: true
3553
+ } : false;
3554
+ return new Pool({
3555
+ host: lease.host,
3556
+ port: lease.port,
3557
+ database: lease.dbName,
3558
+ user: lease.user,
3559
+ password: lease.password,
3560
+ ssl,
3561
+ max: 3,
3562
+ idleTimeoutMillis: 3e4,
3563
+ connectionTimeoutMillis: 1e4
3564
+ });
3565
+ };
3566
+ const createDrizzle = (pool, s) => {
3567
+ const { drizzle } = __require("drizzle-orm/node-postgres");
3568
+ return drizzle(pool, { schema: s });
3569
+ };
3570
+ return createSqlClientWithDeps(
3571
+ {
3572
+ fetchLease,
3573
+ createPool,
3574
+ createDrizzle,
3575
+ schema
3576
+ },
3577
+ options
3578
+ );
3579
+ }
3580
+
3581
+ // src/db/realtime/realtime-client.ts
3582
+ var WS_OPEN = 1;
3583
+ var WS_CLOSED = 3;
3584
+ var JITTER_MIN = 0.5;
3585
+ var JITTER_MAX = 1.5;
3586
+ var DEFAULT_BACKOFF_BASE_MS = 1e3;
3587
+ var DEFAULT_BACKOFF_MAX_MS = 3e4;
3588
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 25e3;
3589
+ var _idCounter = 0;
3590
+ function generateId() {
3591
+ if (typeof globalThis.crypto?.randomUUID === "function") {
3592
+ return globalThis.crypto.randomUUID();
3593
+ }
3594
+ return `nrt-sub-${Date.now()}-${++_idCounter}-${Math.random().toString(36).slice(2)}`;
3595
+ }
3596
+ var NeetruRealtimeClient = class {
3597
+ // ── configuração ──────────────────────────────────────────────────────────
3598
+ _gatewayUrl;
3599
+ _wsFactory;
3600
+ _setTimeout;
3601
+ _clearTimeout;
3602
+ _backoffBaseMs;
3603
+ _backoffMaxMs;
3604
+ _heartbeatIntervalMs;
3605
+ _ticketProvider;
3606
+ /** ID do banco lógico (BL-8) — embutido em query.filter._dbId de cada subscribe. */
3607
+ _dbId;
3608
+ /** Ticket atual — obtido antes de cada abertura de WS e embutido nos frames. */
3609
+ _currentTicket = null;
3610
+ // ── estado interno ────────────────────────────────────────────────────────
3611
+ /** Subscriptions ativas: id → entry */
3612
+ _subscriptions = /* @__PURE__ */ new Map();
3613
+ /** Listeners do estado da conexão. */
3614
+ _stateListeners = [];
3615
+ /** Estado atual da conexão. */
3616
+ _connectionState = "connecting";
3617
+ /** Socket atual (pode ser null entre tentativas). */
3618
+ _ws = null;
3619
+ /** Número da tentativa de reconexão corrente (reseta após conexão bem-sucedida). */
3620
+ _reconnectAttempt = 0;
3621
+ /** Handle do timer de backoff pendente. */
3622
+ _reconnectTimer = null;
3623
+ /** Handle do timer de heartbeat. */
3624
+ _heartbeatTimer = null;
3625
+ /** Indica que o cliente foi encerrado via `close()`. Não reconecta mais. */
3626
+ _closed = false;
3627
+ /**
3628
+ * Frames de subscribe que precisam ser enviados mas a socket ainda não
3629
+ * está aberta (estado CONNECTING). Drenados no onopen.
3630
+ */
3631
+ _pendingFrames = [];
3632
+ // ── construtor ────────────────────────────────────────────────────────────
3633
+ constructor(options) {
3634
+ this._gatewayUrl = options.gatewayUrl;
3635
+ this._wsFactory = options.webSocketFactory ?? defaultWebSocketFactory;
3636
+ this._setTimeout = options.setTimeoutFn ?? globalThis.setTimeout.bind(globalThis);
3637
+ this._clearTimeout = options.clearTimeoutFn ?? globalThis.clearTimeout.bind(globalThis);
3638
+ this._backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
3639
+ this._backoffMaxMs = options.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
3640
+ this._heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
3641
+ this._ticketProvider = options.ticketProvider;
3642
+ this._dbId = options.dbId;
3643
+ this._connect();
3644
+ }
3645
+ // ── API pública ───────────────────────────────────────────────────────────
3646
+ /**
3647
+ * Registra uma subscription em `collection` com `query` opcional.
3648
+ *
3649
+ * Envia um frame `subscribe` ao gateway (ou enfileira se o socket ainda
3650
+ * não está aberto). O `callback` é invocado com cada frame `delta`,
3651
+ * `resync`, `stale` ou `error` roteado para esta subscription.
3652
+ *
3653
+ * @returns O `subscriptionId` opaco — usar para cancelar via `unsubscribe()`.
3654
+ */
3655
+ subscribe(collection, query, callback) {
3656
+ if (this._closed) {
3657
+ return generateId();
3658
+ }
3659
+ const subscriptionId = generateId();
3660
+ this._subscriptions.set(subscriptionId, {
3661
+ subscriptionId,
3662
+ collection,
3663
+ query: Object.keys(query).length > 0 ? query : void 0,
3664
+ callback
3665
+ });
3666
+ const frame = {
3667
+ op: "subscribe",
3668
+ subscriptionId,
3669
+ collection,
3670
+ query: this._buildSubscribeQuery(query)
3671
+ };
3672
+ this._sendOrBuffer(frame);
3673
+ return subscriptionId;
3674
+ }
3675
+ /**
3676
+ * Cancela uma subscription.
3677
+ *
3678
+ * Envia um frame `unsubscribe` ao gateway e remove o listener local.
3679
+ * Frames subsequentes com este `subscriptionId` são silenciosamente descartados.
3680
+ */
3681
+ unsubscribe(subscriptionId) {
3682
+ if (!this._subscriptions.has(subscriptionId)) {
3683
+ return;
3684
+ }
3685
+ this._subscriptions.delete(subscriptionId);
3686
+ const frame = { op: "unsubscribe", subscriptionId };
3687
+ this._sendOrBuffer(frame);
3688
+ }
3689
+ /**
3690
+ * Registra um listener de mudanças no estado da conexão.
3691
+ *
3692
+ * O listener é invocado imediatamente com o estado atual e depois a cada
3693
+ * transição. Retorna uma função `unsubscribe`.
3694
+ */
3695
+ onConnectionState(listener) {
3696
+ this._stateListeners.push(listener);
3697
+ listener(this._connectionState);
3698
+ return () => {
3699
+ this._stateListeners = this._stateListeners.filter((l) => l !== listener);
3700
+ };
3701
+ }
3702
+ /**
3703
+ * Encerra o cliente definitivamente.
3704
+ *
3705
+ * Fecha o socket ativo, cancela timers pendentes e marca o cliente como
3706
+ * encerrado (sem mais reconexões).
3707
+ */
3708
+ close() {
3709
+ this._closed = true;
3710
+ this._cancelReconnectTimer();
3711
+ this._cancelHeartbeatTimer();
3712
+ if (this._ws) {
3713
+ this._detachHandlers(this._ws);
3714
+ if (this._ws.readyState !== WS_CLOSED) {
3715
+ this._ws.close(1e3, "client closed");
3716
+ }
3717
+ this._ws = null;
3718
+ }
3719
+ this._setState("disconnected");
3720
+ }
3721
+ // ── Conexão ───────────────────────────────────────────────────────────────
3722
+ /**
3723
+ * Busca um ticket de autenticação e, em seguida, cria um novo WebSocket e
3724
+ * instala os handlers de evento.
3725
+ *
3726
+ * A busca de ticket é obrigatória antes de abrir qualquer WS (primeira
3727
+ * conexão e reconexões). Se a busca falhar, a conexão é abortada com
3728
+ * estado `'connecting'` e o backoff reconectará em seguida.
3729
+ *
3730
+ * Fail-closed: o WebSocket NUNCA é aberto sem um ticket válido.
3731
+ */
3732
+ _connect() {
3733
+ if (this._closed) return;
3734
+ this._setState("connecting");
3735
+ void this._connectWithTicket();
3736
+ }
3737
+ /** Implementação assíncrona de _connect — busca ticket e abre WS. */
3738
+ async _connectWithTicket() {
3739
+ if (this._closed) return;
3740
+ let ticket;
3741
+ try {
3742
+ ticket = await this._ticketProvider();
3743
+ } catch (err) {
3744
+ if (this._closed) return;
3745
+ const delay = this._computeBackoffDelay(this._reconnectAttempt);
3746
+ this._reconnectAttempt++;
3747
+ this._reconnectTimer = this._setTimeout(() => {
3748
+ this._reconnectTimer = null;
3749
+ if (!this._closed) this._connect();
3750
+ }, delay);
3751
+ return;
3752
+ }
3753
+ if (this._closed) return;
3754
+ this._currentTicket = ticket;
3755
+ const ws = this._wsFactory(this._gatewayUrl);
3756
+ this._ws = ws;
3757
+ ws.onopen = this._handleOpen.bind(this);
3758
+ ws.onmessage = this._handleMessage.bind(this);
3759
+ ws.onclose = this._handleClose.bind(this);
3760
+ ws.onerror = this._handleError.bind(this);
3761
+ }
3762
+ // ── Handlers de WebSocket ─────────────────────────────────────────────────
3763
+ _handleOpen(_event) {
3764
+ if (this._closed) return;
3765
+ this._reconnectAttempt = 0;
3766
+ this._setState("connected");
3767
+ for (const frame of this._pendingFrames) {
3768
+ this._sendNow(frame);
3769
+ }
3770
+ this._pendingFrames = [];
3771
+ this._resubscribeAll();
3772
+ this._scheduleHeartbeat();
3773
+ }
3774
+ _handleMessage(event) {
3775
+ if (this._closed) return;
3776
+ let frame;
3777
+ try {
3778
+ frame = JSON.parse(event.data);
3779
+ } catch {
3780
+ return;
3781
+ }
3782
+ switch (frame.op) {
3783
+ case "delta":
3784
+ case "resync":
3785
+ case "stale":
3786
+ case "error":
3787
+ this._routeToSubscription(frame);
3788
+ break;
3789
+ case "pong":
3790
+ break;
3791
+ case "drain":
3792
+ this._handleDrain();
3793
+ break;
3794
+ }
3795
+ }
3796
+ _handleClose(_event) {
3797
+ if (this._closed) return;
3798
+ this._cancelHeartbeatTimer();
3799
+ this._ws = null;
3800
+ this._scheduleReconnect();
3801
+ }
3802
+ _handleError(_event) {
3803
+ if (this._closed) return;
3804
+ this._setState("connecting");
3805
+ }
3806
+ // ── Drain (graceful shutdown do gateway) ─────────────────────────────────
3807
+ _handleDrain() {
3808
+ if (this._closed) return;
3809
+ this._setState("draining");
3810
+ this._cancelHeartbeatTimer();
3811
+ if (this._ws) {
3812
+ this._detachHandlers(this._ws);
3813
+ if (this._ws.readyState !== WS_CLOSED) {
3814
+ this._ws.close(1001, "drain");
3815
+ }
3816
+ this._ws = null;
3817
+ }
3818
+ this._scheduleReconnect();
3819
+ }
3820
+ // ── Roteamento de frames ──────────────────────────────────────────────────
3821
+ _routeToSubscription(frame) {
3822
+ const entry = this._subscriptions.get(frame.subscriptionId);
3823
+ if (!entry) {
3824
+ return;
3825
+ }
3826
+ try {
3827
+ entry.callback(frame);
3828
+ } catch {
3829
+ }
3830
+ }
3831
+ // ── Resubscrição ──────────────────────────────────────────────────────────
3832
+ /**
3833
+ * Reenvía frames `subscribe` para todas as subscriptions ativas.
3834
+ * Chamado no `onopen` de reconexões para restaurar o estado remoto.
3835
+ *
3836
+ * Durante a PRIMEIRA conexão: _pendingFrames já foi drenado com os
3837
+ * subscribes iniciais acima. ResubscribeAll envia novamente — como a
3838
+ * lista de subscriptions foi populada pelos `subscribe()` calls anteriores
3839
+ * ao open, e os frames drenados também os incluem, teríamos duplicatas.
3840
+ *
3841
+ * Solução: resubscribeAll só é invocado após drenar _pendingFrames, e
3842
+ * como o gateway é idempotente para o mesmo subscriptionId (segundo o
3843
+ * design do componente #19), a duplicata é inócua. Em reconexões, onde
3844
+ * _pendingFrames está vazio, os frames são os únicos enviados.
3845
+ *
3846
+ * Para o caso do primeiro open com subscribes anteriores ao open, os
3847
+ * frames já foram drenados de _pendingFrames — não chamamos resubscribeAll
3848
+ * se era a primeira conexão (_reconnectAttempt era 0 antes de resetar).
3849
+ * Rastreamos isso com _hasConnectedOnce.
3850
+ */
3851
+ _hasConnectedOnce = false;
3852
+ _resubscribeAll() {
3853
+ if (!this._hasConnectedOnce) {
3854
+ this._hasConnectedOnce = true;
3855
+ return;
3856
+ }
3857
+ for (const entry of this._subscriptions.values()) {
3858
+ const frame = {
3859
+ op: "subscribe",
3860
+ subscriptionId: entry.subscriptionId,
3861
+ collection: entry.collection,
3862
+ query: this._buildSubscribeQuery(entry.query ?? {})
3863
+ };
3864
+ this._sendNow(frame);
3865
+ }
3866
+ }
3867
+ // ── Ticket embedding ──────────────────────────────────────────────────────
3868
+ /**
3869
+ * Constrói o descriptor `query` para um frame `subscribe`, embutindo o
3870
+ * `token` do ticket corrente em `query.filter._ticket` e o `_dbId` do
3871
+ * banco lógico em `query.filter._dbId` (BL-8 fix).
3872
+ *
3873
+ * O gateway extrai `filter._ticket` do primeiro frame `subscribe` e valida
3874
+ * contra `POST /api/sdk/v1/db/realtime/validate`. `filter._dbId` é exigido
3875
+ * pelo `@neetru/realtime-changestream._extractDbId` para rotear a
3876
+ * subscription ao banco correto — sem ele a subscription é rejeitada.
3877
+ *
3878
+ * Retorna `undefined` se a query seria vazia E não há ticket (estado
3879
+ * transitório antes do primeiro ticket — não deve ocorrer normalmente pois
3880
+ * `_connect` só abre o WS após obter o ticket).
3881
+ */
3882
+ _buildSubscribeQuery(query) {
3883
+ const ticket = this._currentTicket;
3884
+ const hasUserQuery = Object.keys(query).length > 0;
3885
+ if (!ticket) {
3886
+ return hasUserQuery ? query : void 0;
3887
+ }
3888
+ const mergedFilter = {
3889
+ ...query.filter ?? {},
3890
+ _ticket: ticket.token
3891
+ };
3892
+ if (this._dbId !== void 0) {
3893
+ mergedFilter["_dbId"] = this._dbId;
3894
+ }
3895
+ return {
3896
+ ...query,
3897
+ filter: mergedFilter
3898
+ };
3899
+ }
3900
+ // ── Backoff e reconexão ───────────────────────────────────────────────────
3901
+ /** Agenda uma tentativa de reconexão com backoff exponencial + jitter. */
3902
+ _scheduleReconnect() {
3903
+ if (this._closed) return;
3904
+ this._cancelReconnectTimer();
3905
+ this._setState("connecting");
3906
+ this._currentTicket = null;
3907
+ const delayMs = this._computeBackoffDelay(this._reconnectAttempt);
3908
+ this._reconnectAttempt++;
3909
+ this._reconnectTimer = this._setTimeout(() => {
3910
+ this._reconnectTimer = null;
3911
+ if (!this._closed) {
3912
+ this._connect();
3913
+ }
3914
+ }, delayMs);
3915
+ }
3916
+ /**
3917
+ * Calcula o delay de backoff para o attempt N.
3918
+ *
3919
+ * Fórmula: `min(baseMs * 2^N, maxMs) * jitter`
3920
+ * onde `jitter ∈ [JITTER_MIN, JITTER_MAX]` (±50%).
3921
+ */
3922
+ _computeBackoffDelay(attempt) {
3923
+ const exponential = this._backoffBaseMs * Math.pow(2, attempt);
3924
+ const capped = Math.min(exponential, this._backoffMaxMs);
3925
+ const jitter = JITTER_MIN + Math.random() * (JITTER_MAX - JITTER_MIN);
3926
+ return Math.round(capped * jitter);
3927
+ }
3928
+ _cancelReconnectTimer() {
3929
+ if (this._reconnectTimer !== null) {
3930
+ this._clearTimeout(this._reconnectTimer);
3931
+ this._reconnectTimer = null;
3932
+ }
3933
+ }
3934
+ // ── Heartbeat ─────────────────────────────────────────────────────────────
3935
+ /** Agenda o próximo ping. Não-operacional se heartbeatIntervalMs === 0. */
3936
+ _scheduleHeartbeat() {
3937
+ if (this._heartbeatIntervalMs === 0) return;
3938
+ this._cancelHeartbeatTimer();
3939
+ this._heartbeatTimer = this._setTimeout(() => {
3940
+ this._heartbeatTimer = null;
3941
+ this._sendPing();
3942
+ this._scheduleHeartbeat();
3943
+ }, this._heartbeatIntervalMs);
3944
+ }
3945
+ _cancelHeartbeatTimer() {
3946
+ if (this._heartbeatTimer !== null) {
3947
+ this._clearTimeout(this._heartbeatTimer);
3948
+ this._heartbeatTimer = null;
3949
+ }
3950
+ }
3951
+ _sendPing() {
3952
+ const frame = { op: "ping", subscriptionId: "" };
3953
+ this._sendOrBuffer(frame);
3954
+ }
3955
+ // ── Envio de frames ───────────────────────────────────────────────────────
3956
+ /**
3957
+ * Envia o frame imediatamente se o socket está aberto; caso contrário,
3958
+ * enfileira em `_pendingFrames` para envio no próximo `onopen`.
3959
+ */
3960
+ _sendOrBuffer(frame) {
3961
+ if (this._ws !== null && this._ws.readyState === WS_OPEN) {
3962
+ this._sendNow(frame);
3963
+ } else {
3964
+ if (frame.op !== "unsubscribe") {
3965
+ this._pendingFrames.push(frame);
3966
+ }
3967
+ }
3968
+ }
3969
+ /** Serializa e envia o frame imediatamente via WebSocket. */
3970
+ _sendNow(frame) {
3971
+ if (!this._ws || this._ws.readyState !== WS_OPEN) return;
3972
+ try {
3973
+ this._ws.send(JSON.stringify(frame));
3974
+ } catch {
3975
+ }
3976
+ }
3977
+ // ── Estado da conexão ─────────────────────────────────────────────────────
3978
+ _setState(state) {
3979
+ if (this._connectionState === state) return;
3980
+ this._connectionState = state;
3981
+ for (const listener of this._stateListeners) {
3982
+ try {
3983
+ listener(state);
3984
+ } catch {
3985
+ }
3986
+ }
3987
+ }
3988
+ // ── Desvinculação de handlers ─────────────────────────────────────────────
3989
+ /** Remove todos os handlers de um WebSocket (evita disparo após close()). */
3990
+ _detachHandlers(ws) {
3991
+ ws.onopen = null;
3992
+ ws.onmessage = null;
3993
+ ws.onclose = null;
3994
+ ws.onerror = null;
3995
+ }
3996
+ };
3997
+ var defaultWebSocketFactory = (url) => {
3998
+ if (typeof WebSocket === "undefined") {
3999
+ throw new Error(
4000
+ "[NeetruRealtimeClient] WebSocket n\xE3o dispon\xEDvel neste ambiente. Injete uma implementa\xE7\xE3o via `webSocketFactory` nas op\xE7\xF5es."
4001
+ );
4002
+ }
4003
+ return new WebSocket(url);
4004
+ };
4005
+
4006
+ // src/db/client-db.ts
4007
+ var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
4008
+ var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
4009
+ function resolveTransport(engine, opts, config) {
4010
+ if (opts._transport) {
4011
+ return opts._transport;
4012
+ }
4013
+ if (engine === "firestore") {
4014
+ if (opts.firestoreTransport) {
4015
+ return opts.firestoreTransport;
4016
+ }
4017
+ }
4018
+ if (engine === "nosql-vm" && opts.realtimeGatewayUrl) {
4019
+ return createWebSocketSyncTransport(opts.realtimeGatewayUrl, config, opts);
4020
+ }
4021
+ return createRestSyncTransport(config);
4022
+ }
4023
+ function createRestSyncTransport(config) {
4024
+ return {
4025
+ async pushMutations(mutations) {
4026
+ if (!config) {
4027
+ throw new NeetruDbError(
4028
+ "db_unavailable",
4029
+ "[RestSyncTransport] config n\xE3o dispon\xEDvel \u2014 n\xE3o \xE9 poss\xEDvel enviar ao Core. Inicialize o transporte via createNeetruDb."
4030
+ );
4031
+ }
4032
+ const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
4033
+ const results = [];
4034
+ const tenantScopeId = config.tenantId ?? config.productId;
4035
+ const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
4036
+ for (const m of mutations) {
4037
+ try {
4038
+ let raw;
4039
+ if (m.op === "add") {
4040
+ raw = await httpRequest2(config, {
4041
+ method: "POST",
4042
+ path: DATASTORE_COLLECTION_ENDPOINT(m.collection),
4043
+ body: { data: m.payload ?? {} },
4044
+ requireAuth: true,
4045
+ retries: 0,
4046
+ headers: tenantHeaders
4047
+ });
4048
+ } else if (m.op === "set") {
4049
+ raw = await httpRequest2(config, {
4050
+ method: "PUT",
4051
+ path: DATASTORE_DOC_ENDPOINT(m.collection, m.docId),
4052
+ body: { data: m.payload ?? {} },
4053
+ requireAuth: true,
4054
+ retries: 2,
4055
+ headers: tenantHeaders
4056
+ });
4057
+ } else if (m.op === "update") {
4058
+ raw = await httpRequest2(config, {
4059
+ method: "PATCH",
4060
+ path: DATASTORE_DOC_ENDPOINT(m.collection, m.docId),
4061
+ body: { data: m.payload ?? {} },
4062
+ requireAuth: true,
4063
+ retries: 2,
4064
+ headers: tenantHeaders
4065
+ });
4066
+ } else {
4067
+ raw = await httpRequest2(config, {
4068
+ method: "DELETE",
4069
+ path: DATASTORE_DOC_ENDPOINT(m.collection, m.docId),
4070
+ requireAuth: true,
4071
+ retries: 2,
4072
+ headers: tenantHeaders
4073
+ });
4074
+ }
4075
+ const resp = raw;
4076
+ results.push({
4077
+ mutationId: m.mutationId,
4078
+ outcome: "confirmed",
4079
+ serverVersion: resp?.serverVersion ?? resp?.id ?? `rest_${Date.now()}`,
4080
+ serverTimestamp: Date.now()
4081
+ });
4082
+ } catch (err) {
4083
+ const msg = err instanceof Error ? err.message : String(err);
4084
+ throw new NeetruDbError("db_unavailable", `pushMutations falhou: ${msg}`);
4085
+ }
4086
+ }
4087
+ return { results };
4088
+ },
4089
+ async pullChanges(_watermark, _resumeToken) {
4090
+ if (!config) {
4091
+ return { docs: [], newWatermark: Date.now(), resyncRequired: false };
4092
+ }
4093
+ return { docs: [], newWatermark: Date.now(), resyncRequired: false };
4094
+ },
4095
+ async fullResync(_collections) {
4096
+ if (!config) {
4097
+ return { docs: [], newWatermark: Date.now() };
4098
+ }
4099
+ return { docs: [], newWatermark: Date.now() };
4100
+ }
4101
+ };
4102
+ }
4103
+ function createWebSocketSyncTransport(gatewayUrl, config, opts) {
4104
+ const restTransport = createRestSyncTransport(config);
4105
+ const realtimeClient = new NeetruRealtimeClient({
4106
+ gatewayUrl,
4107
+ ticketProvider: buildTicketProvider(config, opts),
4108
+ dbId: opts.dbId,
4109
+ webSocketFactory: opts._wsFactory
4110
+ });
4111
+ return {
4112
+ pushMutations: restTransport.pushMutations.bind(restTransport),
4113
+ pullChanges: restTransport.pullChanges.bind(restTransport),
4114
+ fullResync: restTransport.fullResync.bind(restTransport),
4115
+ // HIGH-1: subscribeCollection — chamado por DbCollectionRefImpl.onSnapshot
4116
+ // quando o engine é nosql-vm. Registra a subscription no NeetruRealtimeClient
4117
+ // e traduz os frames inbound para o contrato de `onChange`.
4118
+ subscribeCollection(collection, onChange) {
4119
+ const subId = realtimeClient.subscribe(collection, {}, (frame) => {
4120
+ if (frame.op === "resync") {
4121
+ onChange([], true);
4122
+ return;
4123
+ }
4124
+ if (frame.op === "stale") {
4125
+ onChange([], true);
4126
+ return;
4127
+ }
4128
+ if (frame.op === "delta" && frame.changes) {
4129
+ const changes = frame.changes.map((c) => ({
4130
+ type: c.type === "insert" ? "added" : c.type === "delete" ? "removed" : "modified",
4131
+ docId: c.documentId,
4132
+ data: c.data ?? null
4133
+ }));
4134
+ if (changes.length > 0) {
4135
+ onChange(changes, false);
4136
+ }
4137
+ }
4138
+ });
4139
+ return () => {
4140
+ realtimeClient.unsubscribe(subId);
4141
+ };
4142
+ }
4143
+ };
4144
+ }
4145
+ function buildTicketProvider(config, opts) {
4146
+ return async () => {
4147
+ const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
4148
+ const ticket = await httpRequest2(config, {
4149
+ method: "POST",
4150
+ path: "/api/sdk/v1/db/realtime/ticket",
4151
+ body: {
4152
+ productId: config.productId ?? "",
4153
+ collections: opts.collections ?? ["*"]
4154
+ },
4155
+ requireAuth: true,
4156
+ retries: 1
4157
+ });
4158
+ return ticket;
4159
+ };
4160
+ }
4161
+ function createNeetruDb(config, dbOpts = {}) {
4162
+ const engine = dbOpts.engine ?? "rest";
4163
+ const transport = resolveTransport(engine, dbOpts, config);
4164
+ const dbId = dbOpts.dbId ?? "default";
4165
+ const dbName = dbOpts.dbName ?? `neetru-db__${config.productId ?? "sdk"}__${dbId}__${config.env}`;
4166
+ let _docsPromise = null;
4167
+ let _docsResolved = null;
4168
+ function getDocsPromise() {
4169
+ if (!_docsPromise) {
4170
+ _docsPromise = createOfflineDocumentsNamespace({
4171
+ dbName,
4172
+ transport,
4173
+ singleTab: dbOpts.singleTab ?? config.env !== "prod"
4174
+ }).then((docs) => {
4175
+ _docsResolved = docs;
4176
+ return docs;
4177
+ });
4178
+ }
4179
+ return _docsPromise;
4180
+ }
4181
+ return {
4182
+ collection(name) {
4183
+ if (_docsResolved) return _docsResolved.collection(name);
4184
+ return createLazyCollectionRef(name, getDocsPromise);
4185
+ },
4186
+ async sql(schema, options) {
4187
+ if (config.env === "dev") {
4188
+ throw new NeetruDbError(
4189
+ "db_unavailable",
4190
+ "[SDK] db.sql() n\xE3o dispon\xEDvel em NEETRU_ENV=dev. Use `neetru dev` para subir o container Postgres local."
4191
+ );
4192
+ }
4193
+ return createSqlClientFromConfig(config, schema, options);
4194
+ },
4195
+ get syncState() {
4196
+ return _docsResolved?.syncState ?? {
4197
+ status: "idle",
4198
+ pendingWrites: 0,
4199
+ lastSyncedAt: null,
4200
+ isLeaderTab: false
4201
+ };
4202
+ },
4203
+ onSyncStateChanged(cb) {
4204
+ if (_docsResolved) return _docsResolved.onSyncStateChanged(cb);
4205
+ let unsub = null;
4206
+ let cancelled = false;
4207
+ getDocsPromise().then((docs) => {
4208
+ if (!cancelled) {
4209
+ unsub = docs.onSyncStateChanged(cb);
4210
+ }
4211
+ });
4212
+ return () => {
4213
+ cancelled = true;
4214
+ unsub?.();
4215
+ };
4216
+ },
4217
+ async flush() {
4218
+ const docs = await getDocsPromise();
4219
+ return docs.flush();
4220
+ },
4221
+ async clearCache() {
4222
+ const docs = await getDocsPromise();
4223
+ return docs.clearCache();
4224
+ },
4225
+ async getConflicts() {
4226
+ const docs = await getDocsPromise();
4227
+ return docs.getConflicts();
4228
+ }
4229
+ };
4230
+ }
4231
+ function createLazyCollectionRef(name, getDocsPromise) {
4232
+ async function getRef() {
4233
+ const docs = await getDocsPromise();
4234
+ return docs.collection(name);
4235
+ }
4236
+ return {
4237
+ async get(id) {
4238
+ return (await getRef()).get(id);
4239
+ },
4240
+ async list(q) {
4241
+ return (await getRef()).list(q);
4242
+ },
4243
+ async add(data) {
4244
+ return (await getRef()).add(data);
4245
+ },
4246
+ async set(id, data) {
4247
+ return (await getRef()).set(id, data);
4248
+ },
4249
+ async update(id, data) {
4250
+ return (await getRef()).update(id, data);
4251
+ },
4252
+ async remove(id) {
4253
+ return (await getRef()).remove(id);
4254
+ },
4255
+ async batch(ops) {
4256
+ return (await getRef()).batch(ops);
4257
+ },
4258
+ onDoc(id, cb) {
4259
+ let unsub = null;
4260
+ let cancelled = false;
4261
+ getRef().then((ref) => {
4262
+ if (!cancelled) {
4263
+ unsub = ref.onDoc(id, cb);
4264
+ }
4265
+ });
4266
+ return () => {
4267
+ cancelled = true;
4268
+ unsub?.();
4269
+ };
4270
+ },
4271
+ onSnapshot(q, cb) {
4272
+ let unsub = null;
4273
+ let cancelled = false;
4274
+ getRef().then((ref) => {
4275
+ if (!cancelled) {
4276
+ unsub = ref.onSnapshot(q, cb);
4277
+ }
4278
+ });
4279
+ return () => {
4280
+ cancelled = true;
4281
+ unsub?.();
4282
+ };
4283
+ },
4284
+ doc(id) {
4285
+ return {
4286
+ async get() {
4287
+ return (await getRef()).doc(id).get();
4288
+ },
4289
+ async set(data) {
4290
+ return (await getRef()).doc(id).set(data);
4291
+ },
4292
+ async update(data) {
4293
+ return (await getRef()).doc(id).update(data);
4294
+ },
4295
+ async remove() {
4296
+ return (await getRef()).doc(id).remove();
4297
+ },
4298
+ onSnapshot(cb) {
4299
+ let unsub = null;
4300
+ let cancelled = false;
4301
+ getRef().then((ref) => {
4302
+ if (!cancelled) {
4303
+ unsub = ref.doc(id).onSnapshot(cb);
4304
+ }
4305
+ });
4306
+ return () => {
4307
+ cancelled = true;
4308
+ unsub?.();
4309
+ };
4310
+ }
4311
+ };
4312
+ }
4313
+ };
4314
+ }
4315
+
4316
+ // src/checkout.ts
4317
+ init_errors();
4318
+ init_http();
4319
+ function parseStartResponse(raw) {
4320
+ if (!raw || typeof raw !== "object") {
4321
+ throw new NeetruError("invalid_response", "checkout.start response is not an object");
4322
+ }
4323
+ const r = raw;
4324
+ if (typeof r.intentId !== "string" || !r.intentId) {
4325
+ throw new NeetruError("invalid_response", "checkout.start response missing intentId");
4326
+ }
4327
+ if (typeof r.redirectUrl !== "string" || !r.redirectUrl) {
4328
+ throw new NeetruError("invalid_response", "checkout.start response missing redirectUrl");
4329
+ }
4330
+ return {
4331
+ intentId: r.intentId,
4332
+ redirectUrl: r.redirectUrl,
4333
+ status: r.status ?? "pending",
4334
+ expiresAt: typeof r.expiresAt === "string" ? r.expiresAt : (/* @__PURE__ */ new Date()).toISOString(),
4335
+ requiresKyc: r.requiresKyc === true
4336
+ };
4337
+ }
4338
+ function parseGetResponse(raw) {
4339
+ if (!raw || typeof raw !== "object") {
4340
+ throw new NeetruError("invalid_response", "checkout.get response is not an object");
4341
+ }
4342
+ const r = raw;
4343
+ const intent = r.intent;
4344
+ if (!intent || typeof intent !== "object") {
4345
+ throw new NeetruError("invalid_response", "checkout.get response missing intent");
4346
+ }
4347
+ if (typeof intent.intentId !== "string") {
4348
+ throw new NeetruError("invalid_response", "checkout.get response missing intentId");
4349
+ }
4350
+ return {
4351
+ intentId: intent.intentId,
4352
+ uid: intent.uid ?? "",
4353
+ targetTenantId: intent.targetTenantId ?? "",
4354
+ targetTenantType: intent.targetTenantType ?? "pf",
4355
+ productId: intent.productId ?? "",
4356
+ planId: intent.planId ?? "",
4357
+ callbackUrl: intent.callbackUrl ?? "",
4358
+ status: intent.status ?? "pending",
4359
+ stripeSessionId: intent.stripeSessionId ?? null,
4360
+ expiresAt: intent.expiresAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4361
+ isStale: r.isStale === true
4362
+ };
4363
+ }
4364
+ function inBrowser() {
4365
+ try {
4366
+ return typeof globalThis !== "undefined" && typeof globalThis.window !== "undefined" && typeof globalThis.location !== "undefined" && typeof globalThis.location.assign === "function";
4367
+ } catch {
4368
+ return false;
4369
+ }
4370
+ }
4371
+ function performRedirect(url) {
4372
+ try {
4373
+ globalThis.location.assign(url);
4374
+ } catch {
4375
+ }
4376
+ }
4377
+ function createHttpCheckoutNamespace(config) {
4378
+ return {
4379
+ async start(input) {
4380
+ if (!input?.productId) {
4381
+ throw new NeetruError("validation_failed", "checkout.start: productId is required");
4382
+ }
4383
+ if (!input?.planId) {
4384
+ throw new NeetruError("validation_failed", "checkout.start: planId is required");
4385
+ }
4386
+ if (!input?.callbackUrl) {
4387
+ throw new NeetruError("validation_failed", "checkout.start: callbackUrl is required");
4388
+ }
4389
+ const body = {
4390
+ productId: input.productId,
4391
+ planId: input.planId,
4392
+ callbackUrl: input.callbackUrl
4393
+ };
4394
+ if (input.tenantType) body.targetTenantType = input.tenantType;
4395
+ if (input.tenantId) body.targetTenantId = input.tenantId;
4396
+ const raw = await httpRequest(config, {
4397
+ method: "POST",
4398
+ path: "/api/v1/checkout/intents",
4399
+ body,
4400
+ requireAuth: true
4401
+ });
4402
+ const result = parseStartResponse(raw);
4403
+ const shouldRedirect = input.autoRedirect !== false;
4404
+ if (shouldRedirect && inBrowser()) {
4405
+ performRedirect(result.redirectUrl);
4406
+ }
4407
+ return result;
4408
+ },
4409
+ async get(intentId) {
4410
+ if (!intentId || typeof intentId !== "string") {
4411
+ throw new NeetruError("validation_failed", "checkout.get: intentId is required");
4412
+ }
4413
+ const raw = await httpRequest(config, {
795
4414
  method: "GET",
796
4415
  path: `/api/v1/checkout/intents/${encodeURIComponent(intentId)}`,
797
4416
  requireAuth: true
@@ -871,6 +4490,301 @@ function createCheckoutNamespace(config) {
871
4490
  return createHttpCheckoutNamespace(config);
872
4491
  }
873
4492
 
4493
+ // src/webhooks.ts
4494
+ init_errors();
4495
+ init_http();
4496
+ var VALID_EVENTS = [
4497
+ "subscription.activated",
4498
+ "subscription.cancelled",
4499
+ "subscription.payment_failed",
4500
+ "subscription.trial_ending",
4501
+ "usage.quota_exceeded",
4502
+ "account.suspended",
4503
+ "account.reactivated",
4504
+ "support.ticket_replied"
4505
+ ];
4506
+ function toEndpoint(raw) {
4507
+ if (!raw || typeof raw !== "object") {
4508
+ throw new NeetruError("invalid_response", "Webhook response is not an object");
4509
+ }
4510
+ const r = raw;
4511
+ if (typeof r.id !== "string") {
4512
+ throw new NeetruError("invalid_response", "Webhook missing id");
4513
+ }
4514
+ return {
4515
+ id: r.id,
4516
+ url: typeof r.url === "string" ? r.url : "",
4517
+ events: Array.isArray(r.events) ? r.events.filter(
4518
+ (e) => VALID_EVENTS.includes(e)
4519
+ ) : [],
4520
+ hasSecret: r.hasSecret === true,
4521
+ status: r.status === "active" || r.status === "degraded" || r.status === "disabled" ? r.status : "active",
4522
+ lastDeliveryAt: typeof r.lastDeliveryAt === "string" ? r.lastDeliveryAt : void 0,
4523
+ consecutiveFailures: typeof r.consecutiveFailures === "number" ? r.consecutiveFailures : void 0,
4524
+ createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString()
4525
+ };
4526
+ }
4527
+ function validateInput(input) {
4528
+ if (!input.url || typeof input.url !== "string") {
4529
+ throw new NeetruError("validation_failed", "url \xE9 obrigat\xF3ria");
4530
+ }
4531
+ try {
4532
+ const parsed = new URL(input.url);
4533
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
4534
+ throw new Error("invalid protocol");
4535
+ }
4536
+ } catch {
4537
+ throw new NeetruError("validation_failed", `url inv\xE1lida: ${input.url}`);
4538
+ }
4539
+ if (!Array.isArray(input.events) || input.events.length === 0) {
4540
+ throw new NeetruError("validation_failed", "events deve ter pelo menos 1 evento");
4541
+ }
4542
+ for (const ev of input.events) {
4543
+ if (!VALID_EVENTS.includes(ev)) {
4544
+ throw new NeetruError("validation_failed", `evento desconhecido: ${ev}`);
4545
+ }
4546
+ }
4547
+ if (input.secret !== void 0 && input.secret.length < 16) {
4548
+ throw new NeetruError("validation_failed", "secret deve ter \u226516 chars (recomendado 32+)");
4549
+ }
4550
+ }
4551
+ function createWebhooksNamespace(config) {
4552
+ return {
4553
+ async register(input) {
4554
+ validateInput(input);
4555
+ const raw = await httpRequest(config, {
4556
+ method: "POST",
4557
+ path: "/api/sdk/v1/webhooks",
4558
+ body: input,
4559
+ requireAuth: true
4560
+ });
4561
+ return toEndpoint(raw);
4562
+ },
4563
+ async list() {
4564
+ const raw = await httpRequest(config, {
4565
+ method: "GET",
4566
+ path: "/api/sdk/v1/webhooks",
4567
+ requireAuth: true
4568
+ });
4569
+ const list = Array.isArray(raw?.endpoints) ? raw.endpoints : [];
4570
+ return list.map(toEndpoint);
4571
+ },
4572
+ async unregister(id) {
4573
+ if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
4574
+ await httpRequest(config, {
4575
+ method: "DELETE",
4576
+ path: `/api/sdk/v1/webhooks/${encodeURIComponent(id)}`,
4577
+ requireAuth: true
4578
+ });
4579
+ return { ok: true };
4580
+ },
4581
+ async test(id) {
4582
+ if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
4583
+ const raw = await httpRequest(config, {
4584
+ method: "POST",
4585
+ path: `/api/sdk/v1/webhooks/${encodeURIComponent(id)}/test`,
4586
+ requireAuth: true
4587
+ });
4588
+ if (!raw || typeof raw !== "object") {
4589
+ return { ok: false, error: "invalid response" };
4590
+ }
4591
+ const r = raw;
4592
+ return {
4593
+ ok: r.ok === true,
4594
+ statusCode: typeof r.statusCode === "number" ? r.statusCode : void 0,
4595
+ durationMs: typeof r.durationMs === "number" ? r.durationMs : void 0,
4596
+ error: typeof r.error === "string" ? r.error : void 0
4597
+ };
4598
+ }
4599
+ };
4600
+ }
4601
+ var MockWebhooks = class {
4602
+ endpoints = /* @__PURE__ */ new Map();
4603
+ nextId = 1;
4604
+ async register(input) {
4605
+ validateInput(input);
4606
+ const id = `mock_wh_${this.nextId++}`;
4607
+ const endpoint = {
4608
+ id,
4609
+ url: input.url,
4610
+ events: input.events,
4611
+ hasSecret: !!input.secret,
4612
+ status: "active",
4613
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4614
+ };
4615
+ this.endpoints.set(id, endpoint);
4616
+ return endpoint;
4617
+ }
4618
+ async list() {
4619
+ return [...this.endpoints.values()];
4620
+ }
4621
+ async unregister(id) {
4622
+ if (!this.endpoints.has(id)) {
4623
+ throw new NeetruError("not_found", `Webhook ${id} n\xE3o encontrado`);
4624
+ }
4625
+ this.endpoints.delete(id);
4626
+ return { ok: true };
4627
+ }
4628
+ async test(id) {
4629
+ if (!this.endpoints.has(id)) {
4630
+ throw new NeetruError("not_found", `Webhook ${id} n\xE3o encontrado`);
4631
+ }
4632
+ return { ok: true, statusCode: 200, durationMs: 42 };
4633
+ }
4634
+ };
4635
+
4636
+ // src/notifications.ts
4637
+ init_errors();
4638
+ init_http();
4639
+ var VALID_SEVERITIES2 = [
4640
+ "info",
4641
+ "success",
4642
+ "warning",
4643
+ "error"
4644
+ ];
4645
+ function toNotification(raw) {
4646
+ if (!raw || typeof raw !== "object") {
4647
+ throw new NeetruError("invalid_response", "Notification response is not an object");
4648
+ }
4649
+ const r = raw;
4650
+ if (typeof r.id !== "string") {
4651
+ throw new NeetruError("invalid_response", "Notification missing id");
4652
+ }
4653
+ const sev = VALID_SEVERITIES2.includes(r.severity) ? r.severity : "info";
4654
+ return {
4655
+ id: r.id,
4656
+ userId: typeof r.userId === "string" ? r.userId : "",
4657
+ kind: typeof r.kind === "string" ? r.kind : "unknown",
4658
+ severity: sev,
4659
+ title: typeof r.title === "string" ? r.title : "",
4660
+ body: typeof r.body === "string" ? r.body : void 0,
4661
+ link: typeof r.link === "string" ? r.link : void 0,
4662
+ metadata: r.metadata && typeof r.metadata === "object" ? r.metadata : void 0,
4663
+ createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
4664
+ readAt: typeof r.readAt === "string" ? r.readAt : void 0,
4665
+ dismissedAt: typeof r.dismissedAt === "string" ? r.dismissedAt : void 0
4666
+ };
4667
+ }
4668
+ function validateInput2(input) {
4669
+ if (!input.userId) throw new NeetruError("validation_failed", "userId obrigat\xF3rio");
4670
+ if (!input.kind) throw new NeetruError("validation_failed", "kind obrigat\xF3rio");
4671
+ if (!input.title) throw new NeetruError("validation_failed", "title obrigat\xF3rio");
4672
+ if (input.severity && !VALID_SEVERITIES2.includes(input.severity)) {
4673
+ throw new NeetruError(
4674
+ "validation_failed",
4675
+ `severity inv\xE1lida: ${input.severity} (use ${VALID_SEVERITIES2.join("|")})`
4676
+ );
4677
+ }
4678
+ if (input.title.length > 200) {
4679
+ throw new NeetruError("validation_failed", "title m\xE1x 200 chars");
4680
+ }
4681
+ if (input.body && input.body.length > 2e3) {
4682
+ throw new NeetruError("validation_failed", "body m\xE1x 2000 chars");
4683
+ }
4684
+ }
4685
+ function createNotificationsNamespace(config) {
4686
+ return {
4687
+ async send(input) {
4688
+ validateInput2(input);
4689
+ const raw = await httpRequest(config, {
4690
+ method: "POST",
4691
+ path: "/api/sdk/v1/notifications",
4692
+ body: input,
4693
+ requireAuth: true
4694
+ });
4695
+ return toNotification(raw);
4696
+ },
4697
+ async list(userId, options) {
4698
+ if (!userId) throw new NeetruError("validation_failed", "userId obrigat\xF3rio");
4699
+ const params = new URLSearchParams();
4700
+ if (options?.includeDismissed) params.set("includeDismissed", "true");
4701
+ if (options?.onlyUnread) params.set("onlyUnread", "true");
4702
+ if (options?.limit) {
4703
+ params.set("limit", Math.min(Math.max(1, options.limit), 200).toString());
4704
+ }
4705
+ const qs = params.toString();
4706
+ const raw = await httpRequest(config, {
4707
+ method: "GET",
4708
+ path: `/api/sdk/v1/notifications/user/${encodeURIComponent(userId)}${qs ? `?${qs}` : ""}`,
4709
+ requireAuth: true
4710
+ });
4711
+ const list = Array.isArray(raw?.notifications) ? raw.notifications : [];
4712
+ return list.map(toNotification);
4713
+ },
4714
+ async markRead(id) {
4715
+ if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
4716
+ await httpRequest(config, {
4717
+ method: "POST",
4718
+ path: `/api/sdk/v1/notifications/${encodeURIComponent(id)}/read`,
4719
+ requireAuth: true
4720
+ });
4721
+ return { ok: true };
4722
+ },
4723
+ async dismiss(id) {
4724
+ if (!id) throw new NeetruError("validation_failed", "id obrigat\xF3rio");
4725
+ await httpRequest(config, {
4726
+ method: "POST",
4727
+ path: `/api/sdk/v1/notifications/${encodeURIComponent(id)}/dismiss`,
4728
+ requireAuth: true
4729
+ });
4730
+ return { ok: true };
4731
+ }
4732
+ };
4733
+ }
4734
+ var MockNotifications = class {
4735
+ notifications = /* @__PURE__ */ new Map();
4736
+ nextId = 1;
4737
+ async send(input) {
4738
+ validateInput2(input);
4739
+ if (input.fingerprint) {
4740
+ const dayAgo = Date.now() - 864e5;
4741
+ for (const existing of this.notifications.values()) {
4742
+ const meta = existing.metadata ?? {};
4743
+ if (meta.fingerprint === input.fingerprint && existing.userId === input.userId && new Date(existing.createdAt).getTime() > dayAgo) {
4744
+ return existing;
4745
+ }
4746
+ }
4747
+ }
4748
+ const id = `mock_notif_${this.nextId++}`;
4749
+ const notif = {
4750
+ id,
4751
+ userId: input.userId,
4752
+ kind: input.kind,
4753
+ severity: input.severity ?? "info",
4754
+ title: input.title,
4755
+ body: input.body,
4756
+ link: input.link,
4757
+ metadata: input.fingerprint ? { ...input.metadata ?? {}, fingerprint: input.fingerprint } : input.metadata,
4758
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4759
+ };
4760
+ this.notifications.set(id, notif);
4761
+ return notif;
4762
+ }
4763
+ async list(userId, options) {
4764
+ if (!userId) throw new NeetruError("validation_failed", "userId obrigat\xF3rio");
4765
+ const limit = Math.min(Math.max(1, options?.limit ?? 50), 200);
4766
+ 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);
4767
+ }
4768
+ async markRead(id) {
4769
+ const n = this.notifications.get(id);
4770
+ if (!n) throw new NeetruError("not_found", `Notification ${id} n\xE3o encontrada`);
4771
+ if (!n.readAt) {
4772
+ n.readAt = (/* @__PURE__ */ new Date()).toISOString();
4773
+ this.notifications.set(id, n);
4774
+ }
4775
+ return { ok: true };
4776
+ }
4777
+ async dismiss(id) {
4778
+ const n = this.notifications.get(id);
4779
+ if (!n) throw new NeetruError("not_found", `Notification ${id} n\xE3o encontrada`);
4780
+ if (!n.dismissedAt) {
4781
+ n.dismissedAt = (/* @__PURE__ */ new Date()).toISOString();
4782
+ this.notifications.set(id, n);
4783
+ }
4784
+ return { ok: true };
4785
+ }
4786
+ };
4787
+
874
4788
  // src/mocks.ts
875
4789
  var DEV_FIXTURE_USER = Object.freeze({
876
4790
  uid: "dev-fixture-uid-0001",
@@ -1006,14 +4920,14 @@ var MockUsage = class {
1006
4920
  this._counters.clear();
1007
4921
  }
1008
4922
  };
1009
- var mockTicketSeq = 0;
1010
4923
  var MockSupport = class {
1011
4924
  _tickets = [];
4925
+ _ticketSeq = 0;
1012
4926
  async createTicket(input) {
1013
4927
  if (!input?.subject) throw new Error("subject required");
1014
4928
  if (!input?.message) throw new Error("message required");
1015
4929
  const ticket = {
1016
- id: `mock-ticket-${++mockTicketSeq}`,
4930
+ id: `mock-ticket-${++this._ticketSeq}`,
1017
4931
  subject: input.subject,
1018
4932
  message: input.message,
1019
4933
  severity: input.severity ?? "normal",
@@ -1030,6 +4944,7 @@ var MockSupport = class {
1030
4944
  /** Test helper. */
1031
4945
  __reset() {
1032
4946
  this._tickets = [];
4947
+ this._ticketSeq = 0;
1033
4948
  }
1034
4949
  };
1035
4950
  var MockEntitlements = class {
@@ -1055,92 +4970,6 @@ var MockEntitlements = class {
1055
4970
  this._denied.clear();
1056
4971
  }
1057
4972
  };
1058
- var MockDb = class {
1059
- _store = /* @__PURE__ */ new Map();
1060
- _fixtures;
1061
- constructor(initialFixtures = {}) {
1062
- this._fixtures = new Map(Object.entries(initialFixtures));
1063
- }
1064
- collection(name) {
1065
- const _store = this._store;
1066
- const _fixtures = this._fixtures;
1067
- if (!_store.has(name)) {
1068
- const init = _fixtures.get(name);
1069
- _store.set(
1070
- name,
1071
- init ? new Map(Object.entries(init)) : /* @__PURE__ */ new Map()
1072
- );
1073
- }
1074
- const coll = _store.get(name);
1075
- const matchesFilter = (doc, f) => {
1076
- const v = doc[f.field];
1077
- switch (f.op) {
1078
- case "==":
1079
- return v === f.value;
1080
- case "!=":
1081
- return v !== f.value;
1082
- case "<":
1083
- return typeof v === "number" && typeof f.value === "number" && v < f.value;
1084
- case "<=":
1085
- return typeof v === "number" && typeof f.value === "number" && v <= f.value;
1086
- case ">":
1087
- return typeof v === "number" && typeof f.value === "number" && v > f.value;
1088
- case ">=":
1089
- return typeof v === "number" && typeof f.value === "number" && v >= f.value;
1090
- case "in":
1091
- return Array.isArray(f.value) && f.value.includes(v);
1092
- default:
1093
- return true;
1094
- }
1095
- };
1096
- let autoSeq = 0;
1097
- return {
1098
- async list(opts) {
1099
- let items = Array.from(coll.values());
1100
- if (opts?.where && opts.where.length > 0) {
1101
- items = items.filter(
1102
- (doc) => opts.where.every((f) => matchesFilter(doc, f))
1103
- );
1104
- }
1105
- if (opts?.limit !== void 0) items = items.slice(0, opts.limit);
1106
- return items;
1107
- },
1108
- async get(id) {
1109
- return coll.get(id) ?? null;
1110
- },
1111
- async add(data) {
1112
- const id = `mock-${++autoSeq}-${Math.random().toString(36).slice(2, 8)}`;
1113
- coll.set(id, { ...data, id });
1114
- return { ok: true, id };
1115
- },
1116
- async set(id, data) {
1117
- coll.set(id, { ...data, id });
1118
- return { ok: true };
1119
- },
1120
- async update(id, data) {
1121
- const cur = coll.get(id);
1122
- if (!cur) {
1123
- coll.set(id, { ...data, id });
1124
- } else {
1125
- coll.set(id, { ...cur, ...data });
1126
- }
1127
- return { ok: true };
1128
- },
1129
- async remove(id) {
1130
- coll.delete(id);
1131
- return { ok: true };
1132
- }
1133
- };
1134
- }
1135
- /** Test helper — substitui fixture inteira de uma collection. */
1136
- __setFixture(name, items) {
1137
- this._store.set(name, new Map(Object.entries(items)));
1138
- }
1139
- /** Test helper — reset total. */
1140
- __reset() {
1141
- this._store.clear();
1142
- }
1143
- };
1144
4973
 
1145
4974
  // src/auth.ts
1146
4975
  function readEnvApiKey() {
@@ -1252,7 +5081,9 @@ function createOidcAuthNamespace(config) {
1252
5081
  if (storage) storage.removeItem(STORAGE_KEY);
1253
5082
  cachedUser = null;
1254
5083
  try {
1255
- await config.fetch(`${config.baseUrl}/oauth/revoke`, {
5084
+ const overrideAuthUrl = typeof globalThis.NEETRU_AUTH_URL === "string" ? globalThis.NEETRU_AUTH_URL : null;
5085
+ const idpOrigin = overrideAuthUrl ?? config.baseUrl.replace(/^https?:\/\/api\./, "https://auth.");
5086
+ await config.fetch(`${idpOrigin}/api/v1/oauth/revoke`, {
1256
5087
  method: "POST",
1257
5088
  headers: { "content-type": "application/json" }
1258
5089
  });
@@ -1299,7 +5130,9 @@ function createNeetruClient(config = {}) {
1299
5130
  const usage = config.mocks?.usage ?? (isDev ? new MockUsage() : createUsageNamespace(resolved));
1300
5131
  const support = config.mocks?.support ?? (isDev ? new MockSupport() : createSupportNamespace(resolved));
1301
5132
  const entitlements = config.mocks?.entitlements ?? (isDev ? new MockEntitlements() : createEntitlementsNamespace(resolved));
1302
- const db = config.mocks?.db ?? (isDev ? new MockDb() : createDbNamespace(resolved));
5133
+ const db = config.mocks?.db ?? createNeetruDb(resolved, config.db);
5134
+ const webhooks = config.mocks?.webhooks ?? (isDev ? new MockWebhooks() : createWebhooksNamespace(resolved));
5135
+ const notifications = config.mocks?.notifications ?? (isDev ? new MockNotifications() : createNotificationsNamespace(resolved));
1303
5136
  const client = Object.freeze({
1304
5137
  config: resolved,
1305
5138
  auth,
@@ -1309,7 +5142,9 @@ function createNeetruClient(config = {}) {
1309
5142
  usage,
1310
5143
  support,
1311
5144
  db,
1312
- checkout: createCheckoutNamespace(resolved)
5145
+ checkout: createCheckoutNamespace(resolved),
5146
+ webhooks,
5147
+ notifications
1313
5148
  });
1314
5149
  return client;
1315
5150
  }