@doubling/compound-sync 1.14.0 → 1.15.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/files.js +148 -1
- package/operation-log.d.ts +52 -0
- package/operation-log.js +44 -0
- package/package.json +10 -7
package/files.js
CHANGED
|
@@ -28,6 +28,7 @@ import { doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where, } fro
|
|
|
28
28
|
import { ref as storageRef, uploadBytes, deleteObject, getBytes, } from 'firebase/storage';
|
|
29
29
|
import { storagePathFor, extFromPath, contentByteSize, computeContentHash, folderDocId, fileDocId, toBytes, } from './storage-helpers.js';
|
|
30
30
|
import { planFolderChain, splitPath, } from './folder-chain.js';
|
|
31
|
+
import { logOperation } from './operation-log.js';
|
|
31
32
|
// Re-export pure helpers so callers (and existing tests) can import
|
|
32
33
|
// either from here or directly from storage-helpers.js.
|
|
33
34
|
export { storagePathFor, extFromPath, contentByteSize, computeContentHash, isTextMimeType, mimeTypeFromExt, } from './storage-helpers.js';
|
|
@@ -173,11 +174,49 @@ export async function pushFolderToCloud({ filesRef, scope, teamId, userId, docPa
|
|
|
173
174
|
if (scope === 'shared-with-me' || scope === 'shared-by-me') {
|
|
174
175
|
return { action: 'skipped', reason: 'shared-scope' };
|
|
175
176
|
}
|
|
177
|
+
// Validate userId BEFORE any writes so an audit-only failure
|
|
178
|
+
// never leaves committed folder docs behind. Firestore rules
|
|
179
|
+
// require actor.id == request.auth.uid on operationLog creates;
|
|
180
|
+
// an empty actor id would land as permission-denied AFTER the
|
|
181
|
+
// folder chain was already created.
|
|
182
|
+
if (!userId) {
|
|
183
|
+
throw new Error('pushFolderToCloud: userId is required for audit logging');
|
|
184
|
+
}
|
|
176
185
|
const folderId = await ensureFolderChainInCloud({
|
|
177
186
|
filesRef, scope, teamId, userId, docPath, now,
|
|
178
187
|
});
|
|
188
|
+
// Log FolderCreate on any 'ensured' outcome. This is an over-log:
|
|
189
|
+
// ensureFolderChainInCloud is idempotent, so a call that only
|
|
190
|
+
// reuses existing folder docs still emits an entry. Introducing a
|
|
191
|
+
// "was-actually-created" signal requires plumbing through
|
|
192
|
+
// planFolderChain results; deferred to a follow-up because the
|
|
193
|
+
// extra rows are small and BQ dedup on op + subject is trivial.
|
|
194
|
+
if (folderId) {
|
|
195
|
+
// filesRef points at orgs/{orgId}/files; the parent doc is orgs/{orgId}.
|
|
196
|
+
const orgId = orgIdFromFilesRef(filesRef);
|
|
197
|
+
await logOperation({
|
|
198
|
+
db: filesRef.firestore,
|
|
199
|
+
orgId,
|
|
200
|
+
actor: { type: 'user', id: userId },
|
|
201
|
+
subject: { type: 'folder', id: folderId },
|
|
202
|
+
opType: 'FolderCreate',
|
|
203
|
+
path: docPath,
|
|
204
|
+
outcome: 'success',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
179
207
|
return { action: 'ensured', folderId };
|
|
180
208
|
}
|
|
209
|
+
// Derive orgId from a files collection ref. filesRef always points
|
|
210
|
+
// at orgs/{orgId}/files; its parent doc is orgs/{orgId}. Extracted
|
|
211
|
+
// so pushFolderToCloud and deleteFolderFromCloud share a single
|
|
212
|
+
// well-named accessor rather than an inline IIFE.
|
|
213
|
+
function orgIdFromFilesRef(filesRef) {
|
|
214
|
+
const parent = filesRef.parent;
|
|
215
|
+
if (!parent) {
|
|
216
|
+
throw new Error('filesRef has no parent (expected orgs/{orgId}/files)');
|
|
217
|
+
}
|
|
218
|
+
return parent.id;
|
|
219
|
+
}
|
|
181
220
|
/**
|
|
182
221
|
* Delete the folder doc at `docPath` AND every descendant (files and
|
|
183
222
|
* sub-folders) in the same scope. Cascade is authoritative: it does
|
|
@@ -205,6 +244,15 @@ export async function deleteFolderFromCloud({ filesRef, storage, orgId, scope, t
|
|
|
205
244
|
if (scope === 'shared-with-me' || scope === 'shared-by-me') {
|
|
206
245
|
return { action: 'skipped', reason: 'shared-scope' };
|
|
207
246
|
}
|
|
247
|
+
// Validate userId BEFORE any deletes so an audit-only failure
|
|
248
|
+
// never leaves the cascade committed with no audit trail behind
|
|
249
|
+
// it. Firestore rules require actor.id == request.auth.uid on
|
|
250
|
+
// operationLog creates; without the up-front check the descendants
|
|
251
|
+
// could be deleted and only then would the log emit hit
|
|
252
|
+
// permission-denied.
|
|
253
|
+
if (!userId) {
|
|
254
|
+
throw new Error('deleteFolderFromCloud: userId is required for audit logging');
|
|
255
|
+
}
|
|
208
256
|
// Pull every doc in this scope; cheap because vaults are small
|
|
209
257
|
// (hundreds of docs at most). Filtering in memory avoids the
|
|
210
258
|
// Firestore composite-index requirement for a path-prefix range
|
|
@@ -277,9 +325,44 @@ export async function deleteFolderFromCloud({ filesRef, storage, orgId, scope, t
|
|
|
277
325
|
if (folderDocSnap) {
|
|
278
326
|
await deleteDoc(folderDocSnap.ref);
|
|
279
327
|
}
|
|
328
|
+
const deletedFolderId = folderDocSnap ? folderDocSnap.id : null;
|
|
329
|
+
// Emit a single FolderDelete for the top-level folder OR for the
|
|
330
|
+
// orphan-cleanup case where descendants existed without a
|
|
331
|
+
// top-level folder doc (folderDocSnap null but descendants.length
|
|
332
|
+
// > 0). Both are real mutations and must appear in the audit
|
|
333
|
+
// trail. When folderDocSnap is null we compute the deterministic
|
|
334
|
+
// folderDocId as the subject so downstream analytics can still
|
|
335
|
+
// join on subject.id.
|
|
336
|
+
//
|
|
337
|
+
// Cascaded child deletes are NOT logged individually in this
|
|
338
|
+
// pass; the childCount conveys the cascade blast radius. Per-
|
|
339
|
+
// child auditing (one FileDelete entry per cascaded file) is
|
|
340
|
+
// tracked in a follow-up ticket alongside FolderMove.
|
|
341
|
+
const subjectFolderId = deletedFolderId
|
|
342
|
+
?? folderDocId({
|
|
343
|
+
scope,
|
|
344
|
+
teamId: scope === 'team' ? (teamId ?? null) : null,
|
|
345
|
+
ownerId: scope === 'private' ? userId : null,
|
|
346
|
+
path: docPath,
|
|
347
|
+
});
|
|
348
|
+
// userId already validated at the top of the function so no
|
|
349
|
+
// inner guard needed. orgId is derived from filesRef.parent
|
|
350
|
+
// instead of the input param so callers that pass a partial
|
|
351
|
+
// input (older tests without orgId, e.g. delete-empty-folder
|
|
352
|
+
// cases) still produce valid audit writes; filesRef.parent.id
|
|
353
|
+
// IS orgId by definition.
|
|
354
|
+
await logOperation({
|
|
355
|
+
db: filesRef.firestore,
|
|
356
|
+
orgId: orgIdFromFilesRef(filesRef),
|
|
357
|
+
actor: { type: 'user', id: userId },
|
|
358
|
+
subject: { type: 'folder', id: subjectFolderId },
|
|
359
|
+
opType: 'FolderDelete',
|
|
360
|
+
path: docPath,
|
|
361
|
+
outcome: 'success',
|
|
362
|
+
});
|
|
280
363
|
return {
|
|
281
364
|
action: 'deleted',
|
|
282
|
-
folderId:
|
|
365
|
+
folderId: deletedFolderId,
|
|
283
366
|
childCount: descendants.length,
|
|
284
367
|
};
|
|
285
368
|
}
|
|
@@ -385,6 +468,29 @@ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope,
|
|
|
385
468
|
catch (err) {
|
|
386
469
|
storageError = err;
|
|
387
470
|
}
|
|
471
|
+
// Emit-after-mutation (fail-loud): a broken audit emit fails the
|
|
472
|
+
// surrounding pushFileToCloud call. See DOU-271 §5.1.
|
|
473
|
+
//
|
|
474
|
+
// When uploadBlob fails, the Firestore doc is written but the
|
|
475
|
+
// blob is missing; sync/push-outcome.ts treats this as a partial
|
|
476
|
+
// failure ("synced with warnings"), so the audit entry mirrors
|
|
477
|
+
// that: outcome=io_error + captured error object.
|
|
478
|
+
await logOperation({
|
|
479
|
+
db: filesRef.firestore,
|
|
480
|
+
orgId,
|
|
481
|
+
actor: { type: 'user', id: userId },
|
|
482
|
+
subject: { type: 'file', id: fileId },
|
|
483
|
+
opType: 'FileWrite',
|
|
484
|
+
path: docPath,
|
|
485
|
+
preContentHash: null,
|
|
486
|
+
postContentHash: contentHash,
|
|
487
|
+
sizeBytes: size,
|
|
488
|
+
outcome: storageError == null ? 'success' : 'io_error',
|
|
489
|
+
error: storageError == null ? null : {
|
|
490
|
+
code: storageError?.code ?? 'storage-upload-failed',
|
|
491
|
+
message: String(storageError),
|
|
492
|
+
},
|
|
493
|
+
});
|
|
388
494
|
return { action: 'created', fileId, fileData, storageError };
|
|
389
495
|
}
|
|
390
496
|
const docSnap = snapshot.docs[0];
|
|
@@ -413,6 +519,7 @@ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope,
|
|
|
413
519
|
}
|
|
414
520
|
const existingMime = typeof existing['mimeType'] === 'string' ? existing['mimeType'] : undefined;
|
|
415
521
|
const resolvedMime = mimeType || existingMime || 'text/markdown';
|
|
522
|
+
const existingContentHash = typeof existing['contentHash'] === 'string' ? existing['contentHash'] : null;
|
|
416
523
|
await updateDoc(docSnap.ref, {
|
|
417
524
|
type: 'file',
|
|
418
525
|
name: fileName,
|
|
@@ -430,6 +537,26 @@ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope,
|
|
|
430
537
|
catch (err) {
|
|
431
538
|
storageError = err;
|
|
432
539
|
}
|
|
540
|
+
// See the 'created' branch above for the storage-error mapping
|
|
541
|
+
// rationale: partial failures land as outcome=io_error so the
|
|
542
|
+
// audit trail reflects the sync/push-outcome.ts "synced with
|
|
543
|
+
// warnings" state instead of a false 'success'.
|
|
544
|
+
await logOperation({
|
|
545
|
+
db: filesRef.firestore,
|
|
546
|
+
orgId,
|
|
547
|
+
actor: { type: 'user', id: userId },
|
|
548
|
+
subject: { type: 'file', id: docSnap.id },
|
|
549
|
+
opType: 'FileWrite',
|
|
550
|
+
path: docPath,
|
|
551
|
+
preContentHash: existingContentHash,
|
|
552
|
+
postContentHash: contentHash,
|
|
553
|
+
sizeBytes: size,
|
|
554
|
+
outcome: storageError == null ? 'success' : 'io_error',
|
|
555
|
+
error: storageError == null ? null : {
|
|
556
|
+
code: storageError?.code ?? 'storage-upload-failed',
|
|
557
|
+
message: String(storageError),
|
|
558
|
+
},
|
|
559
|
+
});
|
|
433
560
|
return { action: 'updated', fileId: docSnap.id, storageError };
|
|
434
561
|
}
|
|
435
562
|
/**
|
|
@@ -506,6 +633,15 @@ export async function deleteFileFromCloud({ filesRef, storage, orgId, userId, sc
|
|
|
506
633
|
// throws "Null value error" → SDK surfaces permission-denied. The
|
|
507
634
|
// desired end state (no doc) is already achieved, so treat as
|
|
508
635
|
// already-deleted instead of propagating.
|
|
636
|
+
// Read the pre-delete content hash from `preRecheck` rather than
|
|
637
|
+
// the initial query snapshot: preRecheck was fetched immediately
|
|
638
|
+
// before the blob delete, so it reflects any concurrent update in
|
|
639
|
+
// the narrow window between the query and here. Also avoids the
|
|
640
|
+
// duplicate `docSnap.data()` calls the previous version made.
|
|
641
|
+
const preRecheckData = preRecheck.data();
|
|
642
|
+
const existingContentHash = typeof preRecheckData?.['contentHash'] === 'string'
|
|
643
|
+
? preRecheckData['contentHash']
|
|
644
|
+
: null;
|
|
509
645
|
try {
|
|
510
646
|
await deleteDoc(docSnap.ref);
|
|
511
647
|
}
|
|
@@ -519,6 +655,17 @@ export async function deleteFileFromCloud({ filesRef, storage, orgId, userId, sc
|
|
|
519
655
|
}
|
|
520
656
|
throw err;
|
|
521
657
|
}
|
|
658
|
+
await logOperation({
|
|
659
|
+
db: filesRef.firestore,
|
|
660
|
+
orgId,
|
|
661
|
+
actor: { type: 'user', id: userId },
|
|
662
|
+
subject: { type: 'file', id: fileId },
|
|
663
|
+
opType: 'FileDelete',
|
|
664
|
+
path: docPath,
|
|
665
|
+
preContentHash: existingContentHash,
|
|
666
|
+
postContentHash: null,
|
|
667
|
+
outcome: 'success',
|
|
668
|
+
});
|
|
522
669
|
return { action: 'deleted', fileId };
|
|
523
670
|
}
|
|
524
671
|
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Firestore } from 'firebase/firestore';
|
|
2
|
+
export type OpType = 'FileWrite' | 'FileDelete' | 'FolderCreate' | 'FolderDelete' | 'FolderMove';
|
|
3
|
+
export type ActorType = 'user' | 'agent';
|
|
4
|
+
export type SubjectType = 'file' | 'folder';
|
|
5
|
+
export type Outcome = 'success' | 'integrity_error' | 'io_error' | 'permission_denied';
|
|
6
|
+
export interface OperationLogActor {
|
|
7
|
+
type: ActorType;
|
|
8
|
+
id: string;
|
|
9
|
+
}
|
|
10
|
+
export interface OperationLogSubject {
|
|
11
|
+
type: SubjectType;
|
|
12
|
+
id: string;
|
|
13
|
+
}
|
|
14
|
+
export interface OperationLogError {
|
|
15
|
+
code: string;
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
export interface OperationLogEntryInput {
|
|
19
|
+
orgId: string;
|
|
20
|
+
actor: OperationLogActor;
|
|
21
|
+
subject: OperationLogSubject;
|
|
22
|
+
opType: OpType;
|
|
23
|
+
path: string;
|
|
24
|
+
outcome: Outcome;
|
|
25
|
+
preContentHash?: string | null;
|
|
26
|
+
postContentHash?: string | null;
|
|
27
|
+
sizeBytes?: number | null;
|
|
28
|
+
error?: OperationLogError | null;
|
|
29
|
+
agentVersion?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface LogOperationInput extends OperationLogEntryInput {
|
|
32
|
+
db: Firestore;
|
|
33
|
+
}
|
|
34
|
+
export declare const DAEMON_AGENT_VERSION = "1.14.1";
|
|
35
|
+
export interface OperationLogDoc {
|
|
36
|
+
id: string;
|
|
37
|
+
timestamp: string;
|
|
38
|
+
actor: OperationLogActor;
|
|
39
|
+
subject: OperationLogSubject;
|
|
40
|
+
op_type: OpType;
|
|
41
|
+
path: string;
|
|
42
|
+
pre_content_hash: string | null;
|
|
43
|
+
post_content_hash: string | null;
|
|
44
|
+
size_bytes: number | null;
|
|
45
|
+
outcome: Outcome;
|
|
46
|
+
error: OperationLogError | null;
|
|
47
|
+
agent_version: string;
|
|
48
|
+
org_id: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function buildOperationLogDoc(input: OperationLogEntryInput, now?: string, id?: string): OperationLogDoc;
|
|
51
|
+
export declare function logOperation(input: LogOperationInput): Promise<void>;
|
|
52
|
+
//# sourceMappingURL=operation-log.d.ts.map
|
package/operation-log.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Daemon-side operation-log emitter. Writes an append-only doc at
|
|
2
|
+
// orgs/{orgId}/operationLog/{ulid} per DOU-271 §5.1's Firestore-
|
|
3
|
+
// intermediary transport. The emitOperationLog Cloud Function (see
|
|
4
|
+
// DOU-298) reads via Admin SDK and forwards to Cloud Logging.
|
|
5
|
+
//
|
|
6
|
+
// The helper is intentionally minimal: caller supplies the operation
|
|
7
|
+
// details, the helper fills in id (ULID), timestamp, and
|
|
8
|
+
// agent_version, and setDoc's throw propagates. A broken audit emit
|
|
9
|
+
// fails the surrounding mutation (fail-loud, per DOU-271 rationale
|
|
10
|
+
// in §5.1: audit + file-doc writes share the same hard-failure
|
|
11
|
+
// surface, so silencing audit failures would hide the class of bug
|
|
12
|
+
// the log exists to expose).
|
|
13
|
+
import { doc, setDoc } from 'firebase/firestore';
|
|
14
|
+
import { ulid } from 'ulid';
|
|
15
|
+
// Sync-daemon version stamped onto every emitted entry. Kept in
|
|
16
|
+
// step with sync/package.json version by hand; a follow-up can
|
|
17
|
+
// inject at build time if drift becomes a concern.
|
|
18
|
+
export const DAEMON_AGENT_VERSION = '1.14.1';
|
|
19
|
+
// Pure envelope builder. Split out for unit testability without a
|
|
20
|
+
// Firestore instance; logOperation composes it with setDoc. Takes
|
|
21
|
+
// `OperationLogEntryInput` (no `db` field) so tests don't need to
|
|
22
|
+
// synthesize a Firestore stub for a function that never uses it.
|
|
23
|
+
export function buildOperationLogDoc(input, now = new Date().toISOString(), id = ulid()) {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
timestamp: now,
|
|
27
|
+
actor: input.actor,
|
|
28
|
+
subject: input.subject,
|
|
29
|
+
op_type: input.opType,
|
|
30
|
+
path: input.path,
|
|
31
|
+
pre_content_hash: input.preContentHash ?? null,
|
|
32
|
+
post_content_hash: input.postContentHash ?? null,
|
|
33
|
+
size_bytes: input.sizeBytes ?? null,
|
|
34
|
+
outcome: input.outcome,
|
|
35
|
+
error: input.error ?? null,
|
|
36
|
+
agent_version: input.agentVersion ?? DAEMON_AGENT_VERSION,
|
|
37
|
+
org_id: input.orgId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function logOperation(input) {
|
|
41
|
+
const entry = buildOperationLogDoc(input);
|
|
42
|
+
await setDoc(doc(input.db, 'orgs', input.orgId, 'operationLog', entry.id), entry);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=operation-log.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"yjs-file-binding.d.ts",
|
|
44
44
|
"yjs-binding-state.js",
|
|
45
45
|
"yjs-binding-state.d.ts",
|
|
46
|
+
"operation-log.js",
|
|
47
|
+
"operation-log.d.ts",
|
|
46
48
|
"README.md"
|
|
47
49
|
],
|
|
48
50
|
"scripts": {
|
|
@@ -50,8 +52,8 @@
|
|
|
50
52
|
"build": "tsc",
|
|
51
53
|
"typecheck": "tsc --noEmit",
|
|
52
54
|
"prepack": "npm run build",
|
|
53
|
-
"test:unit": "node --import tsx --test config.test.ts paths.test.ts storage-helpers.test.ts folder-chain.test.ts team-registry.test.ts auth-persistence.test.ts pre-mint-app-check.test.ts setup-auth-with-pre-mint.test.ts sync-state.test.ts manifest.test.ts push-outcome.test.ts yjs-file-binding.test.ts yjs-binding-state.test.ts files-helpers.test.ts",
|
|
54
|
-
"test:integration": "node --import tsx --test --test-timeout=180000 files-push-create-private.test.ts files-push-create-team.test.ts files-push-update-private.test.ts files-push-binary.test.ts files-push-staleness.test.ts files-push-storage-fail.test.ts files-push-no-content.test.ts files-push-stamps.test.ts files-push-folder.test.ts files-delete.test.ts files-delete-folder.test.ts files-read-blob.test.ts org-sync-local-edit.test.ts org-sync-file-ops.test.ts org-sync-mtime.test.ts org-sync-rename.test.ts org-sync-yjs-pull.test.ts org-sync-yjs-push.test.ts org-sync-cold-start.test.ts org-sync-startup-ordering.test.ts org-sync-manifest-reconcile.test.ts org-sync-team-membership-filter.test.ts org-sync-special-chars.test.ts org-sync-copy.test.ts two-user-happy-paths.test.ts two-user-yjs-convergence.test.ts files-concurrency-folder-create.test.ts files-concurrency-file-create.test.ts files-concurrency-file-create-sandbox.test.ts files-concurrency-file-delete.test.ts files-concurrency-folder-delete.test.ts",
|
|
55
|
+
"test:unit": "node --import tsx --test config.test.ts paths.test.ts storage-helpers.test.ts folder-chain.test.ts team-registry.test.ts auth-persistence.test.ts pre-mint-app-check.test.ts setup-auth-with-pre-mint.test.ts sync-state.test.ts manifest.test.ts push-outcome.test.ts yjs-file-binding.test.ts yjs-binding-state.test.ts files-helpers.test.ts operation-log.test.ts",
|
|
56
|
+
"test:integration": "node --import tsx --test --test-timeout=180000 files-push-create-private.test.ts files-push-create-team.test.ts files-push-update-private.test.ts files-push-binary.test.ts files-push-staleness.test.ts files-push-storage-fail.test.ts files-push-no-content.test.ts files-push-stamps.test.ts files-push-folder.test.ts files-delete.test.ts files-delete-folder.test.ts files-read-blob.test.ts operation-log-emit.test.ts org-sync-local-edit.test.ts org-sync-file-ops.test.ts org-sync-mtime.test.ts org-sync-rename.test.ts org-sync-yjs-pull.test.ts org-sync-yjs-push.test.ts org-sync-cold-start.test.ts org-sync-startup-ordering.test.ts org-sync-manifest-reconcile.test.ts org-sync-team-membership-filter.test.ts org-sync-special-chars.test.ts org-sync-copy.test.ts two-user-happy-paths.test.ts two-user-yjs-convergence.test.ts files-concurrency-folder-create.test.ts files-concurrency-file-create.test.ts files-concurrency-file-create-sandbox.test.ts files-concurrency-file-delete.test.ts files-concurrency-folder-delete.test.ts",
|
|
55
57
|
"test:integration:emulator": "USE_EMULATOR=true firebase emulators:exec --only firestore,storage,auth 'npm run test:integration'",
|
|
56
58
|
"test": "npm run test:unit && npm run test:integration"
|
|
57
59
|
},
|
|
@@ -59,14 +61,15 @@
|
|
|
59
61
|
"@doubling/types": "*",
|
|
60
62
|
"chokidar": "^5.0.0",
|
|
61
63
|
"fast-diff": "^1.3.0",
|
|
62
|
-
"firebase": "^12.
|
|
64
|
+
"firebase": "^12.15.0",
|
|
65
|
+
"ulid": "^3.0.2",
|
|
63
66
|
"yjs": "^13.6.31"
|
|
64
67
|
},
|
|
65
68
|
"devDependencies": {
|
|
66
69
|
"@firebase/rules-unit-testing": "^5.0.1",
|
|
67
|
-
"@types/node": "
|
|
68
|
-
"firebase-tools": "^15.
|
|
69
|
-
"tsx": "^4.
|
|
70
|
+
"@types/node": "22.20.0",
|
|
71
|
+
"firebase-tools": "^15.22.4",
|
|
72
|
+
"tsx": "^4.23.0",
|
|
70
73
|
"typescript": "^5.9.3"
|
|
71
74
|
},
|
|
72
75
|
"publishConfig": {
|