Define paginated blob sync contract
This commit is contained in:
parent
7ad07321b0
commit
2b2936121d
20
README.md
20
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
56
src/types.ts
56
src/types.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue