@fabrika/platform 0.3.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/platform",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -9,6 +9,18 @@
9
9
  // Runtime-specific implementations live with the workers that use them, or in @fabrika/platform-node.
10
10
 
11
11
  export { SqlDeployLocks, type SqlDeployLocksOptions } from './deploy-locks-sql'
12
- export type { AssetServer, BlobStore, DeployLocks, HttpService, JobQueue, WaitUntil } from './ports'
12
+ export { ObjectExistsError, ObjectTooLargeError } from './ports'
13
+ export type {
14
+ AssetServer,
15
+ BlobStore,
16
+ DeployLocks,
17
+ FileObjectStore,
18
+ HttpService,
19
+ JobQueue,
20
+ ObjectMetadata,
21
+ ObjectWriteOptions,
22
+ StoredObject,
23
+ WaitUntil,
24
+ } from './ports'
13
25
  export type { SqlDatabase, SqlQueryResult, SqlRunResult, SqlStatement } from './sql'
14
26
  export { uuidv7 } from './uuid'
package/src/ports.ts CHANGED
@@ -16,6 +16,65 @@ export interface BlobStore {
16
16
  delete(key: string): Promise<void>
17
17
  }
18
18
 
19
+ /** What the store recorded for an object. `contentType` is what the writer declared, or null. */
20
+ export interface ObjectMetadata {
21
+ readonly size: number
22
+ readonly contentType: string | null
23
+ }
24
+
25
+ export interface StoredObject extends ObjectMetadata {
26
+ /** One stream, consumable once. */
27
+ readonly body: ReadableStream<Uint8Array>
28
+ /** These exact bytes' version, from the same answer as the body: an S3 ETag. It changes when the object is replaced. */
29
+ readonly version: string
30
+ }
31
+
32
+ export interface ObjectWriteOptions {
33
+ readonly contentType: string
34
+ /** The most bytes the object may have. The write stops reading `body` once more arrive. */
35
+ readonly maxBytes: number
36
+ }
37
+
38
+ /** A write named a key that already holds an object: a file's object is written once. Read by `name`. */
39
+ export class ObjectExistsError extends Error {
40
+ override readonly name = 'ObjectExistsError'
41
+
42
+ constructor() {
43
+ super('an object is already stored under this key')
44
+ }
45
+ }
46
+
47
+ /** The body of a write was longer than its `maxBytes`. Read by `name`, never by a cross-package `instanceof`. */
48
+ export class ObjectTooLargeError extends Error {
49
+ override readonly name = 'ObjectTooLargeError'
50
+ readonly maxBytes: number
51
+
52
+ constructor(maxBytes: number) {
53
+ super(`the object is larger than ${maxBytes} bytes`)
54
+ this.maxBytes = maxBytes
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The objects of an application's file collections (S3/MinIO). Separate from `BlobStore` because its callers
60
+ * need a size bound enforced while the bytes stream and the metadata read back; an R2 adapter that ignored
61
+ * either would silently drop a limit.
62
+ */
63
+ export interface FileObjectStore {
64
+ /**
65
+ * Streams `body` to a new `key`. A key that already holds an object is refused with `ObjectExistsError`, and nothing
66
+ * is written. Past `options.maxBytes` it stops reading, keeps nothing under `key` and rejects with
67
+ * `ObjectTooLargeError`. Any other failure also leaves nothing stored.
68
+ */
69
+ write(key: string, body: ReadableStream<Uint8Array>, options: ObjectWriteOptions): Promise<ObjectMetadata>
70
+ /** The object with its metadata and version, in one answer, or `null` for a missing key. `signal` stops it. */
71
+ read(key: string, signal?: AbortSignal): Promise<StoredObject | null>
72
+ /** The object only while it is still `version`, in one answer; `null` when it is missing or was replaced. */
73
+ readVersion(key: string, version: string, signal?: AbortSignal): Promise<StoredObject | null>
74
+ /** Idempotent: a missing key is not an error. */
75
+ delete(key: string): Promise<void>
76
+ }
77
+
19
78
  /** Enqueue a deploy job. The CONSUMER side is platform-specific and deliberately not modelled here. */
20
79
  export interface JobQueue<T> {
21
80
  send(message: T, options?: { delaySeconds?: number }): Promise<void>
package/src/testing.ts CHANGED
@@ -1,4 +1,13 @@
1
- import type { BlobStore, JobQueue } from './ports'
1
+ import {
2
+ type BlobStore,
3
+ type FileObjectStore,
4
+ type JobQueue,
5
+ ObjectExistsError,
6
+ type ObjectMetadata,
7
+ ObjectTooLargeError,
8
+ type ObjectWriteOptions,
9
+ type StoredObject,
10
+ } from './ports'
2
11
  import type { SqlDatabase } from './sql'
3
12
 
4
13
  /** A blob store that keeps string values in memory. */
@@ -23,6 +32,65 @@ export class MemoryBlobs implements BlobStore {
23
32
  }
24
33
  }
25
34
 
35
+ /** A file object store in memory, with the port's size bound enforced while the body streams. */
36
+ export class MemoryFileObjects implements FileObjectStore {
37
+ readonly objects = new Map<string, { readonly bytes: Uint8Array<ArrayBuffer>; readonly contentType: string; readonly version: string }>()
38
+ #writes = 0
39
+
40
+ async write(key: string, body: ReadableStream<Uint8Array>, options: ObjectWriteOptions): Promise<ObjectMetadata> {
41
+ const reader = body.getReader()
42
+ if (this.objects.has(key)) {
43
+ await reader.cancel()
44
+ throw new ObjectExistsError()
45
+ }
46
+ const chunks: Uint8Array[] = []
47
+ let size = 0
48
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
49
+ size += next.value.byteLength
50
+ if (size > options.maxBytes) {
51
+ await reader.cancel()
52
+ throw new ObjectTooLargeError(options.maxBytes)
53
+ }
54
+ chunks.push(next.value)
55
+ }
56
+ const bytes = new Uint8Array(size)
57
+ let offset = 0
58
+ for (const chunk of chunks) {
59
+ bytes.set(chunk, offset)
60
+ offset += chunk.byteLength
61
+ }
62
+ this.replace(key, bytes, options.contentType)
63
+ return { size, contentType: options.contentType }
64
+ }
65
+
66
+ /** Puts `bytes` under `key` whatever it held, under a new version: what a write outside the upload path does. */
67
+ replace(key: string, bytes: Uint8Array<ArrayBuffer>, contentType = 'application/octet-stream'): void {
68
+ this.#writes++
69
+ this.objects.set(key, { bytes, contentType, version: `"memory-${this.#writes}"` })
70
+ }
71
+
72
+ read(key: string): Promise<StoredObject | null> {
73
+ const object = this.objects.get(key)
74
+ if (object === undefined) return Promise.resolve(null)
75
+ return Promise.resolve({
76
+ size: object.bytes.byteLength,
77
+ contentType: object.contentType,
78
+ version: object.version,
79
+ body: new Blob([object.bytes]).stream(),
80
+ })
81
+ }
82
+
83
+ async readVersion(key: string, version: string): Promise<StoredObject | null> {
84
+ const object = await this.read(key)
85
+ return object?.version === version ? object : null
86
+ }
87
+
88
+ delete(key: string): Promise<void> {
89
+ this.objects.delete(key)
90
+ return Promise.resolve()
91
+ }
92
+ }
93
+
26
94
  /** A queue that records every sent message. */
27
95
  export class MemoryQueue<T> implements JobQueue<T> {
28
96
  readonly messages: T[] = []