Add idempotency key for create

This commit is contained in:
Andras Schmelczer 2026-03-15 08:06:22 +00:00
commit ae590e6fc8
25 changed files with 624 additions and 143 deletions

View file

@ -23,8 +23,15 @@ export interface StoredDocumentMetadata {
hash: string;
}
export interface StoredPendingDocument {
relativePath: RelativePath;
idempotencyKey: string;
originalCreationPath: RelativePath;
}
export interface StoredDatabase {
documents: StoredDocumentMetadata[];
pendingDocuments?: StoredPendingDocument[];
lastSeenUpdateId: VaultUpdateId | undefined;
}
@ -39,6 +46,11 @@ export interface DocumentRecord {
metadata: DocumentMetadata | undefined;
isDeleted: boolean;
parallelVersion: number;
/** The path when this pending document was first created locally.
* Survives renames so we can match it against server responses
* when a create request succeeded but the response was lost. */
originalCreationPath?: RelativePath;
idempotencyKey?: string;
}
export class Database {
@ -60,6 +72,26 @@ export class Database {
parallelVersion: 0
})) ?? [];
if (initialState.pendingDocuments) {
for (const pending of initialState.pendingDocuments) {
const existing =
this.getLatestDocumentByRelativePath(
pending.relativePath
);
this.documents.push({
relativePath: pending.relativePath,
metadata: undefined,
isDeleted: false,
parallelVersion:
existing !== undefined
? existing.parallelVersion + 1
: 0,
originalCreationPath: pending.originalCreationPath,
idempotencyKey: pending.idempotencyKey
});
}
}
this.ensureConsistency();
this.logger.debug(`Loaded ${this.documents.length} documents`);
@ -112,6 +144,12 @@ export class Database {
});
}
public get pendingDocuments(): DocumentRecord[] {
return this.documents.filter(
(doc) => doc.metadata === undefined && !doc.isDeleted
);
}
public updateDocumentMetadata(
metadata: {
documentId: DocumentId;
@ -155,19 +193,25 @@ export class Database {
const previousEntry =
this.getLatestDocumentByRelativePath(relativePath);
const entry = {
const entry: DocumentRecord = {
relativePath,
metadata: undefined,
isDeleted: false,
parallelVersion:
previousEntry?.parallelVersion === undefined
? 0
: previousEntry.parallelVersion + 1
: previousEntry.parallelVersion + 1,
originalCreationPath: relativePath,
idempotencyKey: crypto.randomUUID()
};
this.documents.push(entry);
// no need to save as we only save documents which have metadata
// Save without consistency check — pending docs can't violate
// the documentId uniqueness invariant since they have no metadata.
void this.save().catch((error: unknown) => {
this.logger.error(`Error saving data: ${error}`);
});
return entry;
}
@ -222,6 +266,10 @@ export class Database {
this.saveInTheBackground();
}
public containsDocument(target: DocumentRecord): boolean {
return this.documents.includes(target);
}
public getLastSeenUpdateId(): VaultUpdateId {
return this.lastSeenUpdateIds.min;
}
@ -256,6 +304,13 @@ export class Database {
...metadata! // `resolvedDocuments` only returns docs with metadata set
})
),
pendingDocuments: this.pendingDocuments.map(
({ relativePath, idempotencyKey, originalCreationPath }) => ({
relativePath,
idempotencyKey: idempotencyKey!,
originalCreationPath: originalCreationPath!
})
),
lastSeenUpdateId: this.lastSeenUpdateIds.min
});
}

View file

