mirror of
https://github.com/Kong/insomnia
synced 2024-11-08 23:00:30 +00:00
1472 lines
40 KiB
JavaScript
1472 lines
40 KiB
JavaScript
// @flow
|
|
|
|
import type { BaseDriver } from '../store/drivers/base';
|
|
import path from 'path';
|
|
import clone from 'clone';
|
|
import Store from '../store';
|
|
import crypto from 'crypto';
|
|
import compress from '../store/hooks/compress';
|
|
import * as paths from './paths';
|
|
import type {
|
|
Branch,
|
|
DocumentKey,
|
|
Head,
|
|
MergeConflict,
|
|
Project,
|
|
Snapshot,
|
|
SnapshotState,
|
|
Stage,
|
|
StageEntry,
|
|
Status,
|
|
StatusCandidate,
|
|
Team,
|
|
} from '../types';
|
|
import {
|
|
compareBranches,
|
|
generateCandidateMap,
|
|
getRootSnapshot,
|
|
getStagable,
|
|
hashDocument,
|
|
preMergeCheck,
|
|
stateDelta,
|
|
threeWayMerge,
|
|
updateStateWithConflictResolutions,
|
|
} from './util';
|
|
import { chunkArray, generateId } from '../../common/misc';
|
|
import * as crypt from '../../account/crypt';
|
|
import * as session from '../../account/session';
|
|
import * as fetch from '../../account/fetch';
|
|
|
|
const EMPTY_HASH = crypto
|
|
.createHash('sha1')
|
|
.digest('hex')
|
|
.replace(/./g, '0');
|
|
|
|
type ConflictHandler = (conflicts: Array<MergeConflict>) => Promise<Array<MergeConflict>>;
|
|
|
|
export default class VCS {
|
|
_store: Store;
|
|
_driver: BaseDriver;
|
|
_project: Project | null;
|
|
_conflictHandler: ?ConflictHandler;
|
|
|
|
constructor(driver: BaseDriver, conflictHandler?: ConflictHandler) {
|
|
this._store = new Store(driver, [compress]);
|
|
this._conflictHandler = conflictHandler;
|
|
this._driver = driver;
|
|
|
|
// To be set later
|
|
this._project = null;
|
|
}
|
|
|
|
currentProjectId(): string | null {
|
|
return this._project ? this._project.id : null;
|
|
}
|
|
|
|
newInstance(): VCS {
|
|
const newVCS: VCS = (Object.assign({}, this): any);
|
|
Object.setPrototypeOf(newVCS, VCS.prototype);
|
|
return newVCS;
|
|
}
|
|
|
|
async setProject(project: Project): Promise<void> {
|
|
this._project = project;
|
|
console.log(`[sync] Activate project ${project.id}`);
|
|
|
|
// Store it because it might not be yet
|
|
await this._storeProject(project);
|
|
}
|
|
|
|
async removeProjectsForRoot(rootDocumentId: string): Promise<void> {
|
|
const all = await this._allProjects();
|
|
const toRemove = all.filter(p => p.rootDocumentId === rootDocumentId);
|
|
for (const project of toRemove) {
|
|
await this._removeProject(project);
|
|
}
|
|
}
|
|
|
|
async switchProject(rootDocumentId: string, name: string): Promise<Project> {
|
|
const project = await this._getOrCreateProject(rootDocumentId, name);
|
|
await this.setProject(project);
|
|
return project;
|
|
}
|
|
|
|
async teams(): Promise<Array<Team>> {
|
|
return this._queryTeams();
|
|
}
|
|
|
|
async projectTeams(): Promise<Array<Team>> {
|
|
return this._queryProjectTeams();
|
|
}
|
|
|
|
async localProjects(): Promise<Array<Project>> {
|
|
return this._allProjects();
|
|
}
|
|
|
|
async remoteProjects(): Promise<Array<Project>> {
|
|
return this._queryProjects();
|
|
}
|
|
|
|
async blobFromLastSnapshot(key: string): Promise<Object | null> {
|
|
const branch = await this._getCurrentBranch();
|
|
const snapshot = await this._getLatestSnapshot(branch.name);
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
|
|
const entry = snapshot.state.find(e => e.key === key);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
return this._getBlob(entry.blob);
|
|
}
|
|
|
|
async status(candidates: Array<StatusCandidate>, baseStage: Stage): Promise<Status> {
|
|
const stage = clone(baseStage);
|
|
const branch = await this._getCurrentBranch();
|
|
const snapshot: Snapshot | null = await this._getLatestSnapshot(branch.name);
|
|
const state = snapshot ? snapshot.state : [];
|
|
|
|
const unstaged: { [DocumentKey]: StageEntry } = {};
|
|
for (const entry of getStagable(state, candidates)) {
|
|
const { key } = entry;
|
|
const stageEntry = stage[key];
|
|
|
|
if (!stageEntry || stageEntry.blobId !== entry.blobId) {
|
|
unstaged[key] = entry;
|
|
}
|
|
}
|
|
|
|
return {
|
|
stage,
|
|
unstaged,
|
|
key: hashDocument({ stage, unstaged }).hash,
|
|
};
|
|
}
|
|
|
|
async stage(stage: Stage, stageEntries: Array<StageEntry>): Promise<Stage> {
|
|
const blobsToStore: { [string]: string } = {};
|
|
for (const entry of stageEntries) {
|
|
stage[entry.key] = entry;
|
|
|
|
// Only store blobs if we're not deleting it
|
|
if (entry.added || entry.modified) {
|
|
blobsToStore[entry.blobId] = entry.blobContent;
|
|
}
|
|
}
|
|
|
|
await this._storeBlobs(blobsToStore);
|
|
|
|
return stage;
|
|
}
|
|
|
|
async unstage(stage: Stage, stageEntries: Array<StageEntry>): Promise<Stage> {
|
|
for (const entry of stageEntries) {
|
|
delete stage[entry.key];
|
|
}
|
|
|
|
return stage;
|
|
}
|
|
|
|
static validateBranchName(branchName: string): string {
|
|
if (!branchName.match(/^[a-zA-Z0-9][a-zA-Z0-9-_.]{3,}$/)) {
|
|
return 'Branch names can only contain letters, numbers, - and _';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
async compareRemoteBranch(): Promise<{ ahead: number, behind: number }> {
|
|
const localBranch = await this._getCurrentBranch();
|
|
const remoteBranch = await this._queryBranch(localBranch.name);
|
|
return compareBranches(localBranch, remoteBranch);
|
|
}
|
|
|
|
async fork(newBranchName: string): Promise<void> {
|
|
const errMsg = VCS.validateBranchName(newBranchName);
|
|
if (errMsg) {
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
if (await this._getBranch(newBranchName)) {
|
|
throw new Error('Branch already exists by name ' + newBranchName);
|
|
}
|
|
|
|
const newBranch: Branch = {
|
|
name: newBranchName,
|
|
created: new Date(),
|
|
modified: new Date(),
|
|
snapshots: (await this._getCurrentBranch()).snapshots,
|
|
};
|
|
|
|
await this._storeBranch(newBranch);
|
|
}
|
|
|
|
async removeRemoteBranch(branchName: string): Promise<void> {
|
|
if (branchName === 'master') {
|
|
throw new Error('Cannot delete master branch');
|
|
}
|
|
|
|
await this._queryRemoveBranch(branchName);
|
|
}
|
|
|
|
async removeBranch(branchName: string): Promise<void> {
|
|
const branchToDelete = await this._assertBranch(branchName);
|
|
const currentBranch = await this._getCurrentBranch();
|
|
|
|
if (branchToDelete.name === 'master') {
|
|
throw new Error('Cannot delete master branch');
|
|
}
|
|
|
|
if (branchToDelete.name === currentBranch.name) {
|
|
throw new Error('Cannot delete currently-active branch');
|
|
}
|
|
|
|
await this._removeBranch(branchToDelete);
|
|
}
|
|
|
|
async checkout(
|
|
candidates: Array<StatusCandidate>,
|
|
branchName: string,
|
|
): Promise<{
|
|
upsert: Array<Object>,
|
|
remove: Array<Object>,
|
|
}> {
|
|
const branchCurrent = await this._getCurrentBranch();
|
|
const latestSnapshotCurrent: Snapshot | null = await this._getLatestSnapshot(
|
|
branchCurrent.name,
|
|
);
|
|
const latestStateCurrent = latestSnapshotCurrent ? latestSnapshotCurrent.state : [];
|
|
|
|
const branchNext = await this._getOrCreateBranch(branchName);
|
|
const latestSnapshotNext: Snapshot | null = await this._getLatestSnapshot(branchNext.name);
|
|
const latestStateNext = latestSnapshotNext ? latestSnapshotNext.state : [];
|
|
|
|
// Perform pre-checkout checks
|
|
const { conflicts, dirty } = preMergeCheck(latestStateCurrent, latestStateNext, candidates);
|
|
if (conflicts.length) {
|
|
throw new Error('Please snapshot current changes before switching branches');
|
|
}
|
|
|
|
await this._storeHead({ branch: branchNext.name });
|
|
|
|
const dirtyMap = generateCandidateMap(dirty);
|
|
const delta = stateDelta(latestStateCurrent, latestStateNext);
|
|
|
|
// Filter out things that should stay dirty
|
|
const add = delta.add.filter(e => !dirtyMap[e.key]);
|
|
const update = delta.update.filter(e => !dirtyMap[e.key]);
|
|
const remove = delta.remove.filter(e => !dirtyMap[e.key]);
|
|
const upsert = [...add, ...update];
|
|
|
|
// Remove all dirty items from the delta so we keep them around
|
|
return {
|
|
upsert: await this._getBlobs(upsert.map(e => e.blob)),
|
|
remove: await this._getBlobs(remove.map(e => e.blob)),
|
|
};
|
|
}
|
|
|
|
async handleAnyConflicts(
|
|
conflicts: Array<MergeConflict>,
|
|
errorMsg: string,
|
|
): Promise<Array<MergeConflict>> {
|
|
if (conflicts.length === 0) {
|
|
return conflicts;
|
|
}
|
|
|
|
if (!this._conflictHandler) {
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
return this._conflictHandler(conflicts);
|
|
}
|
|
|
|
async allDocuments(): Promise<Object> {
|
|
const branch = await this._getCurrentBranch();
|
|
const snapshot: Snapshot | null = await this._getLatestSnapshot(branch.name);
|
|
if (!snapshot) {
|
|
throw new Error('Failed to get latest snapshot for all documents');
|
|
}
|
|
|
|
return this._getBlobs(snapshot.state.map(s => s.blob));
|
|
}
|
|
|
|
async rollbackToLatest(
|
|
candidates: Array<StatusCandidate>,
|
|
): Promise<{
|
|
upsert: Array<Object>,
|
|
remove: Array<Object>,
|
|
}> {
|
|
const branch = await this._getCurrentBranch();
|
|
const latestSnapshot = await this._getLatestSnapshot(branch.name);
|
|
if (!latestSnapshot) {
|
|
throw new Error('No snapshots to rollback to');
|
|
}
|
|
|
|
return this.rollback(latestSnapshot.id, candidates);
|
|
}
|
|
|
|
async rollback(
|
|
snapshotId: string,
|
|
candidates: Array<StatusCandidate>,
|
|
): Promise<{
|
|
upsert: Array<Object>,
|
|
remove: Array<Object>,
|
|
}> {
|
|
const rollbackSnapshot: Snapshot | null = await this._getSnapshot(snapshotId);
|
|
if (rollbackSnapshot === null) {
|
|
throw new Error(`Failed to find snapshot by id ${snapshotId}`);
|
|
}
|
|
|
|
const potentialNewState: SnapshotState = candidates.map(c => ({
|
|
key: c.key,
|
|
blob: hashDocument(c.document).hash,
|
|
name: c.name,
|
|
}));
|
|
|
|
const delta = stateDelta(potentialNewState, rollbackSnapshot.state);
|
|
|
|
// We need to treat removals of candidates differently because they may not
|
|
// yet have been stored as blobs.
|
|
const remove = [];
|
|
for (const e of delta.remove) {
|
|
const c = candidates.find(c => c.key === e.key);
|
|
if (!c) {
|
|
// Should never happen
|
|
throw new Error('Failed to find removal in candidates');
|
|
}
|
|
remove.push(c.document);
|
|
}
|
|
|
|
const upsert = [...delta.update, ...delta.add];
|
|
return {
|
|
upsert: await this._getBlobs(upsert.map(e => e.blob)),
|
|
remove,
|
|
};
|
|
}
|
|
|
|
async getHistoryCount(): Promise<number> {
|
|
const branch = await this._getCurrentBranch();
|
|
return branch.snapshots.length;
|
|
}
|
|
|
|
async getHistory(): Promise<Array<Snapshot>> {
|
|
const branch = await this._getCurrentBranch();
|
|
const snapshots = [];
|
|
for (const id of branch.snapshots) {
|
|
const snapshot = await this._getSnapshot(id);
|
|
if (snapshot === null) {
|
|
throw new Error(`Failed to get snapshot id=${id}`);
|
|
}
|
|
|
|
snapshots.push(snapshot);
|
|
}
|
|
|
|
return snapshots;
|
|
}
|
|
|
|
async getBranch(): Promise<string> {
|
|
const branch = await this._getCurrentBranch();
|
|
return branch.name;
|
|
}
|
|
|
|
async getRemoteBranches(): Promise<Array<string>> {
|
|
const branches = await this._queryBranches();
|
|
return branches.map(b => b.name);
|
|
}
|
|
|
|
async getBranches(): Promise<Array<string>> {
|
|
const branches = await this._getBranches();
|
|
return branches.map(b => b.name);
|
|
}
|
|
|
|
async merge(
|
|
candidates: Array<StatusCandidate>,
|
|
otherBranchName: string,
|
|
snapshotMessage?: string,
|
|
): Promise<{
|
|
upsert: Array<Object>,
|
|
remove: Array<Object>,
|
|
}> {
|
|
const branch = await this._getCurrentBranch();
|
|
return this._merge(candidates, branch.name, otherBranchName, snapshotMessage);
|
|
}
|
|
|
|
async takeSnapshot(stage: Stage, name: string): Promise<void> {
|
|
const branch: Branch = await this._getCurrentBranch();
|
|
const parent: Snapshot | null = await this._getLatestSnapshot(branch.name);
|
|
|
|
if (!name) {
|
|
throw new Error('Snapshot must have a message');
|
|
}
|
|
|
|
// Ensure there is something on the stage
|
|
if (Object.keys(stage).length === 0) {
|
|
throw new Error('Snapshot does not have any changes');
|
|
}
|
|
|
|
const newState: SnapshotState = [];
|
|
|
|
// Add everything from the old state
|
|
for (const entry of parent ? parent.state : []) {
|
|
// Don't add anything that's in the stage (this covers deleted things too :])
|
|
if (stage[entry.key]) {
|
|
continue;
|
|
}
|
|
|
|
newState.push(entry);
|
|
}
|
|
|
|
// Add the rest of the staged items
|
|
for (const key of Object.keys(stage)) {
|
|
const entry = stage[key];
|
|
if (entry.deleted) {
|
|
continue;
|
|
}
|
|
|
|
const { name, blobId: blob } = entry;
|
|
newState.push({ key, name, blob });
|
|
}
|
|
|
|
await this._createSnapshotFromState(branch, newState, name);
|
|
}
|
|
|
|
async pull(
|
|
candidates: Array<StatusCandidate>,
|
|
): Promise<{
|
|
upsert: Array<Object>,
|
|
remove: Array<Object>,
|
|
}> {
|
|
await this._getOrCreateRemoteProject();
|
|
|
|
const localBranch = await this._getCurrentBranch();
|
|
const tmpBranchForRemote = await this._fetch(localBranch.name + '.hidden', localBranch.name);
|
|
|
|
// Merge branch and ensure that we use the remote's history when merging
|
|
const message = `Synced latest changes from ${localBranch.name}`;
|
|
const delta = await this._merge(
|
|
candidates,
|
|
localBranch.name,
|
|
tmpBranchForRemote.name,
|
|
message,
|
|
true,
|
|
);
|
|
|
|
// Remove tmp branch
|
|
await this._removeBranch(tmpBranchForRemote);
|
|
|
|
return delta;
|
|
}
|
|
|
|
async shareWithTeam(teamId: string): Promise<void> {
|
|
const { memberKeys, projectKey } = await this._queryProjectShareInstructions(teamId);
|
|
const { privateKey } = this._assertSession();
|
|
const symmetricKey = crypt.decryptRSAWithJWK(privateKey, projectKey.encSymmetricKey);
|
|
|
|
const keys = [];
|
|
for (const { accountId, publicKey } of memberKeys) {
|
|
const encSymmetricKey = crypt.encryptRSAWithJWK(JSON.parse(publicKey), symmetricKey);
|
|
keys.push({ accountId, encSymmetricKey });
|
|
}
|
|
|
|
await this._queryProjectShare(teamId, keys);
|
|
}
|
|
|
|
async unShareWithTeam(): Promise<void> {
|
|
await this._queryProjectUnShare();
|
|
}
|
|
|
|
async _getOrCreateRemoteProject(): Promise<Project> {
|
|
const localProject = await this._assertProject();
|
|
let project = await this._queryProject();
|
|
if (!project) {
|
|
project = await this._queryCreateProject(localProject.rootDocumentId, localProject.name);
|
|
}
|
|
|
|
await this._storeProject(project);
|
|
return project;
|
|
}
|
|
|
|
async push(): Promise<void> {
|
|
await this._getOrCreateRemoteProject();
|
|
const branch = await this._getCurrentBranch();
|
|
|
|
// Check branch history to make sure there are no conflicts
|
|
let lastMatchingIndex = 0;
|
|
const remoteBranch: Branch | null = await this._queryBranch(branch.name);
|
|
const remoteBranchSnapshots = remoteBranch ? remoteBranch.snapshots : [];
|
|
for (; lastMatchingIndex < remoteBranchSnapshots.length; lastMatchingIndex++) {
|
|
if (remoteBranchSnapshots[lastMatchingIndex] !== branch.snapshots[lastMatchingIndex]) {
|
|
throw new Error('Remote history conflict. Please pull latest changes and try again');
|
|
}
|
|
}
|
|
|
|
// Get the remaining snapshots to push
|
|
const snapshotIdsToPush = branch.snapshots.slice(lastMatchingIndex);
|
|
if (snapshotIdsToPush.length === 0) {
|
|
throw new Error('Already up to date');
|
|
}
|
|
|
|
// Gather a list of snapshot state entries to push
|
|
const allBlobIds = new Set();
|
|
const snapshots = [];
|
|
for (const id of snapshotIdsToPush) {
|
|
const snapshot = await this._assertSnapshot(id);
|
|
snapshots.push(snapshot);
|
|
|
|
for (const entry of snapshot.state) {
|
|
allBlobIds.add(entry.blob);
|
|
}
|
|
}
|
|
|
|
// Figure out which blobs the backend is missing
|
|
const missingIds = await this._queryBlobsMissing(Array.from(allBlobIds));
|
|
await this._queryPushBlobs(missingIds);
|
|
await this._queryPushSnapshots(snapshots);
|
|
}
|
|
|
|
async _fetch(localBranchName: string, remoteBranchName: string): Promise<Branch> {
|
|
const remoteBranch: Branch | null = await this._queryBranch(remoteBranchName);
|
|
if (!remoteBranch) {
|
|
throw new Error(`The remote branch "${remoteBranchName}" does not exist`);
|
|
}
|
|
|
|
// Fetch snapshots and blobs from remote branch
|
|
let snapshotsToFetch: Array<string> = [];
|
|
for (const snapshotId of remoteBranch.snapshots) {
|
|
const localSnapshot = await this._getSnapshot(snapshotId);
|
|
if (!localSnapshot) {
|
|
snapshotsToFetch.push(snapshotId);
|
|
}
|
|
}
|
|
|
|
// Find blobs to fetch
|
|
const blobsToFetch = new Set();
|
|
const snapshots = await this._querySnapshots(snapshotsToFetch);
|
|
for (const snapshot of snapshots) {
|
|
for (const { blob } of snapshot.state) {
|
|
const hasBlob = await this._hasBlob(blob);
|
|
if (hasBlob) {
|
|
continue;
|
|
}
|
|
|
|
blobsToFetch.add(blob);
|
|
}
|
|
}
|
|
|
|
// Fetch and store the blobs
|
|
const ids = Array.from(blobsToFetch);
|
|
const blobs = await this._queryBlobs(ids);
|
|
await this._storeBlobsBuffer(blobs);
|
|
|
|
// Store the snapshots
|
|
await this._storeSnapshots(snapshots);
|
|
|
|
// Create the new branch and save it
|
|
const branch = clone(remoteBranch);
|
|
branch.created = branch.modified = new Date();
|
|
branch.name = localBranchName;
|
|
await this._storeBranch(branch);
|
|
|
|
return branch;
|
|
}
|
|
|
|
async _merge(
|
|
candidates: Array<StatusCandidate>,
|
|
trunkBranchName: string,
|
|
otherBranchName: string,
|
|
snapshotMessage?: string,
|
|
useOtherBranchHistory?: boolean,
|
|
): Promise<{
|
|
upsert: Array<Object>,
|
|
remove: Array<Object>,
|
|
}> {
|
|
const branchOther = await this._assertBranch(otherBranchName);
|
|
const latestSnapshotOther: Snapshot | null = await this._getLatestSnapshot(branchOther.name);
|
|
|
|
const branchTrunk = await this._assertBranch(trunkBranchName);
|
|
const rootSnapshotId = getRootSnapshot(branchTrunk, branchOther);
|
|
const rootSnapshot: Snapshot | null = await this._getSnapshot(rootSnapshotId || 'n/a');
|
|
const latestSnapshotTrunk: Snapshot | null = await this._getLatestSnapshot(branchTrunk.name);
|
|
const latestStateTrunk = latestSnapshotTrunk ? latestSnapshotTrunk.state : [];
|
|
const latestStateOther = latestSnapshotOther ? latestSnapshotOther.state : [];
|
|
|
|
// Perform pre-merge checks
|
|
const { conflicts: preConflicts, dirty } = preMergeCheck(
|
|
latestStateTrunk,
|
|
latestStateOther,
|
|
candidates,
|
|
);
|
|
|
|
if (preConflicts.length) {
|
|
console.log('[sync] Merge failed', preConflicts);
|
|
throw new Error('Please snapshot current changes before merging');
|
|
}
|
|
|
|
const shouldDoNothing1 = latestSnapshotOther && latestSnapshotOther.id === rootSnapshotId;
|
|
const shouldDoNothing2 = branchOther.snapshots.length === 0;
|
|
const shouldFastForward1 =
|
|
rootSnapshot && (!latestSnapshotTrunk || rootSnapshot.id === latestSnapshotTrunk.id);
|
|
const shouldFastForward2 = branchTrunk.snapshots.length === 0;
|
|
|
|
if (shouldDoNothing1 || shouldDoNothing2) {
|
|
console.log('[sync] Nothing to merge');
|
|
} else if (shouldFastForward1 || shouldFastForward2) {
|
|
console.log('[sync] Performing fast-forward merge');
|
|
branchTrunk.snapshots = branchOther.snapshots;
|
|
await this._storeBranch(branchTrunk);
|
|
} else {
|
|
const rootState = rootSnapshot ? rootSnapshot.state : [];
|
|
|
|
console.log('[sync] Performing 3-way merge');
|
|
const { state: stateBeforeConflicts, conflicts: mergeConflicts } = threeWayMerge(
|
|
rootState,
|
|
latestStateTrunk,
|
|
latestStateOther,
|
|
);
|
|
|
|
// Update state with conflict resolutions applied
|
|
const conflictResolutions = await this.handleAnyConflicts(mergeConflicts, '');
|
|
const state = updateStateWithConflictResolutions(stateBeforeConflicts, conflictResolutions);
|
|
|
|
// Sometimes we want to merge into trunk but keep the other branch's history
|
|
if (useOtherBranchHistory) {
|
|
branchTrunk.snapshots = branchOther.snapshots;
|
|
}
|
|
|
|
const snapshotName = snapshotMessage || `Merged branch ${branchOther.name}`;
|
|
await this._createSnapshotFromState(branchTrunk, state, snapshotName);
|
|
}
|
|
|
|
const newLatestSnapshot = await this._getLatestSnapshot(branchTrunk.name);
|
|
const newLatestSnapshotState = newLatestSnapshot ? newLatestSnapshot.state : [];
|
|
|
|
const { add, update, remove } = stateDelta(latestStateTrunk, newLatestSnapshotState);
|
|
const upsert = [...add, ...update];
|
|
|
|
// Remove all dirty items from the delta so we keep them around
|
|
const dirtyMap = generateCandidateMap(dirty);
|
|
return {
|
|
upsert: await this._getBlobs(upsert.filter(e => !dirtyMap[e.key]).map(e => e.blob)),
|
|
remove: await this._getBlobs(remove.filter(e => !dirtyMap[e.key]).map(e => e.blob)),
|
|
};
|
|
}
|
|
|
|
async _createSnapshotFromState(
|
|
branch: Branch,
|
|
state: SnapshotState,
|
|
name: string,
|
|
): Promise<Snapshot> {
|
|
const parentId = branch.snapshots.length
|
|
? branch.snapshots[branch.snapshots.length - 1]
|
|
: EMPTY_HASH;
|
|
|
|
// Create the snapshot
|
|
const id = _generateSnapshotID(parentId, this._projectId(), state);
|
|
const snapshot: Snapshot = {
|
|
id,
|
|
name,
|
|
state,
|
|
author: '', // Will be set when pushed
|
|
parent: parentId,
|
|
created: new Date(),
|
|
description: '',
|
|
};
|
|
|
|
// Update the branch history
|
|
branch.modified = new Date();
|
|
branch.snapshots.push(snapshot.id);
|
|
|
|
await this._storeBranch(branch);
|
|
await this._storeSnapshot(snapshot);
|
|
console.log(`[sync] Created snapshot '${name}' on ${branch.name}`);
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
async _runGraphQL(query: string, variables: { [string]: any }, name: string): Promise<Object> {
|
|
const { sessionId } = this._assertSession();
|
|
const { data, errors } = await fetch.post('/graphql?' + name, { query, variables }, sessionId);
|
|
|
|
if (errors && errors.length) {
|
|
throw new Error(`Failed to query ${name}`);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async _queryBlobsMissing(ids: Array<string>): Promise<Array<string>> {
|
|
const { blobsMissing } = await this._runGraphQL(
|
|
`
|
|
query ($projectId: ID!, $ids: [ID!]!) {
|
|
blobsMissing(project: $projectId, ids: $ids) {
|
|
missing
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
ids,
|
|
projectId: this._projectId(),
|
|
},
|
|
'missingBlobs',
|
|
);
|
|
|
|
return blobsMissing.missing;
|
|
}
|
|
|
|
async _queryBranches(): Promise<Array<Branch>> {
|
|
const { branches } = await this._runGraphQL(
|
|
`
|
|
query ($projectId: ID!) {
|
|
branches(project: $projectId) {
|
|
created
|
|
modified
|
|
name
|
|
snapshots
|
|
}
|
|
}`,
|
|
{
|
|
projectId: this._projectId(),
|
|
},
|
|
'branches',
|
|
);
|
|
|
|
// TODO: Fix server returning null instead of empty list
|
|
return branches || [];
|
|
}
|
|
|
|
async _queryRemoveBranch(branchName: string): Promise<void> {
|
|
await this._runGraphQL(
|
|
`
|
|
mutation ($projectId: ID!, $branch: String!) {
|
|
branchRemove(project: $projectId, name: $branch)
|
|
}`,
|
|
{
|
|
projectId: this._projectId(),
|
|
branch: branchName,
|
|
},
|
|
'removeBranch',
|
|
);
|
|
}
|
|
|
|
async _queryBranch(branchName: string): Promise<Branch | null> {
|
|
const { branch } = await this._runGraphQL(
|
|
`
|
|
query ($projectId: ID!, $branch: String!) {
|
|
branch(project: $projectId, name: $branch) {
|
|
created
|
|
modified
|
|
name
|
|
snapshots
|
|
}
|
|
}`,
|
|
{
|
|
projectId: this._projectId(),
|
|
branch: branchName,
|
|
},
|
|
'branch',
|
|
);
|
|
|
|
return branch;
|
|
}
|
|
|
|
async _querySnapshots(allIds: Array<string>): Promise<Array<Snapshot>> {
|
|
let allSnapshots = [];
|
|
for (const ids of chunkArray(allIds, 20)) {
|
|
const { snapshots } = await this._runGraphQL(
|
|
`
|
|
query ($ids: [ID!]!, $projectId: ID!) {
|
|
snapshots(ids: $ids, project: $projectId) {
|
|
id
|
|
parent
|
|
created
|
|
author
|
|
authorAccount {
|
|
firstName
|
|
lastName
|
|
email
|
|
}
|
|
name
|
|
description
|
|
state {
|
|
blob
|
|
key
|
|
name
|
|
}
|
|
}
|
|
}`,
|
|
{
|
|
ids,
|
|
projectId: this._projectId(),
|
|
},
|
|
'snapshots',
|
|
);
|
|
allSnapshots = [...allSnapshots, ...snapshots];
|
|
}
|
|
|
|
return allSnapshots;
|
|
}
|
|
|
|
async _queryPushSnapshots(allSnapshots: Array<Snapshot>): Promise<void> {
|
|
const { accountId } = this._assertSession();
|
|
|
|
for (const snapshots of chunkArray(allSnapshots, 20)) {
|
|
// This bit of logic fills in any missing author IDs from times where
|
|
// the user created snapshots while not logged in
|
|
for (const snapshot of snapshots) {
|
|
if (snapshot.author === '') {
|
|
snapshot.author = accountId;
|
|
}
|
|
}
|
|
|
|
const branch = await this._getCurrentBranch();
|
|
const { snapshotsCreate } = await this._runGraphQL(
|
|
`
|
|
mutation ($projectId: ID!, $snapshots: [SnapshotInput!]!, $branchName: String!) {
|
|
snapshotsCreate(project: $projectId, snapshots: $snapshots, branch: $branchName) {
|
|
id
|
|
parent
|
|
created
|
|
author
|
|
authorAccount {
|
|
firstName
|
|
lastName
|
|
email
|
|
}
|
|
name
|
|
description
|
|
state {
|
|
blob
|
|
key
|
|
name
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
branchName: branch.name,
|
|
projectId: this._projectId(),
|
|
snapshots,
|
|
},
|
|
'snapshotsPush',
|
|
);
|
|
|
|
// Store them in case something has changed
|
|
await this._storeSnapshots(snapshotsCreate);
|
|
|
|
console.log('[sync] Pushed snapshots', snapshotsCreate.map(s => s.id).join(', '));
|
|
}
|
|
}
|
|
|
|
async _queryBlobs(allIds: Array<string>): Promise<{ [string]: Buffer }> {
|
|
const symmetricKey = await this._getProjectSymmetricKey();
|
|
const result = {};
|
|
|
|
for (const ids of chunkArray(allIds, 50)) {
|
|
const { blobs } = await this._runGraphQL(
|
|
`
|
|
query ($ids: [ID!]!, $projectId: ID!) {
|
|
blobs(ids: $ids, project: $projectId) {
|
|
id
|
|
content
|
|
}
|
|
}`,
|
|
{
|
|
ids,
|
|
projectId: this._projectId(),
|
|
},
|
|
'blobs',
|
|
);
|
|
|
|
for (const blob of blobs) {
|
|
const encryptedResult = JSON.parse(blob.content);
|
|
result[blob.id] = crypt.decryptAESToBuffer(symmetricKey, encryptedResult);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async _queryPushBlobs(allIds: Array<string>): Promise<void> {
|
|
const symmetricKey = await this._getProjectSymmetricKey();
|
|
|
|
const next = async (items: Array<{ id: string, content: string }>) => {
|
|
const encodedBlobs = items.map(i => ({
|
|
id: i.id,
|
|
content: i.content,
|
|
}));
|
|
|
|
const { blobsCreate } = await this._runGraphQL(
|
|
`
|
|
mutation ($projectId: ID!, $blobs: [BlobInput!]!) {
|
|
blobsCreate(project: $projectId, blobs: $blobs) {
|
|
count
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
blobs: encodedBlobs,
|
|
projectId: this._projectId(),
|
|
},
|
|
'blobsCreate',
|
|
);
|
|
|
|
return blobsCreate.count;
|
|
};
|
|
|
|
// Push each missing blob in batches of 2MB max
|
|
let count = 0;
|
|
let batch = [];
|
|
let batchSizeBytes = 0;
|
|
const maxBatchSize = 1024 * 1024 * 2; // 2 MB
|
|
const maxBatchCount = 200;
|
|
for (let i = 0; i < allIds.length; i++) {
|
|
const id = allIds[i];
|
|
const content = await this._getBlobRaw(id);
|
|
if (content === null) {
|
|
throw new Error(`Failed to get blob id=${id}`);
|
|
}
|
|
|
|
const encryptedResult = crypt.encryptAESBuffer(symmetricKey, content);
|
|
batch.push({ id, content: JSON.stringify(encryptedResult, null, 2) });
|
|
|
|
batchSizeBytes += content.length;
|
|
const isLastId = i === allIds.length - 1;
|
|
if (batchSizeBytes > maxBatchSize || isLastId || batch.length >= maxBatchCount) {
|
|
count += await next(batch);
|
|
const batchSizeMB = Math.round((batchSizeBytes / 1024) * 100) / 100;
|
|
console.log(`[sync] Uploaded ${count}/${allIds.length} blobs in batch ${batchSizeMB} KB`);
|
|
batch = [];
|
|
batchSizeBytes = 0;
|
|
}
|
|
}
|
|
|
|
console.log(`[sync] Finished uploading ${count}/${allIds.length} blobs`);
|
|
}
|
|
|
|
async _queryProjectKey(): Promise<string> {
|
|
const { projectKey } = await this._runGraphQL(
|
|
`
|
|
query ($projectId: ID!) {
|
|
projectKey(projectId: $projectId) {
|
|
encSymmetricKey
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
projectId: this._projectId(),
|
|
},
|
|
'projectKey',
|
|
);
|
|
|
|
return projectKey.encSymmetricKey;
|
|
}
|
|
|
|
async _queryTeams(): Promise<Array<Team>> {
|
|
const { teams } = await this._runGraphQL(
|
|
`
|
|
query {
|
|
teams {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
`,
|
|
{},
|
|
'teams',
|
|
);
|
|
|
|
return teams;
|
|
}
|
|
|
|
async _queryProjectUnShare(): Promise<void> {
|
|
await this._runGraphQL(
|
|
`
|
|
mutation ($id: ID!) {
|
|
projectUnShare(id: $id) {
|
|
id
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
id: this._projectId(),
|
|
},
|
|
'projectUnShare',
|
|
);
|
|
}
|
|
|
|
async _queryProjectShare(
|
|
teamId: string,
|
|
keys: Array<{ accountId: string, encSymmetricKey: string }>,
|
|
): Promise<void> {
|
|
await this._runGraphQL(
|
|
`
|
|
mutation ($id: ID!, $teamId: ID!, $keys: [ProjectShareKeyInput!]!) {
|
|
projectShare(teamId: $teamId, id: $id, keys: $keys) {
|
|
id
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
keys,
|
|
teamId,
|
|
id: this._projectId(),
|
|
},
|
|
'projectShare',
|
|
);
|
|
}
|
|
|
|
async _queryProjectShareInstructions(
|
|
teamId: string,
|
|
): Promise<{
|
|
teamId: string,
|
|
projectKey: {
|
|
encSymmetricKey: string,
|
|
},
|
|
memberKeys: Array<{
|
|
accountId: string,
|
|
publicKey: string,
|
|
}>,
|
|
}> {
|
|
const { projectShareInstructions } = await this._runGraphQL(
|
|
`
|
|
query ($id: ID!, $teamId: ID!) {
|
|
projectShareInstructions(teamId: $teamId, id: $id) {
|
|
teamId
|
|
projectKey {
|
|
encSymmetricKey
|
|
}
|
|
memberKeys {
|
|
accountId
|
|
publicKey
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
id: this._projectId(),
|
|
teamId: teamId,
|
|
},
|
|
'projectShareInstructions',
|
|
);
|
|
|
|
return projectShareInstructions;
|
|
}
|
|
|
|
async _queryProjects(): Promise<Array<Project>> {
|
|
const { projects } = await this._runGraphQL(
|
|
`
|
|
query {
|
|
projects {
|
|
id
|
|
name
|
|
rootDocumentId
|
|
}
|
|
}
|
|
`,
|
|
{},
|
|
'projects',
|
|
);
|
|
|
|
return projects;
|
|
}
|
|
|
|
async _queryProject(): Promise<Project | null> {
|
|
const { project } = await this._runGraphQL(
|
|
`
|
|
query ($id: ID!) {
|
|
project(id: $id) {
|
|
id
|
|
name
|
|
rootDocumentId
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
id: this._projectId(),
|
|
},
|
|
'project',
|
|
);
|
|
|
|
return project;
|
|
}
|
|
|
|
async _queryProjectTeams(): Promise<Array<Team>> {
|
|
const run = async () => {
|
|
const { project } = await this._runGraphQL(
|
|
`
|
|
query ($id: ID!) {
|
|
project(id: $id) {
|
|
teams {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
id: this._projectId(),
|
|
},
|
|
'project.teams',
|
|
);
|
|
return project;
|
|
};
|
|
|
|
let project = await run();
|
|
|
|
// Retry once if project doesn't exist yet
|
|
if (project === null) {
|
|
await this._getOrCreateRemoteProject();
|
|
project = await run();
|
|
}
|
|
|
|
return project.teams;
|
|
}
|
|
|
|
async _queryCreateProject(workspaceId: string, workspaceName: string): Promise<Project> {
|
|
const { publicKey } = this._assertSession();
|
|
|
|
// Generate symmetric key for ResourceGroup
|
|
const symmetricKey = await crypt.generateAES256Key();
|
|
const symmetricKeyStr = JSON.stringify(symmetricKey);
|
|
|
|
// Encrypt the symmetric key with Account public key
|
|
const encSymmetricKey = crypt.encryptRSAWithJWK(publicKey, symmetricKeyStr);
|
|
|
|
const { projectCreate } = await this._runGraphQL(
|
|
`
|
|
mutation ($rootDocumentId: ID!, $name: String!, $id: ID!, $key: String!) {
|
|
projectCreate(name: $name, id: $id, rootDocumentId: $rootDocumentId, encSymmetricKey: $key) {
|
|
id
|
|
name
|
|
rootDocumentId
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
id: this._projectId(),
|
|
rootDocumentId: workspaceId,
|
|
name: workspaceName,
|
|
key: encSymmetricKey,
|
|
},
|
|
'createProject',
|
|
);
|
|
|
|
return projectCreate;
|
|
}
|
|
|
|
async _getProject(): Promise<Project | null> {
|
|
return this._store.getItem(paths.project(this._projectId()));
|
|
}
|
|
|
|
async _getProjectById(id: string): Promise<Project | null> {
|
|
return this._store.getItem(paths.project(id));
|
|
}
|
|
|
|
async _getProjectSymmetricKey(): Promise<Object> {
|
|
const { privateKey } = this._assertSession();
|
|
const encSymmetricKey = await this._queryProjectKey();
|
|
const symmetricKeyStr = crypt.decryptRSAWithJWK(privateKey, encSymmetricKey);
|
|
return JSON.parse(symmetricKeyStr);
|
|
}
|
|
|
|
async _assertProject(): Promise<Project> {
|
|
const project = await this._getProject();
|
|
if (project === null) {
|
|
throw new Error('Failed to find local project id=' + this._projectId());
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
async _storeProject(project: Project): Promise<void> {
|
|
return this._store.setItem(paths.project(project.id), project);
|
|
}
|
|
|
|
async _getHead(): Promise<Head> {
|
|
const head = await this._store.getItem(paths.head(this._projectId()));
|
|
if (head === null) {
|
|
await this._storeHead({ branch: 'master' });
|
|
return this._getHead();
|
|
}
|
|
|
|
return head;
|
|
}
|
|
|
|
async _getCurrentBranch(): Promise<Branch> {
|
|
const head = await this._getHead();
|
|
return this._getOrCreateBranch(head.branch);
|
|
}
|
|
|
|
_assertSession(): {|
|
|
accountId: string,
|
|
sessionId: string,
|
|
privateKey: Object,
|
|
publicKey: Object,
|
|
|} {
|
|
if (!session.isLoggedIn()) {
|
|
throw new Error('Not logged in');
|
|
}
|
|
|
|
return {
|
|
accountId: session.getAccountId(),
|
|
sessionId: session.getCurrentSessionId(),
|
|
privateKey: session.getPrivateKey(),
|
|
publicKey: session.getPublicKey(),
|
|
};
|
|
}
|
|
|
|
async _assertBranch(branchName: string): Promise<Branch> {
|
|
const branch = await this._getBranch(branchName);
|
|
if (branch === null) {
|
|
throw new Error(`Branch does not exist with name ${branchName}`);
|
|
}
|
|
|
|
return branch;
|
|
}
|
|
|
|
_projectId(): string {
|
|
if (this._project === null) {
|
|
throw new Error('No active project');
|
|
}
|
|
|
|
return this._project.id;
|
|
}
|
|
|
|
async _getBranch(name: string, projectId?: string): Promise<Branch | null> {
|
|
const pId = projectId || this._projectId();
|
|
const p = paths.branch(pId, name);
|
|
return this._store.getItem(p);
|
|
}
|
|
|
|
async _getBranches(projectId?: string): Promise<Array<Branch>> {
|
|
const branches = [];
|
|
const pId = projectId || this._projectId();
|
|
for (const p of await this._store.keys(paths.branches(pId))) {
|
|
const b = await this._store.getItem(p);
|
|
if (b === null) {
|
|
// Should never happen
|
|
throw new Error(`Failed to get branch path=${p}`);
|
|
}
|
|
|
|
branches.push(b);
|
|
}
|
|
|
|
return branches;
|
|
}
|
|
|
|
async _getOrCreateBranch(name: string): Promise<Branch> {
|
|
if (!name) {
|
|
throw new Error('No branch name specified for get-or-create operation');
|
|
}
|
|
|
|
const branch = await this._getBranch(name);
|
|
|
|
if (branch === null) {
|
|
await this._storeBranch({
|
|
name,
|
|
created: new Date(),
|
|
modified: new Date(),
|
|
snapshots: [],
|
|
});
|
|
|
|
return this._getOrCreateBranch(name);
|
|
}
|
|
|
|
return branch;
|
|
}
|
|
|
|
async _getOrCreateProject(rootDocumentId: string, name: string): Promise<Project> {
|
|
if (!rootDocumentId) {
|
|
throw new Error('No root document ID supplied for project');
|
|
}
|
|
|
|
if (!name) {
|
|
throw new Error('No name supplied for project');
|
|
}
|
|
|
|
// First, try finding the project
|
|
const projects = await this._allProjects();
|
|
let matchedProjects = projects.filter(p => p.rootDocumentId === rootDocumentId);
|
|
|
|
// If there is more than one project for root, try pruning unused ones by branch activity
|
|
if (matchedProjects.length > 1) {
|
|
for (const p of matchedProjects) {
|
|
const branches = await this._getBranches(p.id);
|
|
if (!branches.find(b => b.snapshots.length > 0)) {
|
|
await this._removeProject(p);
|
|
matchedProjects = matchedProjects.filter(({ id }) => id !== p.id);
|
|
console.log(`[sync] Remove inactive project for root ${rootDocumentId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there are still too many, error out
|
|
if (matchedProjects.length > 1) {
|
|
console.log('[sync] Multiple projects matched for root', {
|
|
projects,
|
|
matchedProjects,
|
|
rootDocumentId,
|
|
});
|
|
throw new Error('More than one project matched query');
|
|
}
|
|
|
|
let project: Project | null = matchedProjects[0] || null;
|
|
|
|
// If we still don't have a project, create one
|
|
if (!project) {
|
|
const id = generateId('prj');
|
|
project = { id, name, rootDocumentId };
|
|
await this._storeProject(project);
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
async _allProjects(): Promise<Array<Project>> {
|
|
const projects = [];
|
|
const basePath = paths.projects();
|
|
const keys = await this._store.keys(basePath, false);
|
|
for (const key of keys) {
|
|
const id = path.basename(key);
|
|
const p: Project | null = await this._getProjectById(id);
|
|
if (p === null) {
|
|
// Folder exists but project meta file is gone
|
|
continue;
|
|
}
|
|
|
|
projects.push(p);
|
|
}
|
|
|
|
return projects;
|
|
}
|
|
|
|
async _assertSnapshot(id: string): Promise<Snapshot> {
|
|
const snapshot = await this._store.getItem(paths.snapshot(this._projectId(), id));
|
|
if (snapshot && typeof snapshot.created === 'string') {
|
|
snapshot.created = new Date(snapshot.created);
|
|
}
|
|
|
|
if (!snapshot) {
|
|
throw new Error(`Failed to find snapshot id=${id}`);
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
async _getSnapshot(id: string): Promise<Snapshot | null> {
|
|
const snapshot = await this._store.getItem(paths.snapshot(this._projectId(), id));
|
|
if (snapshot && typeof snapshot.created === 'string') {
|
|
snapshot.created = new Date(snapshot.created);
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
async _getLatestSnapshot(branchName: string): Promise<Snapshot | null> {
|
|
const branch = await this._getOrCreateBranch(branchName);
|
|
const snapshots = branch ? branch.snapshots : [];
|
|
const parentId = snapshots.length ? snapshots[snapshots.length - 1] : EMPTY_HASH;
|
|
return this._getSnapshot(parentId);
|
|
}
|
|
|
|
async _storeSnapshot(snapshot: Snapshot): Promise<void> {
|
|
return this._store.setItem(paths.snapshot(this._projectId(), snapshot.id), snapshot);
|
|
}
|
|
|
|
async _storeSnapshots(snapshots: Array<Snapshot>): Promise<void> {
|
|
const promises = [];
|
|
for (const snapshot of snapshots) {
|
|
const p = paths.snapshot(this._projectId(), snapshot.id);
|
|
promises.push(this._store.setItem(p, snapshot));
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
async _storeBranch(branch: Branch): Promise<void> {
|
|
const errMsg = VCS.validateBranchName(branch.name);
|
|
if (errMsg) {
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
branch.modified = new Date();
|
|
return this._store.setItem(paths.branch(this._projectId(), branch.name.toLowerCase()), branch);
|
|
}
|
|
|
|
async _removeBranch(branch: Branch): Promise<void> {
|
|
return this._store.removeItem(paths.branch(this._projectId(), branch.name));
|
|
}
|
|
|
|
async _removeProject(project: Project): Promise<void> {
|
|
return this._store.removeItem(paths.project(project.id));
|
|
}
|
|
|
|
async _storeHead(head: Head): Promise<void> {
|
|
await this._store.setItem(paths.head(this._projectId()), head);
|
|
}
|
|
|
|
async _getBlob(id: string): Promise<Object | null> {
|
|
const p = paths.blob(this._projectId(), id);
|
|
return this._store.getItem(p);
|
|
}
|
|
|
|
async _getBlobs(ids: Array<string>): Promise<Array<Object>> {
|
|
const promises = [];
|
|
for (const id of ids) {
|
|
promises.push(this._getBlob(id));
|
|
}
|
|
|
|
return Promise.all(promises);
|
|
}
|
|
|
|
async _storeBlob(id: string, content: Object | null): Promise<void> {
|
|
return this._store.setItem(paths.blob(this._projectId(), id), content);
|
|
}
|
|
|
|
async _storeBlobs(map: { [string]: string }): Promise<void> {
|
|
const promises = [];
|
|
for (const id of Object.keys(map)) {
|
|
const buff = Buffer.from(map[id], 'utf8');
|
|
promises.push(this._storeBlob(id, buff));
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
async _storeBlobsBuffer(map: { [string]: Buffer }): Promise<void> {
|
|
const promises = [];
|
|
for (const id of Object.keys(map)) {
|
|
const p = paths.blob(this._projectId(), id);
|
|
promises.push(this._store.setItemRaw(p, map[id]));
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
async _getBlobRaw(id: string): Promise<Buffer | null> {
|
|
return this._store.getItemRaw(paths.blob(this._projectId(), id));
|
|
}
|
|
|
|
async _hasBlob(id: string): Promise<boolean> {
|
|
return this._store.hasItem(paths.blob(this._projectId(), id));
|
|
}
|
|
}
|
|
|
|
/** Generate snapshot ID from hashing parent, project, and state together */
|
|
function _generateSnapshotID(parentId: string, projectId: string, state: SnapshotState): string {
|
|
const hash = crypto
|
|
.createHash('sha1')
|
|
.update(projectId)
|
|
.update(parentId);
|
|
|
|
const newState = [...state].sort((a, b) => (a.blob > b.blob ? 1 : -1));
|
|
|
|
for (const entry of newState) {
|
|
hash.update(entry.blob);
|
|
}
|
|
|
|
return hash.digest('hex');
|
|
}
|