@doubling/compound-sync 1.12.3 → 1.12.5
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 +17 -0
- package/auth-persistence.d.ts +22 -0
- package/auth-persistence.js +79 -83
- package/config.d.ts +13 -0
- package/config.js +55 -54
- package/files.d.ts +221 -0
- package/files.js +373 -438
- package/folder-chain.d.ts +44 -0
- package/folder-chain.js +52 -59
- package/manifest.d.ts +38 -0
- package/manifest.js +125 -0
- package/org-sync.d.ts +16 -0
- package/org-sync.js +1196 -972
- package/package.json +26 -3
- package/paths.d.ts +1 -1
- package/pre-mint-app-check.d.ts +6 -0
- package/pre-mint-app-check.js +46 -39
- package/push-outcome.d.ts +9 -0
- package/push-outcome.js +11 -0
- package/setup-auth-with-pre-mint.d.ts +24 -0
- package/setup-auth-with-pre-mint.js +48 -55
- package/storage-helpers.d.ts +14 -0
- package/storage-helpers.js +43 -107
- package/sync-state.d.ts +4 -0
- package/sync-state.js +30 -16
- package/sync.d.ts +18 -0
- package/sync.js +464 -515
- package/team-registry.d.ts +41 -0
- package/team-registry.js +91 -70
- package/yjs-binding-state.d.ts +4 -0
- package/yjs-binding-state.js +25 -22
- package/yjs-file-binding.d.ts +32 -0
- package/yjs-file-binding.js +217 -204
- package/yjs-firestore-update-store.d.ts +13 -0
- package/yjs-firestore-update-store.js +99 -103
- package/yjs-provider.d.ts +43 -0
- package/yjs-provider.js +79 -75
package/README.md
CHANGED
|
@@ -42,6 +42,19 @@ Compound Sync watches a local folder and your Compound workspace simultaneously.
|
|
|
42
42
|
Shared with Me/ -- files shared with you (read-only)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
## Hidden sync metadata
|
|
46
|
+
|
|
47
|
+
The daemon maintains per-machine state under your sync folder (dotfiles and
|
|
48
|
+
directories, not uploaded to Compound):
|
|
49
|
+
|
|
50
|
+
| Path | Purpose |
|
|
51
|
+
| ---- | ------- |
|
|
52
|
+
| `.compound-sync/manifest.json` | Maps synced paths to Firestore file IDs (startup reconcile) |
|
|
53
|
+
| `.compound-sync-state.json` | Content-hash baseline for push/pull decisions |
|
|
54
|
+
| `.compound-yjs-binding/` | Offline Yjs merge state per file |
|
|
55
|
+
|
|
56
|
+
Do not edit these by hand. See [docs/features/sync/local-metadata.md](../docs/features/sync/local-metadata.md).
|
|
57
|
+
|
|
45
58
|
## Running
|
|
46
59
|
|
|
47
60
|
After setup, start syncing with:
|
|
@@ -116,6 +129,10 @@ npm test
|
|
|
116
129
|
- **Typecheck:** `npm run typecheck` (alias for `tsc --noEmit`) at the workspace level, or at repo root `npm run typecheck` which runs web + sync together.
|
|
117
130
|
- **Strictness:** `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`. No `any`. No `// @ts-ignore`. If a third-party module lacks types, declare its shape in a `.d.ts` or PR types upstream.
|
|
118
131
|
|
|
132
|
+
### Logging convention
|
|
133
|
+
|
|
134
|
+
Org-scoped output: ` [${orgId}] [tag] message`. Use `console.warn` for recoverable issues, `console.error` for failures. Tags like `[local scan]` and `[manifest reconcile]` identify subsystems.
|
|
135
|
+
|
|
119
136
|
Phase 1 migrated `paths.js` to `paths.ts` as proof of pattern. Phase 3 ([[DOU-183]]) migrates the remaining source files; everything in `sync/` will be `.ts` by the end.
|
|
120
137
|
|
|
121
138
|
### Publishing to npm
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ResolveAuthFilePathInput {
|
|
2
|
+
authFileEnv?: string | null | undefined;
|
|
3
|
+
homeDir: string;
|
|
4
|
+
projectId?: string | undefined;
|
|
5
|
+
accountKey?: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
export interface FilePersistenceClass {
|
|
8
|
+
new (): {
|
|
9
|
+
type: 'LOCAL';
|
|
10
|
+
_isAvailable(): Promise<boolean>;
|
|
11
|
+
_set(key: string, value: unknown): Promise<void>;
|
|
12
|
+
_get(key: string): Promise<unknown>;
|
|
13
|
+
_remove(key: string): Promise<void>;
|
|
14
|
+
_addListener(key: string, listener: unknown): void;
|
|
15
|
+
_removeListener(key: string, listener: unknown): void;
|
|
16
|
+
};
|
|
17
|
+
type: 'LOCAL';
|
|
18
|
+
unfreeze(): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function resolveAuthFilePath({ authFileEnv, homeDir, projectId, accountKey, }: ResolveAuthFilePathInput): string;
|
|
21
|
+
export declare function makeFilePersistence(filePath: string): FilePersistenceClass;
|
|
22
|
+
//# sourceMappingURL=auth-persistence.d.ts.map
|
package/auth-persistence.js
CHANGED
|
@@ -14,98 +14,94 @@
|
|
|
14
14
|
// desktop app give each account its own credentials file.
|
|
15
15
|
//
|
|
16
16
|
// Security: the file holds a refresh token, so it's written 0600.
|
|
17
|
-
|
|
18
17
|
import fs from 'node:fs';
|
|
19
18
|
import path from 'node:path';
|
|
20
|
-
|
|
21
19
|
// Where to store an account's persisted auth. The desktop app passes
|
|
22
20
|
// an explicit per-account path via the COMPOUND_AUTH_FILE env var
|
|
23
21
|
// (each account gets its own file). The CLI falls back to a stable
|
|
24
22
|
// per-(project, account) path under the user's config dir, keyed by
|
|
25
23
|
// email (or orgId) so two accounts on the same project don't collide.
|
|
26
|
-
export function resolveAuthFilePath({ authFileEnv, homeDir, projectId, accountKey }) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
export function resolveAuthFilePath({ authFileEnv, homeDir, projectId, accountKey, }) {
|
|
25
|
+
if (authFileEnv)
|
|
26
|
+
return authFileEnv;
|
|
27
|
+
const safeAccount = String(accountKey || 'default').replace(/[^a-zA-Z0-9._@-]/g, '_');
|
|
28
|
+
const safeProject = String(projectId || 'default').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
29
|
+
return path.join(homeDir, '.config', 'compound-sync', `${safeProject}__${safeAccount}.json`);
|
|
31
30
|
}
|
|
32
|
-
|
|
33
31
|
export function makeFilePersistence(filePath) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
32
|
+
// Freeze guard: starts true. During daemon startup the Firebase Auth
|
|
33
|
+
// SDK validates the cached refresh token against the backend. With
|
|
34
|
+
// App Check enforcement on Authentication enabled, that validation
|
|
35
|
+
// request lacks an App Check header (App Check is initialized after
|
|
36
|
+
// authStateReady today), the backend rejects, and the SDK calls
|
|
37
|
+
// _remove(userKey) on our persistence, wiping credentials. While
|
|
38
|
+
// frozen, _remove is a no-op. The daemon calls FilePersistence.unfreeze()
|
|
39
|
+
// after authStateReady resolves, restoring normal sign-out behavior.
|
|
40
|
+
// Per factory call (per account): unfreezing one account's persistence
|
|
41
|
+
// must not unfreeze another's.
|
|
42
|
+
let frozen = true;
|
|
43
|
+
function readStore() {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
46
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Missing or unreadable file: start from an empty store.
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
52
55
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
fs.writeFileSync(tmp, JSON.stringify(store), { mode: 0o600 });
|
|
66
|
-
fs.renameSync(tmp, filePath);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return class FilePersistence {
|
|
70
|
-
// Read by the SDK both as a static and as an instance property in
|
|
71
|
-
// different code paths; set both. 'LOCAL' = survives restarts.
|
|
72
|
-
static type = 'LOCAL';
|
|
73
|
-
type = 'LOCAL';
|
|
74
|
-
|
|
75
|
-
// Called by the daemon once auth restore is past the validation
|
|
76
|
-
// window (after authStateReady). Sign-out _remove calls after this
|
|
77
|
-
// point are honored normally.
|
|
78
|
-
static unfreeze() {
|
|
79
|
-
frozen = false;
|
|
56
|
+
function writeStore(store) {
|
|
57
|
+
// Atomic write: temp file + rename. fs.writeFileSync is not atomic,
|
|
58
|
+
// so a crash or kill mid-write would truncate the credentials file
|
|
59
|
+
// and the next launch would read it as an empty store (parse fail
|
|
60
|
+
// = {}), forcing a browser popup. rename(2) on the same filesystem
|
|
61
|
+
// is atomic, so on-disk state is always either the prior valid
|
|
62
|
+
// JSON or the new valid JSON.
|
|
63
|
+
const dir = path.dirname(filePath);
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
|
|
66
|
+
fs.writeFileSync(tmp, JSON.stringify(store), { mode: 0o600 });
|
|
67
|
+
fs.renameSync(tmp, filePath);
|
|
80
68
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
69
|
+
return class FilePersistence {
|
|
70
|
+
// Read by the SDK both as a static and as an instance property in
|
|
71
|
+
// different code paths; set both. 'LOCAL' = survives restarts.
|
|
72
|
+
static type = 'LOCAL';
|
|
73
|
+
type = 'LOCAL';
|
|
74
|
+
// Called by the daemon once auth restore is past the validation
|
|
75
|
+
// window (after authStateReady). Sign-out _remove calls after this
|
|
76
|
+
// point are honored normally.
|
|
77
|
+
static unfreeze() {
|
|
78
|
+
frozen = false;
|
|
79
|
+
}
|
|
80
|
+
async _isAvailable() {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
async _set(key, value) {
|
|
84
|
+
const store = readStore();
|
|
85
|
+
store[key] = value;
|
|
86
|
+
writeStore(store);
|
|
87
|
+
}
|
|
88
|
+
async _get(key) {
|
|
89
|
+
const store = readStore();
|
|
90
|
+
return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
|
|
91
|
+
}
|
|
92
|
+
async _remove(key) {
|
|
93
|
+
// See the freeze comment above the closure. During init we keep
|
|
94
|
+
// the cached credential rather than letting the SDK's startup
|
|
95
|
+
// validation wipe it.
|
|
96
|
+
if (frozen)
|
|
97
|
+
return;
|
|
98
|
+
const store = readStore();
|
|
99
|
+
delete store[key];
|
|
100
|
+
writeStore(store);
|
|
101
|
+
}
|
|
102
|
+
// Cross-tab change listeners are a browser concern; no-ops in Node.
|
|
103
|
+
_addListener(_key, _listener) { }
|
|
104
|
+
_removeListener(_key, _listener) { }
|
|
105
|
+
};
|
|
111
106
|
}
|
|
107
|
+
//# sourceMappingURL=auth-persistence.js.map
|
package/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface OrgConfig {
|
|
2
|
+
orgId: string;
|
|
3
|
+
localPath: string;
|
|
4
|
+
}
|
|
5
|
+
export interface NormalizedConfig {
|
|
6
|
+
projectId: string;
|
|
7
|
+
orgs: OrgConfig[];
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveConfigPath(arg: string, defaultDir: string): string;
|
|
10
|
+
export declare function normalizeConfig(raw: unknown): NormalizedConfig;
|
|
11
|
+
export declare function loadConfig(absPath: string): NormalizedConfig | null;
|
|
12
|
+
export declare function saveConfig(absPath: string, configObject: NormalizedConfig): void;
|
|
13
|
+
//# sourceMappingURL=config.d.ts.map
|
package/config.js
CHANGED
|
@@ -19,85 +19,86 @@
|
|
|
19
19
|
// }
|
|
20
20
|
//
|
|
21
21
|
// `normalizeConfig` produces the multi-org shape from either.
|
|
22
|
-
|
|
23
22
|
import fs from 'node:fs';
|
|
24
23
|
import os from 'node:os';
|
|
25
24
|
import path from 'node:path';
|
|
26
|
-
|
|
27
25
|
function expandHome(p) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
if (p === '~')
|
|
27
|
+
return os.homedir();
|
|
28
|
+
if (p.startsWith('~/'))
|
|
29
|
+
return path.join(os.homedir(), p.slice(2));
|
|
30
|
+
return p;
|
|
32
31
|
}
|
|
33
|
-
|
|
34
32
|
// Resolve a --config arg to an absolute path.
|
|
35
33
|
// Absolute paths are returned as-is. ~/ is expanded to the user home.
|
|
36
34
|
// Otherwise the arg is treated as relative to defaultDir.
|
|
37
35
|
export function resolveConfigPath(arg, defaultDir) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
const expanded = expandHome(arg);
|
|
37
|
+
if (path.isAbsolute(expanded))
|
|
38
|
+
return expanded;
|
|
39
|
+
return path.join(defaultDir, expanded);
|
|
41
40
|
}
|
|
42
|
-
|
|
43
41
|
// Normalize a raw config object into the multi-org shape.
|
|
44
42
|
// Throws if required fields are missing or contradictory.
|
|
45
43
|
export function normalizeConfig(raw) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
if (!raw.projectId) {
|
|
50
|
-
throw new Error('Config is missing required field: projectId');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let orgs;
|
|
54
|
-
if (Array.isArray(raw.orgs)) {
|
|
55
|
-
orgs = raw.orgs;
|
|
56
|
-
} else if (raw.orgId) {
|
|
57
|
-
orgs = [{ orgId: raw.orgId, localPath: raw.localPath }];
|
|
58
|
-
} else {
|
|
59
|
-
throw new Error('Config must define either orgs[] or orgId');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (orgs.length === 0) {
|
|
63
|
-
throw new Error('Config must define at least one org');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const seenPaths = new Set();
|
|
67
|
-
const normalizedOrgs = orgs.map((entry, idx) => {
|
|
68
|
-
if (!entry || typeof entry !== 'object') {
|
|
69
|
-
throw new Error(`orgs[${idx}] must be an object`);
|
|
44
|
+
if (!raw || typeof raw !== 'object') {
|
|
45
|
+
throw new Error('Config must be an object');
|
|
70
46
|
}
|
|
71
|
-
|
|
72
|
-
|
|
47
|
+
const r = raw;
|
|
48
|
+
const projectId = r['projectId'];
|
|
49
|
+
if (typeof projectId !== 'string' || !projectId) {
|
|
50
|
+
throw new Error('Config is missing required field: projectId');
|
|
73
51
|
}
|
|
74
|
-
|
|
75
|
-
|
|
52
|
+
let orgs;
|
|
53
|
+
if (Array.isArray(r['orgs'])) {
|
|
54
|
+
orgs = r['orgs'];
|
|
76
55
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
throw new Error(`orgs[${idx}] has a duplicate localPath: ${localPath}`);
|
|
56
|
+
else if (r['orgId']) {
|
|
57
|
+
orgs = [{ orgId: r['orgId'], localPath: r['localPath'] }];
|
|
80
58
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
else {
|
|
60
|
+
throw new Error('Config must define either orgs[] or orgId');
|
|
61
|
+
}
|
|
62
|
+
if (orgs.length === 0) {
|
|
63
|
+
throw new Error('Config must define at least one org');
|
|
64
|
+
}
|
|
65
|
+
const seenPaths = new Set();
|
|
66
|
+
const normalizedOrgs = orgs.map((entry, idx) => {
|
|
67
|
+
if (!entry || typeof entry !== 'object') {
|
|
68
|
+
throw new Error(`orgs[${idx}] must be an object`);
|
|
69
|
+
}
|
|
70
|
+
const e = entry;
|
|
71
|
+
const orgId = e['orgId'];
|
|
72
|
+
if (typeof orgId !== 'string' || !orgId) {
|
|
73
|
+
throw new Error(`orgs[${idx}] is missing required field: orgId`);
|
|
74
|
+
}
|
|
75
|
+
const rawLocalPath = e['localPath'];
|
|
76
|
+
if (typeof rawLocalPath !== 'string' || !rawLocalPath) {
|
|
77
|
+
throw new Error(`orgs[${idx}] is missing required field: localPath`);
|
|
78
|
+
}
|
|
79
|
+
const localPath = expandHome(rawLocalPath);
|
|
80
|
+
if (seenPaths.has(localPath)) {
|
|
81
|
+
throw new Error(`orgs[${idx}] has a duplicate localPath: ${localPath}`);
|
|
82
|
+
}
|
|
83
|
+
seenPaths.add(localPath);
|
|
84
|
+
return { orgId, localPath };
|
|
85
|
+
});
|
|
86
|
+
return { projectId, orgs: normalizedOrgs };
|
|
86
87
|
}
|
|
87
|
-
|
|
88
88
|
// Load a config file from disk and return it normalized.
|
|
89
89
|
// Returns null if the file doesn't exist (caller can trigger setup).
|
|
90
90
|
// Throws if the file exists but is invalid JSON or fails normalization.
|
|
91
91
|
export function loadConfig(absPath) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
if (!fs.existsSync(absPath))
|
|
93
|
+
return null;
|
|
94
|
+
const raw = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
|
|
95
|
+
return normalizeConfig(raw);
|
|
95
96
|
}
|
|
96
|
-
|
|
97
97
|
// Write a config object to disk. Creates the parent directory chain if
|
|
98
98
|
// it doesn't already exist (so callers can target paths like
|
|
99
99
|
// ~/.config/compound-sync/work.json without manual mkdir).
|
|
100
100
|
export function saveConfig(absPath, configObject) {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
102
|
+
fs.writeFileSync(absPath, JSON.stringify(configObject, null, 2) + '\n');
|
|
103
103
|
}
|
|
104
|
+
//# sourceMappingURL=config.js.map
|
package/files.d.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { type CollectionReference, type DocumentData } from 'firebase/firestore';
|
|
2
|
+
import { type FirebaseStorage } from 'firebase/storage';
|
|
3
|
+
import { type StorageData } from './storage-helpers.js';
|
|
4
|
+
export { storagePathFor, extFromPath, contentByteSize, computeContentHash, isTextMimeType, mimeTypeFromExt, } from './storage-helpers.js';
|
|
5
|
+
interface BlobLocation {
|
|
6
|
+
storage: FirebaseStorage;
|
|
7
|
+
orgId: string;
|
|
8
|
+
fileId: string;
|
|
9
|
+
ext: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UploadBlobInput extends BlobLocation {
|
|
12
|
+
data: StorageData;
|
|
13
|
+
mimeType?: string;
|
|
14
|
+
}
|
|
15
|
+
interface ScopedFolderQuery {
|
|
16
|
+
filesRef: CollectionReference<DocumentData>;
|
|
17
|
+
scope: string;
|
|
18
|
+
teamId?: string | null | undefined;
|
|
19
|
+
userId?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface EnsureFolderChainInput extends ScopedFolderQuery {
|
|
22
|
+
docPath: string;
|
|
23
|
+
now?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
export interface PushFolderInput extends ScopedFolderQuery {
|
|
26
|
+
docPath: string;
|
|
27
|
+
now?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
export type PushFolderOutcome = {
|
|
30
|
+
action: 'skipped';
|
|
31
|
+
reason: 'shared-scope';
|
|
32
|
+
} | {
|
|
33
|
+
action: 'ensured';
|
|
34
|
+
folderId: string | null;
|
|
35
|
+
};
|
|
36
|
+
export interface DeleteFolderInput {
|
|
37
|
+
filesRef: CollectionReference<DocumentData>;
|
|
38
|
+
storage: FirebaseStorage;
|
|
39
|
+
orgId: string;
|
|
40
|
+
scope: string;
|
|
41
|
+
teamId?: string | null | undefined;
|
|
42
|
+
userId?: string | undefined;
|
|
43
|
+
docPath: string;
|
|
44
|
+
}
|
|
45
|
+
export type DeleteFolderOutcome = {
|
|
46
|
+
action: 'skipped';
|
|
47
|
+
reason: 'shared-scope';
|
|
48
|
+
} | {
|
|
49
|
+
action: 'not-found';
|
|
50
|
+
} | {
|
|
51
|
+
action: 'deleted';
|
|
52
|
+
folderId: string | null;
|
|
53
|
+
childCount: number;
|
|
54
|
+
};
|
|
55
|
+
export interface PushFileInput {
|
|
56
|
+
filesRef: CollectionReference<DocumentData>;
|
|
57
|
+
storage: FirebaseStorage;
|
|
58
|
+
orgId: string;
|
|
59
|
+
userId: string;
|
|
60
|
+
scope: string;
|
|
61
|
+
teamId?: string | null | undefined;
|
|
62
|
+
docPath: string;
|
|
63
|
+
data: StorageData;
|
|
64
|
+
mimeType?: string | undefined;
|
|
65
|
+
now?: string | undefined;
|
|
66
|
+
localModifiedAt?: string | null | undefined;
|
|
67
|
+
}
|
|
68
|
+
export interface PushedFileData {
|
|
69
|
+
type: 'file';
|
|
70
|
+
name: string;
|
|
71
|
+
parentId: string | null;
|
|
72
|
+
path: string;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
scope: string;
|
|
76
|
+
teamId: string | null;
|
|
77
|
+
ownerId: string | null;
|
|
78
|
+
sharedWith: string[];
|
|
79
|
+
storagePath: string;
|
|
80
|
+
contentHash: string;
|
|
81
|
+
size: number;
|
|
82
|
+
mimeType: string;
|
|
83
|
+
}
|
|
84
|
+
export type PushFileToCloudOutcome = {
|
|
85
|
+
action: 'skipped';
|
|
86
|
+
reason: 'shared-scope';
|
|
87
|
+
} | {
|
|
88
|
+
action: 'noop';
|
|
89
|
+
fileId: string;
|
|
90
|
+
} | {
|
|
91
|
+
action: 'skipped-stale';
|
|
92
|
+
fileId: string;
|
|
93
|
+
} | {
|
|
94
|
+
action: 'created';
|
|
95
|
+
fileId: string;
|
|
96
|
+
fileData: PushedFileData;
|
|
97
|
+
storageError: unknown | null;
|
|
98
|
+
} | {
|
|
99
|
+
action: 'updated';
|
|
100
|
+
fileId: string;
|
|
101
|
+
storageError: unknown | null;
|
|
102
|
+
};
|
|
103
|
+
export interface DeleteFileInput {
|
|
104
|
+
filesRef: CollectionReference<DocumentData>;
|
|
105
|
+
storage: FirebaseStorage;
|
|
106
|
+
orgId: string;
|
|
107
|
+
userId: string;
|
|
108
|
+
scope: string;
|
|
109
|
+
teamId?: string | null | undefined;
|
|
110
|
+
docPath: string;
|
|
111
|
+
}
|
|
112
|
+
export type DeleteFileOutcome = {
|
|
113
|
+
action: 'skipped';
|
|
114
|
+
reason: 'shared-scope';
|
|
115
|
+
} | {
|
|
116
|
+
action: 'not-found';
|
|
117
|
+
fileId?: string;
|
|
118
|
+
} | {
|
|
119
|
+
action: 'deleted';
|
|
120
|
+
fileId: string;
|
|
121
|
+
};
|
|
122
|
+
export declare function uploadBlob({ storage, orgId, fileId, ext, data, mimeType }: UploadBlobInput): Promise<void>;
|
|
123
|
+
export declare function deleteBlob({ storage, orgId, fileId, ext }: BlobLocation): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Read a blob from Cloud Storage as raw bytes. Throws on any failure
|
|
126
|
+
* (missing blob, permission error, network).
|
|
127
|
+
*/
|
|
128
|
+
export declare function readBlob({ storage, orgId, fileId, ext }: BlobLocation): Promise<Buffer>;
|
|
129
|
+
/**
|
|
130
|
+
* Read a blob and decode as UTF-8 text. Convenience for text/markdown
|
|
131
|
+
* callers.
|
|
132
|
+
*/
|
|
133
|
+
export declare function readBlobAsText(input: BlobLocation): Promise<string>;
|
|
134
|
+
/**
|
|
135
|
+
* Ensure folder docs exist for every segment of `docPath` and return
|
|
136
|
+
* the id of the leaf folder. Idempotent: existing folder docs are
|
|
137
|
+
* reused; missing ones are created; folder docs missing the `path`
|
|
138
|
+
* field are backfilled.
|
|
139
|
+
*
|
|
140
|
+
* `docPath` is the FULL slash-joined path of the folder itself (NOT a
|
|
141
|
+
* file path). Pass `'A/B'` to ensure both `A` and `A/B` exist; the
|
|
142
|
+
* returned id is for `A/B`.
|
|
143
|
+
*
|
|
144
|
+
* Returns null if `docPath` is empty (root).
|
|
145
|
+
*/
|
|
146
|
+
export declare function ensureFolderChainInCloud({ filesRef, scope, teamId, userId, docPath, now, }: EnsureFolderChainInput): Promise<string | null>;
|
|
147
|
+
/**
|
|
148
|
+
* Find or create the folder doc at `docPath`. Convenience wrapper
|
|
149
|
+
* around ensureFolderChainInCloud for callers that want to upsert a
|
|
150
|
+
* folder explicitly (e.g., chokidar `addDir` events).
|
|
151
|
+
*
|
|
152
|
+
* Returns:
|
|
153
|
+
* { action: 'skipped', reason: 'shared-scope' }
|
|
154
|
+
* { action: 'ensured', folderId }
|
|
155
|
+
*/
|
|
156
|
+
export declare function pushFolderToCloud({ filesRef, scope, teamId, userId, docPath, now, }: PushFolderInput): Promise<PushFolderOutcome>;
|
|
157
|
+
/**
|
|
158
|
+
* Delete the folder doc at `docPath` AND every descendant (files and
|
|
159
|
+
* sub-folders) in the same scope. Cascade is authoritative: it does
|
|
160
|
+
* not rely on per-child `unlink` events to clean up children first.
|
|
161
|
+
*
|
|
162
|
+
* Pre-DOU-223 this function deleted only the folder doc, on the
|
|
163
|
+
* (wrong) assumption that chokidar would emit `unlink` for every
|
|
164
|
+
* child before the `unlinkDir` for the folder. macOS FSEvents
|
|
165
|
+
* coalescing and burst-event ordering broke that assumption,
|
|
166
|
+
* leaving orphan file docs that reconstituted the folder on the web
|
|
167
|
+
* via `getFoldersAndFiles` (which derives folder names from paths).
|
|
168
|
+
* The cascade closes the gap; if children's `unlink` events also
|
|
169
|
+
* fire in time, the cascade is a no-op for them.
|
|
170
|
+
*
|
|
171
|
+
* Storage blobs for descendant files are deleted before their
|
|
172
|
+
* Firestore docs so the Storage rule (which reads the doc to
|
|
173
|
+
* authorize) still passes.
|
|
174
|
+
*
|
|
175
|
+
* Returns:
|
|
176
|
+
* { action: 'skipped', reason: 'shared-scope' }
|
|
177
|
+
* { action: 'not-found' }
|
|
178
|
+
* { action: 'deleted', folderId, childCount }
|
|
179
|
+
*/
|
|
180
|
+
export declare function deleteFolderFromCloud({ filesRef, storage, orgId, scope, teamId, userId, docPath, }: DeleteFolderInput): Promise<DeleteFolderOutcome>;
|
|
181
|
+
/**
|
|
182
|
+
* Push a file's content to the cloud: ensure the parent folder chain
|
|
183
|
+
* exists, upsert the Firestore metadata doc (with canonical fields
|
|
184
|
+
* including `type: 'file'`, `name`, `parentId`), and upload the blob.
|
|
185
|
+
*
|
|
186
|
+
* Args:
|
|
187
|
+
* - data: the file's content. String, Buffer, or Uint8Array.
|
|
188
|
+
* - mimeType: the file's mime type. Stored on the metadata doc and
|
|
189
|
+
* used as the blob's Content-Type. Optional; if absent
|
|
190
|
+
* for an existing file the existing mime is preserved,
|
|
191
|
+
* and for new files defaults to 'text/markdown' to keep
|
|
192
|
+
* legacy callers working.
|
|
193
|
+
*
|
|
194
|
+
* - localModifiedAt: ISO timestamp of the local file's last
|
|
195
|
+
* modification (mtime). When provided, a push that would
|
|
196
|
+
* overwrite a server copy whose `updatedAt` is strictly
|
|
197
|
+
* newer is skipped instead, so an older local file cannot
|
|
198
|
+
* clobber a newer remote edit (last-write-wins, symmetric
|
|
199
|
+
* with the firestore -> local pull guard). Omit it to
|
|
200
|
+
* force the push regardless of timestamps (legacy callers).
|
|
201
|
+
*
|
|
202
|
+
* Returns one of:
|
|
203
|
+
* { action: 'skipped', reason: 'shared-scope' }
|
|
204
|
+
* { action: 'noop', fileId }
|
|
205
|
+
* { action: 'skipped-stale', fileId } // server copy is newer
|
|
206
|
+
* { action: 'created', fileId, fileData, storageError? }
|
|
207
|
+
* { action: 'updated', fileId, storageError? }
|
|
208
|
+
*/
|
|
209
|
+
export declare function pushFileToCloud({ filesRef, storage, orgId, userId, scope, teamId, docPath, data, mimeType, now, localModifiedAt, }: PushFileInput): Promise<PushFileToCloudOutcome>;
|
|
210
|
+
/**
|
|
211
|
+
* Delete a file from Firestore and the corresponding Storage blob.
|
|
212
|
+
*
|
|
213
|
+
* Returns one of:
|
|
214
|
+
* { action: 'skipped', reason: 'shared-scope' }
|
|
215
|
+
* { action: 'not-found' }
|
|
216
|
+
* { action: 'deleted', fileId }
|
|
217
|
+
*
|
|
218
|
+
* Blob deletion tolerates `storage/object-not-found`.
|
|
219
|
+
*/
|
|
220
|
+
export declare function deleteFileFromCloud({ filesRef, storage, orgId, userId, scope, teamId, docPath, }: DeleteFileInput): Promise<DeleteFileOutcome>;
|
|
221
|
+
//# sourceMappingURL=files.d.ts.map
|