Define paginated blob sync contract

This commit is contained in:
mirivlad 2026-07-17 05:09:50 +08:00
parent 7ad07321b0
commit 2b2936121d
6 changed files with 159 additions and 25 deletions

View File

@ -54,22 +54,28 @@ core and sync-server. The server orders opaque operations by
truth. Sync plugins only use `api.sync` for configuration and status. truth. Sync plugins only use `api.sync` for configuration and status.
- File and folder operations are `create`, `update`, `delete`, or `move`. - File and folder operations are `create`, `update`, `delete`, or `move`.
File payloads carry a vault-relative path, a SHA-256 content hash, and the Small UTF-8 text may be inline; binary and large files carry a `blob`
existing bounded text/base64 representation when the file is supported. `{sha256,size}` reference. The bytes are uploaded/downloaded through the
scoped Blob API before an operation is accepted/applied, never base64 in the
operation log.
- Workspace (`Deal`) operations are core-owned `workspace` entities with - Workspace (`Deal`) operations are core-owned `workspace` entities with
`create`, `rename`, `trash`, and `restore`. Their payload carries the durable `create`, `rename`, `trash`, and `restore`. Their payload carries the durable
`workspaceId`; `.verstak/workspace.json` remains unavailable to plugins and `workspaceId`; `.verstak/workspace.json` remains unavailable to plugins and
is not ordinary file sync data. is not ordinary file sync data.
- A pairing may name an existing remote `vaultId`. Omitting it creates/uses the - A pairing may name an existing remote `vaultId`. Omitting it creates/uses the
local vault identity. `SyncStatus.vaultId` reports the selected remote scope. local vault identity. `SyncStatus.vaultId` reports the selected remote scope.
- Pull uses `since_sequence` and a bounded `page_limit`; each response has
`page_last_sequence` and `has_more`. Clients persist a cursor only after an
operation is safely applied and stop at the first failed sequence.
- `SyncStatus.lastWarning` reports a persistent unresolved scanner problem. - `SyncStatus.lastWarning` reports a persistent unresolved scanner problem.
Files larger than the current 8 MB bounded transport or otherwise A file over the configured blob limit or otherwise unsupported is not marked
unsupported are not marked synchronized and are retried on later scans. synchronized and is retried on later scans.
The snapshot stored by core is implementation state, not a plugin API. It The snapshot stored by core is implementation state, not a plugin API. It
excludes `.verstak`, trash, temporary files, and symlinks. Blob transport, excludes `.verstak`, trash, temporary files, and symlinks. Blob ownership,
quotas, pagination, and retention are deliberately outside this contract and quotas and pagination are part of the current wire contract. Operation-log
remain a later milestone. retention remains a future checkpoint milestone: deleting it now could prevent
a newly paired device from reconstructing a vault.
## Bundled Frontend API Contract ## Bundled Frontend API Contract

47
dist/types.d.ts vendored
View File

