@majikah/majik-message 0.2.20 → 0.3.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.
@@ -0,0 +1,27 @@
1
+ import { MajikContactDirectoryData, MajikContactManagerJSON } from "./types";
2
+ export interface MajikMessageRawJSON extends Record<string, unknown> {
3
+ id: string;
4
+ contacts: MajikContactManagerJSON | MajikContactDirectoryData;
5
+ envelopeCache: unknown;
6
+ ownAccounts?: unknown;
7
+ }
8
+ /**
9
+ * Accepts a raw parsed JSON object from IDB/storage and guarantees the
10
+ * returned value always uses the current `MajikContactManagerJSON` shape
11
+ * for its `contacts` field — even if the blob was saved before groups existed.
12
+ *
13
+ * This is the single migration choke-point. Call it once at the top of
14
+ * `MajikMessage.fromJSON()` before touching any field.
15
+ *
16
+ * Migration performed (legacy → current):
17
+ * contacts: { contacts: [...] }
18
+ * →
19
+ * contacts: { contacts: [...], groups: { groups: [] } }
20
+ *
21
+ * The empty `groups` payload means `MajikContactGroupManager.fromJSON()`
22
+ * will bootstrap the two system groups (Favorites, Blocked) with no members,
23
+ * which is exactly correct for a first-time migration.
24
+ */
25
+ export declare function migrateMajikMessageJSON(raw: unknown): MajikMessageRawJSON & {
26
+ contacts: MajikContactManagerJSON;
27
+ };
@@ -0,0 +1,84 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // majik-contact-migration.ts
3
+ //
4
+ // Detects whether a persisted MajikMessage JSON blob was saved before
5
+ // MajikContactManager existed (legacy) or after (current), and returns a
6
+ // normalised MajikMessageJSON that always carries the new shape.
7
+ //
8
+ // Legacy shape (pre-migration):
9
+ // { id, contacts: { contacts: [...] }, envelopeCache, ownAccounts?, pinHash? }
10
+ // ↑ contacts is a MajikContactDirectoryData — no `groups` field
11
+ //
12
+ // Current shape (post-migration):
13
+ // { id, contacts: { contacts: [...], groups: { groups: [...] } }, envelopeCache, ... }
14
+ // ↑ contacts is a MajikContactManagerJSON — always has both `contacts` and `groups`
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ /* -------------------------------
17
+ * Shape-detection helpers
18
+ * ------------------------------- */
19
+ /**
20
+ * Returns true when the blob looks like a legacy save that only has
21
+ * a flat `{ contacts: [...] }` directory payload — no `groups` key.
22
+ */
23
+ function isLegacyContactsShape(raw) {
24
+ if (!raw || typeof raw !== "object")
25
+ return false;
26
+ const obj = raw;
27
+ // Must have a `contacts` field that is an object …
28
+ if (!obj.contacts || typeof obj.contacts !== "object")
29
+ return false;
30
+ const contacts = obj.contacts;
31
+ // … that contains a `contacts` array (the raw serialized contacts) …
32
+ if (!Array.isArray(contacts.contacts))
33
+ return false;
34
+ // … but does NOT yet have a `groups` field — that is the migration trigger.
35
+ return !("groups" in contacts);
36
+ }
37
+ /**
38
+ * Accepts a raw parsed JSON object from IDB/storage and guarantees the
39
+ * returned value always uses the current `MajikContactManagerJSON` shape
40
+ * for its `contacts` field — even if the blob was saved before groups existed.
41
+ *
42
+ * This is the single migration choke-point. Call it once at the top of
43
+ * `MajikMessage.fromJSON()` before touching any field.
44
+ *
45
+ * Migration performed (legacy → current):
46
+ * contacts: { contacts: [...] }
47
+ * →
48
+ * contacts: { contacts: [...], groups: { groups: [] } }
49
+ *
50
+ * The empty `groups` payload means `MajikContactGroupManager.fromJSON()`
51
+ * will bootstrap the two system groups (Favorites, Blocked) with no members,
52
+ * which is exactly correct for a first-time migration.
53
+ */
54
+ export function migrateMajikMessageJSON(raw) {
55
+ if (!raw || typeof raw !== "object") {
56
+ throw new Error("migrateMajikMessageJSON: input must be a non-null object");
57
+ }
58
+ const obj = raw;
59
+ if (!obj.id || typeof obj.id !== "string") {
60
+ throw new Error("migrateMajikMessageJSON: missing required field 'id'");
61
+ }
62
+ // ── Already in current shape — pass through untouched ────────────────────
63
+ if (!isLegacyContactsShape(obj)) {
64
+ // Validate it at least has the expected structure
65
+ const contacts = obj.contacts;
66
+ if (!contacts || !Array.isArray(contacts.contacts) || !contacts.groups) {
67
+ throw new Error("migrateMajikMessageJSON: 'contacts' field has an unrecognised shape — " +
68
+ "expected either a legacy { contacts: [...] } or current { contacts: [...], groups: {...} }");
69
+ }
70
+ return obj;
71
+ }
72
+ // ── Legacy shape — lift it into the current shape ─────────────────────────
73
+ console.info("[MajikMessage] Migrating persisted state from legacy contacts schema → " +
74
+ "MajikContactManager schema. Groups will be initialised empty.");
75
+ const legacyContacts = obj.contacts;
76
+ const migratedContacts = {
77
+ contacts: legacyContacts, // preserve all serialized contacts as-is
78
+ groups: { groups: [] }, // empty groups — system groups are bootstrapped at runtime
79
+ };
80
+ return {
81
+ ...obj,
82
+ contacts: migratedContacts,
83
+ };
84
+ }
@@ -0,0 +1,11 @@
1
+ import { SerializedMajikContact, SerializedMajikContactGroup } from "@majikah/majik-contact";
2
+ export interface MajikContactManagerJSON {
3
+ contacts: MajikContactDirectoryData;
4
+ groups: MajikContactGroupManagerData;
5
+ }
6
+ export interface MajikContactDirectoryData {
7
+ contacts: SerializedMajikContact[];
8
+ }
9
+ export interface MajikContactGroupManagerData {
10
+ groups: SerializedMajikContactGroup[];
11
+ }
@@ -0,0 +1,4 @@
1
+ /* -------------------------------
2
+ * Types
3
+ * ------------------------------- */
4
+ export {};
@@ -1,27 +1,33 @@
1
- import { MajikContact, type MajikContactMeta, type SerializedMajikContact } from "@majikah/majik-contact";
1
+ import { MajikContact, MajikContactGroup, MajikContactGroupMeta, type MajikContactMeta, type SerializedMajikContact } from "@majikah/majik-contact";
2
2
  import { MessageEnvelope } from "./core/messages/message-envelope";
