@sylphx/contract 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,174 +1,193 @@
1
1
  /**
2
- * Storage — BaaS plane file upload / download. Uses presigned URLs per AWS
3
- * S3 convention. Mirrors `@sylphx/sdk/storage.ts`.
2
+ * Storage — BaaS plane object storage. Per ADR-100.
4
3
  *
5
- * Shape policy (ADR-084 Wave 2d): schemas here are the UNION superset of
6
- * all production SDK / OpenAPI-generated wire shapes. Any field that is not
7
- * universally present on the wire is `optional`, so contract consumers
8
- * never have to narrow to parse a legitimate server response. When the
9
- * server response narrows in future, tighten the optional in a dedicated
10
- * wave never break existing callers by removing a field.
4
+ * Wire is one resource (`uploads`) plus the existing `files` resource. No
5
+ * polymorphic POSTs, no vendor-namespaced wire envelopes, no historical
6
+ * field aliases. Every endpoint has exactly one request and one response
7
+ * schema.
8
+ *
9
+ * Storage backends (B2, R2) are abstracted by `apps/storage-gateway`. The
10
+ * BaaS handler returns presigned URLs against `storage.sylphx.com`; the
11
+ * client uploads bytes there directly. Mid-flow tenant subdomain hops are
12
+ * forbidden (ADR-083.1).
11
13
  */
12
14
  import { Schema } from 'effect';
15
+ // ============================================================================
16
+ // Branded identifiers
17
+ // ============================================================================
13
18
  export const FileId = Schema.String.pipe(Schema.brand('FileId'));
19
+ export const UploadId = Schema.String.pipe(Schema.brand('UploadId'));
20
+ export const FileVersionId = Schema.String.pipe(Schema.brand('FileVersionId'));
21
+ // ============================================================================
22
+ // Enumerations
23
+ // ============================================================================
24
+ export const FileVisibility = Schema.Literal('public', 'private');
25
+ export const SignedUrlDisposition = Schema.Literal('attachment', 'inline');
26
+ /** Method discriminator on the upload-create response. */
27
+ export const UploadMethod = Schema.Literal('PUT', 'MULTIPART');
28
+ // ============================================================================
29
+ // Core types
30
+ // ============================================================================
14
31
  /**
15
- * File metadata supersetreconciles three historical shapes:
16
- *
17
- * - SDK `FileInfo` interface: `{ id, url, name, size, contentType,
18
- * isPrivate, createdAt }`
19
- * - Generated OpenAPI `FileInfo` / `GetFileResponse`: `{ id, filename,
20
- * path, url, mimeType, size, metadata, createdAt }`
21
- * - Previous contract shape: `{ id, key, size, contentType, url?,
22
- * uploadedAt }`
23
- *
24
- * Every historical field is preserved as optional so older and newer
25
- * clients can both parse server responses. `id` and `size` are the only
26
- * guaranteed-present fields across all three shapes.
32
+ * Canonical file metadata. Single shape no aliases, no nullable hacks
33
+ * for legacy callers. ADR-100 §2.6.
27
34
  */