@ -67,10 +67,12 @@ export class SyncService {
public async create({
relativePath,
contentBytes
contentBytes,
idempotencyKey
}: {
relativePath: RelativePath;
contentBytes: Uint8Array;
idempotencyKey?: string;
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
const formData = new FormData();
@ -81,6 +83,10 @@ export class SyncService {
new Blob([new Uint8Array(contentBytes)])
);
if (idempotencyKey !== undefined) {
formData.append("idempotency_key", idempotencyKey);
}
this.logger.debug(
`Creating document with relative path ${relativePath}`
);
@ -362,6 +368,52 @@ export class SyncService {
});
}
public async resolveIdempotencyKeys(
keys: string[]
): Promise<Map<string, string>> {
this.logger.debug(
`Resolving ${keys.length} idempotency keys`
);
try {
const response = await this.client(
this.getUrl("/documents/resolve-keys"),
{
method: "POST",
body: JSON.stringify({ idempotencyKeys: keys }),
headers: this.getDefaultHeaders({ type: "json" })
}
);
if (!response.ok) {
this.logger.warn(
`Failed to resolve idempotency keys: ${await SyncService.errorFromResponse(
response
)}`
);
return new Map();
}
const result: { resolved: Record<string, string> } =
(await response.json()) as { resolved: Record<string, string> }; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const resolved = new Map<string, string>(
Object.entries(result.resolved)
);
this.logger.debug(
`Resolved ${resolved.size}/${keys.length} idempotency keys`
);
return resolved;
} catch (e) {
this.logger.warn(
`Failed to resolve idempotency keys: ${e}`
);
return new Map();
}
}
public async ping(): Promise<PingResponse> {
this.logger.debug("Pinging server");
const response = await this.pingClient(this.getUrl("/ping"), {

View file

@ -1,8 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface ClientCursors {
userName: string;
deviceId: string;
documentsWithCursors: DocumentWithCursors[];
}
export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], }

View file

@ -1,6 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CreateDocumentVersion {
relative_path: string;
content: number[];
}
export interface CreateDocumentVersion { relative_path: string, content: number[], }

View file

@ -1,6 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface CursorPositionFromClient {
documentsWithCursors: DocumentWithCursors[];
}
export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], }

View file

@ -1,6 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientCursors } from "./ClientCursors";
export interface CursorPositionFromServer {
clients: ClientCursors[];
}
export interface CursorPositionFromServer { clients: ClientCursors[], }

View file

@ -1,6 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CursorSpan {
start: number;
end: number;
}
export interface CursorSpan { start: number, end: number, }

View file

@ -1,5 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DeleteDocumentVersion {
relativePath: string;
}
export interface DeleteDocumentVersion { relativePath: string, }

View file

@ -5,6 +5,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/**
* Response to an update document request.
*/
export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;

View file

@ -1,12 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersion {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
}
export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }

View file

@ -1,12 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersionWithoutContent {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}
export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }

View file

@ -1,9 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan";
export interface DocumentWithCursors {
vault_update_id: number | null;
document_id: string;
relative_path: string;
cursors: CursorSpan[];
}
export interface DocumentWithCursors { vault_update_id: number | null, document_id: string, relative_path: string, cursors: CursorSpan[], }

View file

@ -4,10 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/**
* Response to a fetch latest documents request.
*/
export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[];
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
}
export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[],
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint, }

View file

@ -3,23 +3,22 @@
/**
* Response to a ping request.
*/
export interface PingResponse {
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[];
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number;
}
export interface PingResponse {
/**
* Semantic version of the server.
*/
serverVersion: string,
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean,
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[],
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number, }

View file

@ -1,7 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface SerializedError {
errorType: string;
message: string;
causes: string[];
}
export interface SerializedError { errorType: string, message: string, causes: string[], }

View file

@ -1,7 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateTextDocumentVersion {
parentVersionId: number;
relativePath: string;
content: (number | string)[];
}
export interface UpdateTextDocumentVersion { parentVersionId: number, relativePath: string, content: (number | string)[], }

View file

@ -2,6 +2,4 @@
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);
export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient;

View file

@ -1,7 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface WebSocketHandshake {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}
export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }

View file

@ -2,6 +2,4 @@
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);
export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer;

View file

@ -1,7 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export interface WebSocketVaultUpdate {
documents: DocumentVersionWithoutContent[];
isInitialSync: boolean;
}
export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, }

Some files were not shown because too many files have changed in this diff Show more