3
3
  import { EnvelopeCache, type EnvelopeCacheItem, type EnvelopeCacheJSON } from "./core/messages/envelope-cache";
4
4
  import { MajikKeyStore } from "./core/crypto/keystore";
5
- import { MajikContactDirectory, type MajikContactDirectoryData } from "./core/contacts/majik-contact-directory";
6
5
  import type { DecryptFileOptions, EncryptFileOptions, EncryptFileResult, MAJIK_API_RESPONSE, MajikMessagePublicKey } from "./core/types";
7
6
  import { MajikMessageChat } from "./core/database/chat/majik-message-chat";
8
7
  import { MajikMessageIdentity } from "./core/database/system/identity";
9
8
  import { MajikKey } from "@majikah/majik-key";
10
9
  import { MajikFile, MajikFileJSON } from "@majikah/majik-file";
11
10
  import { EnvelopeInfo, ExpectedSigner, MajikSignature, SealInfo, SealVerificationResult, SignatoriesFilter, SignatoriesResult, SignatoryInfo, type MajikSignatureJSON, type MajikSignerPublicKeys, type VerificationResult } from "@majikah/majik-signature";
12
- type MajikMessageEvents = "message" | "envelope" | "untrusted" | "error" | "new-account" | "new-contact" | "removed-account" | "removed-contact" | "active-account-change";
11
+ import { MajikContactManager } from "./core/contacts/majik-contact-manager";
12
+ import { MajikContactManagerJSON } from "./core/contacts/types";
13
+ type MajikMessageEvents = "message" | "envelope" | "untrusted" | "error" | "new-account" | "new-contact" | "new-contact-group" | "removed-account" | "removed-contact" | "removed-contact-group" | "contact-group-change" | "active-account-change";
13
14
  interface MajikMessageStatic<T extends MajikMessage> {
14
15
  new (config: MajikMessageConfig, id?: string): T;
15
16
  fromJSON(json: MajikMessageJSON): Promise<T>;
16
17
  }
