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.
- File and folder operations are `create`, `update`, `delete`, or `move`.
File payloads carry a vault-relative path, a SHA-256 content hash, and the
existing bounded text/base64 representation when the file is supported.
Small UTF-8 text may be inline; binary and large files carry a `blob`
`{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
`create`, `rename`, `trash`, and `restore`. Their payload carries the durable
`workspaceId`; `.verstak/workspace.json` remains unavailable to plugins and
is not ordinary file sync data.
- A pairing may name an existing remote `vaultId`. Omitting it creates/uses the
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.
Files larger than the current 8 MB bounded transport or otherwise
unsupported are not marked synchronized and are retried on later scans.
A file over the configured blob limit or otherwise unsupported is not marked
synchronized and is retried on later scans.
The snapshot stored by core is implementation state, not a plugin API. It
excludes `.verstak`, trash, temporary files, and symlinks. Blob transport,
quotas, pagination, and retention are deliberately outside this contract and
remain a later milestone.
excludes `.verstak`, trash, temporary files, and symlinks. Blob ownership,
quotas and pagination are part of the current wire contract. Operation-log
retention remains a future checkpoint milestone: deleting it now could prevent
a newly paired device from reconstructing a vault.
## Bundled Frontend API Contract

47
dist/types.d.ts vendored
View File

@ -43,6 +43,53 @@ export interface SyncConfig {
namespaces?: string[];
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 interface CapabilityEntry {
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": {
"Operation": { "$ref": "#/$defs/Operation" },
"PushRequest": { "$ref": "#/$defs/PushRequest" },
"PullRequest": { "$ref": "#/$defs/PullRequest" },
"PullResponse": { "$ref": "#/$defs/PullResponse" },
"BlobReference": { "$ref": "#/$defs/BlobReference" },
"Snapshot": { "$ref": "#/$defs/Snapshot" },
"PairingRequest": { "$ref": "#/$defs/PairingRequest" }
},
@ -16,13 +18,13 @@
"type": "object",
"required": ["op_id", "device_id", "entity_type", "entity_id", "op_type", "payload_json", "created_at"],
"properties": {
"op_id": { "type": "string", "minLength": 1 },
"op_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"server_sequence": { "type": "integer", "minimum": 1 },
"device_id": { "type": "string" },
"device_id": { "type": "string", "maxLength": 128 },
"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"] },
"payload_json": { "type": "string" },
"payload_json": { "type": "string", "maxLength": 262144 },
"created_at": { "type": "string", "format": "date-time" },
"client_sequence": { "type": "integer", "minimum": 0 },
"last_seen_server_seq": { "type": "integer", "minimum": 0 }
@ -34,14 +36,23 @@
"required": ["path"],
"properties": {
"path": { "$ref": "#/$defs/VaultPath" },
"content": { "type": "string" },
"dataBase64": { "type": "string", "contentEncoding": "base64" },
"content": { "type": "string", "description": "Bounded inline UTF-8 text only." },
"blob": { "$ref": "#/$defs/BlobReference" },
"contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"fromPath": { "$ref": "#/$defs/VaultPath" },
"toPath": { "$ref": "#/$defs/VaultPath" }
},
"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": {
"type": "object",
"required": ["workspaceId", "path", "name"],
@ -58,17 +69,28 @@
"type": "object",
"required": ["device_id", "ops"],
"properties": {
"device_id": { "type": "string", "minLength": 1 },
"idempotency_key": { "type": "string" },
"ops": { "type": "array", "items": { "$ref": "#/$defs/PushOperation" } }
"device_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"idempotency_key": { "type": "string", "maxLength": 128 },
"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
},
"PullResponse": {
"type": "object",
"required": ["server_sequence", "ops"],
"required": ["server_sequence", "page_last_sequence", "has_more", "ops"],
"properties": {
"server_sequence": { "type": "integer", "minimum": 0 },
"page_last_sequence": { "type": "integer", "minimum": 0 },
"has_more": { "type": "boolean" },
"ops": { "type": "array", "items": { "$ref": "#/$defs/Operation" } }
},
"additionalProperties": false
@ -77,11 +99,11 @@
"type": "object",
"required": ["op_id", "entity_type", "entity_id", "op_type", "payload_json", "created_at"],
"properties": {
"op_id": { "type": "string", "minLength": 1 },
"op_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"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"] },
"payload_json": { "type": "string" },
"payload_json": { "type": "string", "maxLength": 262144 },
"created_at": { "type": "string", "format": "date-time" },
"client_sequence": { "type": "integer", "minimum": 0 },
"last_seen_server_seq": { "type": "integer", "minimum": 0 }
@ -114,11 +136,11 @@
"type": "object",
"required": ["login", "password", "device_name", "vault_id"],
"properties": {
"login": { "type": "string", "minLength": 1 },
"login": { "type": "string", "minLength": 1, "maxLength": 320 },
"password": { "type": "string", "minLength": 1 },
"device_name": { "type": "string", "minLength": 1 },
"client_version": { "type": "string" },
"vault_id": { "type": "string", "minLength": 1 }
"device_name": { "type": "string", "minLength": 1, "maxLength": 128 },
"client_version": { "type": "string", "maxLength": 256 },
"vault_id": { "type": "string", "minLength": 1, "maxLength": 256 }
},
"additionalProperties": false
},

View File

@ -66,6 +66,9 @@ describe('VerstakPluginAPI contract', () => {
expect(defs.Operation.properties.op_type.enum).toContain('restore');
expect(defs.WorkspacePayload.required).toEqual(['workspaceId', 'path', 'name']);
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', () => {

View File

@ -55,6 +55,62 @@ export interface SyncConfig {
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 ────────────────────────────────────────────
export type CapabilityName = string;