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