28
- export const FileInfo = Schema.Struct({
29
- id: Schema.String,
35
+ export const File = Schema.Struct({
36
+ id: FileId,
37
+ filename: Schema.String,
38
+ contentType: Schema.String,
30
39
  size: Schema.Number,
31
- /** SDK-native filename (preferred). Alias of `filename` on newer servers. */
32
- name: Schema.optional(Schema.String),
33
- /** OpenAPI-native filename. Alias of `name` on older servers. */
34
- filename: Schema.optional(Schema.String),
35
- /** Folder path or storage key. */
36
- path: Schema.optional(Schema.String),
37
- /** Legacy storage object key (contract v1). */
38
- key: Schema.optional(Schema.String),
39
- /** Public URL — null for private files, absent on some delete responses. */
40
- url: Schema.optional(Schema.NullOr(Schema.String)),
41
- /** SDK-native MIME type. */
42
- contentType: Schema.optional(Schema.String),
43
- /** OpenAPI-native MIME type (alias of `contentType`). */
44
- mimeType: Schema.optional(Schema.String),
45
- /** Private/public visibility flag. Absent => treat as public. */
46
- isPrivate: Schema.optional(Schema.Boolean),
47
- /** Arbitrary caller-attached metadata. */
48
- metadata: Schema.optional(Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown }))),
49
- /** SDK-native creation timestamp (ISO string). */
50
- createdAt: Schema.optional(Schema.String),
51
- /** Legacy contract v1 upload timestamp (alias of createdAt). */
52
- uploadedAt: Schema.optional(Schema.String),
40
+ checksumSha256: Schema.String,
41
+ etag: Schema.String,
42
+ visibility: FileVisibility,
43
+ folder: Schema.NullOr(Schema.String),
44
+ metadata: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
45
+ /** Public URL when `visibility === 'public'`; `null` otherwise. Use `:signedUrl` for private files. */
46
+ url: Schema.NullOr(Schema.String),
47
+ isDeleted: Schema.Boolean,
48
+ createdAt: Schema.DateFromString,
49
+ updatedAt: Schema.DateFromString,
50
+ });
51
+ export const FileVersion = Schema.Struct({
52
+ id: FileVersionId,
53
+ fileId: FileId,
54
+ versionNumber: Schema.Number,
55
+ size: Schema.Number,
56
+ contentType: Schema.String,
57
+ checksumSha256: Schema.String,
58
+ etag: Schema.String,
59
+ createdAt: Schema.DateFromString,
60
+ createdBy: Schema.NullOr(Schema.String),
61
+ isCurrent: Schema.Boolean,
62
+ });
63
+ // ============================================================================
64
+ // Upload session — POST /v1/storage/uploads
65
+ // ============================================================================
66
+ export const UploadCreateRequest = Schema.Struct({
67
+ filename: Schema.String.pipe(Schema.minLength(1)),
68
+ contentType: Schema.String.pipe(Schema.minLength(1)),
69
+ size: Schema.Number.pipe(Schema.greaterThanOrEqualTo(0)),
70
+ folder: Schema.optional(Schema.String),
71
+ visibility: Schema.optional(FileVisibility),
72
+ metadata: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
73
+ /** Pre-computed SHA-256 (hex); verified server-side on `:complete` (multipart) or after PUT (single). */
74
+ checksumSha256: Schema.optional(Schema.String),
75
+ /** S3-style precondition; if `'*'`, fail when a file already exists at (folder, filename). */
76
+ ifNoneMatch: Schema.optional(Schema.Literal('*')),
77
+ });
78
+ export const UploadPart = Schema.Struct({
79
+ partNumber: Schema.Number.pipe(Schema.greaterThanOrEqualTo(1)),
80
+ url: Schema.String,
81
+ expiresAt: Schema.DateFromString,
53
82
  });
54
83
  /**
55
- * Input for the richer `/storage/upload` token flow used by the SDK for
56
- * client-direct uploads (Vercel Blob pattern). Server mints an upload
57
- * endpoint + client payload, and the SDK performs the actual PUT against
58
- * storage. Superset of `UploadUrlInput`.
84
+ * Single-part response the request body fits in one PUT. Client uploads
85
+ * the whole blob to `url` with the supplied `headers`, captures the `ETag`
86
+ * response header, then calls `:complete` with `parts: [{partNumber: 1,
87
+ * etag}]` to finalise. The `:complete` step is non-optional even for
88
+ * single-part uploads because it is what writes the file row to the
89
+ * database and consumes quota — without it the storage object is
90
+ * orphaned and reclaimed by the multipart-aborts sweeper after
91
+ * `expiresAt`.
59
92
  */
