@elysiumjs/mnemosyne 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @elysiumjs/mnemosyne
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#26](https://github.com/na2axl/elysiumjs/pull/26) [`4cd1ef2`](https://github.com/na2axl/elysiumjs/commit/4cd1ef217826164e69cd641ca99f74c6f5a7ebe7) Thanks [@na2axl](https://github.com/na2axl)! - Type the query string of the query DSL. `Query.define` keeps the names of the filters, their operators and the sortable fields, `Query.Params<typeof spec>` types the parameter that `@Http.query()` decorates (`filter[status]`, `filter[status][in]`, `sort`, `cursor`, `limit`, `fields`, each an optional string), and `Query.schema(spec)` returns a schema of that type. Because the generated routes read the parameter's type, clients get typed query keys. Core re-exports TypeBox's `TOptional` and `TString` types.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`4cd1ef2`](https://github.com/na2axl/elysiumjs/commit/4cd1ef217826164e69cd641ca99f74c6f5a7ebe7)]:
12
+ - @elysiumjs/core@0.11.1
13
+ - @elysiumjs/redis@0.1.0
14
+
3
15
  ## 0.4.0
4
16
 
5
17
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export * from './model';
4
4
  export * from './repository';
5
5
  export { ModelResource, type ModelResourceOptions } from './resource';
6
6
  export { type TransactionRunner, Transactional, UnitOfWork, withUnitOfWork } from './unit-of-work';
7
- export { type CursorPage, type FilterOperator, type ParsedFilter, type ParsedQuery, type ParsedSort, type QueryFieldType, type QueryFilterSpec, type QuerySpec, Query } from './query';
7
+ export { type CursorPage, type FilterOperator, type ParsedFilter, type ParsedQuery, type ParsedSort, type QueryFieldType, type QueryFilterSpec, type QueryFilters, type QueryParamKeys, type QueryParams, type QuerySchemaProperties, type QuerySpec, Query } from './query';
8
8
  export * from './tenancy';
9
9
  export type { TenancyConfig, TenancyMode, ModelTenancyConfig } from './tenancy';
