@prismer/sdk 1.3.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ __require
3
+ } from "./chunk-Y6FXYEAI.mjs";
4
+
1
5
  // src/realtime.ts
2
6
  var TypedEmitter = class {
3
7
  constructor() {
@@ -425,11 +429,1844 @@ var RealtimeSSEClient = class extends TypedEmitter {
425
429
  }
426
430
  };
427
431
 
432
+ // src/offline.ts
433
+ var OfflineEmitter = class {
434
+ constructor() {
435
+ this.listeners = /* @__PURE__ */ new Map();
436
+ }
437
+ on(event, cb) {
438
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
439
+ this.listeners.get(event).add(cb);
440
+ return this;
441
+ }
442
+ off(event, cb) {
443
+ this.listeners.get(event)?.delete(cb);
444
+ return this;
445
+ }
446
+ emit(event, payload) {
447
+ const set = this.listeners.get(event);
448
+ if (set) for (const cb of set) {
449
+ try {
450
+ cb(payload);
451
+ } catch {
452
+ }
453
+ }
454
+ }
455
+ removeAllListeners() {
456
+ this.listeners.clear();
457
+ }
458
+ };
459
+ function generateId() {
460
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
461
+ return crypto.randomUUID();
462
+ }
463
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
464
+ const r = Math.random() * 16 | 0;
465
+ return (c === "x" ? r : r & 3 | 8).toString(16);
466
+ });
467
+ }
468
+ var WRITE_PATTERNS = [
469
+ { method: "POST", pattern: /\/api\/im\/(messages|direct|groups)\//, opType: "message.send" },
470
+ { method: "PATCH", pattern: /\/api\/im\/messages\//, opType: "message.edit" },
471
+ { method: "DELETE", pattern: /\/api\/im\/messages\//, opType: "message.delete" },
472
+ { method: "POST", pattern: /\/api\/im\/conversations\/[^/]+\/read/, opType: "conversation.read" }
473
+ ];
474
+ function matchWriteOp(method, path) {
475
+ for (const { method: m, pattern, opType } of WRITE_PATTERNS) {
476
+ if (method === m && pattern.test(path)) return opType;
477
+ }
478
+ return null;
479
+ }
480
+ var OfflineManager = class extends OfflineEmitter {
481
+ constructor(storage, networkRequest, options = {}) {
482
+ super();
483
+ this.flushTimer = null;
484
+ this.flushing = false;
485
+ this._isOnline = true;
486
+ this._syncState = "idle";
487
+ this.sseSource = null;
488
+ this.sseReconnectTimer = null;
489
+ this.sseReconnectAttempts = 0;
490
+ /** Presence cache for realtime presence events */
491
+ this.presenceCache = /* @__PURE__ */ new Map();
492
+ this.storage = storage;
493
+ this.networkRequest = networkRequest;
494
+ this.options = {
495
+ syncOnConnect: options.syncOnConnect ?? true,
496
+ outboxRetryLimit: options.outboxRetryLimit ?? 5,
497
+ outboxFlushInterval: options.outboxFlushInterval ?? 1e3,
498
+ conflictStrategy: options.conflictStrategy ?? "server",
499
+ onConflict: options.onConflict,
500
+ syncMode: options.syncMode ?? "push",
501
+ quota: options.quota ? {
502
+ maxStorageBytes: options.quota.maxStorageBytes ?? 500 * 1024 * 1024,
503
+ warningThreshold: options.quota.warningThreshold ?? 0.9
504
+ } : void 0
505
+ };
506
+ }
507
+ get isOnline() {
508
+ return this._isOnline;
509
+ }
510
+ get syncState() {
511
+ return this._syncState;
512
+ }
513
+ async init() {
514
+ await this.storage.init();
515
+ this.startFlushTimer();
516
+ }
517
+ async destroy() {
518
+ this.stopFlushTimer();
519
+ this.stopContinuousSync();
520
+ this.removeAllListeners();
521
+ }
522
+ // ── Network state ─────────────────────────────────────────
523
+ setOnline(online) {
524
+ if (this._isOnline === online) return;
525
+ this._isOnline = online;
526
+ this.emit(online ? "network.online" : "network.offline", void 0);
527
+ if (online) {
528
+ this.flush();
529
+ if (this.options.syncOnConnect) {
530
+ if (this.options.syncMode === "push") {
531
+ this.startContinuousSync();
532
+ } else {
533
+ this.sync();
534
+ }
535
+ }
536
+ } else {
537
+ this.stopContinuousSync();
538
+ }
539
+ }
540
+ // ── Request dispatch ──────────────────────────────────────
541
+ /**
542
+ * Dispatch an IM request. Write ops go through outbox; reads check local cache.
543
+ */
544
+ async dispatch(method, path, body, query) {
545
+ const opType = matchWriteOp(method, path);
546
+ if (opType) {
547
+ return this.dispatchWrite(opType, method, path, body, query);
548
+ }
549
+ if (method === "GET") {
550
+ const cached = await this.readFromCache(path, query);
551
+ if (cached !== null) return cached;
552
+ }
553
+ try {
554
+ const result = await this.networkRequest(method, path, body, query);
555
+ if (method === "GET") this.cacheReadResult(path, query, result);
556
+ return result;
557
+ } catch {
558
+ if (!this._isOnline) {
559
+ return { ok: true, data: [] };
560
+ }
561
+ throw new Error("Network request failed");
562
+ }
563
+ }
564
+ // ── Outbox: write operations ──────────────────────────────
565
+ async dispatchWrite(opType, method, path, body, query) {
566
+ const clientId = generateId();
567
+ const idempotencyKey = `sdk-${clientId}`;
568
+ let enrichedBody = body;
569
+ if (body && typeof body === "object" && (opType === "message.send" || opType === "message.edit")) {
570
+ enrichedBody = { ...body };
571
+ enrichedBody.metadata = {
572
+ ...body.metadata,
573
+ _idempotencyKey: idempotencyKey
574
+ };
575
+ }
576
+ let localMessage;
577
+ if (opType === "message.send" && body && typeof body === "object") {
578
+ const b = body;
579
+ const convIdMatch = path.match(/\/(?:messages|direct|groups)\/([^/]+)/);
580
+ const conversationId = convIdMatch?.[1] ?? "";
581
+ localMessage = {
582
+ id: `local-${clientId}`,
583
+ clientId,
584
+ conversationId,
585
+ content: b.content ?? "",
586
+ type: b.type ?? "text",
587
+ senderId: "__self__",
588
+ parentId: b.parentId ?? null,
589
+ status: "pending",
590
+ metadata: b.metadata,
591
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
592
+ };
593
+ await this.storage.putMessages([localMessage]);
594
+ this.emit("message.local", localMessage);
595
+ }
596
+ const op = {
597
+ id: clientId,
598
+ type: opType,
599
+ method,
600
+ path,
601
+ body: enrichedBody,
602
+ query,
603
+ status: "pending",
604
+ createdAt: Date.now(),
605
+ retries: 0,
606
+ maxRetries: this.options.outboxRetryLimit,
607
+ idempotencyKey,
608
+ localData: localMessage
609
+ };
610
+ await this.storage.enqueue(op);
611
+ if (this._isOnline) this.flush();
612
+ const optimisticResult = {
613
+ ok: true,
614
+ data: localMessage ? { conversationId: localMessage.conversationId, message: localMessage } : void 0,
615
+ _pending: true,
616
+ _clientId: clientId
617
+ };
618
+ return optimisticResult;
619
+ }
620
+ // ── Outbox flush ──────────────────────────────────────────
621
+ startFlushTimer() {
622
+ this.stopFlushTimer();
623
+ this.flushTimer = setInterval(() => this.flush(), this.options.outboxFlushInterval);
624
+ }
625
+ stopFlushTimer() {
626
+ if (this.flushTimer) {
627
+ clearInterval(this.flushTimer);
628
+ this.flushTimer = null;
629
+ }
630
+ }
631
+ async flush() {
632
+ if (this.flushing || !this._isOnline) return;
633
+ this.flushing = true;
634
+ try {
635
+ const ops = await this.storage.dequeueReady(10);
636
+ for (const op of ops) {
637
+ this.emit("outbox.sending", { opId: op.id, type: op.type });
638
+ try {
639
+ const result = await this.networkRequest(
640
+ op.method,
641
+ op.path,
642
+ op.body,
643
+ op.query
644
+ );
645
+ if (result.ok) {
646
+ await this.storage.ack(op.id);
647
+ this.emit("outbox.confirmed", { opId: op.id, serverData: result.data });
648
+ if (op.type === "message.send" && op.localData) {
649
+ const local = op.localData;
650
+ const serverMsg = result.data?.message;
651
+ if (serverMsg) {
652
+ await this.storage.deleteMessage(local.id);
653
+ await this.storage.putMessages([{
654
+ id: serverMsg.id,
655
+ clientId: op.id,
656
+ conversationId: serverMsg.conversationId ?? local.conversationId,
657
+ content: serverMsg.content ?? local.content,
658
+ type: serverMsg.type ?? local.type,
659
+ senderId: serverMsg.senderId ?? local.senderId,
660
+ parentId: serverMsg.parentId,
661
+ status: "confirmed",
662
+ metadata: serverMsg.metadata ? typeof serverMsg.metadata === "string" ? JSON.parse(serverMsg.metadata) : serverMsg.metadata : void 0,
663
+ createdAt: serverMsg.createdAt ?? local.createdAt
664
+ }]);
665
+ this.emit("message.confirmed", { clientId: op.id, serverMessage: serverMsg });
666
+ }
667
+ }
668
+ } else {
669
+ const errCode = result.error?.code;
670
+ if (errCode && !errCode.includes("TIMEOUT") && !errCode.includes("NETWORK")) {
671
+ await this.storage.nack(op.id, result.error?.message ?? "Request failed", op.maxRetries);
672
+ this.emit("outbox.failed", { opId: op.id, error: result.error?.message ?? "Request failed", retriesLeft: 0 });
673
+ if (op.type === "message.send") {
674
+ this.emit("message.failed", { clientId: op.id, error: result.error?.message ?? "Request failed" });
675
+ }
676
+ } else {
677
+ await this.storage.nack(op.id, result.error?.message ?? "Transient error", op.retries + 1);
678
+ this.emit("outbox.failed", {
679
+ opId: op.id,
680
+ error: result.error?.message ?? "Transient error",
681
+ retriesLeft: op.maxRetries - op.retries - 1
682
+ });
683
+ }
684
+ }
685
+ } catch (err) {
686
+ const msg = err instanceof Error ? err.message : "Unknown error";
687
+ await this.storage.nack(op.id, msg, op.retries + 1);
688
+ if (op.retries + 1 >= op.maxRetries) {
689
+ this.emit("outbox.failed", { opId: op.id, error: msg, retriesLeft: 0 });
690
+ if (op.type === "message.send") {
691
+ this.emit("message.failed", { clientId: op.id, error: msg });
692
+ }
693
+ }
694
+ }
695
+ }
696
+ } finally {
697
+ this.flushing = false;
698
+ }
699
+ }
700
+ get outboxSize() {
701
+ return this.storage.getPendingCount();
702
+ }
703
+ // ── Sync engine ───────────────────────────────────────────
704
+ async sync() {
705
+ if (this._syncState === "syncing" || !this._isOnline) return;
706
+ this._syncState = "syncing";
707
+ this.emit("sync.start", void 0);
708
+ let totalNew = 0;
709
+ let totalUpdated = 0;
710
+ try {
711
+ let cursor = await this.storage.getCursor("global_sync") ?? "0";
712
+ let hasMore = true;
713
+ while (hasMore) {
714
+ const result = await this.networkRequest(
715
+ "GET",
716
+ "/api/im/sync",
717
+ void 0,
718
+ { since: cursor, limit: "100" }
719
+ );
720
+ if (!result.ok || !result.data) {
721
+ throw new Error(result.error?.message ?? "Sync failed");
722
+ }
723
+ const { events, cursor: newCursor, hasMore: more } = result.data;
724
+ for (const event of events) {
725
+ await this.applySyncEvent(event);
726
+ if (event.type === "message.new") totalNew++;
727
+ if (event.type.startsWith("conversation.")) totalUpdated++;
728
+ }
729
+ cursor = String(newCursor);
730
+ await this.storage.setCursor("global_sync", cursor);
731
+ hasMore = more;
732
+ this.emit("sync.progress", { synced: events.length, total: events.length });
733
+ }
734
+ this._syncState = "idle";
735
+ this.emit("sync.complete", { newMessages: totalNew, updatedConversations: totalUpdated });
736
+ } catch (err) {
737
+ this._syncState = "error";
738
+ this.emit("sync.error", {
739
+ error: err instanceof Error ? err.message : "Sync failed",
740
+ willRetry: false
741
+ });
742
+ }
743
+ }
744
+ async applySyncEvent(event) {
745
+ switch (event.type) {
746
+ case "message.new": {
747
+ const msg = event.data;
748
+ await this.storage.putMessages([{
749
+ id: msg.id,
750
+ conversationId: msg.conversationId ?? event.conversationId ?? "",
751
+ content: msg.content ?? "",
752
+ type: msg.type ?? "text",
753
+ senderId: msg.senderId ?? "",
754
+ parentId: msg.parentId ?? null,
755
+ status: "confirmed",
756
+ metadata: msg.metadata,
757
+ createdAt: msg.createdAt ?? event.at,
758
+ syncSeq: event.seq
759
+ }]);
760
+ break;
761
+ }
762
+ case "message.edit": {
763
+ const existing = await this.storage.getMessage(event.data.id);
764
+ if (existing) {
765
+ const hasLocalEdits = existing.status !== "confirmed";
766
+ if (hasLocalEdits && this.options.onConflict) {
767
+ const resolution = this.options.onConflict(existing, event);
768
+ if (resolution === "keep_local") break;
769
+ if (resolution !== "accept_remote" && typeof resolution === "object") {
770
+ resolution.syncSeq = event.seq;
771
+ await this.storage.putMessages([resolution]);
772
+ break;
773
+ }
774
+ }
775
+ existing.content = event.data.content ?? existing.content;
776
+ existing.updatedAt = event.at;
777
+ existing.syncSeq = event.seq;
778
+ await this.storage.putMessages([existing]);
779
+ }
780
+ break;
781
+ }
782
+ case "message.delete": {
783
+ if (event.data?.id) await this.storage.deleteMessage(event.data.id);
784
+ break;
785
+ }
786
+ case "conversation.create":
787
+ case "conversation.update": {
788
+ const conv = event.data;
789
+ await this.storage.putConversations([{
790
+ id: conv.id ?? event.conversationId ?? "",
791
+ type: conv.type ?? "direct",
792
+ title: conv.title,
793
+ unreadCount: conv.unreadCount ?? 0,
794
+ members: conv.members,
795
+ metadata: conv.metadata,
796
+ syncSeq: event.seq,
797
+ updatedAt: event.at,
798
+ lastMessageAt: conv.lastMessageAt
799
+ }]);
800
+ break;
801
+ }
802
+ case "conversation.archive": {
803
+ const convId = event.data?.id ?? event.conversationId;
804
+ if (convId) {
805
+ const existing = await this.storage.getConversation(convId);
806
+ if (existing) {
807
+ existing.metadata = { ...existing.metadata, _archived: true };
808
+ existing.syncSeq = event.seq;
809
+ existing.updatedAt = event.at;
810
+ await this.storage.putConversations([existing]);
811
+ }
812
+ }
813
+ break;
814
+ }
815
+ case "participant.add": {
816
+ const convId = event.data?.conversationId ?? event.conversationId;
817
+ if (convId) {
818
+ const existing = await this.storage.getConversation(convId);
819
+ if (existing && existing.members) {
820
+ const already = existing.members.find((m) => m.userId === event.data.userId);
821
+ if (!already) {
822
+ existing.members.push({
823
+ userId: event.data.userId,
824
+ username: event.data.username ?? "",
825
+ displayName: event.data.displayName,
826
+ role: event.data.role ?? "member"
827
+ });
828
+ existing.syncSeq = event.seq;
829
+ existing.updatedAt = event.at;
830
+ await this.storage.putConversations([existing]);
831
+ }
832
+ }
833
+ }
834
+ break;
835
+ }
836
+ case "participant.remove": {
837
+ const convId = event.data?.conversationId ?? event.conversationId;
838
+ if (convId) {
839
+ const existing = await this.storage.getConversation(convId);
840
+ if (existing && existing.members) {
841
+ existing.members = existing.members.filter((m) => m.userId !== event.data.userId);
842
+ existing.syncSeq = event.seq;
843
+ existing.updatedAt = event.at;
844
+ await this.storage.putConversations([existing]);
845
+ }
846
+ }
847
+ break;
848
+ }
849
+ }
850
+ }
851
+ /**
852
+ * Handle a realtime event (from WS/SSE) and store locally.
853
+ */
854
+ async handleRealtimeEvent(type, payload) {
855
+ if (type === "message.new" && payload) {
856
+ await this.storage.putMessages([{
857
+ id: payload.id,
858
+ conversationId: payload.conversationId ?? "",
859
+ content: payload.content ?? "",
860
+ type: payload.type ?? "text",
861
+ senderId: payload.senderId ?? "",
862
+ parentId: payload.parentId ?? null,
863
+ status: "confirmed",
864
+ metadata: payload.metadata,
865
+ createdAt: payload.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
866
+ }]);
867
+ }
868
+ if (type === "presence.changed" && payload?.userId) {
869
+ this.presenceCache.set(payload.userId, {
870
+ status: payload.status ?? "offline",
871
+ lastSeen: payload.lastSeen ?? (/* @__PURE__ */ new Date()).toISOString()
872
+ });
873
+ this.emit("presence.changed", payload);
874
+ }
875
+ }
876
+ /**
877
+ * Get cached presence status for a user.
878
+ */
879
+ getPresence(userId) {
880
+ return this.presenceCache.get(userId) ?? null;
881
+ }
882
+ /**
883
+ * Search messages in local storage.
884
+ */
885
+ async searchMessages(query, opts) {
886
+ if (this.storage.searchMessages) {
887
+ return this.storage.searchMessages(query, opts);
888
+ }
889
+ return [];
890
+ }
891
+ /**
892
+ * Get storage size and quota info.
893
+ */
894
+ async getQuotaStatus() {
895
+ const limit = this.options.quota?.maxStorageBytes ?? 500 * 1024 * 1024;
896
+ const threshold = this.options.quota?.warningThreshold ?? 0.9;
897
+ if (this.storage.getStorageSize) {
898
+ const size = await this.storage.getStorageSize();
899
+ const percentage = size.total / limit;
900
+ return {
901
+ used: size.total,
902
+ limit,
903
+ percentage,
904
+ warning: percentage >= threshold,
905
+ exceeded: percentage >= 1
906
+ };
907
+ }
908
+ return { used: 0, limit, percentage: 0, warning: false, exceeded: false };
909
+ }
910
+ /**
911
+ * Clear old messages for a conversation (user-initiated quota management).
912
+ */
913
+ async clearOldMessages(conversationId, keepCount) {
914
+ if (this.storage.clearOldMessages) {
915
+ return this.storage.clearOldMessages(conversationId, keepCount);
916
+ }
917
+ return 0;
918
+ }
919
+ // ── Read cache ────────────────────────────────────────────
920
+ async readFromCache(path, query) {
921
+ if (/\/api\/im\/conversations$/.test(path)) {
922
+ const convos = await this.storage.getConversations({ limit: 50 });
923
+ if (convos.length > 0) return { ok: true, data: convos };
924
+ }
925
+ const msgMatch = path.match(/\/api\/im\/messages\/([^/]+)$/);
926
+ if (msgMatch) {
927
+ const convId = msgMatch[1];
928
+ const limit = query?.limit ? parseInt(query.limit) : 50;
929
+ const messages = await this.storage.getMessages(convId, { limit, before: query?.before });
930
+ if (messages.length > 0) return { ok: true, data: messages };
931
+ }
932
+ if (/\/api\/im\/contacts$/.test(path)) {
933
+ const contacts = await this.storage.getContacts();
934
+ if (contacts.length > 0) return { ok: true, data: contacts };
935
+ }
936
+ return null;
937
+ }
938
+ async cacheReadResult(path, _query, result) {
939
+ if (!result?.ok || !result?.data) return;
940
+ try {
941
+ if (/\/api\/im\/conversations$/.test(path) && Array.isArray(result.data)) {
942
+ const convos = result.data.map((c) => ({
943
+ id: c.id,
944
+ type: c.type ?? "direct",
945
+ title: c.title,
946
+ lastMessage: c.lastMessage,
947
+ lastMessageAt: c.lastMessageAt ?? c.updatedAt,
948
+ unreadCount: c.unreadCount ?? 0,
949
+ members: c.members,
950
+ metadata: c.metadata,
951
+ updatedAt: c.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
952
+ }));
953
+ await this.storage.putConversations(convos);
954
+ }
955
+ const msgMatch = path.match(/\/api\/im\/messages\/([^/]+)$/);
956
+ if (msgMatch && Array.isArray(result.data)) {
957
+ const messages = result.data.map((m) => ({
958
+ id: m.id,
959
+ conversationId: m.conversationId ?? msgMatch[1],
960
+ content: m.content ?? "",
961
+ type: m.type ?? "text",
962
+ senderId: m.senderId ?? "",
963
+ parentId: m.parentId ?? null,
964
+ status: "confirmed",
965
+ metadata: m.metadata,
966
+ createdAt: m.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
967
+ }));
968
+ await this.storage.putMessages(messages);
969
+ }
970
+ if (/\/api\/im\/contacts$/.test(path) && Array.isArray(result.data)) {
971
+ await this.storage.putContacts(result.data);
972
+ }
973
+ } catch {
974
+ }
975
+ }
976
+ // ── SSE continuous sync ────────────────────────────────────
977
+ /**
978
+ * Start continuous sync via SSE (Server-Sent Events).
979
+ * Replaces polling with real-time push when syncMode is 'push'.
980
+ */
981
+ async startContinuousSync() {
982
+ if (this.sseSource) return;
983
+ if (typeof EventSource === "undefined") {
984
+ return this.sync();
985
+ }
986
+ const token = this.tokenProvider?.();
987
+ if (!token) {
988
+ return this.sync();
989
+ }
990
+ const cursor = await this.storage.getCursor("global_sync") ?? "0";
991
+ const baseUrl = this.getBaseUrl();
992
+ const url = `${baseUrl}/api/im/sync/stream?token=${encodeURIComponent(token)}&since=${cursor}`;
993
+ this._syncState = "syncing";
994
+ this.emit("sync.start", void 0);
995
+ this.sseReconnectAttempts = 0;
996
+ try {
997
+ this.sseSource = new EventSource(url);
998
+ let totalNew = 0;
999
+ let totalUpdated = 0;
1000
+ this.sseSource.addEventListener("sync", async (e) => {
1001
+ try {
1002
+ const event = JSON.parse(e.data);
1003
+ await this.applySyncEvent(event);
1004
+ await this.storage.setCursor("global_sync", String(event.seq));
1005
+ if (event.type === "message.new") totalNew++;
1006
+ if (event.type.startsWith("conversation.")) totalUpdated++;
1007
+ this.emit("sync.progress", { synced: 1, total: 1 });
1008
+ if (this.options.quota) {
1009
+ await this.checkQuota();
1010
+ }
1011
+ } catch {
1012
+ }
1013
+ });
1014
+ this.sseSource.addEventListener("caught_up", () => {
1015
+ this._syncState = "idle";
1016
+ this.sseReconnectAttempts = 0;
1017
+ this.emit("sync.complete", { newMessages: totalNew, updatedConversations: totalUpdated });
1018
+ totalNew = 0;
1019
+ totalUpdated = 0;
1020
+ });
1021
+ this.sseSource.addEventListener("error", () => {
1022
+ this._syncState = "error";
1023
+ this.emit("sync.error", { error: "SSE connection error", willRetry: true });
1024
+ });
1025
+ this.sseSource.onerror = () => {
1026
+ if (this.sseSource?.readyState === EventSource.CLOSED) {
1027
+ this.sseSource = null;
1028
+ this._syncState = "error";
1029
+ this.scheduleSseReconnect();
1030
+ }
1031
+ };
1032
+ } catch (err) {
1033
+ this._syncState = "error";
1034
+ this.emit("sync.error", {
1035
+ error: err instanceof Error ? err.message : "SSE init failed",
1036
+ willRetry: true
1037
+ });
1038
+ this.scheduleSseReconnect();
1039
+ }
1040
+ }
1041
+ /**
1042
+ * Stop the SSE continuous sync connection.
1043
+ */
1044
+ stopContinuousSync() {
1045
+ if (this.sseSource) {
1046
+ this.sseSource.close();
1047
+ this.sseSource = null;
1048
+ }
1049
+ if (this.sseReconnectTimer) {
1050
+ clearTimeout(this.sseReconnectTimer);
1051
+ this.sseReconnectTimer = null;
1052
+ }
1053
+ this._syncState = "idle";
1054
+ }
1055
+ scheduleSseReconnect() {
1056
+ if (!this._isOnline) return;
1057
+ this.sseReconnectAttempts++;
1058
+ const delay = Math.min(1e3 * Math.pow(2, this.sseReconnectAttempts - 1), 3e4);
1059
+ this.sseReconnectTimer = setTimeout(() => {
1060
+ this.sseReconnectTimer = null;
1061
+ if (this._isOnline) this.startContinuousSync();
1062
+ }, delay);
1063
+ }
1064
+ /** Get the base URL for SSE connections (strip /api/im prefix). */
1065
+ getBaseUrl() {
1066
+ return typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
1067
+ }
1068
+ // ── Quota check ─────────────────────────────────────────────
1069
+ async checkQuota() {
1070
+ if (!this.options.quota || !this.storage.getStorageSize) return;
1071
+ const size = await this.storage.getStorageSize();
1072
+ const limit = this.options.quota.maxStorageBytes;
1073
+ const threshold = this.options.quota.warningThreshold;
1074
+ const pct = size.total / limit;
1075
+ if (pct >= 1) {
1076
+ this.emit("quota.exceeded", { used: size.total, limit });
1077
+ } else if (pct >= threshold) {
1078
+ this.emit("quota.warning", { used: size.total, limit, percentage: pct });
1079
+ }
1080
+ }
1081
+ };
1082
+ var AttachmentQueue = class {
1083
+ constructor(offline, networkRequest) {
1084
+ this.offline = offline;
1085
+ this.networkRequest = networkRequest;
1086
+ this.queue = /* @__PURE__ */ new Map();
1087
+ this.uploading = false;
1088
+ }
1089
+ /**
1090
+ * Queue a file attachment for offline upload.
1091
+ * Returns the queued attachment with a local ID.
1092
+ */
1093
+ async queueAttachment(conversationId, file, messageContent) {
1094
+ const id = generateId();
1095
+ const attachment = {
1096
+ id,
1097
+ conversationId,
1098
+ file: { name: file.name, size: file.size, type: file.type },
1099
+ data: file.data,
1100
+ status: "pending",
1101
+ progress: 0,
1102
+ messageClientId: generateId(),
1103
+ createdAt: Date.now()
1104
+ };
1105
+ this.queue.set(id, attachment);
1106
+ await this.offline.storage.putMessages([{
1107
+ id: `local-${attachment.messageClientId}`,
1108
+ clientId: attachment.messageClientId,
1109
+ conversationId,
1110
+ content: messageContent ?? `[File: ${file.name}]`,
1111
+ type: "file",
1112
+ senderId: "__self__",
1113
+ status: "pending",
1114
+ metadata: { _attachmentId: id, fileName: file.name, fileSize: file.size },
1115
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1116
+ }]);
1117
+ if (this.offline.isOnline) this.processQueue();
1118
+ return attachment;
1119
+ }
1120
+ /** Process pending uploads. */
1121
+ async processQueue() {
1122
+ if (this.uploading || !this.offline.isOnline) return;
1123
+ this.uploading = true;
1124
+ try {
1125
+ for (const [id, att] of this.queue) {
1126
+ if (att.status !== "pending") continue;
1127
+ att.status = "uploading";
1128
+ try {
1129
+ const presign = await this.networkRequest(
1130
+ "POST",
1131
+ "/api/im/files/presign",
1132
+ { fileName: att.file.name, fileSize: att.file.size, mimeType: att.file.type }
1133
+ );
1134
+ if (!presign.ok || !presign.data?.uploadUrl) {
1135
+ throw new Error(presign.error?.message ?? "Presign failed");
1136
+ }
1137
+ if (att.data) {
1138
+ await fetch(presign.data.uploadUrl, {
1139
+ method: "PUT",
1140
+ body: att.data,
1141
+ headers: { "Content-Type": att.file.type }
1142
+ });
1143
+ }
1144
+ att.progress = 80;
1145
+ const confirm = await this.networkRequest(
1146
+ "POST",
1147
+ "/api/im/files/confirm",
1148
+ { uploadId: presign.data.uploadId }
1149
+ );
1150
+ if (!confirm.ok) {
1151
+ throw new Error(confirm.error?.message ?? "Confirm failed");
1152
+ }
1153
+ att.status = "uploaded";
1154
+ att.progress = 100;
1155
+ await this.networkRequest(
1156
+ "POST",
1157
+ `/api/im/messages/${att.conversationId}`,
1158
+ {
1159
+ type: "file",
1160
+ content: `[File: ${att.file.name}]`,
1161
+ metadata: {
1162
+ fileUrl: confirm.data?.url ?? presign.data.downloadUrl,
1163
+ fileName: att.file.name,
1164
+ fileSize: att.file.size,
1165
+ mimeType: att.file.type,
1166
+ uploadId: presign.data.uploadId
1167
+ }
1168
+ }
1169
+ );
1170
+ await this.offline.storage.deleteMessage(`local-${att.messageClientId}`);
1171
+ this.queue.delete(id);
1172
+ } catch (err) {
1173
+ att.status = "failed";
1174
+ att.error = err instanceof Error ? err.message : "Upload failed";
1175
+ }
1176
+ }
1177
+ } finally {
1178
+ this.uploading = false;
1179
+ }
1180
+ }
1181
+ /** Get all queued attachments. */
1182
+ getQueue() {
1183
+ return Array.from(this.queue.values());
1184
+ }
1185
+ /** Retry a failed attachment upload. */
1186
+ async retry(attachmentId) {
1187
+ const att = this.queue.get(attachmentId);
1188
+ if (att && att.status === "failed") {
1189
+ att.status = "pending";
1190
+ att.error = void 0;
1191
+ if (this.offline.isOnline) this.processQueue();
1192
+ }
1193
+ }
1194
+ /** Cancel and remove a queued attachment. */
1195
+ async cancel(attachmentId) {
1196
+ const att = this.queue.get(attachmentId);
1197
+ if (att) {
1198
+ await this.offline.storage.deleteMessage(`local-${att.messageClientId}`);
1199
+ this.queue.delete(attachmentId);
1200
+ }
1201
+ }
1202
+ };
1203
+
428
1204
  // src/types.ts
429
1205
  var ENVIRONMENTS = {
430
1206
  production: "https://prismer.cloud"
431
1207
  };
432
1208
 
1209
+ // src/storage.ts
1210
+ var MemoryStorage = class {
1211
+ constructor() {
1212
+ this.messages = /* @__PURE__ */ new Map();
1213
+ this.conversations = /* @__PURE__ */ new Map();
1214
+ this.contacts = /* @__PURE__ */ new Map();
1215
+ this.cursors = /* @__PURE__ */ new Map();
1216
+ this.outbox = /* @__PURE__ */ new Map();
1217
+ }
1218
+ async init() {
1219
+ }
1220
+ // ── Messages ────────────────────────────────────────────────
1221
+ async putMessages(messages) {
1222
+ for (const m of messages) this.messages.set(m.id, { ...m });
1223
+ }
1224
+ async getMessages(conversationId, opts) {
1225
+ const all = Array.from(this.messages.values()).filter((m) => m.conversationId === conversationId).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1226
+ if (opts.before) {
1227
+ const idx = all.findIndex((m) => m.id === opts.before);
1228
+ if (idx > 0) return all.slice(Math.max(0, idx - opts.limit), idx);
1229
+ }
1230
+ return all.slice(-opts.limit);
1231
+ }
1232
+ async getMessage(messageId) {
1233
+ return this.messages.get(messageId) ?? null;
1234
+ }
1235
+ async deleteMessage(messageId) {
1236
+ this.messages.delete(messageId);
1237
+ }
1238
+ // ── Conversations ───────────────────────────────────────────
1239
+ async putConversations(conversations) {
1240
+ for (const c of conversations) this.conversations.set(c.id, { ...c });
1241
+ }
1242
+ async getConversations(opts) {
1243
+ const all = Array.from(this.conversations.values()).sort((a, b) => (b.lastMessageAt ?? b.updatedAt).localeCompare(a.lastMessageAt ?? a.updatedAt));
1244
+ const offset = opts?.offset ?? 0;
1245
+ const limit = opts?.limit ?? 50;
1246
+ return all.slice(offset, offset + limit);
1247
+ }
1248
+ async getConversation(id) {
1249
+ return this.conversations.get(id) ?? null;
1250
+ }
1251
+ // ── Contacts ────────────────────────────────────────────────
1252
+ async putContacts(contacts) {
1253
+ for (const c of contacts) this.contacts.set(c.userId, { ...c });
1254
+ }
1255
+ async getContacts() {
1256
+ return Array.from(this.contacts.values());
1257
+ }
1258
+ // ── Cursors ─────────────────────────────────────────────────
1259
+ async getCursor(key) {
1260
+ return this.cursors.get(key) ?? null;
1261
+ }
1262
+ async setCursor(key, value) {
1263
+ this.cursors.set(key, value);
1264
+ }
1265
+ // ── Outbox ──────────────────────────────────────────────────
1266
+ async enqueue(op) {
1267
+ this.outbox.set(op.id, { ...op });
1268
+ }
1269
+ async dequeueReady(limit) {
1270
+ const ready = Array.from(this.outbox.values()).filter((op) => op.status === "pending").sort((a, b) => a.createdAt - b.createdAt).slice(0, limit);
1271
+ for (const op of ready) {
1272
+ op.status = "inflight";
1273
+ this.outbox.set(op.id, op);
1274
+ }
1275
+ return ready;
1276
+ }
1277
+ async ack(opId) {
1278
+ this.outbox.delete(opId);
1279
+ }
1280
+ async nack(opId, error, retries) {
1281
+ const op = this.outbox.get(opId);
1282
+ if (!op) return;
1283
+ op.retries = retries;
1284
+ op.lastError = error;
1285
+ op.status = retries >= op.maxRetries ? "failed" : "pending";
1286
+ this.outbox.set(opId, op);
1287
+ }
1288
+ async getPendingCount() {
1289
+ return Array.from(this.outbox.values()).filter((op) => op.status === "pending" || op.status === "inflight").length;
1290
+ }
1291
+ // ── Search ─────────────────────────────────────────────────
1292
+ async searchMessages(query, opts) {
1293
+ const lower = query.toLowerCase();
1294
+ const limit = opts?.limit ?? 50;
1295
+ return Array.from(this.messages.values()).filter((m) => {
1296
+ if (opts?.conversationId && m.conversationId !== opts.conversationId) return false;
1297
+ return (m.content ?? "").toLowerCase().includes(lower);
1298
+ }).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
1299
+ }
1300
+ // ── Quota ─────────────────────────────────────────────────
1301
+ async getStorageSize() {
1302
+ const msgSize = this.messages.size * 500;
1303
+ const convSize = this.conversations.size * 200;
1304
+ return { messages: msgSize, conversations: convSize, total: msgSize + convSize };
1305
+ }
1306
+ async clearOldMessages(conversationId, keepCount) {
1307
+ const msgs = Array.from(this.messages.values()).filter((m) => m.conversationId === conversationId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1308
+ const toDelete = msgs.slice(keepCount);
1309
+ for (const m of toDelete) this.messages.delete(m.id);
1310
+ return toDelete.length;
1311
+ }
1312
+ // ── Lifecycle ───────────────────────────────────────────────
1313
+ async clear() {
1314
+ this.messages.clear();
1315
+ this.conversations.clear();
1316
+ this.contacts.clear();
1317
+ this.cursors.clear();
1318
+ this.outbox.clear();
1319
+ }
1320
+ };
1321
+ var IDB_STORES = ["messages", "conversations", "contacts", "cursors", "outbox"];
1322
+ var IndexedDBStorage = class {
1323
+ constructor(dbName = "prismer-offline", version = 1) {
1324
+ this.dbName = dbName;
1325
+ this.version = version;
1326
+ this.db = null;
1327
+ }
1328
+ async init() {
1329
+ if (typeof indexedDB === "undefined") {
1330
+ throw new Error("IndexedDB is not available in this environment. Use MemoryStorage or SQLiteStorage instead.");
1331
+ }
1332
+ return new Promise((resolve, reject) => {
1333
+ const req = indexedDB.open(this.dbName, this.version);
1334
+ req.onupgradeneeded = () => {
1335
+ const db = req.result;
1336
+ if (!db.objectStoreNames.contains("messages")) {
1337
+ const store = db.createObjectStore("messages", { keyPath: "id" });
1338
+ store.createIndex("conversationId", "conversationId", { unique: false });
1339
+ store.createIndex("createdAt", "createdAt", { unique: false });
1340
+ }
1341
+ if (!db.objectStoreNames.contains("conversations")) {
1342
+ db.createObjectStore("conversations", { keyPath: "id" });
1343
+ }
1344
+ if (!db.objectStoreNames.contains("contacts")) {
1345
+ db.createObjectStore("contacts", { keyPath: "userId" });
1346
+ }
1347
+ if (!db.objectStoreNames.contains("cursors")) {
1348
+ db.createObjectStore("cursors", { keyPath: "key" });
1349
+ }
1350
+ if (!db.objectStoreNames.contains("outbox")) {
1351
+ const store = db.createObjectStore("outbox", { keyPath: "id" });
1352
+ store.createIndex("status", "status", { unique: false });
1353
+ store.createIndex("createdAt", "createdAt", { unique: false });
1354
+ }
1355
+ };
1356
+ req.onsuccess = () => {
1357
+ this.db = req.result;
1358
+ resolve();
1359
+ };
1360
+ req.onerror = () => reject(req.error);
1361
+ });
1362
+ }
1363
+ tx(stores, mode = "readonly") {
1364
+ if (!this.db) throw new Error("IndexedDB not initialized. Call init() first.");
1365
+ return this.db.transaction(stores, mode);
1366
+ }
1367
+ req(request) {
1368
+ return new Promise((resolve, reject) => {
1369
+ request.onsuccess = () => resolve(request.result);
1370
+ request.onerror = () => reject(request.error);
1371
+ });
1372
+ }
1373
+ // ── Messages ────────────────────────────────────────────────
1374
+ async putMessages(messages) {
1375
+ const tx = this.tx("messages", "readwrite");
1376
+ const store = tx.objectStore("messages");
1377
+ for (const m of messages) store.put(m);
1378
+ return new Promise((resolve, reject) => {
1379
+ tx.oncomplete = () => resolve();
1380
+ tx.onerror = () => reject(tx.error);
1381
+ });
1382
+ }
1383
+ async getMessages(conversationId, opts) {
1384
+ const tx = this.tx("messages");
1385
+ const store = tx.objectStore("messages");
1386
+ const idx = store.index("conversationId");
1387
+ const all = await this.req(idx.getAll(conversationId));
1388
+ all.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1389
+ if (opts.before) {
1390
+ const i = all.findIndex((m) => m.id === opts.before);
1391
+ if (i > 0) return all.slice(Math.max(0, i - opts.limit), i);
1392
+ }
1393
+ return all.slice(-opts.limit);
1394
+ }
1395
+ async getMessage(messageId) {
1396
+ const tx = this.tx("messages");
1397
+ const result = await this.req(tx.objectStore("messages").get(messageId));
1398
+ return result ?? null;
1399
+ }
1400
+ async deleteMessage(messageId) {
1401
+ const tx = this.tx("messages", "readwrite");
1402
+ tx.objectStore("messages").delete(messageId);
1403
+ return new Promise((resolve, reject) => {
1404
+ tx.oncomplete = () => resolve();
1405
+ tx.onerror = () => reject(tx.error);
1406
+ });
1407
+ }
1408
+ // ── Conversations ───────────────────────────────────────────
1409
+ async putConversations(conversations) {
1410
+ const tx = this.tx("conversations", "readwrite");
1411
+ const store = tx.objectStore("conversations");
1412
+ for (const c of conversations) store.put(c);
1413
+ return new Promise((resolve, reject) => {
1414
+ tx.oncomplete = () => resolve();
1415
+ tx.onerror = () => reject(tx.error);
1416
+ });
1417
+ }
1418
+ async getConversations(opts) {
1419
+ const tx = this.tx("conversations");
1420
+ const all = await this.req(tx.objectStore("conversations").getAll());
1421
+ all.sort((a, b) => (b.lastMessageAt ?? b.updatedAt).localeCompare(a.lastMessageAt ?? a.updatedAt));
1422
+ const offset = opts?.offset ?? 0;
1423
+ const limit = opts?.limit ?? 50;
1424
+ return all.slice(offset, offset + limit);
1425
+ }
1426
+ async getConversation(id) {
1427
+ const tx = this.tx("conversations");
1428
+ const result = await this.req(tx.objectStore("conversations").get(id));
1429
+ return result ?? null;
1430
+ }
1431
+ // ── Contacts ────────────────────────────────────────────────
1432
+ async putContacts(contacts) {
1433
+ const tx = this.tx("contacts", "readwrite");
1434
+ const store = tx.objectStore("contacts");
1435
+ for (const c of contacts) store.put(c);
1436
+ return new Promise((resolve, reject) => {
1437
+ tx.oncomplete = () => resolve();
1438
+ tx.onerror = () => reject(tx.error);
1439
+ });
1440
+ }
1441
+ async getContacts() {
1442
+ const tx = this.tx("contacts");
1443
+ return this.req(tx.objectStore("contacts").getAll());
1444
+ }
1445
+ // ── Cursors ─────────────────────────────────────────────────
1446
+ async getCursor(key) {
1447
+ const tx = this.tx("cursors");
1448
+ const result = await this.req(tx.objectStore("cursors").get(key));
1449
+ return result?.value ?? null;
1450
+ }
1451
+ async setCursor(key, value) {
1452
+ const tx = this.tx("cursors", "readwrite");
1453
+ tx.objectStore("cursors").put({ key, value });
1454
+ return new Promise((resolve, reject) => {
1455
+ tx.oncomplete = () => resolve();
1456
+ tx.onerror = () => reject(tx.error);
1457
+ });
1458
+ }
1459
+ // ── Outbox ──────────────────────────────────────────────────
1460
+ async enqueue(op) {
1461
+ const tx = this.tx("outbox", "readwrite");
1462
+ tx.objectStore("outbox").put(op);
1463
+ return new Promise((resolve, reject) => {
1464
+ tx.oncomplete = () => resolve();
1465
+ tx.onerror = () => reject(tx.error);
1466
+ });
1467
+ }
1468
+ async dequeueReady(limit) {
1469
+ const tx = this.tx("outbox", "readwrite");
1470
+ const store = tx.objectStore("outbox");
1471
+ const idx = store.index("status");
1472
+ const pending = await this.req(idx.getAll("pending"));
1473
+ pending.sort((a, b) => a.createdAt - b.createdAt);
1474
+ const batch = pending.slice(0, limit);
1475
+ for (const op of batch) {
1476
+ op.status = "inflight";
1477
+ store.put(op);
1478
+ }
1479
+ return new Promise((resolve, reject) => {
1480
+ tx.oncomplete = () => resolve(batch);
1481
+ tx.onerror = () => reject(tx.error);
1482
+ });
1483
+ }
1484
+ async ack(opId) {
1485
+ const tx = this.tx("outbox", "readwrite");
1486
+ tx.objectStore("outbox").delete(opId);
1487
+ return new Promise((resolve, reject) => {
1488
+ tx.oncomplete = () => resolve();
1489
+ tx.onerror = () => reject(tx.error);
1490
+ });
1491
+ }
1492
+ async nack(opId, error, retries) {
1493
+ const tx = this.tx("outbox", "readwrite");
1494
+ const store = tx.objectStore("outbox");
1495
+ const op = await this.req(store.get(opId));
1496
+ if (!op) return;
1497
+ op.retries = retries;
1498
+ op.lastError = error;
1499
+ op.status = retries >= op.maxRetries ? "failed" : "pending";
1500
+ store.put(op);
1501
+ return new Promise((resolve, reject) => {
1502
+ tx.oncomplete = () => resolve();
1503
+ tx.onerror = () => reject(tx.error);
1504
+ });
1505
+ }
1506
+ async getPendingCount() {
1507
+ const tx = this.tx("outbox");
1508
+ const idx = tx.objectStore("outbox").index("status");
1509
+ const pending = await this.req(idx.count("pending"));
1510
+ const inflight = await this.req(idx.count("inflight"));
1511
+ return pending + inflight;
1512
+ }
1513
+ // ── Search ─────────────────────────────────────────────────
1514
+ async searchMessages(query, opts) {
1515
+ const lower = query.toLowerCase();
1516
+ const limit = opts?.limit ?? 50;
1517
+ const tx = this.tx("messages");
1518
+ let all;
1519
+ if (opts?.conversationId) {
1520
+ const idx = tx.objectStore("messages").index("conversationId");
1521
+ all = await this.req(idx.getAll(opts.conversationId));
1522
+ } else {
1523
+ all = await this.req(tx.objectStore("messages").getAll());
1524
+ }
1525
+ return all.filter((m) => (m.content ?? "").toLowerCase().includes(lower)).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
1526
+ }
1527
+ // ── Quota ─────────────────────────────────────────────────
1528
+ async getStorageSize() {
1529
+ if (typeof navigator !== "undefined" && navigator.storage?.estimate) {
1530
+ const est = await navigator.storage.estimate();
1531
+ const total = est.usage ?? 0;
1532
+ return { messages: Math.floor(total * 0.8), conversations: Math.floor(total * 0.2), total };
1533
+ }
1534
+ const tx = this.tx(["messages", "conversations"]);
1535
+ const msgCount = await this.req(tx.objectStore("messages").count());
1536
+ const convCount = await this.req(tx.objectStore("conversations").count());
1537
+ return { messages: msgCount * 500, conversations: convCount * 200, total: msgCount * 500 + convCount * 200 };
1538
+ }
1539
+ async clearOldMessages(conversationId, keepCount) {
1540
+ const tx = this.tx("messages", "readwrite");
1541
+ const store = tx.objectStore("messages");
1542
+ const idx = store.index("conversationId");
1543
+ const all = await this.req(idx.getAll(conversationId));
1544
+ all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1545
+ const toDelete = all.slice(keepCount);
1546
+ for (const m of toDelete) store.delete(m.id);
1547
+ return new Promise((resolve, reject) => {
1548
+ tx.oncomplete = () => resolve(toDelete.length);
1549
+ tx.onerror = () => reject(tx.error);
1550
+ });
1551
+ }
1552
+ // ── Lifecycle ───────────────────────────────────────────────
1553
+ async clear() {
1554
+ const tx = this.tx(IDB_STORES, "readwrite");
1555
+ for (const name of IDB_STORES) tx.objectStore(name).clear();
1556
+ return new Promise((resolve, reject) => {
1557
+ tx.oncomplete = () => resolve();
1558
+ tx.onerror = () => reject(tx.error);
1559
+ });
1560
+ }
1561
+ };
1562
+ var SQLiteStorage = class {
1563
+ constructor(dbPath = "prismer-offline.db") {
1564
+ this.db = null;
1565
+ this.dbPath = dbPath;
1566
+ }
1567
+ async init() {
1568
+ let Database;
1569
+ try {
1570
+ Database = __require("better-sqlite3");
1571
+ } catch {
1572
+ throw new Error(
1573
+ 'SQLiteStorage requires the "better-sqlite3" package. Install it with: npm install better-sqlite3\nFor browser environments, use IndexedDBStorage instead.'
1574
+ );
1575
+ }
1576
+ this.db = new Database(this.dbPath);
1577
+ this.db.pragma("journal_mode = WAL");
1578
+ this.db.pragma("synchronous = NORMAL");
1579
+ this.db.exec(`
1580
+ CREATE TABLE IF NOT EXISTS messages (
1581
+ id TEXT PRIMARY KEY,
1582
+ clientId TEXT,
1583
+ conversationId TEXT NOT NULL,
1584
+ content TEXT,
1585
+ type TEXT DEFAULT 'text',
1586
+ senderId TEXT,
1587
+ parentId TEXT,
1588
+ status TEXT DEFAULT 'confirmed',
1589
+ metadata TEXT,
1590
+ createdAt TEXT,
1591
+ updatedAt TEXT,
1592
+ syncSeq INTEGER
1593
+ );
1594
+ CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversationId, createdAt);
1595
+ CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(createdAt);
1596
+
1597
+ CREATE TABLE IF NOT EXISTS conversations (
1598
+ id TEXT PRIMARY KEY,
1599
+ type TEXT DEFAULT 'direct',
1600
+ title TEXT,
1601
+ lastMessage TEXT,
1602
+ lastMessageAt TEXT,
1603
+ unreadCount INTEGER DEFAULT 0,
1604
+ lastReadMessageId TEXT,
1605
+ members TEXT,
1606
+ metadata TEXT,
1607
+ syncSeq INTEGER,
1608
+ updatedAt TEXT
1609
+ );
1610
+
1611
+ CREATE TABLE IF NOT EXISTS contacts (
1612
+ userId TEXT PRIMARY KEY,
1613
+ username TEXT,
1614
+ displayName TEXT,
1615
+ role TEXT,
1616
+ conversationId TEXT,
1617
+ lastMessageAt TEXT,
1618
+ unreadCount INTEGER DEFAULT 0,
1619
+ syncSeq INTEGER
1620
+ );
1621
+
1622
+ CREATE TABLE IF NOT EXISTS cursors (
1623
+ key TEXT PRIMARY KEY,
1624
+ value TEXT
1625
+ );
1626
+
1627
+ CREATE TABLE IF NOT EXISTS outbox (
1628
+ id TEXT PRIMARY KEY,
1629
+ type TEXT,
1630
+ method TEXT,
1631
+ path TEXT,
1632
+ body TEXT,
1633
+ query TEXT,
1634
+ status TEXT DEFAULT 'pending',
1635
+ createdAt INTEGER,
1636
+ retries INTEGER DEFAULT 0,
1637
+ maxRetries INTEGER DEFAULT 5,
1638
+ lastError TEXT,
1639
+ idempotencyKey TEXT,
1640
+ localData TEXT
1641
+ );
1642
+ CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(status, createdAt);
1643
+ `);
1644
+ this.db.exec(`
1645
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
1646
+ content, id UNINDEXED, conversationId UNINDEXED
1647
+ );
1648
+ `);
1649
+ }
1650
+ ensureDb() {
1651
+ if (!this.db) throw new Error("SQLiteStorage not initialized. Call init() first.");
1652
+ return this.db;
1653
+ }
1654
+ // ── Messages ────────────────────────────────────────────────
1655
+ async putMessages(messages) {
1656
+ const db = this.ensureDb();
1657
+ const insert = db.prepare(`
1658
+ INSERT OR REPLACE INTO messages (id, clientId, conversationId, content, type, senderId, parentId, status, metadata, createdAt, updatedAt, syncSeq)
1659
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1660
+ `);
1661
+ const insertFts = db.prepare(`
1662
+ INSERT OR REPLACE INTO messages_fts (rowid, content, id, conversationId)
1663
+ VALUES ((SELECT rowid FROM messages WHERE id = ?), ?, ?, ?)
1664
+ `);
1665
+ const txn = db.transaction((msgs) => {
1666
+ for (const m of msgs) {
1667
+ insert.run(
1668
+ m.id,
1669
+ m.clientId ?? null,
1670
+ m.conversationId,
1671
+ m.content,
1672
+ m.type,
1673
+ m.senderId,
1674
+ m.parentId ?? null,
1675
+ m.status,
1676
+ m.metadata ? JSON.stringify(m.metadata) : null,
1677
+ m.createdAt,
1678
+ m.updatedAt ?? null,
1679
+ m.syncSeq ?? null
1680
+ );
1681
+ if (m.content) {
1682
+ insertFts.run(m.id, m.content, m.id, m.conversationId);
1683
+ }
1684
+ }
1685
+ });
1686
+ txn(messages);
1687
+ }
1688
+ async getMessages(conversationId, opts) {
1689
+ const db = this.ensureDb();
1690
+ let rows;
1691
+ if (opts.before) {
1692
+ const beforeRow = db.prepare("SELECT createdAt FROM messages WHERE id = ?").get(opts.before);
1693
+ if (beforeRow) {
1694
+ rows = db.prepare(
1695
+ "SELECT * FROM messages WHERE conversationId = ? AND createdAt < ? ORDER BY createdAt DESC LIMIT ?"
1696
+ ).all(conversationId, beforeRow.createdAt, opts.limit);
1697
+ } else {
1698
+ rows = db.prepare(
1699
+ "SELECT * FROM messages WHERE conversationId = ? ORDER BY createdAt DESC LIMIT ?"
1700
+ ).all(conversationId, opts.limit);
1701
+ }
1702
+ } else {
1703
+ rows = db.prepare(
1704
+ "SELECT * FROM messages WHERE conversationId = ? ORDER BY createdAt DESC LIMIT ?"
1705
+ ).all(conversationId, opts.limit);
1706
+ }
1707
+ return rows.reverse().map(this.rowToMessage);
1708
+ }
1709
+ async getMessage(messageId) {
1710
+ const db = this.ensureDb();
1711
+ const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId);
1712
+ return row ? this.rowToMessage(row) : null;
1713
+ }
1714
+ async deleteMessage(messageId) {
1715
+ const db = this.ensureDb();
1716
+ db.prepare("DELETE FROM messages WHERE id = ?").run(messageId);
1717
+ db.prepare("DELETE FROM messages_fts WHERE id = ?").run(messageId);
1718
+ }
1719
+ rowToMessage(row) {
1720
+ return {
1721
+ id: row.id,
1722
+ clientId: row.clientId ?? void 0,
1723
+ conversationId: row.conversationId,
1724
+ content: row.content ?? "",
1725
+ type: row.type ?? "text",
1726
+ senderId: row.senderId ?? "",
1727
+ parentId: row.parentId ?? null,
1728
+ status: row.status ?? "confirmed",
1729
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
1730
+ createdAt: row.createdAt ?? "",
1731
+ updatedAt: row.updatedAt ?? void 0,
1732
+ syncSeq: row.syncSeq ?? void 0
1733
+ };
1734
+ }
1735
+ // ── Conversations ───────────────────────────────────────────
1736
+ async putConversations(conversations) {
1737
+ const db = this.ensureDb();
1738
+ const insert = db.prepare(`
1739
+ INSERT OR REPLACE INTO conversations (id, type, title, lastMessage, lastMessageAt, unreadCount, lastReadMessageId, members, metadata, syncSeq, updatedAt)
1740
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1741
+ `);
1742
+ const txn = db.transaction((convs) => {
1743
+ for (const c of convs) {
1744
+ insert.run(
1745
+ c.id,
1746
+ c.type,
1747
+ c.title ?? null,
1748
+ c.lastMessage ? JSON.stringify(c.lastMessage) : null,
1749
+ c.lastMessageAt ?? null,
1750
+ c.unreadCount,
1751
+ c.lastReadMessageId ?? null,
1752
+ c.members ? JSON.stringify(c.members) : null,
1753
+ c.metadata ? JSON.stringify(c.metadata) : null,
1754
+ c.syncSeq ?? null,
1755
+ c.updatedAt
1756
+ );
1757
+ }
1758
+ });
1759
+ txn(conversations);
1760
+ }
1761
+ async getConversations(opts) {
1762
+ const db = this.ensureDb();
1763
+ const limit = opts?.limit ?? 50;
1764
+ const offset = opts?.offset ?? 0;
1765
+ const rows = db.prepare(
1766
+ "SELECT * FROM conversations ORDER BY COALESCE(lastMessageAt, updatedAt) DESC LIMIT ? OFFSET ?"
1767
+ ).all(limit, offset);
1768
+ return rows.map(this.rowToConversation);
1769
+ }
1770
+ async getConversation(id) {
1771
+ const db = this.ensureDb();
1772
+ const row = db.prepare("SELECT * FROM conversations WHERE id = ?").get(id);
1773
+ return row ? this.rowToConversation(row) : null;
1774
+ }
1775
+ rowToConversation(row) {
1776
+ return {
1777
+ id: row.id,
1778
+ type: row.type ?? "direct",
1779
+ title: row.title ?? void 0,
1780
+ lastMessage: row.lastMessage ? JSON.parse(row.lastMessage) : void 0,
1781
+ lastMessageAt: row.lastMessageAt ?? void 0,
1782
+ unreadCount: row.unreadCount ?? 0,
1783
+ lastReadMessageId: row.lastReadMessageId ?? void 0,
1784
+ members: row.members ? JSON.parse(row.members) : void 0,
1785
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
1786
+ syncSeq: row.syncSeq ?? void 0,
1787
+ updatedAt: row.updatedAt ?? ""
1788
+ };
1789
+ }
1790
+ // ── Contacts ────────────────────────────────────────────────
1791
+ async putContacts(contacts) {
1792
+ const db = this.ensureDb();
1793
+ const insert = db.prepare(`
1794
+ INSERT OR REPLACE INTO contacts (userId, username, displayName, role, conversationId, lastMessageAt, unreadCount, syncSeq)
1795
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1796
+ `);
1797
+ const txn = db.transaction((cs) => {
1798
+ for (const c of cs) {
1799
+ insert.run(
1800
+ c.userId,
1801
+ c.username,
1802
+ c.displayName,
1803
+ c.role,
1804
+ c.conversationId,
1805
+ c.lastMessageAt ?? null,
1806
+ c.unreadCount,
1807
+ c.syncSeq ?? null
1808
+ );
1809
+ }
1810
+ });
1811
+ txn(contacts);
1812
+ }
1813
+ async getContacts() {
1814
+ const db = this.ensureDb();
1815
+ return db.prepare("SELECT * FROM contacts").all().map((row) => ({
1816
+ userId: row.userId,
1817
+ username: row.username ?? "",
1818
+ displayName: row.displayName ?? "",
1819
+ role: row.role ?? "member",
1820
+ conversationId: row.conversationId ?? "",
1821
+ lastMessageAt: row.lastMessageAt ?? void 0,
1822
+ unreadCount: row.unreadCount ?? 0,
1823
+ syncSeq: row.syncSeq ?? void 0
1824
+ }));
1825
+ }
1826
+ // ── Cursors ─────────────────────────────────────────────────
1827
+ async getCursor(key) {
1828
+ const db = this.ensureDb();
1829
+ const row = db.prepare("SELECT value FROM cursors WHERE key = ?").get(key);
1830
+ return row?.value ?? null;
1831
+ }
1832
+ async setCursor(key, value) {
1833
+ const db = this.ensureDb();
1834
+ db.prepare("INSERT OR REPLACE INTO cursors (key, value) VALUES (?, ?)").run(key, value);
1835
+ }
1836
+ // ── Outbox ──────────────────────────────────────────────────
1837
+ async enqueue(op) {
1838
+ const db = this.ensureDb();
1839
+ db.prepare(`
1840
+ INSERT OR REPLACE INTO outbox (id, type, method, path, body, query, status, createdAt, retries, maxRetries, lastError, idempotencyKey, localData)
1841
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1842
+ `).run(
1843
+ op.id,
1844
+ op.type,
1845
+ op.method,
1846
+ op.path,
1847
+ op.body ? JSON.stringify(op.body) : null,
1848
+ op.query ? JSON.stringify(op.query) : null,
1849
+ op.status,
1850
+ op.createdAt,
1851
+ op.retries,
1852
+ op.maxRetries,
1853
+ op.lastError ?? null,
1854
+ op.idempotencyKey,
1855
+ op.localData ? JSON.stringify(op.localData) : null
1856
+ );
1857
+ }
1858
+ async dequeueReady(limit) {
1859
+ const db = this.ensureDb();
1860
+ const rows = db.prepare(
1861
+ "SELECT * FROM outbox WHERE status = ? ORDER BY createdAt ASC LIMIT ?"
1862
+ ).all("pending", limit);
1863
+ const ops = rows.map(this.rowToOutbox);
1864
+ const update = db.prepare("UPDATE outbox SET status = ? WHERE id = ?");
1865
+ const txn = db.transaction((items) => {
1866
+ for (const op of items) update.run("inflight", op.id);
1867
+ });
1868
+ txn(ops);
1869
+ return ops.map((op) => ({ ...op, status: "inflight" }));
1870
+ }
1871
+ async ack(opId) {
1872
+ const db = this.ensureDb();
1873
+ db.prepare("DELETE FROM outbox WHERE id = ?").run(opId);
1874
+ }
1875
+ async nack(opId, error, retries) {
1876
+ const db = this.ensureDb();
1877
+ const row = db.prepare("SELECT maxRetries FROM outbox WHERE id = ?").get(opId);
1878
+ const newStatus = row && retries >= row.maxRetries ? "failed" : "pending";
1879
+ db.prepare("UPDATE outbox SET retries = ?, lastError = ?, status = ? WHERE id = ?").run(retries, error, newStatus, opId);
1880
+ }
1881
+ async getPendingCount() {
1882
+ const db = this.ensureDb();
1883
+ const row = db.prepare(
1884
+ "SELECT COUNT(*) as cnt FROM outbox WHERE status IN ('pending', 'inflight')"
1885
+ ).get();
1886
+ return row?.cnt ?? 0;
1887
+ }
1888
+ // ── Search (FTS5) ─────────────────────────────────────────
1889
+ async searchMessages(query, opts) {
1890
+ const db = this.ensureDb();
1891
+ const limit = opts?.limit ?? 50;
1892
+ const safeQuery = query.replace(/['"*(){}[\]^~\\]/g, " ").trim();
1893
+ if (!safeQuery) return [];
1894
+ let rows;
1895
+ if (opts?.conversationId) {
1896
+ rows = db.prepare(`
1897
+ SELECT m.* FROM messages m
1898
+ JOIN messages_fts f ON m.id = f.id
1899
+ WHERE messages_fts MATCH ? AND m.conversationId = ?
1900
+ ORDER BY m.createdAt DESC LIMIT ?
1901
+ `).all(safeQuery, opts.conversationId, limit);
1902
+ } else {
1903
+ rows = db.prepare(`
1904
+ SELECT m.* FROM messages m
1905
+ JOIN messages_fts f ON m.id = f.id
1906
+ WHERE messages_fts MATCH ?
1907
+ ORDER BY m.createdAt DESC LIMIT ?
1908
+ `).all(safeQuery, limit);
1909
+ }
1910
+ return rows.map(this.rowToMessage);
1911
+ }
1912
+ // ── Quota ─────────────────────────────────────────────────
1913
+ async getStorageSize() {
1914
+ const db = this.ensureDb();
1915
+ const pageSize = db.pragma("page_size", { simple: true }) ?? 4096;
1916
+ const pageCount = db.pragma("page_count", { simple: true }) ?? 0;
1917
+ const total = pageSize * pageCount;
1918
+ const msgCount = db.prepare("SELECT COUNT(*) as cnt FROM messages").get()?.cnt ?? 0;
1919
+ const convCount = db.prepare("SELECT COUNT(*) as cnt FROM conversations").get()?.cnt ?? 0;
1920
+ const totalRecords = msgCount + convCount;
1921
+ const msgRatio = totalRecords > 0 ? msgCount / totalRecords : 0.8;
1922
+ return {
1923
+ messages: Math.floor(total * msgRatio),
1924
+ conversations: Math.floor(total * (1 - msgRatio)),
1925
+ total
1926
+ };
1927
+ }
1928
+ async clearOldMessages(conversationId, keepCount) {
1929
+ const db = this.ensureDb();
1930
+ const keepIds = db.prepare(
1931
+ "SELECT id FROM messages WHERE conversationId = ? ORDER BY createdAt DESC LIMIT ?"
1932
+ ).all(conversationId, keepCount).map((r) => r.id);
1933
+ if (keepIds.length === 0) return 0;
1934
+ const placeholders = keepIds.map(() => "?").join(",");
1935
+ const result = db.prepare(
1936
+ `DELETE FROM messages WHERE conversationId = ? AND id NOT IN (${placeholders})`
1937
+ ).run(conversationId, ...keepIds);
1938
+ db.prepare(
1939
+ `DELETE FROM messages_fts WHERE conversationId = ? AND id NOT IN (${placeholders})`
1940
+ ).run(conversationId, ...keepIds);
1941
+ return result.changes;
1942
+ }
1943
+ // ── Lifecycle ───────────────────────────────────────────────
1944
+ async clear() {
1945
+ const db = this.ensureDb();
1946
+ db.exec("DELETE FROM messages; DELETE FROM messages_fts; DELETE FROM conversations; DELETE FROM contacts; DELETE FROM cursors; DELETE FROM outbox;");
1947
+ }
1948
+ rowToOutbox(row) {
1949
+ return {
1950
+ id: row.id,
1951
+ type: row.type,
1952
+ method: row.method,
1953
+ path: row.path,
1954
+ body: row.body ? JSON.parse(row.body) : void 0,
1955
+ query: row.query ? JSON.parse(row.query) : void 0,
1956
+ status: row.status,
1957
+ createdAt: row.createdAt,
1958
+ retries: row.retries ?? 0,
1959
+ maxRetries: row.maxRetries ?? 5,
1960
+ lastError: row.lastError ?? void 0,
1961
+ idempotencyKey: row.idempotencyKey ?? "",
1962
+ localData: row.localData ? JSON.parse(row.localData) : void 0
1963
+ };
1964
+ }
1965
+ };
1966
+
1967
+ // src/multitab.ts
1968
+ var TabCoordinator = class {
1969
+ constructor(offline, channelName = "prismer-tab-sync") {
1970
+ this.offline = offline;
1971
+ this.channelName = channelName;
1972
+ this.channel = null;
1973
+ this._isLeader = false;
1974
+ this.disposed = false;
1975
+ this.tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1976
+ }
1977
+ get isLeader() {
1978
+ return this._isLeader;
1979
+ }
1980
+ /**
1981
+ * Initialize tab coordination.
1982
+ * Claims leadership immediately (last-login-wins).
1983
+ */
1984
+ init() {
1985
+ if (typeof BroadcastChannel === "undefined") {
1986
+ this._isLeader = true;
1987
+ return;
1988
+ }
1989
+ this.channel = new BroadcastChannel(this.channelName);
1990
+ this.channel.onmessage = (e) => this.handleMessage(e.data);
1991
+ this.claimLeadership();
1992
+ }
1993
+ /**
1994
+ * Release leadership and clean up.
1995
+ */
1996
+ destroy() {
1997
+ this.disposed = true;
1998
+ if (this.channel) {
1999
+ if (this._isLeader) {
2000
+ this.broadcast({ type: "tab.release", tabId: this.tabId });
2001
+ }
2002
+ this.channel.close();
2003
+ this.channel = null;
2004
+ }
2005
+ this._isLeader = false;
2006
+ }
2007
+ /**
2008
+ * Relay a sync event to passive tabs.
2009
+ * Called by the leader tab after processing a sync event.
2010
+ */
2011
+ relaySyncEvent(event) {
2012
+ if (this._isLeader && this.channel) {
2013
+ this.broadcast({ type: "sync.event", tabId: this.tabId, payload: event });
2014
+ }
2015
+ }
2016
+ // ── Private ──────────────────────────────────────────────────
2017
+ claimLeadership() {
2018
+ this._isLeader = true;
2019
+ this.broadcast({ type: "tab.claim", tabId: this.tabId });
2020
+ this.onBecomeLeader();
2021
+ }
2022
+ demoteToPassive() {
2023
+ if (!this._isLeader) return;
2024
+ this._isLeader = false;
2025
+ this.onBecomePassive();
2026
+ }
2027
+ handleMessage(msg) {
2028
+ if (this.disposed) return;
2029
+ switch (msg.type) {
2030
+ case "tab.claim": {
2031
+ if (msg.tabId !== this.tabId) {
2032
+ this.demoteToPassive();
2033
+ this.broadcast({ type: "tab.ack", tabId: this.tabId });
2034
+ }
2035
+ break;
2036
+ }
2037
+ case "tab.release": {
2038
+ if (!this._isLeader) {
2039
+ this.claimLeadership();
2040
+ }
2041
+ break;
2042
+ }
2043
+ case "sync.event": {
2044
+ if (!this._isLeader && msg.payload) {
2045
+ this.offline["applySyncEvent"](msg.payload).catch(() => {
2046
+ });
2047
+ }
2048
+ break;
2049
+ }
2050
+ }
2051
+ }
2052
+ onBecomeLeader() {
2053
+ }
2054
+ onBecomePassive() {
2055
+ this.offline.stopContinuousSync();
2056
+ }
2057
+ broadcast(msg) {
2058
+ try {
2059
+ this.channel?.postMessage(msg);
2060
+ } catch {
2061
+ }
2062
+ }
2063
+ };
2064
+
2065
+ // src/encryption.ts
2066
+ function getSubtleCrypto() {
2067
+ if (typeof globalThis.crypto?.subtle !== "undefined") {
2068
+ return globalThis.crypto.subtle;
2069
+ }
2070
+ try {
2071
+ const { webcrypto } = __require("crypto");
2072
+ return webcrypto.subtle;
2073
+ } catch {
2074
+ throw new Error("No SubtleCrypto available. Requires browser or Node.js 16+.");
2075
+ }
2076
+ }
2077
+ function getRandomValues(arr) {
2078
+ if (typeof globalThis.crypto?.getRandomValues !== "undefined") {
2079
+ return globalThis.crypto.getRandomValues(arr);
2080
+ }
2081
+ try {
2082
+ const { webcrypto } = __require("crypto");
2083
+ return webcrypto.getRandomValues(arr);
2084
+ } catch {
2085
+ throw new Error("No crypto.getRandomValues available.");
2086
+ }
2087
+ }
2088
+ var subtle = () => getSubtleCrypto();
2089
+ var PBKDF2_ITERATIONS = 1e5;
2090
+ var SALT_LENGTH = 16;
2091
+ var IV_LENGTH = 12;
2092
+ var KEY_LENGTH = 256;
2093
+ var E2EEncryption = class {
2094
+ constructor() {
2095
+ this.masterKey = null;
2096
+ this.keyPair = null;
2097
+ this.sessionKeys = /* @__PURE__ */ new Map();
2098
+ // conversationId → AES key
2099
+ this.salt = null;
2100
+ }
2101
+ /**
2102
+ * Initialize encryption with user passphrase.
2103
+ * Derives a master key via PBKDF2 and generates an ECDH key pair.
2104
+ */
2105
+ async init(passphrase) {
2106
+ this.salt = getRandomValues(new Uint8Array(SALT_LENGTH));
2107
+ const passphraseKey = await subtle().importKey(
2108
+ "raw",
2109
+ new TextEncoder().encode(passphrase),
2110
+ "PBKDF2",
2111
+ false,
2112
+ ["deriveKey"]
2113
+ );
2114
+ this.masterKey = await subtle().deriveKey(
2115
+ {
2116
+ name: "PBKDF2",
2117
+ salt: this.salt,
2118
+ iterations: PBKDF2_ITERATIONS,
2119
+ hash: "SHA-256"
2120
+ },
2121
+ passphraseKey,
2122
+ { name: "AES-GCM", length: KEY_LENGTH },
2123
+ false,
2124
+ ["encrypt", "decrypt"]
2125
+ );
2126
+ this.keyPair = await subtle().generateKey(
2127
+ { name: "ECDH", namedCurve: "P-256" },
2128
+ true,
2129
+ ["deriveKey"]
2130
+ );
2131
+ }
2132
+ /**
2133
+ * Export public key for sharing with conversation peers.
2134
+ */
2135
+ async exportPublicKey() {
2136
+ if (!this.keyPair) throw new Error("E2E not initialized. Call init() first.");
2137
+ return subtle().exportKey("jwk", this.keyPair.publicKey);
2138
+ }
2139
+ /**
2140
+ * Derive a shared session key for a conversation using ECDH.
2141
+ * Call this with each peer's public key.
2142
+ */
2143
+ async deriveSessionKey(conversationId, peerPublicKey) {
2144
+ if (!this.keyPair) throw new Error("E2E not initialized. Call init() first.");
2145
+ const importedPeerKey = await subtle().importKey(
2146
+ "jwk",
2147
+ peerPublicKey,
2148
+ { name: "ECDH", namedCurve: "P-256" },
2149
+ false,
2150
+ []
2151
+ );
2152
+ const sessionKey = await subtle().deriveKey(
2153
+ { name: "ECDH", public: importedPeerKey },
2154
+ this.keyPair.privateKey,
2155
+ { name: "AES-GCM", length: KEY_LENGTH },
2156
+ false,
2157
+ ["encrypt", "decrypt"]
2158
+ );
2159
+ this.sessionKeys.set(conversationId, sessionKey);
2160
+ }
2161
+ /**
2162
+ * Set a pre-shared session key for a conversation.
2163
+ * Useful when the key is exchanged out-of-band or derived from a group key.
2164
+ */
2165
+ async setSessionKey(conversationId, rawKey) {
2166
+ const key = await subtle().importKey(
2167
+ "raw",
2168
+ rawKey,
2169
+ { name: "AES-GCM", length: KEY_LENGTH },
2170
+ false,
2171
+ ["encrypt", "decrypt"]
2172
+ );
2173
+ this.sessionKeys.set(conversationId, key);
2174
+ }
2175
+ /**
2176
+ * Generate a random session key for a conversation.
2177
+ * Returns the raw key bytes for sharing with peers.
2178
+ */
2179
+ async generateSessionKey(conversationId) {
2180
+ const key = await subtle().generateKey(
2181
+ { name: "AES-GCM", length: KEY_LENGTH },
2182
+ true,
2183
+ ["encrypt", "decrypt"]
2184
+ );
2185
+ this.sessionKeys.set(conversationId, key);
2186
+ return subtle().exportKey("raw", key);
2187
+ }
2188
+ /**
2189
+ * Encrypt plaintext for a conversation.
2190
+ * Returns base64-encoded ciphertext with prepended IV.
2191
+ */
2192
+ async encrypt(conversationId, plaintext) {
2193
+ const key = this.sessionKeys.get(conversationId);
2194
+ if (!key) throw new Error(`No session key for conversation ${conversationId}. Call deriveSessionKey() first.`);
2195
+ const iv = getRandomValues(new Uint8Array(IV_LENGTH));
2196
+ const encoded = new TextEncoder().encode(plaintext);
2197
+ const ciphertext = await subtle().encrypt(
2198
+ { name: "AES-GCM", iv },
2199
+ key,
2200
+ encoded
2201
+ );
2202
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
2203
+ combined.set(iv, 0);
2204
+ combined.set(new Uint8Array(ciphertext), iv.length);
2205
+ return arrayBufferToBase64(combined.buffer);
2206
+ }
2207
+ /**
2208
+ * Decrypt ciphertext from a conversation.
2209
+ * Expects base64-encoded data with prepended IV.
2210
+ */
2211
+ async decrypt(conversationId, ciphertext) {
2212
+ const key = this.sessionKeys.get(conversationId);
2213
+ if (!key) throw new Error(`No session key for conversation ${conversationId}. Call deriveSessionKey() first.`);
2214
+ const combined = base64ToArrayBuffer(ciphertext);
2215
+ const iv = combined.slice(0, IV_LENGTH);
2216
+ const data = combined.slice(IV_LENGTH);
2217
+ const decrypted = await subtle().decrypt(
2218
+ { name: "AES-GCM", iv: new Uint8Array(iv) },
2219
+ key,
2220
+ data
2221
+ );
2222
+ return new TextDecoder().decode(decrypted);
2223
+ }
2224
+ /**
2225
+ * Check if a session key exists for a conversation.
2226
+ */
2227
+ hasSessionKey(conversationId) {
2228
+ return this.sessionKeys.has(conversationId);
2229
+ }
2230
+ /**
2231
+ * Remove session key for a conversation.
2232
+ */
2233
+ removeSessionKey(conversationId) {
2234
+ this.sessionKeys.delete(conversationId);
2235
+ }
2236
+ /**
2237
+ * Clear all keys and reset state.
2238
+ */
2239
+ destroy() {
2240
+ this.masterKey = null;
2241
+ this.keyPair = null;
2242
+ this.sessionKeys.clear();
2243
+ this.salt = null;
2244
+ }
2245
+ };
2246
+ function arrayBufferToBase64(buffer) {
2247
+ if (typeof btoa !== "undefined") {
2248
+ const bytes = new Uint8Array(buffer);
2249
+ let binary = "";
2250
+ for (let i = 0; i < bytes.byteLength; i++) {
2251
+ binary += String.fromCharCode(bytes[i]);
2252
+ }
2253
+ return btoa(binary);
2254
+ }
2255
+ return Buffer.from(buffer).toString("base64");
2256
+ }
2257
+ function base64ToArrayBuffer(base64) {
2258
+ if (typeof atob !== "undefined") {
2259
+ const binary = atob(base64);
2260
+ const bytes = new Uint8Array(binary.length);
2261
+ for (let i = 0; i < binary.length; i++) {
2262
+ bytes[i] = binary.charCodeAt(i);
2263
+ }
2264
+ return bytes.buffer;
2265
+ }
2266
+ const buf = Buffer.from(base64, "base64");
2267
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
2268
+ }
2269
+
433
2270
  // src/index.ts
434
2271
  var AccountClient = class {
435
2272
  constructor(_r) {
@@ -464,8 +2301,8 @@ var DirectClient = class {
464
2301
  /** Get direct message history with a user */
465
2302
  async getMessages(userId, options) {
466
2303
  const query = {};
467
- if (options?.limit) query.limit = String(options.limit);
468
- if (options?.offset) query.offset = String(options.offset);
2304
+ if (options?.limit != null) query.limit = String(options.limit);
2305
+ if (options?.offset != null) query.offset = String(options.offset);
469
2306
  return this._r("GET", `/api/im/direct/${userId}/messages`, void 0, query);
470
2307
  }
471
2308
  };
@@ -497,8 +2334,8 @@ var GroupsClient = class {
497
2334
  /** Get group message history */
498
2335
  async getMessages(groupId, options) {
499
2336
  const query = {};
500
- if (options?.limit) query.limit = String(options.limit);
501
- if (options?.offset) query.offset = String(options.offset);
2337
+ if (options?.limit != null) query.limit = String(options.limit);
2338
+ if (options?.offset != null) query.offset = String(options.offset);
502
2339
  return this._r("GET", `/api/im/groups/${groupId}/messages`, void 0, query);
503
2340
  }
504
2341
  /** Add a member to a group (owner/admin only) */
@@ -550,8 +2387,8 @@ var MessagesClient = class {
550
2387
  /** Get message history for a conversation */
551
2388
  async getHistory(conversationId, options) {
552
2389
  const query = {};
553
- if (options?.limit) query.limit = String(options.limit);
554
- if (options?.offset) query.offset = String(options.offset);
2390
+ if (options?.limit != null) query.limit = String(options.limit);
2391
+ if (options?.offset != null) query.offset = String(options.offset);
555
2392
  return this._r("GET", `/api/im/messages/${conversationId}`, void 0, query);
556
2393
  }
557
2394
  /** Edit a message */
@@ -611,8 +2448,8 @@ var CreditsClient = class {
611
2448
  /** Get credit transaction history */
612
2449
  async transactions(options) {
613
2450
  const query = {};
614
- if (options?.limit) query.limit = String(options.limit);
615
- if (options?.offset) query.offset = String(options.offset);
2451
+ if (options?.limit != null) query.limit = String(options.limit);
2452
+ if (options?.offset != null) query.offset = String(options.offset);
616
2453
  return this._r("GET", "/api/im/credits/transactions", void 0, query);
617
2454
  }
618
2455
  };
@@ -643,6 +2480,211 @@ var WorkspaceClient = class {
643
2480
  return this._r("GET", "/api/im/workspace/mentions/autocomplete", void 0, q);
644
2481
  }
645
2482
  };
2483
+ function guessMimeType(fileName) {
2484
+ const ext = fileName.split(".").pop()?.toLowerCase() || "";
2485
+ const map = {
2486
+ png: "image/png",
2487
+ jpg: "image/jpeg",
2488
+ jpeg: "image/jpeg",
2489
+ gif: "image/gif",
2490
+ webp: "image/webp",
2491
+ svg: "image/svg+xml",
2492
+ ico: "image/x-icon",
2493
+ bmp: "image/bmp",
2494
+ pdf: "application/pdf",
2495
+ doc: "application/msword",
2496
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
2497
+ xls: "application/vnd.ms-excel",
2498
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2499
+ ppt: "application/vnd.ms-powerpoint",
2500
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
2501
+ txt: "text/plain",
2502
+ csv: "text/csv",
2503
+ html: "text/html",
2504
+ css: "text/css",
2505
+ js: "text/javascript",
2506
+ json: "application/json",
2507
+ xml: "application/xml",
2508
+ md: "text/markdown",
2509
+ yaml: "text/yaml",
2510
+ yml: "text/yaml",
2511
+ zip: "application/zip",
2512
+ gz: "application/gzip",
2513
+ tar: "application/x-tar",
2514
+ mp3: "audio/mpeg",
2515
+ wav: "audio/wav",
2516
+ mp4: "video/mp4",
2517
+ webm: "video/webm"
2518
+ };
2519
+ return map[ext] || "application/octet-stream";
2520
+ }
2521
+ var FilesClient = class {
2522
+ constructor(_r, _baseUrl, _fetchFn, _getAuthHeaders) {
2523
+ this._r = _r;
2524
+ this._baseUrl = _baseUrl;
2525
+ this._fetchFn = _fetchFn;
2526
+ this._getAuthHeaders = _getAuthHeaders;
2527
+ }
2528
+ /** Get a presigned upload URL */
2529
+ async presign(options) {
2530
+ return this._r("POST", "/api/im/files/presign", options);
2531
+ }
2532
+ /** Confirm an uploaded file (triggers validation + CDN activation) */
2533
+ async confirm(uploadId) {
2534
+ return this._r("POST", "/api/im/files/confirm", { uploadId });
2535
+ }
2536
+ /** Get storage quota */
2537
+ async quota() {
2538
+ return this._r("GET", "/api/im/files/quota");
2539
+ }
2540
+ /** Delete a file */
2541
+ async delete(uploadId) {
2542
+ return this._r("DELETE", `/api/im/files/${uploadId}`);
2543
+ }
2544
+ /** List allowed MIME types */
2545
+ async types() {
2546
+ return this._r("GET", "/api/im/files/types");
2547
+ }
2548
+ /** Initialize a multipart upload (for files > 10 MB) */
2549
+ async initMultipart(opts) {
2550
+ return this._r("POST", "/api/im/files/upload/init", opts);
2551
+ }
2552
+ /** Complete a multipart upload */
2553
+ async completeMultipart(uploadId, parts) {
2554
+ return this._r("POST", "/api/im/files/upload/complete", { uploadId, parts });
2555
+ }
2556
+ // --------------------------------------------------------------------------
2557
+ // High-level convenience methods
2558
+ // --------------------------------------------------------------------------
2559
+ /**
2560
+ * Upload a file (full lifecycle: presign → upload → confirm).
2561
+ *
2562
+ * @param input - File, Blob, Buffer, Uint8Array, or file path (Node.js string)
2563
+ * @param opts - Optional fileName, mimeType, onProgress
2564
+ * @returns Confirmed upload result with CDN URL
2565
+ */
2566
+ async upload(input, opts) {
2567
+ let bytes;
2568
+ let fileName;
2569
+ if (typeof input === "string") {
2570
+ const fs = await import("fs");
2571
+ const path = await import("path");
2572
+ const buf = await fs.promises.readFile(input);
2573
+ bytes = new Uint8Array(buf);
2574
+ fileName = opts?.fileName || path.basename(input);
2575
+ } else if (typeof Blob !== "undefined" && input instanceof Blob) {
2576
+ const ab = await input.arrayBuffer();
2577
+ bytes = new Uint8Array(ab);
2578
+ fileName = opts?.fileName || (input instanceof File ? input.name : "");
2579
+ if (!fileName) throw new Error("fileName is required when uploading Blob without name");
2580
+ } else if (input instanceof Uint8Array) {
2581
+ bytes = input;
2582
+ fileName = opts?.fileName || "";
2583
+ if (!fileName) throw new Error("fileName is required when uploading Buffer or Uint8Array");
2584
+ } else {
2585
+ throw new Error("Unsupported input type");
2586
+ }
2587
+ const fileSize = bytes.byteLength;
2588
+ const mimeType = opts?.mimeType || guessMimeType(fileName);
2589
+ if (fileSize > 50 * 1024 * 1024) {
2590
+ throw new Error("File exceeds maximum size of 50 MB");
2591
+ }
2592
+ if (fileSize <= 10 * 1024 * 1024) {
2593
+ return this._uploadSimple(bytes, fileName, fileSize, mimeType, opts?.onProgress);
2594
+ }
2595
+ return this._uploadMultipart(bytes, fileName, fileSize, mimeType, opts?.onProgress);
2596
+ }
2597
+ /**
2598
+ * Upload a file and send it as a message in one call.
2599
+ *
2600
+ * @param conversationId - Target conversation
2601
+ * @param input - File input (same as upload())
2602
+ * @param opts - Upload options + optional message content/parentId
2603
+ */
2604
+ async sendFile(conversationId, input, opts) {
2605
+ const uploaded = await this.upload(input, opts);
2606
+ const msgRes = await this._r("POST", `/api/im/messages/${conversationId}`, {
2607
+ content: opts?.content || uploaded.fileName,
2608
+ type: "file",
2609
+ metadata: {
2610
+ uploadId: uploaded.uploadId,
2611
+ fileUrl: uploaded.cdnUrl,
2612
+ fileName: uploaded.fileName,
2613
+ fileSize: uploaded.fileSize,
2614
+ mimeType: uploaded.mimeType
2615
+ },
2616
+ parentId: opts?.parentId
2617
+ });
2618
+ if (!msgRes.ok) {
2619
+ throw new Error(msgRes.error?.message || "Failed to send file message");
2620
+ }
2621
+ return { upload: uploaded, message: msgRes.data };
2622
+ }
2623
+ // --------------------------------------------------------------------------
2624
+ // Private upload helpers
2625
+ // --------------------------------------------------------------------------
2626
+ async _uploadSimple(bytes, fileName, fileSize, mimeType, onProgress) {
2627
+ const presignRes = await this.presign({ fileName, fileSize, mimeType });
2628
+ if (!presignRes.ok || !presignRes.data) {
2629
+ throw new Error(presignRes.error?.message || "Presign failed");
2630
+ }
2631
+ const { uploadId, url, fields } = presignRes.data;
2632
+ const formData = new FormData();
2633
+ const isS3 = url.startsWith("http");
2634
+ const uploadUrl = isS3 ? url : `${this._baseUrl}${url}`;
2635
+ if (isS3) {
2636
+ for (const [k, v] of Object.entries(fields)) formData.append(k, v);
2637
+ }
2638
+ const ab = new ArrayBuffer(bytes.byteLength);
2639
+ new Uint8Array(ab).set(bytes);
2640
+ formData.append("file", new Blob([ab], { type: mimeType }), fileName);
2641
+ const headers = {};
2642
+ if (!isS3) Object.assign(headers, this._getAuthHeaders());
2643
+ const resp = await this._fetchFn(uploadUrl, { method: "POST", body: formData, headers });
2644
+ if (!resp.ok) {
2645
+ const text = await resp.text();
2646
+ throw new Error(`Upload failed (${resp.status}): ${text}`);
2647
+ }
2648
+ onProgress?.(fileSize, fileSize);
2649
+ const confirmRes = await this.confirm(uploadId);
2650
+ if (!confirmRes.ok || !confirmRes.data) {
2651
+ throw new Error(confirmRes.error?.message || "Confirm failed");
2652
+ }
2653
+ return confirmRes.data;
2654
+ }
2655
+ async _uploadMultipart(bytes, fileName, fileSize, mimeType, onProgress) {
2656
+ const initRes = await this.initMultipart({ fileName, fileSize, mimeType });
2657
+ if (!initRes.ok || !initRes.data) {
2658
+ throw new Error(initRes.error?.message || "Multipart init failed");
2659
+ }
2660
+ const { uploadId, parts: partUrls } = initRes.data;
2661
+ const CHUNK_SIZE = 5 * 1024 * 1024;
2662
+ const completedParts = [];
2663
+ let uploaded = 0;
2664
+ for (const part of partUrls) {
2665
+ const start = (part.partNumber - 1) * CHUNK_SIZE;
2666
+ const end = Math.min(start + CHUNK_SIZE, fileSize);
2667
+ const chunk = bytes.slice(start, end);
2668
+ const isS3 = part.url.startsWith("http");
2669
+ const partUrl = isS3 ? part.url : `${this._baseUrl}${part.url}`;
2670
+ const headers = { "Content-Type": mimeType };
2671
+ if (!isS3) Object.assign(headers, this._getAuthHeaders());
2672
+ const resp = await this._fetchFn(partUrl, { method: "PUT", body: chunk, headers });
2673
+ if (!resp.ok) {
2674
+ throw new Error(`Part ${part.partNumber} upload failed (${resp.status})`);
2675
+ }
2676
+ const etag = resp.headers.get("ETag") || `"part-${part.partNumber}"`;
2677
+ completedParts.push({ partNumber: part.partNumber, etag });
2678
+ uploaded += chunk.byteLength;
2679
+ onProgress?.(uploaded, fileSize);
2680
+ }
2681
+ const completeRes = await this.completeMultipart(uploadId, completedParts);
2682
+ if (!completeRes.ok || !completeRes.data) {
2683
+ throw new Error(completeRes.error?.message || "Multipart complete failed");
2684
+ }
2685
+ return completeRes.data;
2686
+ }
2687
+ };
646
2688
  var IMRealtimeClient = class {
647
2689
  constructor(_wsBase) {
648
2690
  this._wsBase = _wsBase;
@@ -666,7 +2708,7 @@ var IMRealtimeClient = class {
666
2708
  }
667
2709
  };
668
2710
  var IMClient = class {
669
- constructor(request, wsBase) {
2711
+ constructor(request, wsBase, fetchFn, getAuthHeaders, offlineManager) {
670
2712
  this.account = new AccountClient(request);
671
2713
  this.direct = new DirectClient(request);
672
2714
  this.groups = new GroupsClient(request);
@@ -676,7 +2718,9 @@ var IMClient = class {
676
2718
  this.bindings = new BindingsClient(request);
677
2719
  this.credits = new CreditsClient(request);
678
2720
  this.workspace = new WorkspaceClient(request);
2721
+ this.files = new FilesClient(request, wsBase, fetchFn, getAuthHeaders);
679
2722
  this.realtime = new IMRealtimeClient(wsBase);
2723
+ this.offline = offlineManager ?? null;
680
2724
  }
681
2725
  /** IM health check */
682
2726
  async health() {
@@ -685,6 +2729,7 @@ var IMClient = class {
685
2729
  };
686
2730
  var PrismerClient = class {
687
2731
  constructor(config = {}) {
2732
+ this._offlineManager = null;
688
2733
  if (config.apiKey && !config.apiKey.startsWith("sk-prismer-") && !config.apiKey.startsWith("eyJ")) {
689
2734
  console.warn('Warning: API key should start with "sk-prismer-" (or "eyJ" for IM JWT)');
690
2735
  }
@@ -694,11 +2739,32 @@ var PrismerClient = class {
694
2739
  this.timeout = config.timeout || 3e4;
695
2740
  this.fetchFn = config.fetch || fetch;
696
2741
  this.imAgent = config.imAgent;
2742
+ if (config.offline) {
2743
+ this._offlineManager = new OfflineManager(
2744
+ config.offline.storage,
2745
+ (m, p, b, q) => this._request(m, p, b, q),
2746
+ config.offline
2747
+ );
2748
+ this._offlineManager.init().catch(
2749
+ (err) => console.warn("[PrismerSDK] Offline storage init failed:", err)
2750
+ );
2751
+ }
2752
+ const imRequest = this._offlineManager ? (m, p, b, q) => this._offlineManager.dispatch(m, p, b, q) : (m, p, b, q) => this._request(m, p, b, q);
697
2753
  this.im = new IMClient(
698
- (method, path, body, query) => this._request(method, path, body, query),
699
- this.baseUrl
2754
+ imRequest,
2755
+ this.baseUrl,
2756
+ this.fetchFn,
2757
+ () => this._getAuthHeaders(),
2758
+ this._offlineManager
700
2759
  );
701
2760
  }
2761
+ /** Build auth headers for raw HTTP requests (used by file upload) */
2762
+ _getAuthHeaders() {
2763
+ const headers = {};
2764
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
2765
+ if (this.imAgent) headers["X-IM-Agent"] = this.imAgent;
2766
+ return headers;
2767
+ }
702
2768
  /**
703
2769
  * Set or update the auth token (API key or IM JWT).
704
2770
  * Useful after anonymous registration to set the returned JWT.
@@ -706,10 +2772,16 @@ var PrismerClient = class {
706
2772
  setToken(token) {
707
2773
  this.apiKey = token;
708
2774
  }
2775
+ /** Cleanup resources (offline manager, timers). Call when disposing the client. */
2776
+ async destroy() {
2777
+ if (this._offlineManager) {
2778
+ await this._offlineManager.destroy();
2779
+ }
2780
+ }
709
2781
  // --------------------------------------------------------------------------
710
2782
  // Internal request helper
711
2783
  // --------------------------------------------------------------------------
712
- async _request(method, path, body, query) {
2784
+ async _request(method, path, body, query, _isRetry) {
713
2785
  const controller = new AbortController();
714
2786
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
715
2787
  try {
@@ -731,6 +2803,16 @@ var PrismerClient = class {
731
2803
  }
732
2804
  const response = await this.fetchFn(url, init);
733
2805
  const data = await response.json();
2806
+ if (response.status === 401 && this.apiKey.startsWith("eyJ") && !_isRetry && !path.includes("/token/refresh")) {
2807
+ try {
2808
+ const refreshRes = await this._request("POST", "/api/im/token/refresh", void 0, void 0, true);
2809
+ if (refreshRes?.ok && refreshRes?.data?.token) {
2810
+ this.apiKey = refreshRes.data.token;
2811
+ return this._request(method, path, body, query, true);
2812
+ }
2813
+ } catch {
2814
+ }
2815
+ }
734
2816
  if (!response.ok) {
735
2817
  const err = data.error || { code: "HTTP_ERROR", message: `Request failed with status ${response.status}` };
736
2818
  return { ...data, success: false, ok: false, error: err };
@@ -810,19 +2892,27 @@ function createClient(config) {
810
2892
  }
811
2893
  export {
812
2894
  AccountClient,
2895
+ AttachmentQueue,
813
2896
  BindingsClient,
814
2897
  ContactsClient,
815
2898
  ConversationsClient,
816
2899
  CreditsClient,
817
2900
  DirectClient,
2901
+ E2EEncryption,
818
2902
  ENVIRONMENTS,
2903
+ FilesClient,
819
2904
  GroupsClient,
820
2905
  IMClient,
821
2906
  IMRealtimeClient,
2907
+ IndexedDBStorage,
2908
+ MemoryStorage,
822
2909
  MessagesClient,
2910
+ OfflineManager,
823
2911
  PrismerClient,
824
2912
  RealtimeSSEClient,
825
2913
  RealtimeWSClient,
2914
+ SQLiteStorage,
2915
+ TabCoordinator,
826
2916
  WorkspaceClient,
827
2917
  createClient,
828
2918
  index_default as default