17
18
  export interface MajikMessageConfig {
18
19
  keyStore?: typeof MajikKeyStore;
19
- contactDirectory?: MajikContactDirectory;
20
+ /**
21
+ * Optional pre-constructed MajikContactManager.
22
+ * When omitted, MajikMessage creates a fresh one internally.
23
+ * Pass one when restoring state from fromJSON() or loadState().
24
+ */
25
+ contactManager?: MajikContactManager;
20
26
  envelopeCache?: EnvelopeCache;
21
27
  }
22
28
  export interface MajikMessageJSON {
23
29
  id: string;
24
- contacts: MajikContactDirectoryData;
30
+ contacts: MajikContactManagerJSON;
25
31
  envelopeCache: EnvelopeCacheJSON;
26
32
  ownAccounts?: {
27
33
  accounts: SerializedMajikContact[];
@@ -31,11 +37,9 @@ export interface MajikMessageJSON {
31
37
  type EventCallback = (...args: any[]) => void;
32
38
  export declare class MajikMessage {
33
39
  private userProfile;
34
- private pinHash?;
35
40
  private id;
36
- private contactDirectory;
41
+ private contacts;
37
42
  private envelopeCache;
38
- private scanner;
39
43
  private listeners;
40
44
  private ownAccounts;
41
45
  private ownAccountsOrder;
@@ -104,9 +108,10 @@ export declare class MajikMessage {
104
108
  updatePassphrase(currentPassphrase: string, newPassphrase: string, id?: string): Promise<void>;
105
109
  getContactByID(id: string): MajikContact | null;
106
110
  hasContact(id: string): boolean;
107
- getContactByPublicKey(publicKeyBase64: string): Promise<MajikContact | null>;
108
- exportContactAsJSON(contactId: string): Promise<string | null>;
109
- exportContactAsString(contactId: string): Promise<string | null>;
111
+ hasContactByPublicKeyBase64(publicKey: MajikMessagePublicKey): Promise<boolean>;
112
+ getContactByPublicKey(publicKeyBase64: MajikMessagePublicKey): Promise<MajikContact | null>;
113
+ exportContactAsJSON(contactID: string): Promise<string | null>;
114
+ exportContactAsString(contactID: string): Promise<string | null>;
110
115
  importContactFromJSON(jsonStr: string): Promise<MAJIK_API_RESPONSE>;
111
116
  importContactFromString(base64Str: string): Promise<MAJIK_API_RESPONSE>;
112
117
  exportContactCompressed(contact: MajikContact): Promise<string>;
@@ -120,6 +125,134 @@ export declare class MajikMessage {
120
125
  isContactMajikahRegistered(id: string): boolean;
121
126
  isContactMajikahIdentityChecked(id: string): boolean;
122
127
  setContactMajikahStatus(id: string, status: boolean): void;
128
+ /**
129
+ * Creates and registers a new user-defined group.
130
+ * Throws if a group with the same ID already exists.
131
+ */
132
+ createGroup(id: string, name: string, meta?: Partial<Omit<MajikContactGroupMeta, "name">>, initialMemberIds?: string[]): this;
133
+ /**
134
+ * Registers an already-constructed MajikContactGroup instance.
135
+ * Throws if a group with the same ID already exists.
136
+ */
137
+ addGroup(group: MajikContactGroup): this;
138
+ /**
139
+ * Removes a user group by ID.
140
+ * System groups (Favorites, Blocked) cannot be deleted.
141
+ */
142
+ removeGroup(id: string): MAJIK_API_RESPONSE;
143
+ /**
144
+ * Returns a group by ID, or undefined if not found.
145
+ */
146
+ getContactGroup(id: string): MajikContactGroup | undefined;
147
+ /**
148
+ * Returns a group by ID. Throws if not found.
149
+ */
150
+ getGroupOrThrow(id: string): MajikContactGroup;
151
+ /**
152
+ * Returns true if a group with the given ID exists.
153
+ */
154
+ hasGroup(id: string): boolean;
155
+ /**
156
+ * Returns all groups.
157
+ *
158
+ * @param includeSystem Include system groups (Favorites, Blocked). Default: true.
159
+ * @param sortedByName Sort results alphabetically by group name. Default: false.
160
+ */
161
+ listContactGroups(includeSystem?: boolean, sortedByName?: boolean): MajikContactGroup[];
162
+ /**
163
+ * Returns only user-created groups (excludes Favorites and Blocked).
164
+ * Sorted alphabetically by name.
165
+ */
166
+ listUserGroups(sortedByName?: boolean): MajikContactGroup[];
167
+ /**
168
+ * Returns only system groups (Favorites and Blocked).
169
+ */
170
+ listSystemGroups(): MajikContactGroup[];
171
+ /**
172
+ * Updates mutable metadata on a group (name, description).
173
+ * Name is locked on system groups — will throw if attempted.
174
+ */
175
+ updateGroupMeta(id: string, meta: Partial<Pick<MajikContactGroupMeta, "name" | "description">>): this;
176
+ /**
177
+ * Adds a contact to a group.
178
+ * Validates the contact exists in the directory.
179
+ * If the group is the system Blocked group, also calls contact.block().
180
+ * Throws if the contact is already a member — use addContactToGroupIfAbsent for idempotent.
181
+ */
182
+ addContactToGroup(groupID: string, contactID: string): this;
183
+ /**
184
+ * Adds multiple contacts to a group in one call (all-or-nothing).
185
+ */
186
+ addContactsToGroup(groupID: string, contactIds: string[]): this;
187
+ /**
188
+ * Removes a contact from a group.
189
+ * If the group is the system Blocked group, also calls contact.unblock().
190
+ * Throws if the contact is not a member — use removeContactFromGroupIfPresent for idempotent.
191
+ */
192
+ removeContactFromGroup(groupID: string, contactID: string): this;
193
+ /**
194
+ * Moves a contact from one group to another atomically.
195
+ * Throws if the contact is not a member of the source group.
196
+ */
197
+ moveContactBetweenGroups(contactID: string, fromGroupId: string, toGroupId: string): this;
198
+ /**
199
+ * Returns all hydrated MajikContact instances in the given group.
200
+ * Contacts removed from the directory since last save are silently skipped.
201
+ */
202
+ getContactsInGroup(groupID: string): MajikContact[];
203
+ /**
204
+ * Returns hydrated contacts in the group, sorted by label (or ID if no label).
205
+ */
206
+ getContactsInGroupSorted(groupID: string): MajikContact[];
207
+ /**
208
+ * Returns true if the contact is a member of the given group.
209
+ */
210
+ isContactInGroup(groupID: string, contactID: string): boolean;
211
+ /**
212
+ * Returns all groups the contact belongs to.
213
+ */
214
+ getGroupsForContact(contactID: string): MajikContactGroup[];
215
+ /**
216
+ * Returns all group IDs the contact belongs to.
217
+ */
218
+ getGroupIdsForContact(contactID: string): string[];
219
+ /**
220
+ * Adds the contact to the Favorites group (idempotent).
221
+ */
222
+ addContactToFavorites(contactID: string): this;
223
+ /**
224
+ * Removes the contact from the Favorites group (idempotent).
225
+ */
226
+ removeContactFromFavorites(contactID: string): this;
227
+ /**
228
+ * Returns true if the contact is in the Favorites group.
229
+ */
230
+ isContactFavorite(contactID: string): boolean;
231
+ /**
232
+ * Returns true if the contact is in the Blocked group.
233
+ */
234
+ isContactBlocked(contactID: string): boolean;
235
+ /**
236
+ * Returns the Favorites system group instance.
237
+ */
238
+ getFavoritesGroup(): MajikContactGroup;
239
+ /**
240
+ * Returns the Blocked system group instance.
241
+ */
242
+ getBlockedGroup(): MajikContactGroup;
243
+ /**
244
+ * Returns all contacts in the Favorites group as hydrated MajikContact instances.
245
+ */
246
+ getFavoriteContacts(): MajikContact[];
247
+ /**
248
+ * Returns all contacts in the Blocked group as hydrated MajikContact instances.
249
+ */
250
+ getBlockedContacts(): MajikContact[];
251
+ /**
252
+ * Clears both the directory and all group memberships.
253
+ * System groups are preserved (re-bootstrapped by the group manager).
254
+ */
255
+ clearDirectory(): this;
123
256
  /**
124
257
  * Compose and encrypt a message for one or more recipients.
125
258
  * Single recipient → solo envelope. Two or more → group envelope.
@@ -208,7 +341,7 @@ export declare class MajikMessage {
208
341
  * metadata: row,
209
342
  * });
210
343
  * if (signature) {
211
- * const result = await majik.verifyMajikFile(file, { contactId: row.user_id });
344
+ * const result = await majik.verifyMajikFile(file, { contactID: row.user_id });
212
345
  * }
213
346
  * ```
214
347
  */
@@ -248,7 +381,7 @@ export declare class MajikMessage {
248
381
  * if the instance was restored from a metadata-only Supabase row.
249
382
  *
250
383
  * Signer resolution:
251
- * - contactId: looked up in the contact directory (own accounts included)
384
+ * - contactID: looked up in the contact directory (own accounts included)
252
385
  * - publicKeyBase64: looked up via contact directory
253
386
  * - key: used directly (skips directory lookup)
254
387
  * - none provided: falls back to public keys embedded in the signature
@@ -259,12 +392,12 @@ export declare class MajikMessage {
259
392
  * @example — verify against the file's owner contact
260
393
  * file.attachBinary(await r2.get(row.r2_key).arrayBuffer());
261
394
  * const result = await majik.verifyMajikFile(file, {
262
- * contactId: ownerContactId,
395
+ * contactID: ownerContactId,
263
396
  * });
264
397
  * if (result?.valid) console.log("Verified, signed by", result.signerId);
265
398
  */
266
399
  verifyMajikFile(file: MajikFile, options?: {
267
- contactId?: string;
400
+ contactID?: string;
268
401
  publicKeyBase64?: string;
269
402
  key?: MajikKey;
270
403
  }): Promise<VerificationResult | null>;
@@ -284,12 +417,12 @@ export declare class MajikMessage {
284
417
  *
285
418
  * @example
286
419
  * const result = await majik.verifyMajikFileBinary(file, {
287
- * contactId: "contact_abc",
420
+ * contactID: "contact_abc",
288
421
  * });
289
422
  * if (result.valid) console.log("Plaintext verified");
290
423
  */
291
424
  verifyMajikFileBinary(file: MajikFile, options?: {
292
- contactId?: string;
425
+ contactID?: string;
293
426
  publicKeyBase64?: string;
294
427
  key?: MajikKey;
295
428
  decryptAccountId?: string;
@@ -454,12 +587,12 @@ export declare class MajikMessage {
454
587
  *
455
588
  * @example
456
589
  * const result = await majik.verifyText("Hello world", sig, {
457
- * contactId: "contact_abc",
590
+ * contactID: "contact_abc",
458
591
  * });
459
592
  * if (result.valid) console.log("Authentic");
460
593
  */
461
594
  verifyText(text: string, signature: MajikSignature | MajikSignatureJSON | string, options?: {
462
- contactId?: string;
595
+ contactID?: string;
463
596
  publicKeyBase64?: string;
464
597
  key?: MajikKey;
465
598
  expectedSignerId?: string;
@@ -478,12 +611,12 @@ export declare class MajikMessage {
478
611
  * @example
479
612
  * const row = await db.findOne({ doc_id });
480
613
  * const result = await majik.verifyDetached(docBytes, row.signature, {
481
- * contactId: row.signer_contact_id,
614
+ * contactID: row.signer_contact_id,
482
615
  * });
483
616
  * if (result.valid) console.log("Signed by", result.signerId);
484
617
  */
485
618
  verifyDetached(content: Uint8Array | string, serializedSignature: string, options?: {
486
- contactId?: string;
619
+ contactID?: string;
487
620
  publicKeyBase64?: string;
488
621
  key?: MajikKey;
489
622
  expectedSignerId?: string;
@@ -554,9 +687,6 @@ export declare class MajikMessage {
554
687
  raw: Uint8Array;
555
688
  }>;
556
689
  isPassphraseValid(passphrase: string, id?: string): Promise<boolean>;
557
- scanDOM(rootNode: Node): void;
558
- startDOMObserver(rootNode: Node): void;
559
- stopDOMObserver(): void;
560
690
  on(event: MajikMessageEvents, callback: EventCallback): void;
561
691
  off(event: MajikMessageEvents, callback?: EventCallback): void;
562
692
  private emit;
@@ -655,7 +785,7 @@ export declare class MajikMessage {
655
785
  * > against a known contact fingerprint before trusting the result.
656
786
  *
657
787
  * @example — verify against a known contact
658
- * const result = await majik.verifyContent(docBytes, sig, { contactId: "contact_abc" });
788
+ * const result = await majik.verifyContent(docBytes, sig, { contactID: "contact_abc" });
659
789
  * if (result.valid) console.log("Authentic, signed by:", result.signerId);
660
790
  *
661
791
  * @example — verify using embedded keys (self-reported)
@@ -663,7 +793,7 @@ export declare class MajikMessage {
663
793
  * // always check result.signerId matches a known fingerprint
664
794
  */
665
795
  verifyContent(content: Uint8Array | string, signature: MajikSignature | MajikSignatureJSON, options?: {
666
- contactId?: string;
796
+ contactID?: string;
667
797
  publicKeyBase64?: string;
668
798
  key?: MajikKey;
669
799
  expectedSignerId?: string;
@@ -680,16 +810,16 @@ export declare class MajikMessage {
680
810
  * envelope are used (self-reported — see security note on verifyContent).
681
811
  *
682
812
  * @example — verify a signed PDF against a known contact
683
- * const result = await majik.verifyFile(signedPdf, { contactId: "contact_abc" });
813
+ * const result = await majik.verifyFile(signedPdf, { contactID: "contact_abc" });
684
814
  * if (result.valid) console.log("Verified:", result.signerId, result.timestamp);
685
815
  *
686
816
  * @example — check own signed file using active account
687
817
  * const result = await majik.verifyFile(signedWav, {
688
- * contactId: majik.getActiveAccount()?.id,
818
+ * contactID: majik.getActiveAccount()?.id,
689
819
  * });
690
820
  */
691
821
  verifyFile(file: Blob, options?: {
692
- contactId?: string;
822
+ contactID?: string;
693
823
  publicKeyBase64?: string;
694
824
  key?: MajikKey;
695
825
  expectedSignerId?: string;
@@ -708,7 +838,7 @@ export declare class MajikMessage {
708
838
  * @example
709
839
  * const results = await majik.batchVerifyFiles(
710
840
  * [pdfBlob, wavBlob, mp4Blob],
711
- * { contactId: "contact_abc" },
841
+ * { contactID: "contact_abc" },
712
842
  * );
713
843
  * const allValid = results.every(r => r.valid);
714
844
  */
@@ -717,7 +847,7 @@ export declare class MajikMessage {
717
847
  mimeType?: string;
718
848
  expectedSignerId?: string;
719
849
  }>, options?: {
720
- contactId?: string;
850
+ contactID?: string;
721
851
  publicKeyBase64?: string;
722
852
  key?: MajikKey;
723
853
  expectedSignerId?: string;
@@ -757,7 +887,7 @@ export declare class MajikMessage {
757
887
  *
758
888
  * @example
759
889
  * if (await majik.isFileSigned(file)) {
760
- * const result = await majik.verifyFile(file, { contactId });
890
+ * const result = await majik.verifyFile(file, { contactID });
761
891
  * }
762
892
  */
763
893
  isFileSigned(file: Blob, options?: {
@@ -994,11 +1124,6 @@ export declare class MajikMessage {
994
1124
  * throughout MajikMessage — consistent account/contact resolution in one place.
995
1125
  */
996
1126
  private _resolveSignerPublicKeys;
997
- setPIN(pin: string): Promise<void>;
998
- clearPIN(): Promise<void>;
999
- isValidPIN(pin: string): Promise<boolean>;
1000
- getPinHash(): string | null;
1001
- private static _hashPIN;
1002
1127
  toJSON(): Promise<MajikMessageJSON>;
1003
1128
  static fromJSON<T extends MajikMessage>(this: new (config: MajikMessageConfig, id?: string) => T, json: MajikMessageJSON): Promise<T>;
1004
1129
  private attachAutosaveHandlers;