60
- export const UploadTokenInput = Schema.Struct({
61
- filename: Schema.String,
62
- contentType: Schema.optional(Schema.String),
63
- size: Schema.optional(Schema.Number),
64
- /** Folder path for organizing the file. */
65
- path: Schema.optional(Schema.String),
66
- /** `file` | `avatar` — avatars apply user-scoped policy. */
67
- type: Schema.optional(Schema.Literal('file', 'avatar')),
68
- /** User ID (required for `type: 'avatar'`). */
69
- userId: Schema.optional(Schema.String),
70
- /** Arbitrary caller-attached metadata persisted alongside the blob. */
71
- metadata: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
93
+ export const UploadCreateSinglePartResult = Schema.Struct({
94
+ method: Schema.Literal('PUT'),
95
+ uploadId: UploadId,
96
+ fileId: FileId,
97
+ url: Schema.String,
98
+ headers: Schema.Record({ key: Schema.String, value: Schema.String }),
99
+ expiresAt: Schema.DateFromString,
72
100
  });
73
101
  /**
74
- * Upload token response. Supports both the simple `{ uploadUrl, publicUrl,
75
- * fileId, expiresAt }` shape and the richer Vercel-Blob-style
76
- * `{ uploadEndpoint, clientPayload, instructions, ... }` shape.
102
+ * Multipart response client PUTs each part to its presigned URL, then
103
+ * calls `POST /uploads/{uploadId}:complete` with the per-part etags.
77
104
  */
78
- export const UploadTokenResult = Schema.Struct({
79
- // Simple flow
80
- uploadUrl: Schema.optional(Schema.String),
81
- publicUrl: Schema.optional(Schema.String),
82
- fileId: Schema.optional(Schema.String),
83
- expiresAt: Schema.optional(Schema.String),
84
- // Presigned S3 flow returned by the Effect-native upload handler
85
- presignedUrl: Schema.optional(Schema.String),
86
- storageKey: Schema.optional(Schema.String),
87
- tokenPayload: Schema.optional(Schema.String),
88
- url: Schema.optional(Schema.String),
89
- // Vercel Blob client-upload flow
90
- uploadEndpoint: Schema.optional(Schema.String),
91
- clientPayload: Schema.optional(Schema.String),
92
- maxSize: Schema.optional(Schema.Number),
93
- maxMultipartSize: Schema.optional(Schema.Number),
94
- multipartThreshold: Schema.optional(Schema.Number),
95
- allowedContentTypes: Schema.optional(Schema.Array(Schema.String)),
96
- instructions: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
97
- });
98
- export const UploadUrlInput = Schema.Struct({
99
- filename: Schema.String,
100
- contentType: Schema.String,
101
- size: Schema.optional(Schema.Number),
105
+ export const UploadCreateMultipartResult = Schema.Struct({
106
+ method: Schema.Literal('MULTIPART'),
107
+ uploadId: UploadId,
108
+ fileId: FileId,
109
+ partSize: Schema.Number,
110
+ partCount: Schema.Number,
111
+ parts: Schema.Array(UploadPart),
112
+ expiresAt: Schema.DateFromString,
113
+ });
114
+ /**
115
+ * Discriminated union on `method`. SDK narrows via the literal; downstream
116
+ * code never has to inspect optional fields.
117
+ */
118
+ export const UploadCreateResult = Schema.Union(UploadCreateSinglePartResult, UploadCreateMultipartResult);
119
+ // ============================================================================
120
+ // Multipart support
121
+ // ============================================================================
122
+ /** Refresh a single part presigned URL — used when an upload pauses past `expiresAt`. */
123
+ export const UploadPartPresignResult = Schema.Struct({
124
+ url: Schema.String,
125
+ expiresAt: Schema.DateFromString,
126
+ });
127
+ export const UploadCompletePart = Schema.Struct({
128
+ partNumber: Schema.Number.pipe(Schema.greaterThanOrEqualTo(1)),
129
+ etag: Schema.String.pipe(Schema.minLength(1)),
102
130
  });
103
- export const UploadUrlResult = Schema.Struct({
104
- uploadUrl: Schema.String,
105
- fileId: Schema.String,
106
- expiresAt: Schema.String,
131
+ export const UploadCompleteRequest = Schema.Struct({
132
+ parts: Schema.Array(UploadCompletePart),
133
+ });
134
+ export const UploadCompleteResult = Schema.Struct({
135
+ fileId: FileId,
136
+ url: Schema.NullOr(Schema.String),
137
+ size: Schema.Number,
138
+ etag: Schema.String,
139
+ checksumSha256: Schema.String,
107
140
  });
141
+ // ============================================================================
142
+ // Files resource
143
+ // ============================================================================
108
144
  export const ListFilesQuery = Schema.Struct({
109
- prefix: Schema.optional(Schema.String),
110
- limit: Schema.optional(Schema.String),
145
+ folder: Schema.optional(Schema.String),
111
146
  cursor: Schema.optional(Schema.String),
147
+ /** Page size; clamped server-side to [1, 100]; default 25. */
148
+ limit: Schema.optional(Schema.Number),
149
+ includeDeleted: Schema.optional(Schema.Boolean),
112
150
  });
113
151
  export const ListFilesResult = Schema.Struct({
114
- files: Schema.Array(FileInfo),
152
+ files: Schema.Array(File),
115
153
  nextCursor: Schema.NullOr(Schema.String),
116
- /** True if there are more files beyond this page. */
117
- hasMore: Schema.optional(Schema.Boolean),
118
154
  });
119
- export const DeleteFileResult = Schema.Struct({ success: Schema.Boolean });
120
- /**
121
- * Signed download URL input. Returns a short-lived URL for private files
122
- * without exposing permanent credentials.
123
- */
124
- export const SignedUrlInput = Schema.Struct({
125
- fileId: Schema.String,
126
- /** Seconds until the URL expires (default: 3600, max: 604800 = 7 days). */
127
- expiresIn: Schema.optional(Schema.Number),
128
- /** `attachment` forces download, `inline` displays in browser. */
129
- disposition: Schema.optional(Schema.Literal('attachment', 'inline')),
130
- /** Restrict URL access to a specific user. */
155
+ export const SoftDeleteFileResult = Schema.Struct({
156
+ id: FileId,
157
+ isDeleted: Schema.Literal(true),
158
+ });
159
+ export const RestoreFileResult = File;
160
+ // ============================================================================
161
+ // Signed URLs
162
+ // ============================================================================
163
+ export const SignedUrlRequest = Schema.Struct({
164
+ expiresIn: Schema.optional(Schema.Number.pipe(Schema.greaterThanOrEqualTo(1))),
165
+ disposition: Schema.optional(SignedUrlDisposition),
166
+ /** Restrict the signed URL to a specific authenticated user (claim baked into the signature). */
131
167
  userId: Schema.optional(Schema.String),
132
168
  });
133
169
  export const SignedUrlResult = Schema.Struct({
134
170
  url: Schema.String,
135
- expiresAt: Schema.String,
136
- file: Schema.Struct({
137
- id: Schema.String,
138
- filename: Schema.String,
139
- mimeType: Schema.String,
140
- sizeBytes: Schema.Number,
141
- isPrivate: Schema.Boolean,
142
- }),
171
+ expiresAt: Schema.DateFromString,
172
+ file: File,
143
173
  });
144
174
  // ============================================================================
145
- // ADR-089 Phase 5.8 — Versioning
175
+ // Copy
146
176
  // ============================================================================
147
- /**
148
- * A single version of a file. Append-only per-file history — every
149
- * upload creates one row with a monotonic `versionNumber`. Restore
150
- * creates a NEW version row pointing at the old object (never mutates
151
- * history).
152
- */
153
- export const StorageFileVersion = Schema.Struct({
154
- id: Schema.String,
155
- fileId: Schema.String,
156
- versionNumber: Schema.Number,
157
- sizeBytes: Schema.Number,
158
- contentType: Schema.NullOr(Schema.String),
159
- checksumSha256: Schema.NullOr(Schema.String),
160
- createdAt: Schema.String,
161
- createdBy: Schema.NullOr(Schema.String),
162
- isCurrent: Schema.Boolean,
177
+ export const CopyFileRequest = Schema.Struct({
178
+ folder: Schema.optional(Schema.String),
179
+ filename: Schema.optional(Schema.String),
180
+ visibility: Schema.optional(FileVisibility),
181
+ metadata: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
163
182
  });
183
+ export const CopyFileResult = File;
184
+ // ============================================================================
185
+ // Versions
186
+ // ============================================================================
164
187
  export const ListFileVersionsResult = Schema.Struct({
165
- versions: Schema.Array(StorageFileVersion),
188
+ versions: Schema.Array(FileVersion),
166
189
  });
167
190
  export const RestoreVersionResult = Schema.Struct({
168
- success: Schema.Literal(true),
169
- version: StorageFileVersion,
170
- });
171
- export const RestoreFileResult = Schema.Struct({
172
- success: Schema.Literal(true),
173
- file: FileInfo,
191
+ file: File,
192
+ version: FileVersion,
174
193
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/contract",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sylphx Platform contract — Effect Schema SSOT for every API endpoint (ADR-084).",
5
5
  "type": "module",
6
6
  "sideEffects": false,