10
10
  export { Cache, type CacheInterface, cacheStoreService, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};import{Application,Service as Service2,ServiceNotFoundError as ServiceNotFoundError2}from"@elysiumjs/core";import{Service,ServiceNotFoundError}from"@elysiumjs/core";import{createCache}from"cache-manager";import{Keyv}from"keyv";import{alphabetical}from"radash";var REDIS_CACHE_SERVICE="mnemosyne.cache.redis",MEMORY_CACHE_SERVICE="mnemosyne.cache.memory",cacheStoreService=(name)=>`mnemosyne.cache.store.${name}`,makeCacheInterface=(createStore)=>{let makeNamespace=(namespace)=>createCache({stores:[new Keyv({store:createStore(),namespace})]}),base=makeNamespace("cache"),tagsCache=new Map;return{...base,tags(...tags){let namespace=`cache__${alphabetical(tags,(item)=>item).join("__")}`,cache=tagsCache.get(namespace);if(!cache)cache=makeNamespace(namespace),tagsCache.set(namespace,cache);return cache}}},getCache=(name)=>{let cache=Service.get(name);if(!cache)throw new ServiceNotFoundError(name);return cache};class Cache{static weak=Object.seal({map:new WeakMap,get(ctx){if(!this.map.has(ctx))this.map.set(ctx,{});return this.map.get(ctx)},del(ctx){this.map.delete(ctx)}});static get redis(){return getCache(REDIS_CACHE_SERVICE)}static get memory(){return getCache(MEMORY_CACHE_SERVICE)}static store(name){return getCache(cacheStoreService(name))}}import{DATABASE_TRANSACTION_SERVICE}from"@elysiumjs/core";var createDatabaseCacheStorage=(config)=>Cache[config.storage??"redis"].tags("database");class AbstractDatabase{static getConnectionName(name){return`db.connection.${name}`}getConnection(name="default"){if(name==="default"){let legacy=Application.context.getStore()?.get("db:tx");if(legacy)return legacy}let connection=this.getBaseConnection(name);return this.getActiveTransaction(connection)??connection}getBaseConnection(name="default"){let serviceName=AbstractDatabase.getConnectionName(name),connection=Service2.get(serviceName);if(connection===null)throw new ServiceNotFoundError2(serviceName);return connection}inTransaction(name="default"){return this.getActiveTransaction(this.getBaseConnection(name))!==void 0}async transaction(callback,name="default"){let connection=this.getBaseConnection(name),active=this.getActiveTransaction(connection);if(active!==void 0)return callback(active);return this.driver.withTransaction(connection,(tx)=>this.runInTransaction(connection,tx,()=>callback(tx)))}runInTransaction(connection,tx,callback){let store=Application.context.getStore(),transactions=new Map(store?.get("db:transactions"));transactions.set(connection,tx);let next=new Map(store);if(next.set("db:transactions",transactions),this.isDefaultConnection(connection))next.set("db:tx",tx);return Application.context.run(next,callback)}getActiveTransaction(connection){return Application.context.getStore()?.get("db:transactions")?.get(connection)}isDefaultConnection(connection){return Service2.get(AbstractDatabase.getConnectionName("default"))===connection}connectionExists(name){return Service2.exists(AbstractDatabase.getConnectionName(name))}getDefaultConnection(){return this.getConnection("default")}getRawClient(name="default"){return this.driver.getRawClient(this.getConnection(name))}}import{Application as Application3}from"@elysiumjs/core";import{t}from"elysia";import{Application as Application2,Middleware,Service as Service3}from"@elysiumjs/core";var TENANCY_CONFIG_SERVICE="mnemosyne.tenancy.config",TENANCY_STRATEGY_SERVICE="mnemosyne.tenancy.strategy",getTenancyConfig=()=>Service3.get(TENANCY_CONFIG_SERVICE)??{mode:"schema"},getTenancyStrategy=()=>Service3.get(TENANCY_STRATEGY_SERVICE),getCurrentTenant=()=>Application2.context.getStore()?.get("tenant")??null,withTenant=(tenant,callback)=>{let currentStore=Application2.context.getStore(),newStore=new Map(currentStore);return newStore.set("tenant",tenant),Application2.context.run(newStore,callback)},TENANT_ID_PATTERN=/^(?!pg_)(?!information_schema$)[a-z0-9_-]{1,63}$/i,isValidTenantId=(tenant)=>TENANT_ID_PATTERN.test(tenant);class TenantMiddleware extends Middleware{header="x-tenant-id";allowPublic=!0;connectionName="default";async validateTenant(_tenant){return!1}async onTenantResolved(_tenant,_ctx){}onTenantError(reason,_ctx){if(reason==="missing")throw Error("Tenant ID required");else throw Error("Invalid tenant")}async onRequest(ctx){let tenantId=ctx.request.headers.get(this.header);if(!tenantId||tenantId==="public"){if(this.allowPublic){ctx.tenant="public";return}return this.onTenantError("missing",ctx)}if(!(isValidTenantId(tenantId)&&await this.validateTenant(tenantId)))return this.onTenantError("invalid",ctx);ctx.tenant=tenantId,await this.onTenantResolved(tenantId,ctx)}}class SimpleTenantMiddleware extends TenantMiddleware{}class StrictTenantMiddleware extends TenantMiddleware{allowPublic=!1}var createSchemaFromModel=(columns,{mode="select"}={})=>{if(columns===void 0||columns.length===0)return t.Object({});let properties={};for(let column of columns){if(["create","update"].includes(mode)&&column.isPrimaryKey)continue;if(properties[column.name]=parseColumnType(column),column.nullable)properties[column.name]=t.Nullable(properties[column.name]);if(mode==="update"||column.hasDefault||column.nullable)properties[column.name]=t.Optional(properties[column.name])}return t.Object(properties)},parseColumnType=(column)=>{switch(column.dataType){case"string":return t.String();case"uuid":return t.String({format:"uuid"});case"number":return t.Number();case"boolean":return t.Boolean();case"array":return t.Array(t.Any());case"json":return t.Object({});case"date":return t.Codec(t.String({format:"date"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"datetime":return t.Codec(t.String({format:"date-time"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"bigint":return t.BigInt();case"buffer":return t.File();default:return t.Never()}},AbstractModel=(tableName,columns,adapter,baseTable)=>{let columnMetadata=adapter.getColumns(baseTable);class M{static $inferSelect=void 0;static $inferInsert=void 0;static $inferUpdate=void 0;static get table(){if(!Application3.instance||!Application3.context.getStore)return baseTable;if(this.supportTenancy){let tenant=getCurrentTenant()??"public";if(tenant!=="public"){let strategy=getTenancyStrategy();if(strategy)return strategy.resolveTable(baseTable,tenant);return adapter.createTenantTable(baseTable,tenant)}}return baseTable}static tableName=tableName;static columns=columns;static insertSchema=createSchemaFromModel(columnMetadata,{mode:"create"});static updateSchema=createSchemaFromModel(columnMetadata,{mode:"update"});static selectSchema=createSchemaFromModel(columnMetadata,{mode:"select"});static supportTenancy=!1}return M};var AbstractRepository=(model,database)=>{class R{static Model=model;static connection="default";get db(){return database.getConnection(this.constructor.connection)}}return R};import{Resource}from"@elysiumjs/core";import{t as t2}from"elysia";var ModelResource=(model,options={})=>{let select=model.selectSchema,hidden=new Set(options.hidden??[]),omit=new Set(options.omit??[]),pick=options.pick?new Set(options.pick):void 0,properties={};for(let[name,schema]of Object.entries(select.properties)){if(omit.has(name)||pick&&!pick.has(name)&&!hidden.has(name))continue;properties[name]=hidden.has(name)?Resource.hidden(schema):schema}return t2.Object({...properties,...options.extend})};import{Application as Application4,DATABASE_TRANSACTION_SERVICE as DATABASE_TRANSACTION_SERVICE2,DomainError,getLogger,Service as Service4}from"@elysiumjs/core";var FRAME="uow:frame";class Refusal{error;constructor(error){this.error=error}}var currentFrame=()=>Application4.context.getStore()?.get(FRAME),runHooks=async(hooks,phase)=>{for(let hook of hooks)try{await hook()}catch(error){getLogger().error(`A unit of work ${phase} hook failed`,{error})}},withUnitOfWork=(transaction)=>async(callback,connection)=>{if(currentFrame())return transaction(callback,connection);let frame={afterCommit:[],afterRollback:[]},store=new Map(Application4.context.getStore());store.set(FRAME,frame);let result;try{result=await Application4.context.run(store,()=>transaction(async()=>{let value=await callback();if(value instanceof DomainError)throw new Refusal(value);return value},connection))}catch(error){if(await runHooks(frame.afterRollback,"afterRollback"),error instanceof Refusal)return error.error;throw error}return await runHooks(frame.afterCommit,"afterCommit"),result},UnitOfWork;((UnitOfWork)=>(UnitOfWork.run=(callback,options={})=>{let runner=Service4.get(DATABASE_TRANSACTION_SERVICE2);if(!runner)throw Error(`Units of work need the "${DATABASE_TRANSACTION_SERVICE2}" service. Import a database module (such as DrizzleModule) in the application.`);return runner(callback,options.connection)},UnitOfWork.active=()=>currentFrame()!==void 0,UnitOfWork.afterCommit=async(hook)=>{let frame=currentFrame();if(frame)frame.afterCommit.push(hook);else await runHooks([hook],"afterCommit")},UnitOfWork.afterRollback=(hook)=>{currentFrame()?.afterRollback.push(hook)}))(UnitOfWork||={});var Transactional=(options={})=>(_target,_key,descriptor)=>{let method=descriptor.value;return descriptor.value=function(...args){return UnitOfWork.run(()=>method.apply(this,args),options)},descriptor};import{BadRequestError}from"@elysiumjs/core";import{t as t3}from"elysia";var DEFAULT_LIMIT=20,DEFAULT_MAX_LIMIT=100,FILTER_KEY=/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/,toBase64Url=(text)=>btoa(String.fromCharCode(...new TextEncoder().encode(text))).replaceAll("+","-").replaceAll("/","_").replace(/=+$/,""),fromBase64Url=(text)=>{let base64=text.replaceAll("-","+").replaceAll("_","/"),binary=atob(base64+"=".repeat((4-base64.length%4)%4));return new TextDecoder().decode(Uint8Array.from(binary,(char)=>char.charCodeAt(0)))},UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,readValue=(type,raw)=>{switch(type){case"number":{let value=Number(raw);return raw.trim()!==""&&Number.isFinite(value)?{value}:{error:"must be a number"}}case"boolean":return raw==="true"||raw==="false"?{value:raw==="true"}:{error:"must be true or false"};case"date":{let value=new Date(raw);return Number.isNaN(value.getTime())?{error:"must be a date"}:{value}}case"uuid":return UUID.test(raw)?{value:raw}:{error:"must be a UUID"};default:return{value:raw}}},typeOfSchema=(schema)=>{let node=schema.anyOf?.find((branch)=>branch.type!=="null")??schema,{type,format}=node;if(type==="number"||type==="integer"||type==="bigint")return"number";if(type==="boolean")return"boolean";if(format==="date"||format==="date-time")return"date";if(format==="uuid")return"uuid";return"string"},normalizeFilter=(spec)=>Array.isArray(spec)?{operators:spec}:spec,Query;((Query)=>{Query.define=(model,spec)=>{let properties=model.selectSchema.properties??{},types={};for(let[name,schema]of Object.entries(properties))types[name]=typeOfSchema(schema);return{...spec,types}};let typeOf=(spec,field,column)=>{let filter=spec.filters?.[field];return(filter&&normalizeFilter(filter).type)??spec.types?.[column]??spec.types?.[field]??"string"},columnOf=(spec,field)=>{let filter=spec.filters?.[field];return(filter&&normalizeFilter(filter).column)??spec.columns?.[field]??field};Query.schema=(spec)=>{let properties={};for(let[field,raw]of Object.entries(spec.filters??{})){let filter=normalizeFilter(raw);properties[`filter[${field}]`]=t3.Optional(t3.String({description:`Filters ${field} (${filter.operators[0]}).`}));for(let operator of filter.operators)properties[`filter[${field}][${operator}]`]=t3.Optional(t3.String({description:`Filters ${field} with ${operator}.`}))}if(spec.sort?.length)properties.sort=t3.Optional(t3.String({description:`Comma-separated sort fields, "-" for descending: ${spec.sort.join(", ")}.`}));return properties.cursor=t3.Optional(t3.String({description:"The cursor of the next page."})),properties.limit=t3.Optional(t3.String({description:`Rows per page, up to ${spec.limit?.max??DEFAULT_MAX_LIMIT}.`})),properties.fields=t3.Optional(t3.String({description:"Comma-separated fields to return."})),t3.Object(properties,{additionalProperties:!0})},Query.parse=(spec,query={})=>{let errors=[],fail=(parameter,message,code)=>errors.push({pointer:`/query/${parameter.replaceAll("/","~1")}`,message,code}),filters=[];for(let[key,raw]of Object.entries(query)){let match=FILTER_KEY.exec(key);if(!match)continue;let[,field,named]=match,filterSpec=spec.filters?.[field];if(!filterSpec){fail(key,`cannot filter by "${field}"`,"filter");continue}let{operators}=normalizeFilter(filterSpec),operator=named??operators[0];if(!operators.includes(operator)){fail(key,`"${operator}" is not allowed on "${field}"`,"operator");continue}let column=columnOf(spec,field),type=typeOf(spec,field,column),text=String(Array.isArray(raw)?raw.at(-1):raw);if(operator==="isNull"){let read=readValue("boolean",text);if("error"in read)fail(key,read.error,"type");else filters.push({field,column,operator,value:read.value});continue}if(operator==="in"){let values=[];for(let part of text.split(",")){let read=readValue(type,part.trim());if("error"in read)fail(key,`each value ${read.error}`,"type");else values.push(read.value)}if(values.length>0&&values.length===text.split(",").length)filters.push({field,column,operator,value:values});continue}let read=readValue(operator==="contains"||operator==="startsWith"?"string":type,text);if("error"in read)fail(key,read.error,"type");else filters.push({field,column,operator,value:read.value})}let key=spec.key??"id",sort=[],rawSort=typeof query.sort==="string"&&query.sort!==""?query.sort:spec.defaultSort;for(let part of(rawSort??"").split(",").map((item)=>item.trim()).filter(Boolean)){let direction=part.startsWith("-")?"desc":"asc",field=part.replace(/^[-+]/,"");if(!spec.sort?.includes(field)){fail("sort",`cannot sort by "${field}"`,"sort");continue}sort.push({field,column:columnOf(spec,field),direction})}if(!sort.some((item)=>item.column===key))sort.push({field:key,column:key,direction:sort.at(-1)?.direction??"asc"});let max=spec.limit?.max??DEFAULT_MAX_LIMIT,limit=spec.limit?.default??DEFAULT_LIMIT;if(query.limit!==void 0&&query.limit!==""){let value=Number(query.limit);if(!Number.isInteger(value)||value<1||value>max)fail("limit",`must be a whole number from 1 to ${max}`,"limit");else limit=value}let after;if(typeof query.cursor==="string"&&query.cursor!==""){let values=decodeCursor(query.cursor,sort.length)?.map((value,index)=>{if(value===null)return{value:null};if(!["string","number","boolean"].includes(typeof value))return{error:"bad"};let{field,column}=sort[index];return readValue(typeOf(spec,field,column),String(value))});if(!values||values.some((read)=>("error"in read)))fail("cursor","is not a cursor of this listing","cursor");else after=values.map((read)=>read.value)}if(errors.length>0)throw new BadRequestError("The query is invalid.",{errors});return{filters,sort,after,limit}},Query.cursorAfter=(query,row)=>toBase64Url(JSON.stringify(query.sort.map(({column})=>{let value=row[column];return value instanceof Date?value.toISOString():value})));let decodeCursor=(cursor,length)=>{try{let values=JSON.parse(fromBase64Url(cursor));return Array.isArray(values)&&values.length===length?values:void 0}catch{return}};Query.page=(query,rows)=>{let data=rows.slice(0,query.limit),more=rows.length>query.limit;return{data,nextCursor:more?Query.cursorAfter(query,data.at(-1)):null}},Query.pageSchema=(item)=>t3.Object({data:t3.Array(item),nextCursor:t3.Nullable(t3.String())})})(Query||={});import{Module}from"@elysiumjs/core";import{KeyvCacheableMemory}from"cacheable";import{EventEmitter}from"events";import{Lifecycle,Service as Service5}from"@elysiumjs/core";import{Redis}from"@elysiumjs/redis";class KeyvRedis extends EventEmitter{#client;#namespace;#keyPrefixSeparator="::";#clearBatchSize=1000;#useUnlink=!0;#noNamespaceAffectsAll=!1;#connection;constructor(props){super();this.#connection=props.connection,this.#namespace=props.namespace,this.#keyPrefixSeparator=props.keyPrefixSeparator??"::",this.#clearBatchSize=props.clearBatchSize??1000,this.#useUnlink=props.useUnlink??!0,this.#noNamespaceAffectsAll=props.noNamespaceAffectsAll??!1,this.on("error",(error)=>{Lifecycle.emit("elysium:error",error)})}async getClient(){return this.#client??=await Service5.resolve(Redis.getConnectionName(this.#connection)),this.#client}get opts(){return{namespace:this.#namespace,keyPrefixSeparator:this.#keyPrefixSeparator,clearBatchSize:this.#clearBatchSize,noNamespaceAffectsAll:this.#noNamespaceAffectsAll,dialect:"redis",url:this.#connection}}get namespace(){return this.#namespace}set namespace(value){this.#namespace=value}async get(key){let client=await this.getClient();key=this.createKeyPrefix(key,this.#namespace);let value=await client.get(key);return value===null?void 0:value}async set(key,value,ttl){let client=await this.getClient();if(key=this.createKeyPrefix(key,this.#namespace),ttl)await client.send("SET",[key,value,"PX",String(ttl)]);else await client.set(key,value)}async setMany(values){let client=await this.getClient(),multi=[];for(let{key,value,ttl}of values){let prefixedKey=this.createKeyPrefix(key,this.#namespace);if(ttl)multi.push(client.send("SET",[prefixedKey,value,"PX",String(ttl)]));else multi.push(client.set(prefixedKey,value))}await Promise.all(multi)}async delete(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),await this.#remove(client,[key])>0}async clear(){try{let client=await this.getClient();if(!this.#namespace&&this.#noNamespaceAffectsAll){await client.send("FLUSHDB",[]);return}let cursor="0",batchSize=this.#clearBatchSize,match=this.#namespace?`${this.#namespace}${this.#keyPrefixSeparator}*`:"*";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"COUNT",batchSize.toString(),"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),keys.length===0)continue;if(!this.#namespace)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0)await this.#remove(client,keys)}while(cursor!=="0")}catch(error){this.emit("error",error)}}async has(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),Number(await client.send("EXISTS",[key]))>0}async hasMany(keys){let client=await this.getClient(),multi=[];for(let key of keys){let prefixedKey=this.createKeyPrefix(key,this.#namespace);multi.push(client.send("EXISTS",[prefixedKey]).then((count)=>Number(count)>0))}return Promise.all(multi)}async getMany(keys){let client=await this.getClient();if(keys.length===0)return[];return keys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace)),await client.send("MGET",keys)}async disconnect(){this.#client=void 0}async deleteMany(keys){if(keys.length===0)return!1;let client=await this.getClient(),prefixedKeys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace));return await this.#remove(client,prefixedKeys)>0}#remove(client,keys){return this.#useUnlink?client.send("UNLINK",keys):client.del(...keys)}async*iterator(namespace){let client=await this.getClient(),match=namespace?`${namespace}${this.#keyPrefixSeparator}*`:"*",cursor="0";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),!namespace&&!this.#noNamespaceAffectsAll)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0){let values=await client.send("MGET",keys);for(let i of keys.keys()){let key=this.getKeyWithoutPrefix(keys[i],namespace),value=values[i];yield[key,value]}}}while(cursor!=="0")}createKeyPrefix(key,namespace){return namespace?`${namespace}${this.#keyPrefixSeparator}${key}`:key}getKeyWithoutPrefix(key,namespace){return namespace?key.replace(`${namespace}${this.#keyPrefixSeparator}`,""):key}}class MnemosyneModule extends Module.configurable({defaults:{cache:{redis:{connection:"cache"},memory:{ttl:60000,lruSize:5000}}}}){}MnemosyneModule=__legacyDecorateClassTS([Module.register({services:(config)=>{let services=[],{redis,memory,stores}=config.cache;if(redis)services.push({name:REDIS_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(()=>new KeyvRedis({connection:redis.connection}))});if(memory)services.push({name:MEMORY_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(()=>new KeyvCacheableMemory({ttl:memory.ttl,lruSize:memory.lruSize}))});for(let[name,createStore]of Object.entries(stores??{}))services.push({name:cacheStoreService(name),global:!0,factory:()=>makeCacheInterface(createStore)});return services}})],MnemosyneModule);var resolveStore=(store)=>store==="memory"?Cache.memory:store==="redis"?Cache.redis:Cache.store(store),defaultKey=(ctx)=>{let url=new URL(ctx.request.url);return`http:${typeof ctx.tenant==="string"?ctx.tenant:"public"}:${ctx.request.method}:${url.pathname}${url.search}`};class CacheInterceptor{static with(options){let{ttl,store="memory",key=defaultKey}=options;return class{async intercept(ctx,next){let method=ctx.request.method;if(method!=="GET"&&method!=="HEAD")return next();let cache=resolveStore(store),cacheKey=key(ctx),cached=await cache.get(cacheKey);if(cached!==void 0)return ctx.set.headers["x-cache"]="hit",cached.value;let value=await next();if(ctx.set.headers["x-cache"]="miss",!(value instanceof Response))await cache.set(cacheKey,{value},ttl);return value}}}}export{AbstractDatabase,AbstractModel,AbstractRepository,Cache,CacheInterceptor,DATABASE_TRANSACTION_SERVICE,KeyvRedis,MEMORY_CACHE_SERVICE,MnemosyneModule,ModelResource,Query,REDIS_CACHE_SERVICE,SimpleTenantMiddleware,StrictTenantMiddleware,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE,TENANT_ID_PATTERN,TenantMiddleware,Transactional,UnitOfWork,cacheStoreService,createDatabaseCacheStorage,createSchemaFromModel,getCurrentTenant,getTenancyConfig,getTenancyStrategy,isValidTenantId,withTenant,withUnitOfWork};
2
+ var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};import{Application,Service as Service2,ServiceNotFoundError as ServiceNotFoundError2}from"@elysiumjs/core";import{Service,ServiceNotFoundError}from"@elysiumjs/core";import{createCache}from"cache-manager";import{Keyv}from"keyv";import{alphabetical}from"radash";var REDIS_CACHE_SERVICE="mnemosyne.cache.redis",MEMORY_CACHE_SERVICE="mnemosyne.cache.memory",cacheStoreService=(name)=>`mnemosyne.cache.store.${name}`,makeCacheInterface=(createStore)=>{let makeNamespace=(namespace)=>createCache({stores:[new Keyv({store:createStore(),namespace})]}),base=makeNamespace("cache"),tagsCache=new Map;return{...base,tags(...tags){let namespace=`cache__${alphabetical(tags,(item)=>item).join("__")}`,cache=tagsCache.get(namespace);if(!cache)cache=makeNamespace(namespace),tagsCache.set(namespace,cache);return cache}}},getCache=(name)=>{let cache=Service.get(name);if(!cache)throw new ServiceNotFoundError(name);return cache};class Cache{static weak=Object.seal({map:new WeakMap,get(ctx){if(!this.map.has(ctx))this.map.set(ctx,{});return this.map.get(ctx)},del(ctx){this.map.delete(ctx)}});static get redis(){return getCache(REDIS_CACHE_SERVICE)}static get memory(){return getCache(MEMORY_CACHE_SERVICE)}static store(name){return getCache(cacheStoreService(name))}}import{DATABASE_TRANSACTION_SERVICE}from"@elysiumjs/core";var createDatabaseCacheStorage=(config)=>Cache[config.storage??"redis"].tags("database");class AbstractDatabase{static getConnectionName(name){return`db.connection.${name}`}getConnection(name="default"){if(name==="default"){let legacy=Application.context.getStore()?.get("db:tx");if(legacy)return legacy}let connection=this.getBaseConnection(name);return this.getActiveTransaction(connection)??connection}getBaseConnection(name="default"){let serviceName=AbstractDatabase.getConnectionName(name),connection=Service2.get(serviceName);if(connection===null)throw new ServiceNotFoundError2(serviceName);return connection}inTransaction(name="default"){return this.getActiveTransaction(this.getBaseConnection(name))!==void 0}async transaction(callback,name="default"){let connection=this.getBaseConnection(name),active=this.getActiveTransaction(connection);if(active!==void 0)return callback(active);return this.driver.withTransaction(connection,(tx)=>this.runInTransaction(connection,tx,()=>callback(tx)))}runInTransaction(connection,tx,callback){let store=Application.context.getStore(),transactions=new Map(store?.get("db:transactions"));transactions.set(connection,tx);let next=new Map(store);if(next.set("db:transactions",transactions),this.isDefaultConnection(connection))next.set("db:tx",tx);return Application.context.run(next,callback)}getActiveTransaction(connection){return Application.context.getStore()?.get("db:transactions")?.get(connection)}isDefaultConnection(connection){return Service2.get(AbstractDatabase.getConnectionName("default"))===connection}connectionExists(name){return Service2.exists(AbstractDatabase.getConnectionName(name))}getDefaultConnection(){return this.getConnection("default")}getRawClient(name="default"){return this.driver.getRawClient(this.getConnection(name))}}import{Application as Application3}from"@elysiumjs/core";import{t}from"elysia";import{Application as Application2,Middleware,Service as Service3}from"@elysiumjs/core";var TENANCY_CONFIG_SERVICE="mnemosyne.tenancy.config",TENANCY_STRATEGY_SERVICE="mnemosyne.tenancy.strategy",getTenancyConfig=()=>Service3.get(TENANCY_CONFIG_SERVICE)??{mode:"schema"},getTenancyStrategy=()=>Service3.get(TENANCY_STRATEGY_SERVICE),getCurrentTenant=()=>Application2.context.getStore()?.get("tenant")??null,withTenant=(tenant,callback)=>{let currentStore=Application2.context.getStore(),newStore=new Map(currentStore);return newStore.set("tenant",tenant),Application2.context.run(newStore,callback)},TENANT_ID_PATTERN=/^(?!pg_)(?!information_schema$)[a-z0-9_-]{1,63}$/i,isValidTenantId=(tenant)=>TENANT_ID_PATTERN.test(tenant);class TenantMiddleware extends Middleware{header="x-tenant-id";allowPublic=!0;connectionName="default";async validateTenant(_tenant){return!1}async onTenantResolved(_tenant,_ctx){}onTenantError(reason,_ctx){if(reason==="missing")throw Error("Tenant ID required");else throw Error("Invalid tenant")}async onRequest(ctx){let tenantId=ctx.request.headers.get(this.header);if(!tenantId||tenantId==="public"){if(this.allowPublic){ctx.tenant="public";return}return this.onTenantError("missing",ctx)}if(!(isValidTenantId(tenantId)&&await this.validateTenant(tenantId)))return this.onTenantError("invalid",ctx);ctx.tenant=tenantId,await this.onTenantResolved(tenantId,ctx)}}class SimpleTenantMiddleware extends TenantMiddleware{}class StrictTenantMiddleware extends TenantMiddleware{allowPublic=!1}var createSchemaFromModel=(columns,{mode="select"}={})=>{if(columns===void 0||columns.length===0)return t.Object({});let properties={};for(let column of columns){if(["create","update"].includes(mode)&&column.isPrimaryKey)continue;if(properties[column.name]=parseColumnType(column),column.nullable)properties[column.name]=t.Nullable(properties[column.name]);if(mode==="update"||column.hasDefault||column.nullable)properties[column.name]=t.Optional(properties[column.name])}return t.Object(properties)},parseColumnType=(column)=>{switch(column.dataType){case"string":return t.String();case"uuid":return t.String({format:"uuid"});case"number":return t.Number();case"boolean":return t.Boolean();case"array":return t.Array(t.Any());case"json":return t.Object({});case"date":return t.Codec(t.String({format:"date"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"datetime":return t.Codec(t.String({format:"date-time"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"bigint":return t.BigInt();case"buffer":return t.File();default:return t.Never()}},AbstractModel=(tableName,columns,adapter,baseTable)=>{let columnMetadata=adapter.getColumns(baseTable);class M{static $inferSelect=void 0;static $inferInsert=void 0;static $inferUpdate=void 0;static get table(){if(!Application3.instance||!Application3.context.getStore)return baseTable;if(this.supportTenancy){let tenant=getCurrentTenant()??"public";if(tenant!=="public"){let strategy=getTenancyStrategy();if(strategy)return strategy.resolveTable(baseTable,tenant);return adapter.createTenantTable(baseTable,tenant)}}return baseTable}static tableName=tableName;static columns=columns;static insertSchema=createSchemaFromModel(columnMetadata,{mode:"create"});static updateSchema=createSchemaFromModel(columnMetadata,{mode:"update"});static selectSchema=createSchemaFromModel(columnMetadata,{mode:"select"});static supportTenancy=!1}return M};var AbstractRepository=(model,database)=>{class R{static Model=model;static connection="default";get db(){return database.getConnection(this.constructor.connection)}}return R};import{Resource}from"@elysiumjs/core";import{t as t2}from"elysia";var ModelResource=(model,options={})=>{let select=model.selectSchema,hidden=new Set(options.hidden??[]),omit=new Set(options.omit??[]),pick=options.pick?new Set(options.pick):void 0,properties={};for(let[name,schema]of Object.entries(select.properties)){if(omit.has(name)||pick&&!pick.has(name)&&!hidden.has(name))continue;properties[name]=hidden.has(name)?Resource.hidden(schema):schema}return t2.Object({...properties,...options.extend})};import{Application as Application4,DATABASE_TRANSACTION_SERVICE as DATABASE_TRANSACTION_SERVICE2,DomainError,getLogger,Service as Service4}from"@elysiumjs/core";var FRAME="uow:frame";class Refusal{error;constructor(error){this.error=error}}var currentFrame=()=>Application4.context.getStore()?.get(FRAME),runHooks=async(hooks,phase)=>{for(let hook of hooks)try{await hook()}catch(error){getLogger().error(`A unit of work ${phase} hook failed`,{error})}},withUnitOfWork=(transaction)=>async(callback,connection)=>{if(currentFrame())return transaction(callback,connection);let frame={afterCommit:[],afterRollback:[]},store=new Map(Application4.context.getStore());store.set(FRAME,frame);let result;try{result=await Application4.context.run(store,()=>transaction(async()=>{let value=await callback();if(value instanceof DomainError)throw new Refusal(value);return value},connection))}catch(error){if(await runHooks(frame.afterRollback,"afterRollback"),error instanceof Refusal)return error.error;throw error}return await runHooks(frame.afterCommit,"afterCommit"),result},UnitOfWork;((UnitOfWork)=>(UnitOfWork.run=(callback,options={})=>{let runner=Service4.get(DATABASE_TRANSACTION_SERVICE2);if(!runner)throw Error(`Units of work need the "${DATABASE_TRANSACTION_SERVICE2}" service. Import a database module (such as DrizzleModule) in the application.`);return runner(callback,options.connection)},UnitOfWork.active=()=>currentFrame()!==void 0,UnitOfWork.afterCommit=async(hook)=>{let frame=currentFrame();if(frame)frame.afterCommit.push(hook);else await runHooks([hook],"afterCommit")},UnitOfWork.afterRollback=(hook)=>{currentFrame()?.afterRollback.push(hook)}))(UnitOfWork||={});var Transactional=(options={})=>(_target,_key,descriptor)=>{let method=descriptor.value;return descriptor.value=function(...args){return UnitOfWork.run(()=>method.apply(this,args),options)},descriptor};import{BadRequestError}from"@elysiumjs/core";import{t as t3}from"elysia";var DEFAULT_LIMIT=20,DEFAULT_MAX_LIMIT=100,FILTER_KEY=/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/,toBase64Url=(text)=>btoa(String.fromCharCode(...new TextEncoder().encode(text))).replaceAll("+","-").replaceAll("/","_").replace(/=+$/,""),fromBase64Url=(text)=>{let base64=text.replaceAll("-","+").replaceAll("_","/"),binary=atob(base64+"=".repeat((4-base64.length%4)%4));return new TextDecoder().decode(Uint8Array.from(binary,(char)=>char.charCodeAt(0)))},UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,readValue=(type,raw)=>{switch(type){case"number":{let value=Number(raw);return raw.trim()!==""&&Number.isFinite(value)?{value}:{error:"must be a number"}}case"boolean":return raw==="true"||raw==="false"?{value:raw==="true"}:{error:"must be true or false"};case"date":{let value=new Date(raw);return Number.isNaN(value.getTime())?{error:"must be a date"}:{value}}case"uuid":return UUID.test(raw)?{value:raw}:{error:"must be a UUID"};default:return{value:raw}}},typeOfSchema=(schema)=>{let node=schema.anyOf?.find((branch)=>branch.type!=="null")??schema,{type,format}=node;if(type==="number"||type==="integer"||type==="bigint")return"number";if(type==="boolean")return"boolean";if(format==="date"||format==="date-time")return"date";if(format==="uuid")return"uuid";return"string"},normalizeFilter=(spec)=>("operators"in spec)?spec:{operators:spec},Query;((Query)=>{Query.define=(model,spec)=>{let properties=model.selectSchema.properties??{},types={};for(let[name,schema]of Object.entries(properties))types[name]=typeOfSchema(schema);return{...spec,types}};let typeOf=(spec,field,column)=>{let filter=spec.filters?.[field];return(filter&&normalizeFilter(filter).type)??spec.types?.[column]??spec.types?.[field]??"string"},columnOf=(spec,field)=>{let filter=spec.filters?.[field];return(filter&&normalizeFilter(filter).column)??spec.columns?.[field]??field};Query.schema=(spec)=>{let properties={};for(let[field,raw]of Object.entries(spec.filters??{})){let filter=normalizeFilter(raw);properties[`filter[${field}]`]=t3.Optional(t3.String({description:`Filters ${field} (${filter.operators[0]}).`}));for(let operator of filter.operators)properties[`filter[${field}][${operator}]`]=t3.Optional(t3.String({description:`Filters ${field} with ${operator}.`}))}if(spec.sort?.length)properties.sort=t3.Optional(t3.String({description:`Comma-separated sort fields, "-" for descending: ${spec.sort.join(", ")}.`}));return properties.cursor=t3.Optional(t3.String({description:"The cursor of the next page."})),properties.limit=t3.Optional(t3.String({description:`Rows per page, up to ${spec.limit?.max??DEFAULT_MAX_LIMIT}.`})),properties.fields=t3.Optional(t3.String({description:"Comma-separated fields to return."})),t3.Object(properties,{additionalProperties:!0})},Query.parse=(spec,query={})=>{let errors=[],fail=(parameter,message,code)=>errors.push({pointer:`/query/${parameter.replaceAll("/","~1")}`,message,code}),filters=[];for(let[key,raw]of Object.entries(query)){let match=FILTER_KEY.exec(key);if(!match)continue;let[,field,named]=match,filterSpec=spec.filters?.[field];if(!filterSpec){fail(key,`cannot filter by "${field}"`,"filter");continue}let{operators}=normalizeFilter(filterSpec),operator=named??operators[0];if(!operators.includes(operator)){fail(key,`"${operator}" is not allowed on "${field}"`,"operator");continue}let column=columnOf(spec,field),type=typeOf(spec,field,column),text=String(Array.isArray(raw)?raw.at(-1):raw);if(operator==="isNull"){let read=readValue("boolean",text);if("error"in read)fail(key,read.error,"type");else filters.push({field,column,operator,value:read.value});continue}if(operator==="in"){let values=[];for(let part of text.split(",")){let read=readValue(type,part.trim());if("error"in read)fail(key,`each value ${read.error}`,"type");else values.push(read.value)}if(values.length>0&&values.length===text.split(",").length)filters.push({field,column,operator,value:values});continue}let read=readValue(operator==="contains"||operator==="startsWith"?"string":type,text);if("error"in read)fail(key,read.error,"type");else filters.push({field,column,operator,value:read.value})}let key=spec.key??"id",sort=[],rawSort=typeof query.sort==="string"&&query.sort!==""?query.sort:spec.defaultSort;for(let part of(rawSort??"").split(",").map((item)=>item.trim()).filter(Boolean)){let direction=part.startsWith("-")?"desc":"asc",field=part.replace(/^[-+]/,"");if(!spec.sort?.includes(field)){fail("sort",`cannot sort by "${field}"`,"sort");continue}sort.push({field,column:columnOf(spec,field),direction})}if(!sort.some((item)=>item.column===key))sort.push({field:key,column:key,direction:sort.at(-1)?.direction??"asc"});let max=spec.limit?.max??DEFAULT_MAX_LIMIT,limit=spec.limit?.default??DEFAULT_LIMIT;if(query.limit!==void 0&&query.limit!==""){let value=Number(query.limit);if(!Number.isInteger(value)||value<1||value>max)fail("limit",`must be a whole number from 1 to ${max}`,"limit");else limit=value}let after;if(typeof query.cursor==="string"&&query.cursor!==""){let values=decodeCursor(query.cursor,sort.length)?.map((value,index)=>{if(value===null)return{value:null};if(!["string","number","boolean"].includes(typeof value))return{error:"bad"};let{field,column}=sort[index];return readValue(typeOf(spec,field,column),String(value))});if(!values||values.some((read)=>("error"in read)))fail("cursor","is not a cursor of this listing","cursor");else after=values.map((read)=>read.value)}if(errors.length>0)throw new BadRequestError("The query is invalid.",{errors});return{filters,sort,after,limit}},Query.cursorAfter=(query,row)=>toBase64Url(JSON.stringify(query.sort.map(({column})=>{let value=row[column];return value instanceof Date?value.toISOString():value})));let decodeCursor=(cursor,length)=>{try{let values=JSON.parse(fromBase64Url(cursor));return Array.isArray(values)&&values.length===length?values:void 0}catch{return}};Query.page=(query,rows)=>{let data=rows.slice(0,query.limit),more=rows.length>query.limit;return{data,nextCursor:more?Query.cursorAfter(query,data.at(-1)):null}},Query.pageSchema=(item)=>t3.Object({data:t3.Array(item),nextCursor:t3.Nullable(t3.String())})})(Query||={});import{Module}from"@elysiumjs/core";import{KeyvCacheableMemory}from"cacheable";import{EventEmitter}from"events";import{Lifecycle,Service as Service5}from"@elysiumjs/core";import{Redis}from"@elysiumjs/redis";class KeyvRedis extends EventEmitter{#client;#namespace;#keyPrefixSeparator="::";#clearBatchSize=1000;#useUnlink=!0;#noNamespaceAffectsAll=!1;#connection;constructor(props){super();this.#connection=props.connection,this.#namespace=props.namespace,this.#keyPrefixSeparator=props.keyPrefixSeparator??"::",this.#clearBatchSize=props.clearBatchSize??1000,this.#useUnlink=props.useUnlink??!0,this.#noNamespaceAffectsAll=props.noNamespaceAffectsAll??!1,this.on("error",(error)=>{Lifecycle.emit("elysium:error",error)})}async getClient(){return this.#client??=await Service5.resolve(Redis.getConnectionName(this.#connection)),this.#client}get opts(){return{namespace:this.#namespace,keyPrefixSeparator:this.#keyPrefixSeparator,clearBatchSize:this.#clearBatchSize,noNamespaceAffectsAll:this.#noNamespaceAffectsAll,dialect:"redis",url:this.#connection}}get namespace(){return this.#namespace}set namespace(value){this.#namespace=value}async get(key){let client=await this.getClient();key=this.createKeyPrefix(key,this.#namespace);let value=await client.get(key);return value===null?void 0:value}async set(key,value,ttl){let client=await this.getClient();if(key=this.createKeyPrefix(key,this.#namespace),ttl)await client.send("SET",[key,value,"PX",String(ttl)]);else await client.set(key,value)}async setMany(values){let client=await this.getClient(),multi=[];for(let{key,value,ttl}of values){let prefixedKey=this.createKeyPrefix(key,this.#namespace);if(ttl)multi.push(client.send("SET",[prefixedKey,value,"PX",String(ttl)]));else multi.push(client.set(prefixedKey,value))}await Promise.all(multi)}async delete(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),await this.#remove(client,[key])>0}async clear(){try{let client=await this.getClient();if(!this.#namespace&&this.#noNamespaceAffectsAll){await client.send("FLUSHDB",[]);return}let cursor="0",batchSize=this.#clearBatchSize,match=this.#namespace?`${this.#namespace}${this.#keyPrefixSeparator}*`:"*";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"COUNT",batchSize.toString(),"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),keys.length===0)continue;if(!this.#namespace)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0)await this.#remove(client,keys)}while(cursor!=="0")}catch(error){this.emit("error",error)}}async has(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),Number(await client.send("EXISTS",[key]))>0}async hasMany(keys){let client=await this.getClient(),multi=[];for(let key of keys){let prefixedKey=this.createKeyPrefix(key,this.#namespace);multi.push(client.send("EXISTS",[prefixedKey]).then((count)=>Number(count)>0))}return Promise.all(multi)}async getMany(keys){let client=await this.getClient();if(keys.length===0)return[];return keys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace)),await client.send("MGET",keys)}async disconnect(){this.#client=void 0}async deleteMany(keys){if(keys.length===0)return!1;let client=await this.getClient(),prefixedKeys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace));return await this.#remove(client,prefixedKeys)>0}#remove(client,keys){return this.#useUnlink?client.send("UNLINK",keys):client.del(...keys)}async*iterator(namespace){let client=await this.getClient(),match=namespace?`${namespace}${this.#keyPrefixSeparator}*`:"*",cursor="0";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),!namespace&&!this.#noNamespaceAffectsAll)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0){let values=await client.send("MGET",keys);for(let i of keys.keys()){let key=this.getKeyWithoutPrefix(keys[i],namespace),value=values[i];yield[key,value]}}}while(cursor!=="0")}createKeyPrefix(key,namespace){return namespace?`${namespace}${this.#keyPrefixSeparator}${key}`:key}getKeyWithoutPrefix(key,namespace){return namespace?key.replace(`${namespace}${this.#keyPrefixSeparator}`,""):key}}class MnemosyneModule extends Module.configurable({defaults:{cache:{redis:{connection:"cache"},memory:{ttl:60000,lruSize:5000}}}}){}MnemosyneModule=__legacyDecorateClassTS([Module.register({services:(config)=>{let services=[],{redis,memory,stores}=config.cache;if(redis)services.push({name:REDIS_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(()=>new KeyvRedis({connection:redis.connection}))});if(memory)services.push({name:MEMORY_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(()=>new KeyvCacheableMemory({ttl:memory.ttl,lruSize:memory.lruSize}))});for(let[name,createStore]of Object.entries(stores??{}))services.push({name:cacheStoreService(name),global:!0,factory:()=>makeCacheInterface(createStore)});return services}})],MnemosyneModule);var resolveStore=(store)=>store==="memory"?Cache.memory:store==="redis"?Cache.redis:Cache.store(store),defaultKey=(ctx)=>{let url=new URL(ctx.request.url);return`http:${typeof ctx.tenant==="string"?ctx.tenant:"public"}:${ctx.request.method}:${url.pathname}${url.search}`};class CacheInterceptor{static with(options){let{ttl,store="memory",key=defaultKey}=options;return class{async intercept(ctx,next){let method=ctx.request.method;if(method!=="GET"&&method!=="HEAD")return next();let cache=resolveStore(store),cacheKey=key(ctx),cached=await cache.get(cacheKey);if(cached!==void 0)return ctx.set.headers["x-cache"]="hit",cached.value;let value=await next();if(ctx.set.headers["x-cache"]="miss",!(value instanceof Response))await cache.set(cacheKey,{value},ttl);return value}}}}export{AbstractDatabase,AbstractModel,AbstractRepository,Cache,CacheInterceptor,DATABASE_TRANSACTION_SERVICE,KeyvRedis,MEMORY_CACHE_SERVICE,MnemosyneModule,ModelResource,Query,REDIS_CACHE_SERVICE,SimpleTenantMiddleware,StrictTenantMiddleware,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE,TENANT_ID_PATTERN,TenantMiddleware,Transactional,UnitOfWork,cacheStoreService,createDatabaseCacheStorage,createSchemaFromModel,getCurrentTenant,getTenancyConfig,getTenancyStrategy,isValidTenantId,withTenant,withUnitOfWork};
package/dist/query.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { TObject, TSchema } from '@elysiumjs/core';
1
+ import type { TObject, TOptional, TSchema, TString } from '@elysiumjs/core';
2
2
  import type { ModelClass } from './interfaces';
3
3
  /**
4
4
  * A filter operator of the query DSL.
@@ -19,23 +19,31 @@ export type QueryFieldType = 'string' | 'number' | 'boolean' | 'date' | 'uuid';
19
19
  * A filterable field.
20
20
  * @author Axel Nana <axel.nana@workbud.com>
21
21
  */
22
- export type QueryFilterSpec = {
22
+ export type QueryFilterSpec<TOperator extends FilterOperator = FilterOperator> = {
23
23
  /** The operators the field accepts. The first one is used by `filter[field]=value`. */
24
- operators: FilterOperator[];
24
+ operators: readonly TOperator[];
25
25
  /** The type of the values. Read from the model by {@link Query.define}; `string` otherwise. */
26
26
  type?: QueryFieldType;
27
27
  /** The model property behind the field, when it has another name. */
28
28
  column?: string;
29
29
  };
30
+ /**
31
+ * The filterable fields of a spec, each with its operators or its {@link QueryFilterSpec}.
32
+ * @author Axel Nana <axel.nana@workbud.com>
33
+ */
34
+ export type QueryFilters = Record<string, QueryFilterSpec | readonly FilterOperator[]>;
30
35
  /**
31
36
  * What clients may filter and sort by, and how many rows a page holds.
37
+ *
38
+ * The type parameters keep the names of the filters and the sortable fields, so that
39
+ * {@link Query.Params} and {@link Query.schema} can type the query string.
32
40
  * @author Axel Nana <axel.nana@workbud.com>
33
41
  */
34
- export type QuerySpec = {
42
+ export type QuerySpec<TFilters extends QueryFilters = QueryFilters, TSort extends string = string> = {
35
43
  /** The filterable fields, with their operators. */
36
- filters?: Record<string, QueryFilterSpec | FilterOperator[]>;
44
+ filters?: TFilters;
37
45
  /** The sortable fields. Sortable columns should not be nullable, for cursor pagination. */
38
- sort?: string[];
46
+ sort?: readonly TSort[];
39
47
  /** The sort used when the query sets none, such as `-createdAt`. */
40
48
  defaultSort?: string;
41
49
  /** The page size: the default, and the largest a client may ask for. */
@@ -90,6 +98,33 @@ export type CursorPage<T> = {
90
98
  /** The cursor of the next page, or `null` on the last page. */
91
99
  nextCursor: string | null;
92
100
  };
101
+ /** The operators of one filter of a spec. */
102
+ type OperatorsOf<TFilter> = TFilter extends readonly (infer TOperator)[] ? TOperator : TFilter extends {
103
+ operators: readonly (infer TOperator)[];
104
+ } ? TOperator : never;
105
+ /** The filter keys of a spec: `filter[field]` and `filter[field][operator]`. */
106
+ type FilterKeysOf<TFilters> = {
107
+ [K in keyof TFilters & string]: `filter[${K}]` | `filter[${K}][${OperatorsOf<TFilters[K]> & string}]`;
108
+ }[keyof TFilters & string];
109
+ /**
110
+ * The parameter names of the query string of a spec.
111
+ * @author Axel Nana <axel.nana@workbud.com>
112
+ */
113
+ export type QueryParamKeys<TSpec> = TSpec extends QuerySpec<infer TFilters, infer TSort> ? FilterKeysOf<TFilters> | ([TSort] extends [never] ? never : 'sort') | 'cursor' | 'limit' | 'fields' : never;
114
+ /**
115
+ * The query string of a spec, as Elysia parses it: every parameter is an optional string.
116
+ * @author Axel Nana <axel.nana@workbud.com>
117
+ */
118
+ export type QueryParams<TSpec> = {
119
+ [K in QueryParamKeys<TSpec>]?: string;
120
+ };
121
+ /**
122
+ * The properties of the query schema of a spec.
123
+ * @author Axel Nana <axel.nana@workbud.com>
124
+ */
125
+ export type QuerySchemaProperties<TSpec> = {
126
+ [K in QueryParamKeys<TSpec>]: TOptional<TString>;
127
+ };
93
128
  export declare namespace Query {
94
129
  /**
95
130
  * Builds a query spec for a model: the types of the filtered and sorted fields are read from
@@ -106,14 +141,27 @@ export declare namespace Query {
106
141
  * });
107
142
  * ```
108
143
  */
109
- const define: <TModel extends ModelClass>(model: TModel, spec: Omit<QuerySpec, 'types'>) => QuerySpec;
144
+ const define: <TModel extends ModelClass, const TFilters extends QueryFilters = {}, const TSort extends string = never>(model: TModel, spec: Omit<QuerySpec<TFilters, TSort>, 'types'>) => QuerySpec<TFilters, TSort>;
145
+ /**
146
+ * The query string of a spec, for the parameter that `@Http.query()` decorates.
147
+ * @author Axel Nana <axel.nana@workbud.com>
148
+ * @example
149
+ * ```ts
150
+ * @Http.get('/posts')
151
+ * list(@Http.query(Query.schema(PostQuery)) query: Query.Params<typeof PostQuery>) {
152
+ * return this.posts.list(Query.parse(PostQuery, query));
153
+ * }
154
+ * ```
155
+ */
156
+ type Params<TSpec> = QueryParams<TSpec>;
110
157
  /**
111
158
  * The query schema of a spec, for `@Http.query()`: it documents the filters, `sort`, `cursor`,
112
- * `limit` and `fields` parameters in OpenAPI.
159
+ * `limit` and `fields` parameters in OpenAPI. Its static type is {@link Query.Params}, and other
160
+ * parameters are let through.
113
161
  * @author Axel Nana <axel.nana@workbud.com>
114
162
  * @param spec The query spec.
115
163
  */
116
- const schema: (spec: QuerySpec) => TObject;
164
+ const schema: <TSpec extends QuerySpec>(spec: TSpec) => TObject<QuerySchemaProperties<TSpec>>;
117
165
  /**
118
166
  * Validates a query string against a spec.
119
167
  * @author Axel Nana <axel.nana@workbud.com>
@@ -145,6 +193,7 @@ export declare namespace Query {
145
193
  */
146
194
  const pageSchema: <S extends TSchema>(item: S) => TObject<{
147
195
  data: import("typebox").TArray<S>;
148
- nextCursor: import("typebox").TUnion<[import("typebox").TString, import("typebox").TNull]>;
196
+ nextCursor: import("typebox").TUnion<[TString, import("typebox").TNull]>;
149
197
  }>;
150
198
  }
199
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elysiumjs/mnemosyne",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Database and caching for Elysium.js applications",
6
6
  "private": false,
@@ -40,11 +40,11 @@
40
40
  ],
41
41
  "sideEffects": false,
42
42
  "peerDependencies": {
43
- "@elysiumjs/core": "^0.11.0",
43
+ "@elysiumjs/core": "^0.11.1",
44
44
  "@elysiumjs/redis": "^0.1.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@elysiumjs/core": "0.11.0",
47
+ "@elysiumjs/core": "0.11.1",
48
48
  "@elysiumjs/redis": "0.1.0"
49
49
  },
50
50
  "dependencies": {