@ -43,6 +43,53 @@ export interface SyncConfig {
namespaces?: string[]; namespaces?: string[];
participate?: boolean; participate?: boolean;
} }
/** A content-addressed binary payload. Blob bytes are uploaded before the
* operation that references them and are never base64-embedded in the log. */
export interface SyncBlobReference {
sha256: string;
size: number;
}
export interface SyncFilePayload {
path: string;
content?: string;
blob?: SyncBlobReference;
contentHash?: string;
fromPath?: string;
toPath?: string;
}
export interface SyncOperation {
op_id: string;
server_sequence?: number;
device_id?: string;
entity_type: 'file' | 'folder' | 'workspace';
entity_id: string;
op_type: 'create' | 'update' | 'delete' | 'move' | 'rename' | 'trash' | 'restore';
payload_json: string;
created_at: string;
client_sequence?: number;
last_seen_server_seq?: number;
}
export interface SyncPushRequest {
device_id: string;
idempotency_key?: string;
ops: Omit<SyncOperation, 'server_sequence' | 'device_id'>[];
}
export interface SyncPullRequest {
since_sequence: number;
page_limit?: number;
}
export interface SyncPullResponse {
server_sequence: number;
page_last_sequence: number;
has_more: boolean;
ops: SyncOperation[];
}
/** Stable public errors. UI must map `code` to localized copy rather than
* displaying server diagnostics. */
export interface SyncServerError {
error: string;
code: string;
}
export type CapabilityName = string; export type CapabilityName = string;
export interface CapabilityEntry { export interface CapabilityEntry {
name: CapabilityName; name: CapabilityName;

2
dist/types.d.ts.map vendored

File diff suppressed because one or more lines are too long

View File

@ -7,7 +7,9 @@
"properties": { "properties": {
"Operation": { "$ref": "#/$defs/Operation" }, "Operation": { "$ref": "#/$defs/Operation" },
"PushRequest": { "$ref": "#/$defs/PushRequest" }, "PushRequest": { "$ref": "#/$defs/PushRequest" },
"PullRequest": { "$ref": "#/$defs/PullRequest" },
"PullResponse": { "$ref": "#/$defs/PullResponse" }, "PullResponse": { "$ref": "#/$defs/PullResponse" },
"BlobReference": { "$ref": "#/$defs/BlobReference" },
"Snapshot": { "$ref": "#/$defs/Snapshot" }, "Snapshot": { "$ref": "#/$defs/Snapshot" },
"PairingRequest": { "$ref": "#/$defs/PairingRequest" } "PairingRequest": { "$ref": "#/$defs/PairingRequest" }
}, },
@ -16,13 +18,13 @@
"type": "object", "type": "object",
"required": ["op_id", "device_id", "entity_type", "entity_id", "op_type", "payload_json", "created_at"], "required": ["op_id", "device_id", "entity_type", "entity_id", "op_type", "payload_json", "created_at"],
"properties": { "properties": {
"op_id": { "type": "string", "minLength": 1 }, "op_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"server_sequence": { "type": "integer", "minimum": 1 }, "server_sequence": { "type": "integer", "minimum": 1 },
"device_id": { "type": "string" }, "device_id": { "type": "string", "maxLength": 128 },
"entity_type": { "enum": ["file", "folder", "workspace"] }, "entity_type": { "enum": ["file", "folder", "workspace"] },
"entity_id": { "type": "string", "minLength": 1 }, "entity_id": { "type": "string", "minLength": 1, "maxLength": 4096 },
"op_type": { "enum": ["create", "update", "delete", "move", "rename", "trash", "restore"] }, "op_type": { "enum": ["create", "update", "delete", "move", "rename", "trash", "restore"] },
"payload_json": { "type": "string" }, "payload_json": { "type": "string", "maxLength": 262144 },
"created_at": { "type": "string", "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" },
"client_sequence": { "type": "integer", "minimum": 0 }, "client_sequence": { "type": "integer", "minimum": 0 },
"last_seen_server_seq": { "type": "integer", "minimum": 0 } "last_seen_server_seq": { "type": "integer", "minimum": 0 }
@ -34,14 +36,23 @@
"required": ["path"], "required": ["path"],
"properties": { "properties": {
"path": { "$ref": "#/$defs/VaultPath" }, "path": { "$ref": "#/$defs/VaultPath" },
"content": { "type": "string" }, "content": { "type": "string", "description": "Bounded inline UTF-8 text only." },
"dataBase64": { "type": "string", "contentEncoding": "base64" }, "blob": { "$ref": "#/$defs/BlobReference" },
"contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"fromPath": { "$ref": "#/$defs/VaultPath" }, "fromPath": { "$ref": "#/$defs/VaultPath" },
"toPath": { "$ref": "#/$defs/VaultPath" } "toPath": { "$ref": "#/$defs/VaultPath" }
}, },
"additionalProperties": false "additionalProperties": false
}, },
"BlobReference": {
"type": "object",
"required": ["sha256", "size"],
"properties": {
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"size": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"WorkspacePayload": { "WorkspacePayload": {
"type": "object", "type": "object",
"required": ["workspaceId", "path", "name"], "required": ["workspaceId", "path", "name"],
@ -58,17 +69,28 @@
"type": "object", "type": "object",
"required": ["device_id", "ops"], "required": ["device_id", "ops"],
"properties": { "properties": {
"device_id": { "type": "string", "minLength": 1 }, "device_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"idempotency_key": { "type": "string" }, "idempotency_key": { "type": "string", "maxLength": 128 },
"ops": { "type": "array", "items": { "$ref": "#/$defs/PushOperation" } } "ops": { "type": "array", "maxItems": 100, "items": { "$ref": "#/$defs/PushOperation" } }
},
"additionalProperties": false
},
"PullRequest": {
"type": "object",
"required": ["since_sequence"],
"properties": {
"since_sequence": { "type": "integer", "minimum": 0 },
"page_limit": { "type": "integer", "minimum": 1, "maximum": 100 }
}, },
"additionalProperties": false "additionalProperties": false
}, },
"PullResponse": { "PullResponse": {
"type": "object", "type": "object",
"required": ["server_sequence", "ops"], "required": ["server_sequence", "page_last_sequence", "has_more", "ops"],
"properties": { "properties": {
"server_sequence": { "type": "integer", "minimum": 0 }, "server_sequence": { "type": "integer", "minimum": 0 },
"page_last_sequence": { "type": "integer", "minimum": 0 },
"has_more": { "type": "boolean" },
"ops": { "type": "array", "items": { "$ref": "#/$defs/Operation" } } "ops": { "type": "array", "items": { "$ref": "#/$defs/Operation" } }
}, },
"additionalProperties": false "additionalProperties": false
@ -77,11 +99,11 @@
"type": "object", "type": "object",
"required": ["op_id", "entity_type", "entity_id", "op_type", "payload_json", "created_at"], "required": ["op_id", "entity_type", "entity_id", "op_type", "payload_json", "created_at"],
"properties": { "properties": {
"op_id": { "type": "string", "minLength": 1 }, "op_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"entity_type": { "enum": ["file", "folder", "workspace"] }, "entity_type": { "enum": ["file", "folder", "workspace"] },
"entity_id": { "type": "string", "minLength": 1 }, "entity_id": { "type": "string", "minLength": 1, "maxLength": 4096 },
"op_type": { "enum": ["create", "update", "delete", "move", "rename", "trash", "restore"] }, "op_type": { "enum": ["create", "update", "delete", "move", "rename", "trash", "restore"] },
"payload_json": { "type": "string" }, "payload_json": { "type": "string", "maxLength": 262144 },
"created_at": { "type": "string", "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" },
"client_sequence": { "type": "integer", "minimum": 0 }, "client_sequence": { "type": "integer", "minimum": 0 },
"last_seen_server_seq": { "type": "integer", "minimum": 0 } "last_seen_server_seq": { "type": "integer", "minimum": 0 }
@ -114,11 +136,11 @@
"type": "object", "type": "object",
"required": ["login", "password", "device_name", "vault_id"], "required": ["login", "password", "device_name", "vault_id"],
"properties": { "properties": {
"login": { "type": "string", "minLength": 1 }, "login": { "type": "string", "minLength": 1, "maxLength": 320 },
"password": { "type": "string", "minLength": 1 }, "password": { "type": "string", "minLength": 1 },
"device_name": { "type": "string", "minLength": 1 }, "device_name": { "type": "string", "minLength": 1, "maxLength": 128 },
"client_version": { "type": "string" }, "client_version": { "type": "string", "maxLength": 256 },
"vault_id": { "type": "string", "minLength": 1 } "vault_id": { "type": "string", "minLength": 1, "maxLength": 256 }
}, },
"additionalProperties": false "additionalProperties": false
}, },

View File

@ -66,6 +66,9 @@ describe('VerstakPluginAPI contract', () => {
expect(defs.Operation.properties.op_type.enum).toContain('restore'); expect(defs.Operation.properties.op_type.enum).toContain('restore');
expect(defs.WorkspacePayload.required).toEqual(['workspaceId', 'path', 'name']); expect(defs.WorkspacePayload.required).toEqual(['workspaceId', 'path', 'name']);
expect(defs.Snapshot.properties.unresolved).toBeDefined(); expect(defs.Snapshot.properties.unresolved).toBeDefined();
expect(defs.FilePayload.properties.blob.$ref).toBe('#/$defs/BlobReference');
expect(defs.FilePayload.properties.dataBase64).toBeUndefined();
expect(defs.PullResponse.required).toContain('has_more');
}); });
test('manifest schema accepts files permissions used by platform-test', () => { test('manifest schema accepts files permissions used by platform-test', () => {

View File

@ -55,6 +55,62 @@ export interface SyncConfig {
participate?: boolean; participate?: boolean;
} }
// ─── Core sync wire contract ────────────────────────────────
/** A content-addressed binary payload. Blob bytes are uploaded before the
* operation that references them and are never base64-embedded in the log. */
export interface SyncBlobReference {
sha256: string;
size: number;
}
export interface SyncFilePayload {
path: string;
content?: string;
blob?: SyncBlobReference;
contentHash?: string;
fromPath?: string;
toPath?: string;
}
export interface SyncOperation {
op_id: string;
server_sequence?: number;
device_id?: string;
entity_type: 'file' | 'folder' | 'workspace';
entity_id: string;
op_type: 'create' | 'update' | 'delete' | 'move' | 'rename' | 'trash' | 'restore';
payload_json: string;
created_at: string;
client_sequence?: number;
last_seen_server_seq?: number;
}
export interface SyncPushRequest {
device_id: string;
idempotency_key?: string;
ops: Omit<SyncOperation, 'server_sequence' | 'device_id'>[];
}
export interface SyncPullRequest {
since_sequence: number;
page_limit?: number;
}
export interface SyncPullResponse {
server_sequence: number;
page_last_sequence: number;
has_more: boolean;
ops: SyncOperation[];
}
/** Stable public errors. UI must map `code` to localized copy rather than
* displaying server diagnostics. */
export interface SyncServerError {
error: string;
code: string;
}
// ─── Capabilities ──────────────────────────────────────────── // ─── Capabilities ────────────────────────────────────────────
export type CapabilityName = string; export type CapabilityName = string;