ff-serv 0.1.10 → 0.1.11
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/adapter-DLhjFlOu.d.cts +31 -0
- package/dist/adapter-DLhjFlOu.d.ts +31 -0
- package/dist/cli.js +47 -47
- package/dist/exports/cache-bun-redis.cjs +50 -0
- package/dist/exports/cache-bun-redis.cjs.map +1 -0
- package/dist/exports/cache-bun-redis.d.cts +11 -0
- package/dist/exports/cache-bun-redis.d.ts +11 -0
- package/dist/exports/cache-bun-redis.js +23 -0
- package/dist/exports/cache-bun-redis.js.map +1 -0
- package/dist/exports/cache-ioredis.cjs +51 -0
- package/dist/exports/cache-ioredis.cjs.map +1 -0
- package/dist/exports/cache-ioredis.d.cts +11 -0
- package/dist/exports/cache-ioredis.d.ts +11 -0
- package/dist/exports/cache-ioredis.js +24 -0
- package/dist/exports/cache-ioredis.js.map +1 -0
- package/dist/exports/cache.cjs +220 -0
- package/dist/exports/cache.cjs.map +1 -0
- package/dist/exports/cache.d.cts +30 -0
- package/dist/exports/cache.d.ts +30 -0
- package/dist/exports/cache.js +199 -0
- package/dist/exports/cache.js.map +1 -0
- package/dist/exports/orpc.cjs +15 -10
- package/dist/exports/orpc.cjs.map +1 -1
- package/dist/exports/orpc.js +16 -11
- package/dist/exports/orpc.js.map +1 -1
- package/dist/index.cjs +15 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -7
- package/dist/index.d.ts +10 -7
- package/dist/index.js +17 -12
- package/dist/index.js.map +1 -1
- package/package.json +26 -2
- package/src/cache/AGENTS.md +8 -0
- package/src/cache/adapter.test.ts +181 -0
- package/src/cache/adapter.ts +120 -0
- package/src/cache/adapters/bun-redis.ts +29 -0
- package/src/cache/adapters/ioredis.ts +30 -0
- package/src/cache/cache.test.ts +461 -0
- package/src/cache/cache.ts +184 -0
- package/src/cli/commands/db/pull.ts +5 -15
- package/src/cli/commands/db/shared.ts +7 -7
- package/src/cli/config/index.ts +1 -3
- package/src/cli/index.ts +1 -1
- package/src/cli/utils/database-source.ts +1 -4
- package/src/exports/cache-bun-redis.ts +1 -0
- package/src/exports/cache-ioredis.ts +1 -0
- package/src/exports/cache.ts +6 -0
- package/src/exports/orpc.ts +1 -1
- package/src/http/fetch-handler.test.ts +1 -1
- package/src/index.ts +1 -0
- package/src/logger.test.ts +168 -0
- package/src/logger.ts +41 -19
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/http/basic.ts","../src/http/fetch-handler.ts","../src/logger.ts","../src/port.ts"],"sourcesContent":["import { Effect } from 'effect';\nimport { type AnyResponse, Handler } from './fetch-handler.js';\n\nexport namespace Path {\n\texport type Type = `/${string}` | ((url: URL) => boolean);\n\texport function matched(path: Type, url: URL) {\n\t\treturn typeof path === 'function' ? path(url) : path === url.pathname;\n\t}\n}\n\nexport namespace Fn {\n\ttype Input = [request: Request];\n\n\ttype OutputSync = AnyResponse;\n\ttype OutputEffect<R> = Effect.Effect<AnyResponse, unknown, R>;\n\n\texport type FnSync = (...input: Input) => OutputSync;\n\texport type FnEffect<R> = (...input: Input) => OutputEffect<R>;\n\texport type FnAny<R> = (...input: Input) => OutputSync | OutputEffect<R>;\n\n\texport function exec<R>(fn: FnAny<R>, ...[request]: Input) {\n\t\tconst response = fn(request);\n\t\tif (!Effect.isEffect(response)) return Effect.succeed(response);\n\t\treturn response;\n\t}\n}\n\nexport function basicHandler(\n\tpath: Path.Type,\n\tfn: Fn.FnSync,\n): Handler<'basicHandler', never>;\nexport function basicHandler<R>(\n\tpath: Path.Type,\n\tfn: Fn.FnEffect<R>,\n): Handler<'basicHandler', R>;\nexport function basicHandler<R>(path: Path.Type, fn: Fn.FnAny<R>) {\n\treturn new Handler('basicHandler', ({ url, request }) => {\n\t\tif (!Path.matched(path, url))\n\t\t\treturn Effect.succeed({ matched: false, response: undefined });\n\n\t\treturn Effect.gen(function* () {\n\t\t\treturn {\n\t\t\t\tmatched: true,\n\t\t\t\tresponse: yield* Fn.exec(fn, request),\n\t\t\t};\n\t\t});\n\t});\n}\n","import { Effect, FiberSet } from 'effect';\nimport type { Cause } from 'effect/Cause';\nimport { nanoid } from 'nanoid';\nimport { Logger } from '../logger.js';\n\n// #region Handler\n\nexport type AnyResponse = Response | Promise<Response>;\n\nexport type HandlerResult =\n\t| {\n\t\t\tmatched: true;\n\t\t\tresponse: AnyResponse;\n\t }\n\t| {\n\t\t\tmatched: false;\n\t\t\tresponse: undefined;\n\t };\n\nexport class Handler<NAME extends string, R> {\n\tconstructor(\n\t\treadonly _tag: NAME,\n\t\treadonly handle: (opt: {\n\t\t\turl: URL;\n\t\t\trequest: Request;\n\t\t}) => Effect.Effect<HandlerResult, unknown, R>,\n\t) {}\n}\n\n// #endregion\n\ntype ExtractRequirements<T> = T extends Handler<string, infer R> ? R : never;\n\nexport const createFetchHandler = <\n\tconst HANDLERS extends [\n\t\tHandler<string, unknown>,\n\t\t...Array<Handler<string, unknown>>,\n\t],\n\tR = ExtractRequirements<HANDLERS[number]>,\n>(\n\thandlers: HANDLERS,\n\topts?: {\n\t\tdebug?: boolean;\n\t\tonError?: (ctx: {\n\t\t\terror: Cause<unknown>;\n\t\t}) => Effect.Effect<unknown, unknown>;\n\t},\n) =>\n\tEffect.gen(function* () {\n\t\tconst runFork = yield* FiberSet.makeRuntimePromise<R>();\n\t\treturn async (request: Request) => {\n\t\t\tconst urlObj = new URL(request.url);\n\t\t\tconst requestId = nanoid(6);\n\n\t\t\tconst effect = Effect.gen(function* () {\n\t\t\t\tyield* Logger.info(\n\t\t\t\t\t{ request: { pathname: urlObj.pathname } },\n\t\t\t\t\t'Request started',\n\t\t\t\t);\n\n\t\t\t\tfor (const handler of handlers) {\n\t\t\t\t\tif (!handler) continue;\n\n\t\t\t\t\tconst result = yield* handler.handle({ url: urlObj, request }).pipe(\n\t\t\t\t\t\tEffect.flatMap(({ matched, response }) =>\n\t\t\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\t\t\tif (matched) {\n\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\tmatched: true,\n\t\t\t\t\t\t\t\t\t\tresponse:\n\t\t\t\t\t\t\t\t\t\t\tresponse instanceof Promise\n\t\t\t\t\t\t\t\t\t\t\t\t? yield* Effect.tryPromise(() => response)\n\t\t\t\t\t\t\t\t\t\t\t\t: response,\n\t\t\t\t\t\t\t\t\t} as const;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn { matched: false, response: undefined } as const;\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t\tEffect.catchAllCause((error) =>\n\t\t\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\t\t\tyield* Logger.error(\n\t\t\t\t\t\t\t\t\t{ error },\n\t\t\t\t\t\t\t\t\t`Unhandled exception in HTTP handler '${handler._tag}'`,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tif (opts?.onError) yield* opts.onError({ error });\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\tmatched: true,\n\t\t\t\t\t\t\t\t\tresponse: new Response('Internal Server Error', {\n\t\t\t\t\t\t\t\t\t\tstatus: 500,\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t} as const;\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\n\t\t\t\t\tif (opts?.debug)\n\t\t\t\t\t\tyield* Logger.debug(\n\t\t\t\t\t\t\t{ handler: handler._tag, request, result },\n\t\t\t\t\t\t\t'Processed handler',\n\t\t\t\t\t\t);\n\n\t\t\t\t\tif (!result.matched) continue;\n\t\t\t\t\treturn result.response;\n\t\t\t\t}\n\n\t\t\t\treturn new Response('Not Found', { status: 404 });\n\t\t\t}).pipe(\n\t\t\t\tEffect.tap((response) =>\n\t\t\t\t\tresponse.ok\n\t\t\t\t\t\t? Logger.info(`Request completed with status ${response.status}`)\n\t\t\t\t\t\t: Logger.warn(`Request completed with status ${response.status}`),\n\t\t\t\t),\n\t\t\t\tEffect.withSpan('http'),\n\t\t\t\tEffect.annotateLogs({ requestId }),\n\t\t\t\tEffect.scoped,\n\t\t\t) as Effect.Effect<Response, never, R>;\n\n\t\t\treturn runFork(effect);\n\t\t};\n\t});\n","import { Effect, FiberSet, Runtime } from 'effect';\n\ntype LogParams = [obj: unknown, msg?: string];\nfunction extractParams(...[obj, msg]: LogParams) {\n\tif (typeof obj === 'string') {\n\t\treturn { message: obj };\n\t}\n\treturn { message: msg, attributes: obj as Record<string, any> };\n}\n\nexport namespace Logger {\n\texport const sync = () =>\n\t\tEffect.gen(function* () {\n\t\t\tconst runtime = yield* Effect.runtime();\n\n\t\t\tconst run = (e: Effect.Effect<void, never, never>) => {\n\t\t\t\t// Intentionally ignoring the await here\n\t\t\t\t// void runPromise(e);\n\t\t\t\tvoid Runtime.runPromise(runtime)(e);\n\t\t\t};\n\n\t\t\treturn {\n\t\t\t\tinfo: (...params: Parameters<typeof Logger.info>) =>\n\t\t\t\t\trun(Logger.info(...params)),\n\t\t\t\tdebug: (...params: Parameters<typeof Logger.debug>) =>\n\t\t\t\t\trun(Logger.debug(...params)),\n\t\t\t\twarn: (...params: Parameters<typeof Logger.warn>) =>\n\t\t\t\t\trun(Logger.warn(...params)),\n\t\t\t\terror: (...params: Parameters<typeof Logger.error>) =>\n\t\t\t\t\trun(Logger.error(...params)),\n\t\t\t};\n\t\t});\n\n\t// --\n\n\texport const info = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logInfo(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n\n\texport const debug = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logDebug(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n\n\texport const warn = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logWarning(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n\n\texport const error = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logError(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n}\n","import { Effect } from 'effect';\nimport * as GetPort from 'get-port';\n\nexport const getPort = (options?: GetPort.Options) =>\n\tEffect.tryPromise(() => GetPort.default(options));\n"],"mappings":"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","names":["Effect","Effect","FiberSet","Logger","Effect","FiberSet","Path","Fn","Effect","Effect"]}
|
|
1
|
+
{"version":3,"sources":["../src/http/basic.ts","../src/http/fetch-handler.ts","../src/logger.ts","../src/port.ts"],"sourcesContent":["import { Effect } from 'effect';\nimport { type AnyResponse, Handler } from './fetch-handler.js';\n\nexport namespace Path {\n\texport type Type = `/${string}` | ((url: URL) => boolean);\n\texport function matched(path: Type, url: URL) {\n\t\treturn typeof path === 'function' ? path(url) : path === url.pathname;\n\t}\n}\n\nexport namespace Fn {\n\ttype Input = [request: Request];\n\n\ttype OutputSync = AnyResponse;\n\ttype OutputEffect<R> = Effect.Effect<AnyResponse, unknown, R>;\n\n\texport type FnSync = (...input: Input) => OutputSync;\n\texport type FnEffect<R> = (...input: Input) => OutputEffect<R>;\n\texport type FnAny<R> = (...input: Input) => OutputSync | OutputEffect<R>;\n\n\texport function exec<R>(fn: FnAny<R>, ...[request]: Input) {\n\t\tconst response = fn(request);\n\t\tif (!Effect.isEffect(response)) return Effect.succeed(response);\n\t\treturn response;\n\t}\n}\n\nexport function basicHandler(\n\tpath: Path.Type,\n\tfn: Fn.FnSync,\n): Handler<'basicHandler', never>;\nexport function basicHandler<R>(\n\tpath: Path.Type,\n\tfn: Fn.FnEffect<R>,\n): Handler<'basicHandler', R>;\nexport function basicHandler<R>(path: Path.Type, fn: Fn.FnAny<R>) {\n\treturn new Handler('basicHandler', ({ url, request }) => {\n\t\tif (!Path.matched(path, url))\n\t\t\treturn Effect.succeed({ matched: false, response: undefined });\n\n\t\treturn Effect.gen(function* () {\n\t\t\treturn {\n\t\t\t\tmatched: true,\n\t\t\t\tresponse: yield* Fn.exec(fn, request),\n\t\t\t};\n\t\t});\n\t});\n}\n","import { Effect, FiberSet } from 'effect';\nimport type { Cause } from 'effect/Cause';\nimport { nanoid } from 'nanoid';\nimport { Logger } from '../logger.js';\n\n// #region Handler\n\nexport type AnyResponse = Response | Promise<Response>;\n\nexport type HandlerResult =\n\t| {\n\t\t\tmatched: true;\n\t\t\tresponse: AnyResponse;\n\t }\n\t| {\n\t\t\tmatched: false;\n\t\t\tresponse: undefined;\n\t };\n\nexport class Handler<NAME extends string, R> {\n\tconstructor(\n\t\treadonly _tag: NAME,\n\t\treadonly handle: (opt: {\n\t\t\turl: URL;\n\t\t\trequest: Request;\n\t\t}) => Effect.Effect<HandlerResult, unknown, R>,\n\t) {}\n}\n\n// #endregion\n\ntype ExtractRequirements<T> = T extends Handler<string, infer R> ? R : never;\n\nexport const createFetchHandler = <\n\tconst HANDLERS extends [\n\t\tHandler<string, unknown>,\n\t\t...Array<Handler<string, unknown>>,\n\t],\n\tR = ExtractRequirements<HANDLERS[number]>,\n>(\n\thandlers: HANDLERS,\n\topts?: {\n\t\tdebug?: boolean;\n\t\tonError?: (ctx: {\n\t\t\terror: Cause<unknown>;\n\t\t}) => Effect.Effect<unknown, unknown>;\n\t},\n) =>\n\tEffect.gen(function* () {\n\t\tconst runFork = yield* FiberSet.makeRuntimePromise<R>();\n\t\treturn async (request: Request) => {\n\t\t\tconst urlObj = new URL(request.url);\n\t\t\tconst requestId = nanoid(6);\n\n\t\t\tconst effect = Effect.gen(function* () {\n\t\t\t\tyield* Logger.info(\n\t\t\t\t\t{ request: { pathname: urlObj.pathname } },\n\t\t\t\t\t'Request started',\n\t\t\t\t);\n\n\t\t\t\tfor (const handler of handlers) {\n\t\t\t\t\tif (!handler) continue;\n\n\t\t\t\t\tconst result = yield* handler.handle({ url: urlObj, request }).pipe(\n\t\t\t\t\t\tEffect.flatMap(({ matched, response }) =>\n\t\t\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\t\t\tif (matched) {\n\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\tmatched: true,\n\t\t\t\t\t\t\t\t\t\tresponse:\n\t\t\t\t\t\t\t\t\t\t\tresponse instanceof Promise\n\t\t\t\t\t\t\t\t\t\t\t\t? yield* Effect.tryPromise(() => response)\n\t\t\t\t\t\t\t\t\t\t\t\t: response,\n\t\t\t\t\t\t\t\t\t} as const;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn { matched: false, response: undefined } as const;\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t\tEffect.catchAllCause((error) =>\n\t\t\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\t\t\tyield* Logger.error(\n\t\t\t\t\t\t\t\t\t{ error },\n\t\t\t\t\t\t\t\t\t`Unhandled exception in HTTP handler '${handler._tag}'`,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tif (opts?.onError) yield* opts.onError({ error });\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\tmatched: true,\n\t\t\t\t\t\t\t\t\tresponse: new Response('Internal Server Error', {\n\t\t\t\t\t\t\t\t\t\tstatus: 500,\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t} as const;\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\n\t\t\t\t\tif (opts?.debug)\n\t\t\t\t\t\tyield* Logger.debug(\n\t\t\t\t\t\t\t{ handler: handler._tag, request, result },\n\t\t\t\t\t\t\t'Processed handler',\n\t\t\t\t\t\t);\n\n\t\t\t\t\tif (!result.matched) continue;\n\t\t\t\t\treturn result.response;\n\t\t\t\t}\n\n\t\t\t\treturn new Response('Not Found', { status: 404 });\n\t\t\t}).pipe(\n\t\t\t\tEffect.tap((response) =>\n\t\t\t\t\tresponse.ok\n\t\t\t\t\t\t? Logger.info(`Request completed with status ${response.status}`)\n\t\t\t\t\t\t: Logger.warn(`Request completed with status ${response.status}`),\n\t\t\t\t),\n\t\t\t\tEffect.withSpan('http'),\n\t\t\t\tEffect.annotateLogs({ requestId }),\n\t\t\t\tEffect.scoped,\n\t\t\t) as Effect.Effect<Response, never, R>;\n\n\t\t\treturn runFork(effect);\n\t\t};\n\t});\n","import { Effect, Runtime } from 'effect';\n\ntype LogParams = [obj: unknown, msg?: string];\nfunction extractParams(...[obj, msg]: LogParams) {\n\tif (typeof obj === 'string') {\n\t\treturn { message: obj };\n\t}\n\t// biome-ignore lint/suspicious/noExplicitAny: log attributes are unstructured\n\treturn { message: msg, attributes: obj as Record<string, any> };\n}\n\n// biome-ignore lint/suspicious/noExplicitAny: log annotations are unstructured\ntype LogAnnotations = Record<string, any>;\n\nexport type SyncLogger = {\n\tinfo: (...params: Parameters<typeof Logger.info>) => void;\n\tdebug: (...params: Parameters<typeof Logger.debug>) => void;\n\twarn: (...params: Parameters<typeof Logger.warn>) => void;\n\terror: (...params: Parameters<typeof Logger.error>) => void;\n\tchild: (annotations: LogAnnotations) => SyncLogger;\n};\n\nfunction makeSyncLogger(\n\truntime: Runtime.Runtime<never>,\n\tannotations: LogAnnotations,\n): SyncLogger {\n\tconst run = (e: Effect.Effect<void, never, never>) => {\n\t\tconst annotated =\n\t\t\tObject.keys(annotations).length > 0\n\t\t\t\t? e.pipe(Effect.annotateLogs(annotations))\n\t\t\t\t: e;\n\t\tvoid Runtime.runPromise(runtime)(annotated);\n\t};\n\n\treturn {\n\t\tinfo: (...params: Parameters<typeof Logger.info>) =>\n\t\t\trun(Logger.info(...params)),\n\t\tdebug: (...params: Parameters<typeof Logger.debug>) =>\n\t\t\trun(Logger.debug(...params)),\n\t\twarn: (...params: Parameters<typeof Logger.warn>) =>\n\t\t\trun(Logger.warn(...params)),\n\t\terror: (...params: Parameters<typeof Logger.error>) =>\n\t\t\trun(Logger.error(...params)),\n\t\tchild: (childAnnotations: LogAnnotations) =>\n\t\t\tmakeSyncLogger(runtime, { ...annotations, ...childAnnotations }),\n\t};\n}\n\nexport namespace Logger {\n\texport const sync = (annotations?: LogAnnotations) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst runtime = yield* Effect.runtime();\n\t\t\treturn makeSyncLogger(runtime, annotations ?? {});\n\t\t});\n\n\t// --\n\n\texport const info = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logInfo(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n\n\texport const debug = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logDebug(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n\n\texport const warn = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logWarning(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n\n\texport const error = (...params: LogParams) =>\n\t\tEffect.gen(function* () {\n\t\t\tconst { message, attributes } = extractParams(...params);\n\t\t\tyield* Effect.logError(message).pipe(\n\t\t\t\tattributes ? Effect.annotateLogs(attributes) : (e) => e,\n\t\t\t);\n\t\t});\n}\n","import { Effect } from 'effect';\nimport * as GetPort from 'get-port';\n\nexport const getPort = (options?: GetPort.Options) =>\n\tEffect.tryPromise(() => GetPort.default(options));\n"],"mappings":"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","names":["Effect","Effect","Logger","Effect","Path","Fn","Effect","Effect"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ff-serv",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ff-serv": "./dist/cli.js"
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
"types": "./dist/exports/orpc.d.ts",
|
|
16
16
|
"import": "./dist/exports/orpc.js",
|
|
17
17
|
"require": "./dist/exports/orpc.cjs"
|
|
18
|
+
},
|
|
19
|
+
"./cache": {
|
|
20
|
+
"types": "./dist/exports/cache.d.ts",
|
|
21
|
+
"import": "./dist/exports/cache.js",
|
|
22
|
+
"require": "./dist/exports/cache.cjs"
|
|
23
|
+
},
|
|
24
|
+
"./cache/ioredis": {
|
|
25
|
+
"types": "./dist/exports/cache-ioredis.d.ts",
|
|
26
|
+
"import": "./dist/exports/cache-ioredis.js",
|
|
27
|
+
"require": "./dist/exports/cache-ioredis.cjs"
|
|
28
|
+
},
|
|
29
|
+
"./cache/bun-redis": {
|
|
30
|
+
"types": "./dist/exports/cache-bun-redis.d.ts",
|
|
31
|
+
"import": "./dist/exports/cache-bun-redis.js",
|
|
32
|
+
"require": "./dist/exports/cache-bun-redis.cjs"
|
|
18
33
|
}
|
|
19
34
|
},
|
|
20
35
|
"files": [
|
|
@@ -24,6 +39,8 @@
|
|
|
24
39
|
"scripts": {
|
|
25
40
|
"build": "tsup && bun run build:cli",
|
|
26
41
|
"build:cli": "bun build src/cli/index.ts --production --target=bun --outfile dist/cli.js",
|
|
42
|
+
"check:tsc": "tsgo --noEmit",
|
|
43
|
+
"check:lint": "biome check",
|
|
27
44
|
"test": "bun -b vitest run",
|
|
28
45
|
"dev": "tsup --watch"
|
|
29
46
|
},
|
|
@@ -34,6 +51,7 @@
|
|
|
34
51
|
"@orpc/client": "^1.13.2",
|
|
35
52
|
"@types/bun": "^1.3.2",
|
|
36
53
|
"@types/cli-progress": "^3.11.6",
|
|
54
|
+
"@typescript/native-preview": "^7.0.0-dev.20260216.1",
|
|
37
55
|
"cli-progress": "^3.12.0",
|
|
38
56
|
"ff-effect": "^0.0.10",
|
|
39
57
|
"inquirer": "^12.10.0",
|
|
@@ -46,7 +64,13 @@
|
|
|
46
64
|
"effect": "^3",
|
|
47
65
|
"@effect/opentelemetry": "^0.60.0",
|
|
48
66
|
"get-port": "^7",
|
|
49
|
-
"@orpc/server": "^1"
|
|
67
|
+
"@orpc/server": "^1",
|
|
68
|
+
"ioredis": "^5"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"ioredis": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
50
74
|
},
|
|
51
75
|
"publishConfig": {
|
|
52
76
|
"access": "public"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Cache Module
|
|
2
|
+
|
|
3
|
+
In-memory caching with SWR (stale-while-revalidate) and pluggable persistence adapters, built on Effect's `Cache`.
|
|
4
|
+
|
|
5
|
+
- `cache.ts` — `Cache.make()`, SWR logic, `Cache.entry()` for per-entry TTL overrides
|
|
6
|
+
- `adapter.ts` — `CacheAdapter` interface with `memory()`, `redis()`, `tiered()`
|
|
7
|
+
- `adapters/` — Redis client adapters (ioredis, bun-redis) that wrap clients to `RedisClient` interface
|
|
8
|
+
- User-facing docs: `docs/cache.md`
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { it } from '@effect/vitest';
|
|
2
|
+
import { Duration, Effect, Option } from 'effect';
|
|
3
|
+
import { describe, expect } from 'vitest';
|
|
4
|
+
import { CacheAdapter } from './adapter.js';
|
|
5
|
+
|
|
6
|
+
describe('CacheAdapter', () => {
|
|
7
|
+
describe('memory', () => {
|
|
8
|
+
it.effect('get returns None', () =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const adapter = CacheAdapter.memory();
|
|
11
|
+
const result = yield* adapter.get('key');
|
|
12
|
+
expect(Option.isNone(result)).toBe(true);
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
it.effect('set and remove are no-ops', () =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const adapter = CacheAdapter.memory();
|
|
19
|
+
yield* adapter.set(
|
|
20
|
+
'key',
|
|
21
|
+
{ value: 'val', storedAt: 0 },
|
|
22
|
+
Duration.minutes(5),
|
|
23
|
+
);
|
|
24
|
+
yield* adapter.remove('key');
|
|
25
|
+
yield* adapter.removeAll;
|
|
26
|
+
// Should not throw
|
|
27
|
+
const result = yield* adapter.get('key');
|
|
28
|
+
expect(Option.isNone(result)).toBe(true);
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
it.effect('carries capacity', () =>
|
|
33
|
+
Effect.sync(() => {
|
|
34
|
+
const adapter = CacheAdapter.memory({ capacity: 100 });
|
|
35
|
+
expect(adapter.capacity).toBe(100);
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('redis', () => {
|
|
41
|
+
const makeInMemoryRedisClient = () => {
|
|
42
|
+
const store = new Map<string, { value: string; expiresAt: number }>();
|
|
43
|
+
return {
|
|
44
|
+
client: {
|
|
45
|
+
get: (key: string) =>
|
|
46
|
+
Effect.sync(() => {
|
|
47
|
+
const entry = store.get(key);
|
|
48
|
+
if (!entry) return Option.none<string>();
|
|
49
|
+
if (Date.now() > entry.expiresAt) {
|
|
50
|
+
store.delete(key);
|
|
51
|
+
return Option.none<string>();
|
|
52
|
+
}
|
|
53
|
+
return Option.some(entry.value);
|
|
54
|
+
}),
|
|
55
|
+
set: (key: string, value: string, ttlMs: number) =>
|
|
56
|
+
Effect.sync(() => {
|
|
57
|
+
store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
58
|
+
}),
|
|
59
|
+
del: (key: string) =>
|
|
60
|
+
Effect.sync(() => {
|
|
61
|
+
store.delete(key);
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
store,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it.effect('prefixes keys', () =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const redis = makeInMemoryRedisClient();
|
|
71
|
+
const adapter = CacheAdapter.redis<number, string>({
|
|
72
|
+
client: redis.client,
|
|
73
|
+
keyPrefix: 'user',
|
|
74
|
+
});
|
|
75
|
+
yield* adapter.set(
|
|
76
|
+
123,
|
|
77
|
+
{ value: 'alice', storedAt: 1000 },
|
|
78
|
+
Duration.minutes(5),
|
|
79
|
+
);
|
|
80
|
+
expect(redis.store.has('user:123')).toBe(true);
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it.effect('roundtrips entries', () =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const redis = makeInMemoryRedisClient();
|
|
87
|
+
const adapter = CacheAdapter.redis<number, string>({
|
|
88
|
+
client: redis.client,
|
|
89
|
+
keyPrefix: 'user',
|
|
90
|
+
});
|
|
91
|
+
yield* adapter.set(
|
|
92
|
+
1,
|
|
93
|
+
{ value: 'alice', storedAt: 1000 },
|
|
94
|
+
Duration.minutes(5),
|
|
95
|
+
);
|
|
96
|
+
const result = yield* adapter.get(1);
|
|
97
|
+
expect(Option.isSome(result)).toBe(true);
|
|
98
|
+
if (Option.isSome(result)) {
|
|
99
|
+
expect(result.value.value).toBe('alice');
|
|
100
|
+
expect(result.value.storedAt).toBe(1000);
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
it.effect('removes entries', () =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const redis = makeInMemoryRedisClient();
|
|
108
|
+
const adapter = CacheAdapter.redis<number, string>({
|
|
109
|
+
client: redis.client,
|
|
110
|
+
keyPrefix: 'user',
|
|
111
|
+
});
|
|
112
|
+
yield* adapter.set(
|
|
113
|
+
1,
|
|
114
|
+
{ value: 'alice', storedAt: 1000 },
|
|
115
|
+
Duration.minutes(5),
|
|
116
|
+
);
|
|
117
|
+
yield* adapter.remove(1);
|
|
118
|
+
const result = yield* adapter.get(1);
|
|
119
|
+
expect(Option.isNone(result)).toBe(true);
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('tiered', () => {
|
|
125
|
+
it.effect('falls through L1 to L2', () =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const l1 = CacheAdapter.memory<string, string>();
|
|
128
|
+
const l2Store = new Map<string, { value: string; storedAt: number }>();
|
|
129
|
+
const l2: typeof l1 = {
|
|
130
|
+
get: (key) => {
|
|
131
|
+
const entry = l2Store.get(key);
|
|
132
|
+
return Effect.succeed(
|
|
133
|
+
entry !== undefined ? Option.some(entry) : Option.none(),
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
set: (key, entry) =>
|
|
137
|
+
Effect.sync(() => {
|
|
138
|
+
l2Store.set(key, entry);
|
|
139
|
+
}),
|
|
140
|
+
remove: (key) =>
|
|
141
|
+
Effect.sync(() => {
|
|
142
|
+
l2Store.delete(key);
|
|
143
|
+
}),
|
|
144
|
+
removeAll: Effect.sync(() => {
|
|
145
|
+
l2Store.clear();
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const tiered = CacheAdapter.tiered(l1, l2);
|
|
150
|
+
|
|
151
|
+
// Set in tiered (should write to both)
|
|
152
|
+
yield* tiered.set(
|
|
153
|
+
'k',
|
|
154
|
+
{ value: 'v', storedAt: 1000 },
|
|
155
|
+
Duration.minutes(5),
|
|
156
|
+
);
|
|
157
|
+
expect(l2Store.has('k')).toBe(true);
|
|
158
|
+
|
|
159
|
+
// Get from tiered (L1 memory is no-op, so falls through to L2)
|
|
160
|
+
const result = yield* tiered.get('k');
|
|
161
|
+
expect(Option.isSome(result)).toBe(true);
|
|
162
|
+
if (Option.isSome(result)) {
|
|
163
|
+
expect(result.value.value).toBe('v');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Remove from tiered
|
|
167
|
+
yield* tiered.remove('k');
|
|
168
|
+
expect(l2Store.has('k')).toBe(false);
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
it.effect('uses L1 capacity', () =>
|
|
173
|
+
Effect.sync(() => {
|
|
174
|
+
const l1 = CacheAdapter.memory<string, string>({ capacity: 50 });
|
|
175
|
+
const l2 = CacheAdapter.memory<string, string>({ capacity: 1000 });
|
|
176
|
+
const tiered = CacheAdapter.tiered(l1, l2);
|
|
177
|
+
expect(tiered.capacity).toBe(50);
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Duration, Effect, Option, Schema } from 'effect';
|
|
2
|
+
|
|
3
|
+
export type CacheEntry<Value> = {
|
|
4
|
+
readonly value: Value;
|
|
5
|
+
readonly storedAt: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type RedisClient = {
|
|
9
|
+
readonly get: (key: string) => Effect.Effect<Option.Option<string>>;
|
|
10
|
+
readonly set: (
|
|
11
|
+
key: string,
|
|
12
|
+
value: string,
|
|
13
|
+
ttlMs: number,
|
|
14
|
+
) => Effect.Effect<void>;
|
|
15
|
+
readonly del: (key: string) => Effect.Effect<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CacheAdapter<Key, Value> = {
|
|
19
|
+
readonly get: (key: Key) => Effect.Effect<Option.Option<CacheEntry<Value>>>;
|
|
20
|
+
readonly set: (
|
|
21
|
+
key: Key,
|
|
22
|
+
entry: CacheEntry<Value>,
|
|
23
|
+
ttl: Duration.Duration,
|
|
24
|
+
) => Effect.Effect<void>;
|
|
25
|
+
readonly remove: (key: Key) => Effect.Effect<void>;
|
|
26
|
+
readonly removeAll: Effect.Effect<void>;
|
|
27
|
+
readonly capacity?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export namespace CacheAdapter {
|
|
31
|
+
// No-op adapter — Effect Cache IS the in-memory store. This only carries `capacity`.
|
|
32
|
+
export function memory<Key, Value>(opts?: {
|
|
33
|
+
capacity?: number;
|
|
34
|
+
}): CacheAdapter<Key, Value> {
|
|
35
|
+
return {
|
|
36
|
+
get: () => Effect.succeed(Option.none()),
|
|
37
|
+
set: () => Effect.void,
|
|
38
|
+
remove: () => Effect.void,
|
|
39
|
+
removeAll: Effect.void,
|
|
40
|
+
capacity: opts?.capacity,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function redis<Key, Value>(opts: {
|
|
45
|
+
client: RedisClient;
|
|
46
|
+
keyPrefix: string;
|
|
47
|
+
schema?: Schema.Schema<Value, string>;
|
|
48
|
+
}): CacheAdapter<Key, Value> {
|
|
49
|
+
const encodeKey = (key: Key) => `${opts.keyPrefix}:${JSON.stringify(key)}`;
|
|
50
|
+
|
|
51
|
+
const encodeEntry = (entry: CacheEntry<Value>): Effect.Effect<string> => {
|
|
52
|
+
if (opts.schema) {
|
|
53
|
+
return Schema.encode(opts.schema)(entry.value).pipe(
|
|
54
|
+
Effect.map((encoded) =>
|
|
55
|
+
JSON.stringify({ value: encoded, storedAt: entry.storedAt }),
|
|
56
|
+
),
|
|
57
|
+
Effect.orDie,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return Effect.succeed(JSON.stringify(entry));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const decodeEntry = (raw: string): Effect.Effect<CacheEntry<Value>> => {
|
|
64
|
+
const parsed = JSON.parse(raw);
|
|
65
|
+
if (opts.schema) {
|
|
66
|
+
return Schema.decode(opts.schema)(parsed.value).pipe(
|
|
67
|
+
Effect.map((value) => ({
|
|
68
|
+
value,
|
|
69
|
+
storedAt: parsed.storedAt as number,
|
|
70
|
+
})),
|
|
71
|
+
Effect.orDie,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return Effect.succeed(parsed as CacheEntry<Value>);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get: (key) =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const raw = yield* opts.client.get(encodeKey(key));
|
|
81
|
+
if (Option.isNone(raw)) return Option.none<CacheEntry<Value>>();
|
|
82
|
+
const entry = yield* decodeEntry(raw.value);
|
|
83
|
+
return Option.some(entry);
|
|
84
|
+
}),
|
|
85
|
+
set: (key, entry, ttl) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const encoded = yield* encodeEntry(entry);
|
|
88
|
+
yield* opts.client.set(
|
|
89
|
+
encodeKey(key),
|
|
90
|
+
encoded,
|
|
91
|
+
Duration.toMillis(ttl),
|
|
92
|
+
);
|
|
93
|
+
}),
|
|
94
|
+
remove: (key) => opts.client.del(encodeKey(key)),
|
|
95
|
+
removeAll: Effect.void,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function tiered<Key, Value>(
|
|
100
|
+
l1: CacheAdapter<Key, Value>,
|
|
101
|
+
l2: CacheAdapter<Key, Value>,
|
|
102
|
+
): CacheAdapter<Key, Value> {
|
|
103
|
+
return {
|
|
104
|
+
get: (key) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const fromL1 = yield* l1.get(key);
|
|
107
|
+
if (Option.isSome(fromL1)) return fromL1;
|
|
108
|
+
return yield* l2.get(key);
|
|
109
|
+
}),
|
|
110
|
+
set: (key, entry, ttl) =>
|
|
111
|
+
Effect.all([l1.set(key, entry, ttl), l2.set(key, entry, ttl)], {
|
|
112
|
+
discard: true,
|
|
113
|
+
}),
|
|
114
|
+
remove: (key) =>
|
|
115
|
+
Effect.all([l1.remove(key), l2.remove(key)], { discard: true }),
|
|
116
|
+
removeAll: Effect.all([l1.removeAll, l2.removeAll], { discard: true }),
|
|
117
|
+
capacity: l1.capacity,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Effect, Option } from 'effect';
|
|
2
|
+
import type { RedisClient } from '../adapter.js';
|
|
3
|
+
|
|
4
|
+
type BunRedisClient = {
|
|
5
|
+
get(key: string): Promise<string | null>;
|
|
6
|
+
send(command: string, args: string[]): Promise<unknown>;
|
|
7
|
+
del(key: string): Promise<number>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function bunRedis(client: BunRedisClient): RedisClient {
|
|
11
|
+
return {
|
|
12
|
+
get: (key) =>
|
|
13
|
+
Effect.tryPromise(() => client.get(key)).pipe(
|
|
14
|
+
Effect.map((value) =>
|
|
15
|
+
value !== null ? Option.some(value) : Option.none(),
|
|
16
|
+
),
|
|
17
|
+
Effect.orDie,
|
|
18
|
+
),
|
|
19
|
+
set: (key, value, ttlMs) =>
|
|
20
|
+
Effect.tryPromise(() =>
|
|
21
|
+
client.send('SET', [key, value, 'PX', String(ttlMs)]),
|
|
22
|
+
).pipe(Effect.asVoid, Effect.orDie),
|
|
23
|
+
del: (key) =>
|
|
24
|
+
Effect.tryPromise(() => client.del(key)).pipe(
|
|
25
|
+
Effect.asVoid,
|
|
26
|
+
Effect.orDie,
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect, Option } from 'effect';
|
|
2
|
+
import type { RedisClient } from '../adapter.js';
|
|
3
|
+
|
|
4
|
+
type IORedisClient = {
|
|
5
|
+
get(key: string): Promise<string | null>;
|
|
6
|
+
set(key: string, value: string, px: 'PX', ttlMs: number): Promise<unknown>;
|
|
7
|
+
del(key: string): Promise<number>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function ioredis(client: IORedisClient): RedisClient {
|
|
11
|
+
return {
|
|
12
|
+
get: (key) =>
|
|
13
|
+
Effect.tryPromise(() => client.get(key)).pipe(
|
|
14
|
+
Effect.map((value) =>
|
|
15
|
+
value !== null ? Option.some(value) : Option.none(),
|
|
16
|
+
),
|
|
17
|
+
Effect.orDie,
|
|
18
|
+
),
|
|
19
|
+
set: (key, value, ttlMs) =>
|
|
20
|
+
Effect.tryPromise(() => client.set(key, value, 'PX', ttlMs)).pipe(
|
|
21
|
+
Effect.asVoid,
|
|
22
|
+
Effect.orDie,
|
|
23
|
+
),
|
|
24
|
+
del: (key) =>
|
|
25
|
+
Effect.tryPromise(() => client.del(key)).pipe(
|
|
26
|
+
Effect.asVoid,
|
|
27
|
+
Effect.orDie,
|
|
28
|
+
),
|
|
29
|
+
};
|
|
30
|
+
}
|