lecodes-sdk 2.0.6 → 2.0.8
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/dist/global.d.ts +4 -0
- package/dist/types/inject.d.ts +1 -0
- package/dist/types/runtime/rpc.d.ts +2 -0
- package/dist/types/server/context.d.ts +4 -0
- package/dist/types/server/db/defineDb.d.ts +1 -1
- package/dist/types/server/db/fields.d.ts +25 -1
- package/dist/types/server/db/marci/query.d.ts +39 -2
- package/dist/types/server/db/types.d.ts +13 -7
- package/dist/types/server/files/db.d.ts +39 -0
- package/dist/types/server/files/models.d.ts +131 -0
- package/dist/types/server/inject.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +3 -2
- package/src/compile/libraryImports.ts +9 -0
- package/src/compile/serverTypes.ts +6 -0
- package/src/inject.ts +1 -0
- package/src/runtime/rpc.ts +15 -3
- package/src/server/context.ts +4 -0
- package/src/server/db/defineDb.ts +62 -9
- package/src/server/db/fields.ts +21 -1
- package/src/server/db/httpTransport.ts +9 -1
- package/src/server/db/marci/query.ts +71 -4
- package/src/server/db/types.ts +16 -5
- package/src/server/files/db.ts +182 -0
- package/src/server/files/host.ts +496 -0
- package/src/server/files/models.ts +116 -0
- package/src/server/host.ts +17 -4
- package/src/server/inject.ts +1 -0
- package/src/server/runtime.ts +41 -0
- package/src/server/validate.ts +5 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOST's half of stored files (./models.ts) — the same code on the runner and under `lecodes dev`.
|
|
3
|
+
* Never bundled into project code.
|
|
4
|
+
*
|
|
5
|
+
* The bytes of a file are one file on disk, `<dir>/<id>`, named by its `File` row. Three steps:
|
|
6
|
+
*
|
|
7
|
+
* receiveUploads the process that owns the socket reads a multipart endpoint call: the files go to
|
|
8
|
+
* `<dir>/tmp/<id>`, what is known of them travels to the worker in the request
|
|
9
|
+
* context (`uploads`), the arguments keep `{ "$file": <index> }` in their places
|
|
10
|
+
* createFilesHost the worker, around every endpoint call: `adopt` gives each upload its `File` row
|
|
11
|
+
* (ownerless) and moves the bytes into place — the endpoint gets a `File` it can
|
|
12
|
+
* write to a `t.file()` field; `finish` deletes the uploads the endpoint attached
|
|
13
|
+
* to nothing, then removes the bytes of every row the database let go of
|
|
14
|
+
* fileResponse `GET <base>/files/<id>/<name>`
|
|
15
|
+
*
|
|
16
|
+
* An upload written to a `t.image()` field is rewritten before its row is attached (`seam.image`):
|
|
17
|
+
* turned as its Exif says, its metadata dropped, no larger than the field's `max`, WebP. The codec is
|
|
18
|
+
* sharp, which the host process brings (`FilesHostConfig.sharp`) — this package does not depend on it.
|
|
19
|
+
* sharp's own builds read no HEIC (an iPhone's photo as it is stored): a host that also brings
|
|
20
|
+
* `heic-decode` (libheif as wasm, `FilesHostConfig.heic`) has those decoded by it, and sharp is handed
|
|
21
|
+
* the pixels.
|
|
22
|
+
*
|
|
23
|
+
* One rule decides when bytes are removed: the `File` row was deleted. However it went — a field was
|
|
24
|
+
* given another file, its row was deleted, a cascade took it — marcidb's journal of the model says so
|
|
25
|
+
* (`$journal`, deletes), and `finish` reads it. Rows are the truth; a start reconciles the folder with
|
|
26
|
+
* them once, for the case where the database itself was replaced under the files (a reset, a schema
|
|
27
|
+
* that could not be migrated).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createReadStream } from "node:fs"
|
|
31
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
32
|
+
import { join } from "node:path"
|
|
33
|
+
import { Readable } from "node:stream"
|
|
34
|
+
import { currentRequest, type RequestContext } from "../context"
|
|
35
|
+
import type { MarciOp, MarciTransport } from "../db/marci/query"
|
|
36
|
+
import type { Db } from "../db/types"
|
|
37
|
+
import { FILE_MODEL, setFilesSeam, type FilesSeam, type ImageFacts, type ImageRule } from "./models"
|
|
38
|
+
|
|
39
|
+
/** What the receiving process knows of one uploaded file; its bytes are in `<dir>/tmp/<id>`. */
|
|
40
|
+
export type Upload = { id: string, name: string, type: string, size: number, width?: number, height?: number }
|
|
41
|
+
|
|
42
|
+
export type UploadLimits = {
|
|
43
|
+
/** One file, bytes (default 10 MB). */
|
|
44
|
+
maxFile?: number
|
|
45
|
+
/** Files in one call (default 10). */
|
|
46
|
+
maxFiles?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A refused upload: `status` is what the endpoint call answers with. */
|
|
50
|
+
export class UploadError extends Error {
|
|
51
|
+
readonly status: number
|
|
52
|
+
constructor(status: number, message: string) {
|
|
53
|
+
super(message)
|
|
54
|
+
// read by the invoker the way a project's own ApiError is
|
|
55
|
+
this.name = "ApiError"
|
|
56
|
+
this.status = status
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
61
|
+
const JOURNAL = "lecodes-files"
|
|
62
|
+
const MB = 1024 * 1024
|
|
63
|
+
const TMP_MAX_AGE_MS = 60 * 60 * 1000
|
|
64
|
+
/** The most pixels a picture may have to be decoded at all: a small file can claim a huge frame. */
|
|
65
|
+
const MAX_IMAGE_PIXELS = 50_000_000
|
|
66
|
+
const WEBP_QUALITY = 80
|
|
67
|
+
|
|
68
|
+
// ───────────────────────────── what a file is ─────────────────────────────
|
|
69
|
+
|
|
70
|
+
const ascii = (b: Uint8Array, at: number, text: string) => {
|
|
71
|
+
for (let i = 0; i < text.length; i++) if (b[at + i] !== text.charCodeAt(i)) return false
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The media type the BYTES say (their first ones are enough); null when they say nothing known. */
|
|
76
|
+
export const sniffType = (b: Uint8Array): string | null => {
|
|
77
|
+
if (b.length < 12) return null
|
|
78
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"
|
|
79
|
+
if (b[0] === 0x89 && ascii(b, 1, "PNG")) return "image/png"
|
|
80
|
+
if (ascii(b, 0, "GIF8")) return "image/gif"
|
|
81
|
+
if (ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return "image/webp"
|
|
82
|
+
if (ascii(b, 0, "RIFF") && ascii(b, 8, "WAVE")) return "audio/wav"
|
|
83
|
+
if (ascii(b, 4, "ftyp")) {
|
|
84
|
+
const brand = String.fromCharCode(b[8], b[9], b[10], b[11])
|
|
85
|
+
if (brand === "avif" || brand === "avis") return "image/avif"
|
|
86
|
+
if (brand.startsWith("hei") || brand === "mif1") return "image/heic"
|
|
87
|
+
if (brand === "M4A ") return "audio/mp4"
|
|
88
|
+
if (brand === "qt ") return "video/quicktime"
|
|
89
|
+
return "video/mp4"
|
|
90
|
+
}
|
|
91
|
+
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return "video/webm"
|
|
92
|
+
if (ascii(b, 0, "OggS")) return "audio/ogg"
|
|
93
|
+
if (ascii(b, 0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return "audio/mpeg"
|
|
94
|
+
if (ascii(b, 0, "%PDF")) return "application/pdf"
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Types a browser may be handed to SHOW; anything else is a download (no page runs from this origin). */
|
|
99
|
+
const INLINE = /^(image\/(jpeg|png|gif|webp|avif)|video\/|audio\/|application\/pdf$)/
|
|
100
|
+
|
|
101
|
+
const EXT: Record<string, string> = {
|
|
102
|
+
"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/heic": "heic",
|
|
103
|
+
"video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
|
|
104
|
+
"audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/wav": "wav", "audio/ogg": "ogg", "application/pdf": "pdf",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const u16 = (b: Uint8Array, at: number) => (b[at] << 8) | b[at + 1]
|
|
108
|
+
const u32 = (b: Uint8Array, at: number) => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0
|
|
109
|
+
const le16 = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8)
|
|
110
|
+
const le24 = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The orientation a JPEG's Exif block names (1…8; 1 = as stored), 1 when it names none. A camera
|
|
114
|
+
* stores the sensor's frame and says how to turn it: 5…8 are the quarter turns, where the picture
|
|
115
|
+
* as it is SHOWN is as wide as the stored frame is tall. `at` is the APP1 segment's payload.
|
|
116
|
+
*/
|
|
117
|
+
const exifOrientation = (b: Uint8Array, at: number, end: number): number => {
|
|
118
|
+
if (end - at < 14 || !ascii(b, at, "Exif")) return 1
|
|
119
|
+
const tiff = at + 6
|
|
120
|
+
const little = b[tiff] === 0x49
|
|
121
|
+
const r16 = (i: number) => little ? le16(b, i) : u16(b, i)
|
|
122
|
+
const r32 = (i: number) => little ? (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0 : u32(b, i)
|
|
123
|
+
const ifd = tiff + r32(tiff + 4)
|
|
124
|
+
if (ifd + 2 > end) return 1
|
|
125
|
+
const count = r16(ifd)
|
|
126
|
+
for (let i = 0; i < count; i++) {
|
|
127
|
+
const entry = ifd + 2 + i * 12
|
|
128
|
+
if (entry + 12 > end) break
|
|
129
|
+
if (r16(entry) === 0x0112) { const value = r16(entry + 8); return value >= 1 && value <= 8 ? value : 1 }
|
|
130
|
+
}
|
|
131
|
+
return 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** An image's pixel size AS IT IS SHOWN, read from its header (PNG, GIF, JPEG, WebP); null for anything else. */
|
|
135
|
+
export const imageSize = (b: Uint8Array, type: string | null): { width: number, height: number } | null => {
|
|
136
|
+
if (type === "image/png" && b.length >= 24) return { width: u32(b, 16), height: u32(b, 20) }
|
|
137
|
+
if (type === "image/gif" && b.length >= 10) return { width: le16(b, 6), height: le16(b, 8) }
|
|
138
|
+
if (type === "image/webp" && b.length >= 30) {
|
|
139
|
+
if (ascii(b, 12, "VP8X")) return { width: le24(b, 24) + 1, height: le24(b, 27) + 1 }
|
|
140
|
+
if (ascii(b, 12, "VP8L")) { const v = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24); return { width: (v & 0x3fff) + 1, height: ((v >> 14) & 0x3fff) + 1 } }
|
|
141
|
+
if (ascii(b, 12, "VP8 ")) return { width: le16(b, 26) & 0x3fff, height: le16(b, 28) & 0x3fff }
|
|
142
|
+
}
|
|
143
|
+
if (type === "image/jpeg") {
|
|
144
|
+
// the frame header is the first SOFn marker; the segments before it are skipped by their lengths
|
|
145
|
+
let at = 2
|
|
146
|
+
let orientation = 1
|
|
147
|
+
while (at + 9 < b.length) {
|
|
148
|
+
if (b[at] !== 0xff) return null
|
|
149
|
+
const marker = b[at + 1]
|
|
150
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
151
|
+
const width = u16(b, at + 7), height = u16(b, at + 5)
|
|
152
|
+
// a quarter turn: every renderer shows the picture turned, so this is its size
|
|
153
|
+
return orientation >= 5 ? { width: height, height: width } : { width, height }
|
|
154
|
+
}
|
|
155
|
+
if (marker === 0xe1 && orientation === 1) orientation = exifOrientation(b, at + 4, Math.min(b.length, at + 2 + u16(b, at + 2)))
|
|
156
|
+
at += 2 + u16(b, at + 2)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A name fit for a url's last segment; the extension follows what the bytes are. */
|
|
163
|
+
const cleanName = (raw: string, type: string | null): string => {
|
|
164
|
+
let name = raw.split(/[\\/]/).pop()!.replace(/[\u0000-\u001f\u007f"<>|:*?]/g, "").trim().slice(-120) || "file"
|
|
165
|
+
const ext = type ? EXT[type] : undefined
|
|
166
|
+
if (ext && !new RegExp(`\\.(${ext === "jpg" ? "jpe?g" : ext})$`, "i").test(name)) name = `${name.replace(/\.[A-Za-z0-9]{1,5}$/, "")}.${ext}`
|
|
167
|
+
return name
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ───────────────────────────── pictures ─────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** The part of sharp this file uses (`import sharp from "sharp"` is one). */
|
|
173
|
+
export type Sharp = (input: Uint8Array, options?: { limitInputPixels?: number, raw?: { width: number, height: number, channels: 4 } }) => {
|
|
174
|
+
rotate(): ReturnType<Sharp>
|
|
175
|
+
resize(options: { width: number, height: number, fit: "inside", withoutEnlargement: boolean }): ReturnType<Sharp>
|
|
176
|
+
webp(options?: { quality?: number }): ReturnType<Sharp>
|
|
177
|
+
toBuffer(options: { resolveWithObject: true }): Promise<{ data: Uint8Array, info: { width: number, height: number, size: number } }>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The part of heic-decode this file uses (`import heic from "heic-decode"` is one). */
|
|
181
|
+
export type Heic = {
|
|
182
|
+
all(input: { buffer: Uint8Array }): Promise<{ width: number, height: number, decode(): Promise<{ width: number, height: number, data: Uint8ClampedArray }> }[] & { dispose(): void }>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `bytes` as a `t.image()` field keeps a picture: as it is SHOWN (the Exif turn applied to the
|
|
187
|
+
* pixels), nothing of its metadata, its longer side at most `rule.max`, WebP. Null for a GIF, which
|
|
188
|
+
* stays as it came (its frames). Throws `UploadError` for what is no picture, or one that cannot be read.
|
|
189
|
+
*/
|
|
190
|
+
export const fitImage = async (sharp: Sharp, bytes: Uint8Array, rule: ImageRule, heic?: Heic | null): Promise<{ data: Uint8Array, width: number, height: number } | null> => {
|
|
191
|
+
const type = sniffType(bytes)
|
|
192
|
+
if (!type?.startsWith("image/")) throw new UploadError(400, "a picture is expected")
|
|
193
|
+
if (type === "image/gif") return null
|
|
194
|
+
try {
|
|
195
|
+
let source = sharp(bytes, { limitInputPixels: MAX_IMAGE_PIXELS })
|
|
196
|
+
if (type === "image/heic" && heic) {
|
|
197
|
+
// the file's first picture, as libheif shows it (it applies the turn the file names)
|
|
198
|
+
const pictures = await heic.all({ buffer: bytes })
|
|
199
|
+
try {
|
|
200
|
+
const [first] = pictures
|
|
201
|
+
if (first.width * first.height > MAX_IMAGE_PIXELS) throw new Error("too large")
|
|
202
|
+
const { width, height, data } = await first.decode()
|
|
203
|
+
source = sharp(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), { raw: { width, height, channels: 4 } })
|
|
204
|
+
} finally {
|
|
205
|
+
pictures.dispose()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const { data, info } = await source
|
|
209
|
+
.rotate()
|
|
210
|
+
.resize({ width: rule.max, height: rule.max, fit: "inside", withoutEnlargement: true })
|
|
211
|
+
.webp({ quality: WEBP_QUALITY })
|
|
212
|
+
.toBuffer({ resolveWithObject: true })
|
|
213
|
+
return { data, width: info.width, height: info.height }
|
|
214
|
+
} catch {
|
|
215
|
+
throw new UploadError(400, type === "image/heic" ? "a HEIC picture cannot be read — send it as JPEG" : "the picture cannot be read")
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ───────────────────────────── receiving ─────────────────────────────
|
|
220
|
+
|
|
221
|
+
export const isMultipart = (req: Request) => /^multipart\/form-data/i.test(req.headers.get("content-type") ?? "")
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read a multipart endpoint call: the field `args` is the JSON body of a plain call (`{"args":[…]}`,
|
|
225
|
+
* a file's place taken by `{"$file": <index>}`), the fields `file0…` are the files. Throws
|
|
226
|
+
* `UploadError`; nothing is left on disk then.
|
|
227
|
+
*/
|
|
228
|
+
export const receiveUploads = async (req: Request, dir: string, limits: UploadLimits = {}): Promise<{ args: unknown[], uploads: Upload[] }> => {
|
|
229
|
+
const maxFile = limits.maxFile ?? 10 * MB
|
|
230
|
+
const maxFiles = limits.maxFiles ?? 10
|
|
231
|
+
const tooBig = () => new UploadError(413, `a file is at most ${Math.round(maxFile / MB)} MB`)
|
|
232
|
+
if (Number(req.headers.get("content-length") ?? 0) > maxFile * maxFiles + MB) throw tooBig()
|
|
233
|
+
|
|
234
|
+
let form: Awaited<ReturnType<Request["formData"]>>
|
|
235
|
+
try { form = await req.formData() } catch { throw new UploadError(400, "the body is not multipart form data") }
|
|
236
|
+
let args: unknown
|
|
237
|
+
try { args = JSON.parse(String(form.get("args")))?.args } catch { args = null }
|
|
238
|
+
if (!Array.isArray(args)) throw new UploadError(400, "the field \"args\" must be {\"args\":[…]}")
|
|
239
|
+
|
|
240
|
+
const blobs: Blob[] = []
|
|
241
|
+
for (let i = 0; ; i++) {
|
|
242
|
+
const part = form.get(`file${i}`)
|
|
243
|
+
if (part === null) break
|
|
244
|
+
if (typeof part === "string") throw new UploadError(400, `file${i} is not a file`)
|
|
245
|
+
if (i >= maxFiles) throw new UploadError(413, `a call carries at most ${maxFiles} files`)
|
|
246
|
+
if (part.size > maxFile) throw tooBig()
|
|
247
|
+
blobs.push(part)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const tmp = join(dir, "tmp")
|
|
251
|
+
await mkdir(tmp, { recursive: true })
|
|
252
|
+
const uploads: Upload[] = []
|
|
253
|
+
try {
|
|
254
|
+
for (const blob of blobs) {
|
|
255
|
+
const bytes = new Uint8Array(await blob.arrayBuffer())
|
|
256
|
+
const sniffed = sniffType(bytes)
|
|
257
|
+
const size = imageSize(bytes, sniffed)
|
|
258
|
+
const upload: Upload = {
|
|
259
|
+
id: crypto.randomUUID(),
|
|
260
|
+
name: cleanName((blob as { name?: string }).name ?? "", sniffed),
|
|
261
|
+
// what the bytes are; for the rest, what the app said
|
|
262
|
+
type: sniffed ?? (blob.type && !/^(text\/html|image\/svg)/i.test(blob.type) ? blob.type : "application/octet-stream"),
|
|
263
|
+
size: bytes.length,
|
|
264
|
+
...(size ?? {}),
|
|
265
|
+
}
|
|
266
|
+
await writeFile(join(tmp, upload.id), bytes)
|
|
267
|
+
uploads.push(upload)
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
await dropUploads(dir, uploads)
|
|
271
|
+
throw e
|
|
272
|
+
}
|
|
273
|
+
return { args, uploads }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Remove what `receiveUploads` left, for a call that never reached the worker. */
|
|
277
|
+
export const dropUploads = async (dir: string, uploads: Upload[]) => {
|
|
278
|
+
for (const u of uploads) await rm(join(dir, "tmp", u.id), { force: true })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ───────────────────────────── serving ─────────────────────────────
|
|
282
|
+
|
|
283
|
+
/** `GET <base>/files/<id>/<name>` — `rest` is what follows `/files/`. */
|
|
284
|
+
export const fileResponse = async (dir: string, rest: string, req: Request, headers: Record<string, string> = {}): Promise<Response> => {
|
|
285
|
+
const id = rest.split("/")[0]
|
|
286
|
+
const path = join(dir, id)
|
|
287
|
+
const info = UUID.test(id) ? await stat(path).catch(() => null) : null
|
|
288
|
+
if (!info?.isFile()) return new Response("Not found", { status: 404, headers })
|
|
289
|
+
|
|
290
|
+
// the type is what the BYTES say — never the name in the url, which anyone can write
|
|
291
|
+
const head = new Uint8Array(64)
|
|
292
|
+
await new Promise<void>((resolve, reject) => {
|
|
293
|
+
let at = 0
|
|
294
|
+
createReadStream(path, { start: 0, end: 63 })
|
|
295
|
+
.on("data", (chunk) => { head.set(chunk as Uint8Array, at); at += chunk.length })
|
|
296
|
+
.on("end", resolve).on("error", reject)
|
|
297
|
+
})
|
|
298
|
+
const type = sniffType(head)
|
|
299
|
+
const inline = type !== null && INLINE.test(type)
|
|
300
|
+
|
|
301
|
+
const out: Record<string, string> = {
|
|
302
|
+
...headers,
|
|
303
|
+
"content-type": inline ? type : "application/octet-stream",
|
|
304
|
+
"x-content-type-options": "nosniff",
|
|
305
|
+
// an id is one upload: its bytes never change
|
|
306
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
307
|
+
"accept-ranges": "bytes",
|
|
308
|
+
...(inline ? {} : { "content-disposition": "attachment" }),
|
|
309
|
+
}
|
|
310
|
+
let start = 0, end = info.size - 1, status = 200
|
|
311
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "")
|
|
312
|
+
if (range && (range[1] || range[2])) {
|
|
313
|
+
if (range[1]) { start = Number(range[1]); if (range[2]) end = Math.min(end, Number(range[2])) }
|
|
314
|
+
else start = Math.max(0, info.size - Number(range[2]))
|
|
315
|
+
if (start > end) return new Response(null, { status: 416, headers: { ...headers, "content-range": `bytes */${info.size}` } })
|
|
316
|
+
status = 206
|
|
317
|
+
out["content-range"] = `bytes ${start}-${end}/${info.size}`
|
|
318
|
+
}
|
|
319
|
+
out["content-length"] = String(end - start + 1)
|
|
320
|
+
if (req.method === "HEAD" || info.size === 0) return new Response(null, { status, headers: out })
|
|
321
|
+
return new Response(Readable.toWeb(createReadStream(path, { start, end })) as unknown as ReadableStream, { status, headers: out })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ───────────────────────────── the worker ─────────────────────────────
|
|
325
|
+
|
|
326
|
+
export type FilesHostConfig = {
|
|
327
|
+
/** The project's db (the one with `t.file()` fields, when it has any). */
|
|
328
|
+
db: Db<any> | null
|
|
329
|
+
/** Where this database's files are. */
|
|
330
|
+
dir: string
|
|
331
|
+
/** The bytes all files of the database may take together (absent = no limit). */
|
|
332
|
+
quota?: number
|
|
333
|
+
/** The codec of `t.image()` fields, asked for at the first picture (`() => import("sharp")`'s default). */
|
|
334
|
+
sharp?: () => Promise<Sharp | null>
|
|
335
|
+
/** The reader of HEIC pictures, asked for at the first of them (`() => import("heic-decode")`'s default). */
|
|
336
|
+
heic?: () => Promise<Heic | null>
|
|
337
|
+
log?: (level: "info" | "warn" | "error", text: string) => void
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export type FilesHost = {
|
|
341
|
+
/** The project has `t.file()` fields. */
|
|
342
|
+
readonly enabled: boolean
|
|
343
|
+
/** Give the bundle's db layer its seam (once, after the bundle is loaded). */
|
|
344
|
+
install(): void
|
|
345
|
+
/** The arguments with every `{ "$file": i }` turned into the `File` the endpoint receives. */
|
|
346
|
+
adopt(args: unknown[], rc: RequestContext): Promise<unknown[]>
|
|
347
|
+
/** After the call, whatever it ended with. */
|
|
348
|
+
finish(rc: RequestContext): Promise<void>
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const isPlaceholder = (v: unknown): v is { $file: number } =>
|
|
352
|
+
typeof v === "object" && v !== null && typeof (v as { $file?: unknown }).$file === "number" && Object.keys(v).length === 1
|
|
353
|
+
|
|
354
|
+
const hasPlaceholder = (v: unknown): boolean =>
|
|
355
|
+
isPlaceholder(v) || (Array.isArray(v) ? v.some(hasPlaceholder) : typeof v === "object" && v !== null && Object.values(v).some(hasPlaceholder))
|
|
356
|
+
|
|
357
|
+
export const createFilesHost = (cfg: FilesHostConfig): FilesHost => {
|
|
358
|
+
const fields = cfg.db?.$files ?? []
|
|
359
|
+
const enabled = fields.length > 0
|
|
360
|
+
const log = cfg.log ?? (() => {})
|
|
361
|
+
const transport = (): MarciTransport => {
|
|
362
|
+
const t = (globalThis as unknown as { __lecodesDbTransport?: MarciTransport }).__lecodesDbTransport
|
|
363
|
+
if (!t) throw new Error("files: no db transport")
|
|
364
|
+
return t
|
|
365
|
+
}
|
|
366
|
+
const exec = (op: Omit<MarciOp, "model">) => transport().exec({ model: FILE_MODEL, ...op })
|
|
367
|
+
/** No field of any row holds it. */
|
|
368
|
+
const ownerless = Object.fromEntries(fields.map(f => [f.owner, null]))
|
|
369
|
+
|
|
370
|
+
/** The `File` objects of the calls in flight → their rows. */
|
|
371
|
+
const uploaded = new WeakMap<object, string>()
|
|
372
|
+
const inFlight = new WeakMap<RequestContext, string[]>()
|
|
373
|
+
/** What the uploads of the calls in flight are called (an image field renames: `cat.jpg` → `cat.webp`). */
|
|
374
|
+
const names = new Map<string, string>()
|
|
375
|
+
let sharp: Promise<Sharp | null> | null = null
|
|
376
|
+
let heic: Promise<Heic | null> | null = null
|
|
377
|
+
const image = async (id: string, rule: ImageRule): Promise<ImageFacts> => {
|
|
378
|
+
const codec = await (sharp ??= cfg.sharp?.() ?? Promise.resolve(null))
|
|
379
|
+
if (!codec) throw new Error("files: a t.image() field needs sharp, and this host has none")
|
|
380
|
+
const path = join(cfg.dir, id)
|
|
381
|
+
const bytes = new Uint8Array(await readFile(path))
|
|
382
|
+
const fit = await fitImage(codec, bytes, rule, sniffType(bytes) === "image/heic" ? await (heic ??= cfg.heic?.().catch(() => null) ?? Promise.resolve(null)) : null)
|
|
383
|
+
if (!fit) {
|
|
384
|
+
const size = imageSize(bytes, "image/gif")
|
|
385
|
+
if (!size) throw new UploadError(400, "the picture cannot be read")
|
|
386
|
+
return { name: cleanName(names.get(id) ?? "", "image/gif"), type: "image/gif", size: bytes.length, ...size }
|
|
387
|
+
}
|
|
388
|
+
// beside, then over: whoever reads the file sees one picture or the other, whole
|
|
389
|
+
await writeFile(join(cfg.dir, "tmp", id), fit.data)
|
|
390
|
+
await rename(join(cfg.dir, "tmp", id), path)
|
|
391
|
+
return { name: cleanName(names.get(id) ?? "", "image/webp"), type: "image/webp", size: fit.data.length, width: fit.width, height: fit.height }
|
|
392
|
+
}
|
|
393
|
+
const seam: FilesSeam = {
|
|
394
|
+
uploaded: (value) => typeof value === "object" && value !== null ? uploaded.get(value) : undefined,
|
|
395
|
+
url: (id, name) => `${currentRequest()?.filesBase ?? ""}/files/${id}/${encodeURIComponent(name)}`,
|
|
396
|
+
image,
|
|
397
|
+
dirty: false,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The bytes of the rows the database let go of. One reader at a time: a read confirms the entries
|
|
401
|
+
// of the read before it.
|
|
402
|
+
let settling: Promise<void> = Promise.resolve()
|
|
403
|
+
const settle = () => (settling = settling.catch(() => {}).then(async () => {
|
|
404
|
+
let after: number | undefined
|
|
405
|
+
for (;;) {
|
|
406
|
+
const entries: { seq: number, row: { id: string } }[] = await exec({ action: "$journalRead", journal: { name: JOURNAL, after, limit: 100 } })
|
|
407
|
+
if (!entries.length) return
|
|
408
|
+
for (const entry of entries) {
|
|
409
|
+
await rm(join(cfg.dir, entry.row.id), { force: true })
|
|
410
|
+
after = entry.seq
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}))
|
|
414
|
+
|
|
415
|
+
// Once, before the first call that has to do with files — not at load: a deploy loads the bundle
|
|
416
|
+
// before the database has its schema.
|
|
417
|
+
let ready: Promise<void> | null = null
|
|
418
|
+
const start = () => (ready ??= (async () => {
|
|
419
|
+
await mkdir(join(cfg.dir, "tmp"), { recursive: true })
|
|
420
|
+
await exec({ action: "$journalOpen", journal: { name: JOURNAL, on: ["delete"] } })
|
|
421
|
+
// uploads a crashed process attached to nothing (the ones of a call still running are younger)
|
|
422
|
+
await exec({ action: "deleteMany", query: { $where: { ...ownerless, createdAt: { $lt: Date.now() - TMP_MAX_AGE_MS } } } })
|
|
423
|
+
await settle()
|
|
424
|
+
// …and the folder against the rows: the database may be another one than the files were stored under
|
|
425
|
+
const rows: { id: string }[] = await exec({ action: "findMany", query: { id: true } })
|
|
426
|
+
const known = new Set(rows.map(r => r.id))
|
|
427
|
+
for (const name of await readdir(cfg.dir)) {
|
|
428
|
+
if (UUID.test(name) && !known.has(name)) await rm(join(cfg.dir, name), { force: true })
|
|
429
|
+
}
|
|
430
|
+
for (const name of await readdir(join(cfg.dir, "tmp"))) {
|
|
431
|
+
const path = join(cfg.dir, "tmp", name)
|
|
432
|
+
const info = await stat(path).catch(() => null)
|
|
433
|
+
if (info && Date.now() - info.mtimeMs > TMP_MAX_AGE_MS) await rm(path, { force: true })
|
|
434
|
+
}
|
|
435
|
+
})().catch((e) => { ready = null; throw e }))
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
enabled,
|
|
439
|
+
install: () => setFilesSeam(enabled ? seam : null),
|
|
440
|
+
|
|
441
|
+
adopt: async (args, rc) => {
|
|
442
|
+
const uploads = (rc.uploads ?? []) as Upload[]
|
|
443
|
+
if (!uploads.length && !hasPlaceholder(args)) return args
|
|
444
|
+
try {
|
|
445
|
+
if (!enabled) throw new UploadError(400, "this project stores no files — a model needs a t.file() field")
|
|
446
|
+
await start()
|
|
447
|
+
if (cfg.quota !== undefined && uploads.length) {
|
|
448
|
+
const used = (await exec({ action: "aggregate", query: { $sum: "size" } }))?.sum ?? 0
|
|
449
|
+
if (used + uploads.reduce((n, u) => n + u.size, 0) > cfg.quota) throw new UploadError(413, "the project's file storage is full")
|
|
450
|
+
}
|
|
451
|
+
// the rows first, then the bytes into place: a file on disk always has its row
|
|
452
|
+
if (uploads.length) {
|
|
453
|
+
await transport().batch(uploads.map(u => ({
|
|
454
|
+
model: FILE_MODEL, action: "insert",
|
|
455
|
+
data: { id: u.id, name: u.name, type: u.type, size: u.size, ...(u.width ? { width: u.width, height: u.height } : {}) },
|
|
456
|
+
})))
|
|
457
|
+
inFlight.set(rc, uploads.map(u => u.id))
|
|
458
|
+
for (const u of uploads) names.set(u.id, u.name)
|
|
459
|
+
for (const u of uploads) await rename(join(cfg.dir, "tmp", u.id), join(cfg.dir, u.id))
|
|
460
|
+
}
|
|
461
|
+
const files = uploads.map((u) => {
|
|
462
|
+
const file = Object.freeze({ name: u.name, size: u.size, type: u.type })
|
|
463
|
+
uploaded.set(file, u.id)
|
|
464
|
+
return file
|
|
465
|
+
})
|
|
466
|
+
const place = (v: unknown): unknown => {
|
|
467
|
+
if (isPlaceholder(v)) return files[v.$file] ?? (() => { throw new UploadError(400, `the call names file ${v.$file}, and carries ${files.length}`) })()
|
|
468
|
+
if (Array.isArray(v)) return v.map(place)
|
|
469
|
+
if (typeof v === "object" && v !== null) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, place(x)]))
|
|
470
|
+
return v
|
|
471
|
+
}
|
|
472
|
+
return place(args) as unknown[]
|
|
473
|
+
} catch (e) {
|
|
474
|
+
if (!inFlight.has(rc)) await dropUploads(cfg.dir, uploads)
|
|
475
|
+
throw e
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
finish: async (rc) => {
|
|
480
|
+
if (!enabled) return
|
|
481
|
+
const ids = inFlight.get(rc)
|
|
482
|
+
if (!ids && !seam.dirty) return
|
|
483
|
+
try {
|
|
484
|
+
inFlight.delete(rc)
|
|
485
|
+
for (const id of ids ?? []) names.delete(id)
|
|
486
|
+
// an upload the endpoint wrote to no field
|
|
487
|
+
if (ids) await exec({ action: "deleteMany", query: { $where: { ...ownerless, id: { $in: ids } } } })
|
|
488
|
+
seam.dirty = false
|
|
489
|
+
await start()
|
|
490
|
+
await settle()
|
|
491
|
+
} catch (e) {
|
|
492
|
+
log("error", `files: ${(e as Error)?.message ?? e}`)
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored files: `t.file()` on a model.
|
|
3
|
+
*
|
|
4
|
+
* A project never declares where a file lives. A model that has a `t.file()` field makes the db carry
|
|
5
|
+
* one more model, the PLATFORM's `File` — a row per stored file: what it is (name, type, size, an
|
|
6
|
+
* image's dimensions) and whose it is. The owner is a reference FROM the file TO its row, with
|
|
7
|
+
* `onDelete("cascade")`, one optional reference per `t.file()` field of the schema (`postImage`,
|
|
8
|
+
* `userAvatar`): deleting a post — by name, or because its author was deleted — deletes its files in
|
|
9
|
+
* the same transaction, by the database's own rule. The field on the owner is the reverse side of
|
|
10
|
+
* that reference.
|
|
11
|
+
*
|
|
12
|
+
* The bytes are the host's (./host.ts): a file on disk named by the row's id. They go only because
|
|
13
|
+
* the row went — the host reads the deletions from marcidb's journal of this model.
|
|
14
|
+
*
|
|
15
|
+
* What a project sees is a VALUE: it writes a `File` (an upload, an endpoint's parameter) and reads a
|
|
16
|
+
* `StoredFile`. The url is computed when the row is read, never stored.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { t, type Field } from "../db/fields"
|
|
20
|
+
|
|
21
|
+
export const FILE_MODEL = "File"
|
|
22
|
+
|
|
23
|
+
/** A stored file as a project reads it. */
|
|
24
|
+
export type StoredFile = {
|
|
25
|
+
/** Where the bytes are served from. Public, not guessable; it never changes for this file. */
|
|
26
|
+
url: string
|
|
27
|
+
name: string
|
|
28
|
+
/** The media type: `image/jpeg`, `video/mp4`, `application/pdf`. */
|
|
29
|
+
type: string
|
|
30
|
+
/** Bytes. */
|
|
31
|
+
size: number
|
|
32
|
+
/** An image's dimensions in pixels (absent for anything else). */
|
|
33
|
+
width?: number
|
|
34
|
+
height?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A stored picture (`t.image()`): its size is always known. */
|
|
38
|
+
export type StoredImage = StoredFile & { width: number, height: number }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A file as an endpoint receives it — the app's `File` (a picked file, a photo), uploaded with the
|
|
42
|
+
* call. Written to a `t.file()` field, it is stored; an endpoint that writes it nowhere drops it.
|
|
43
|
+
*/
|
|
44
|
+
export type UploadedFile = { readonly name: string, readonly size: number, readonly type: string }
|
|
45
|
+
|
|
46
|
+
/** One `t.file()` field of the schema; `image` = it is a `t.image()`, with its rule. */
|
|
47
|
+
export type FileField = { model: string, key: string, array: boolean, owner: string, image?: ImageRule }
|
|
48
|
+
|
|
49
|
+
export type ImageRule = { max: number }
|
|
50
|
+
/** What a file became when it was made fit for an image field: the facts its row carries from then on. */
|
|
51
|
+
export type ImageFacts = { name: string, type: string, size: number, width: number, height: number }
|
|
52
|
+
|
|
53
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
54
|
+
const uncap = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)
|
|
55
|
+
/** The reference on `File` that says "I am this field of that row": `Post.image` → `postImage`. */
|
|
56
|
+
export const fileOwnerKey = (model: string, key: string) => `${uncap(model)}${cap(key)}`
|
|
57
|
+
|
|
58
|
+
/** What a data browser may show of a file (the rest is the platform's bookkeeping). */
|
|
59
|
+
export const filePublicFields = () => ({
|
|
60
|
+
name: t.string(),
|
|
61
|
+
type: t.string(),
|
|
62
|
+
size: t.int(),
|
|
63
|
+
width: t.int().optional(),
|
|
64
|
+
height: t.int().optional(),
|
|
65
|
+
createdAt: t.date().default("now"),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/** The whole `File` of a schema with these `t.file()` fields. */
|
|
69
|
+
export const fileFields = (fields: FileField[]): Record<string, Field<any, any>> => {
|
|
70
|
+
const out: Record<string, Field<any, any>> = {
|
|
71
|
+
/** Also the name of the bytes on disk and the id in the url. */
|
|
72
|
+
id: t.uuid(),
|
|
73
|
+
...filePublicFields(),
|
|
74
|
+
/** The place in a `t.file().array()` field. */
|
|
75
|
+
position: t.int().default(0),
|
|
76
|
+
}
|
|
77
|
+
for (const f of fields) {
|
|
78
|
+
if (f.owner in out) throw new Error(`defineDb: ${f.model}.${f.key} — the name "${f.owner}" is taken on the platform's File model; name the field differently`)
|
|
79
|
+
out[f.owner] = t.one(f.model).optional().onDelete("cascade")
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ───────────────────────────── the host's seam ─────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What the host (./host.ts) gives the db layer of a bundle — a global, because the bundle carries its
|
|
88
|
+
* own copy of this module. Absent (a unit test without a host): nothing is an upload, urls are relative.
|
|
89
|
+
*/
|
|
90
|
+
export type FilesSeam = {
|
|
91
|
+
/** The id of the `File` row behind an upload of the current request; undefined for anything else —
|
|
92
|
+
* a value the app sent as JSON is never an upload, whatever it looks like. */
|
|
93
|
+
uploaded(value: unknown): string | undefined
|
|
94
|
+
url(id: string, name: string): string
|
|
95
|
+
/** Make the upload `id` fit for a `t.image()` field (its bytes are rewritten in place) and say what
|
|
96
|
+
* it is now. Throws an `ApiError` for what is no picture. */
|
|
97
|
+
image(id: string, rule: ImageRule): Promise<ImageFacts>
|
|
98
|
+
/** A write that may have removed rows of `File` went through the db (a delete, a rewritten file field): their bytes are due. */
|
|
99
|
+
dirty: boolean
|
|
100
|
+
}
|
|
101
|
+
const seam = globalThis as unknown as { __lecodesFiles?: FilesSeam }
|
|
102
|
+
export const filesSeam = (): FilesSeam | undefined => seam.__lecodesFiles
|
|
103
|
+
export const setFilesSeam = (value: FilesSeam | null) => {
|
|
104
|
+
if (value) seam.__lecodesFiles = value
|
|
105
|
+
else delete seam.__lecodesFiles
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const URL_ID = /\/files\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/
|
|
109
|
+
/** The row a `StoredFile` the app handed back stands for (by its url); undefined for anything else. */
|
|
110
|
+
export const storedFileId = (value: unknown): string | undefined => {
|
|
111
|
+
const url = (value as { url?: unknown } | null)?.url
|
|
112
|
+
return typeof url === "string" ? URL_ID.exec(url)?.[1] : undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const fileUrl = (id: string, name: string): string =>
|
|
116
|
+
seam.__lecodesFiles?.url(id, name) ?? `/files/${id}/${encodeURIComponent(name)}`
|
package/src/server/host.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
|
-
loadServerModules, createInvoker, createSubscribeGuard, setChannelPublisher, setRequestProvider,
|
|
9
|
+
loadServerModules, createInvoker, createFileWriter, createSubscribeGuard, setChannelPublisher, setRequestProvider,
|
|
10
10
|
} from "./runtime"
|
|
11
11
|
export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration } from "./runtime"
|
|
12
12
|
export type { RequestContext } from "./context"
|
|
@@ -25,6 +25,10 @@ export type { AuthHost, AuthHostConfig, AuthMail } from "./auth/host"
|
|
|
25
25
|
export type { AuthState, AuthOps } from "./auth/types"
|
|
26
26
|
export type { AppConfig } from "./auth/appConfig"
|
|
27
27
|
export type { IdentityProvider } from "./auth/models"
|
|
28
|
+
export { createFilesHost, receiveUploads, dropUploads, fileResponse, isMultipart, UploadError } from "./files/host"
|
|
29
|
+
export type { FilesHost, FilesHostConfig, Heic, Sharp, Upload, UploadLimits } from "./files/host"
|
|
30
|
+
export type { StoredFile, StoredImage } from "./files/models"
|
|
31
|
+
import { FILE_MODEL, filePublicFields } from "./files/models"
|
|
28
32
|
import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityPublicFields, sessionPublicFields } from "./auth/models"
|
|
29
33
|
|
|
30
34
|
import type { LoadedServer } from "./runtime"
|
|
@@ -42,8 +46,10 @@ export type ServerDescription = {
|
|
|
42
46
|
marci: string
|
|
43
47
|
/** `auth: true` = one of the platform's two models (`Session`, `Identity` — they come with
|
|
44
48
|
* `withAuth`), described WITHOUT its secrets (token and password hashes, a pending code): what a
|
|
45
|
-
* data browser may show, read-only.
|
|
46
|
-
|
|
49
|
+
* data browser may show, read-only. `files: true` = the platform's `File` (it comes with a
|
|
50
|
+
* `t.file()` field): what each stored file is, read-only too — a file is written through its field.
|
|
51
|
+
* A `t.file()` field itself is described as `kind: "file"`. */
|
|
52
|
+
models: Record<string, { fields: Record<string, FieldDescription>, auth?: true, files?: true }>
|
|
47
53
|
endpoints: { id: string, params: unknown[] | null }[]
|
|
48
54
|
channels: string[]
|
|
49
55
|
/** The project's model of a user (`null` = its db has no sign-in). */
|
|
@@ -72,9 +78,16 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
|
|
|
72
78
|
[AUTH_SESSION_MODEL]: [...Object.keys(sessionPublicFields()), "user"],
|
|
73
79
|
[AUTH_IDENTITY_MODEL]: [...Object.keys(identityPublicFields()), "user"],
|
|
74
80
|
} : {}
|
|
81
|
+
const files = db.$files
|
|
82
|
+
if (files.length) shown[FILE_MODEL] = ["id", ...Object.keys(filePublicFields())]
|
|
75
83
|
for (const [name, m] of Object.entries(db.$models as Record<string, Model>)) {
|
|
76
84
|
const fields = Object.entries(m.fields as Record<string, Field<any, any>>).filter(([k]) => !shown[name] || shown[name].includes(k))
|
|
77
|
-
|
|
85
|
+
const described = Object.fromEntries(fields.map(([k, f]) => [k, describeField(f)]))
|
|
86
|
+
// a file field is, in the database, a relation to File: what a project declared is said here
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
if (f.model === name) described[f.key] = { kind: "file", optional: false, unique: false, index: false, hasDefault: false, array: f.array, isId: false }
|
|
89
|
+
}
|
|
90
|
+
models[name] = { fields: described, ...(name === FILE_MODEL ? { files: true as const } : shown[name] ? { auth: true as const } : {}) }
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
const params = (server.manifest.params ?? {}) as Record<string, unknown[]>
|
package/src/server/inject.ts
CHANGED