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.
@@ -14,6 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"
14
14
  import type { ServerManifest } from "../compile/serverSplit"
15
15
  import type { AuthHost } from "./auth/host"
16
16
  import type { AuthState } from "./auth/types"
17
+ import type { FilesHost } from "./files/host"
17
18
  import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
18
19
  import { setRequestProvider, type RequestContext } from "./context"
19
20
  import type { Db } from "./db/types"
@@ -69,6 +70,9 @@ export type InvokerOptions = {
69
70
  onError?: (id: string, error: unknown) => void
70
71
  /** The auth host (./auth/host.ts): resolves the bearer token into `ctx.auth` before each call. Absent = no sessions. */
71
72
  auth?: AuthHost | null
73
+ /** The files host (./files/host.ts): the uploads of a call become `File` arguments, and what the call
74
+ * left unused — or deleted — is cleaned up after it. Absent = a call with files is refused. */
75
+ files?: FilesHost | null
72
76
  }
73
77
 
74
78
  // The bundle's ApiError is a different class object than ours (its own SDK copy) — duck-type it.
@@ -96,6 +100,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
96
100
  const onError = opts.onError ?? ((id, e) => console.error(`[server] ${id}:`, e))
97
101
  const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
98
102
  const auth = opts.auth ?? null
103
+ const files = opts.files ?? null
99
104
 
100
105
  return async (id: string, args: unknown[], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
101
106
  const fn = server.endpoints.get(id)
@@ -103,6 +108,9 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
103
108
  if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
104
109
  const rc: RequestContext = { ...ctx, id }
105
110
  try {
111
+ // an upload is an argument like any other once it is adopted: validated, then passed
112
+ if (files) args = await files.adopt(args, rc)
113
+ else if (rc.uploads?.length) return { ok: false, status: 400, message: "this server takes no files" }
106
114
  if (validate && params?.[id]) validateArgs(params[id], args)
107
115
  if (auth) await auth.resolve(rc)
108
116
  const result = await als.run(rc, () => fn(...args))
@@ -113,6 +121,39 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
113
121
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
114
122
  onError(id, e)
115
123
  return withSession({ ok: false, status: 500, message: "Internal error" }, rc)
124
+ } finally {
125
+ // in the request's scope: the urls of what the call returned are already made, this is the cleanup
126
+ if (files) await als.run(rc, () => files.finish(rc))
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * A file field of one row written from OUTSIDE the project's code — a data browser's form. It goes
133
+ * the way an endpoint's write does, through the bundle's own db: `args` are `[model, id, key, value]`,
134
+ * the value what an endpoint would write to the field — an upload of this call (`{ "$file": i }`), a
135
+ * file the row already has (`{ url }`, kept), a list of them, or null. So the field's rule (`t.image()`),
136
+ * the quota and the removal of what is no longer listed are the same.
137
+ */
138
+ export const createFileWriter = (server: LoadedServer, opts: { files?: FilesHost | null } = {}) => {
139
+ installProvider()
140
+ const files = opts.files ?? null
141
+ return async (args: unknown[], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
142
+ const refuse = (message: string): InvokeResult => ({ ok: false, status: 400, message })
143
+ if (!files?.enabled) return refuse("this project stores no files — a model needs a t.file() field")
144
+ const rc: RequestContext = { ...ctx, id: "$file" }
145
+ try {
146
+ const [model, id, key, value] = await files.adopt(args, rc)
147
+ const db = server.dbs.find(d => d.$files.some(f => f.model === model && f.key === key))
148
+ if (!db) return refuse(`${model}.${key} is not a file field`)
149
+ const name = model as string
150
+ await als.run(rc, () => (db as any)[name.charAt(0).toLowerCase() + name.slice(1)].update(id, { [key as string]: value }))
151
+ return { ok: true, result: null }
152
+ } catch (e) {
153
+ // whoever asks is the project's developer: the reason as it is
154
+ return { ok: false, status: apiStatus(e) ?? 500, message: (e as Error)?.message ?? String(e) }
155
+ } finally {
156
+ await als.run(rc, () => files.finish(rc))
116
157
  }
117
158
  }
118
159
  }
@@ -12,6 +12,8 @@ export type Schema =
12
12
  | { t: "tuple", items: Schema[], rest?: Schema }
13
13
  | { t: "object", props: Record<string, Schema>, optional?: string[], index?: Schema }
14
14
  | { t: "union", of: Schema[] }
15
+ /** A `File` parameter: an upload of this very call (./files/host.ts) — nothing the app can spell in JSON. */
16
+ | { t: "file" }
15
17
 
16
18
  /** One endpoint parameter: its schema and whether it may be omitted (`?`, default value, rest). */
17
19
  export type ParamSchema = { name: string, schema: Schema, optional?: boolean, rest?: boolean }
@@ -61,6 +63,9 @@ export const check = (schema: Schema, value: unknown, path: string): string | nu
61
63
  }
62
64
  return null
63
65
  }
66
+ case "file":
67
+ return (globalThis as { __lecodesFiles?: { uploaded(v: unknown): string | undefined } }).__lecodesFiles?.uploaded(value)
68
+ ? null : `${path}: expected a file, got ${typeName(value)}`
64
69
  case "union": {
65
70
  const errors: string[] = []
66
71
  for (const s of schema.of) { const e = check(s, value, path); if (!e) return null; errors.push(e) }
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.0.6"
7
+ export const SDK_VERSION = "2.0.8"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {