@spytecgps/nova-orm 1.4.240 → 1.4.241
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/cache/InstrumentedQueryResultCache.d.ts +81 -0
- package/dist/cache/InstrumentedQueryResultCache.js +1 -0
- package/dist/cache/attachIoredisErrorHandler.d.ts +27 -0
- package/dist/cache/attachIoredisErrorHandler.js +1 -0
- package/dist/cache/index.d.ts +2 -0
- package/dist/cache/index.js +1 -1
- package/dist/migration/_virtual/_virtual_multi-entry.js +2 -2
- package/dist/novaDataSource.d.ts +12 -0
- package/dist/novaDataSource.js +1 -1
- package/package.json +1 -1
- package/schema.sql +2 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { QueryRunner } from 'typeorm';
|
|
2
|
+
import { QueryResultCache } from 'typeorm/cache/QueryResultCache';
|
|
3
|
+
import { QueryResultCacheOptions } from 'typeorm/cache/QueryResultCacheOptions';
|
|
4
|
+
import { Logger } from '../types/logger';
|
|
5
|
+
export type CacheKind = 'lru' | 'redis' | 'unknown' | (string & Record<never, never>);
|
|
6
|
+
export interface InstrumentedQueryResultCacheOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Tag for the underlying backend. Surfaces as the `kind` attribute on every
|
|
9
|
+
* emitted metric so dashboards can split LRU vs Redis cost/hit behavior.
|
|
10
|
+
*/
|
|
11
|
+
kind: CacheKind;
|
|
12
|
+
/**
|
|
13
|
+
* Optional logger. When provided, a summary line is logged via `flushSummary()`
|
|
14
|
+
* and sampled per-op trace logs are emitted if `traceSampleRate > 0`.
|
|
15
|
+
*/
|
|
16
|
+
logger?: Logger;
|
|
17
|
+
/**
|
|
18
|
+
* Fraction in [0, 1] of get/store ops to log at trace level with the full
|
|
19
|
+
* identifier. Useful for tracking down hot keys; default 0 (off).
|
|
20
|
+
*/
|
|
21
|
+
traceSampleRate?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Cap on how many distinct key prefixes are tracked in the per-instance
|
|
24
|
+
* miss-count map. Prevents unbounded memory growth on services with
|
|
25
|
+
* unexpected key shapes. Default 256.
|
|
26
|
+
*/
|
|
27
|
+
maxTrackedPrefixes?: number;
|
|
28
|
+
}
|
|
29
|
+
interface InstrumentationStats {
|
|
30
|
+
hit: number;
|
|
31
|
+
miss: number;
|
|
32
|
+
expired: number;
|
|
33
|
+
store: number;
|
|
34
|
+
remove: number;
|
|
35
|
+
error: number;
|
|
36
|
+
getLatencyMsSum: number;
|
|
37
|
+
getLatencyMsMax: number;
|
|
38
|
+
storeLatencyMsSum: number;
|
|
39
|
+
storeLatencyMsMax: number;
|
|
40
|
+
payloadBytesSum: number;
|
|
41
|
+
payloadBytesMax: number;
|
|
42
|
+
missesByPrefix: Map<string, number>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decorates any QueryResultCache (LRU, Redis-backed, or otherwise) with
|
|
46
|
+
* OTel metrics + a lightweight in-process counter that can be dumped as a
|
|
47
|
+
* single log line per Lambda invocation.
|
|
48
|
+
*
|
|
49
|
+
* Hit-rate, lookup latency, payload size, and a low-cardinality miss-by-prefix
|
|
50
|
+
* breakdown all become visible without per-callsite changes. Same wrapper for
|
|
51
|
+
* every backend, so cross-service comparisons (Redis hit% on alerts-2.0 vs
|
|
52
|
+
* forwarder, LRU thrash on api-auth) come for free.
|
|
53
|
+
*/
|
|
54
|
+
export declare class InstrumentedQueryResultCache implements QueryResultCache {
|
|
55
|
+
private readonly inner;
|
|
56
|
+
private readonly kind;
|
|
57
|
+
private readonly logger?;
|
|
58
|
+
private readonly traceSampleRate;
|
|
59
|
+
private readonly maxTrackedPrefixes;
|
|
60
|
+
private stats;
|
|
61
|
+
constructor(inner: QueryResultCache, options: InstrumentedQueryResultCacheOptions);
|
|
62
|
+
connect(): Promise<void>;
|
|
63
|
+
disconnect(): Promise<void>;
|
|
64
|
+
synchronize(queryRunner?: QueryRunner): Promise<void>;
|
|
65
|
+
getFromCache(options: QueryResultCacheOptions, queryRunner?: QueryRunner): Promise<QueryResultCacheOptions | undefined>;
|
|
66
|
+
storeInCache(options: QueryResultCacheOptions, savedCache?: QueryResultCacheOptions, queryRunner?: QueryRunner): Promise<void>;
|
|
67
|
+
isExpired(saved: QueryResultCacheOptions): boolean;
|
|
68
|
+
remove(identifiers: string[], queryRunner?: QueryRunner): Promise<void>;
|
|
69
|
+
clear(): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Emit one log line summarizing every op since the last call, then reset.
|
|
72
|
+
* Intended for per-Lambda-invocation middleware hooks; safe to call
|
|
73
|
+
* periodically from long-running processes.
|
|
74
|
+
*/
|
|
75
|
+
flushSummary(): void;
|
|
76
|
+
/** Test-only / introspection accessor. Do not rely on shape across versions. */
|
|
77
|
+
snapshotStats(): Readonly<InstrumentationStats>;
|
|
78
|
+
private trackMissPrefix;
|
|
79
|
+
private maybeTraceLog;
|
|
80
|
+
}
|
|
81
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var t=require("@opentelemetry/api");const e=()=>({hit:0,miss:0,expired:0,store:0,remove:0,error:0,getLatencyMsSum:0,getLatencyMsMax:0,storeLatencyMsSum:0,storeLatencyMsMax:0,payloadBytesSum:0,payloadBytesMax:0,missesByPrefix:new Map}),s=t.metrics.getMeter("@spytecgps/nova-orm"),r=s.createCounter("nova.cache.ops",{description:"Count of nova-orm query-result cache operations"}),i=s.createHistogram("nova.cache.lookup_duration_ms",{description:"Duration of nova-orm query-result cache lookups",unit:"ms"}),o=s.createHistogram("nova.cache.store_duration_ms",{description:"Duration of nova-orm query-result cache stores",unit:"ms"}),a=s.createHistogram("nova.cache.payload_bytes",{description:"Approximate cached payload size (JSON length proxy)",unit:"By"});const n=/[0-9]/;function c(){const t=process.hrtime();return 1e3*t[0]+t[1]/1e6}function h(t){if(null==t)return 0;if("string"==typeof t)return t.length;if(Buffer.isBuffer(t))return t.length;try{return JSON.stringify(t).length}catch{return 0}}exports.InstrumentedQueryResultCache=class InstrumentedQueryResultCache{inner;kind;logger;traceSampleRate;maxTrackedPrefixes;stats=e();constructor(t,e){this.inner=t,this.kind=e.kind,this.logger=e.logger,this.traceSampleRate=e.traceSampleRate??0,this.maxTrackedPrefixes=e.maxTrackedPrefixes??256}async connect(){return this.inner.connect()}async disconnect(){return this.inner.disconnect()}async synchronize(t){return this.inner.synchronize?.(t)}async getFromCache(t,e){const s=c();try{const o=await this.inner.getFromCache(t,e),n=c()-s;let d;d=o?this.inner.isExpired(o)?"expired":"hit":"miss",this.stats[d]+=1,this.stats.getLatencyMsSum+=n,n>this.stats.getLatencyMsMax&&(this.stats.getLatencyMsMax=n);const u={kind:this.kind,op:"get",outcome:d};if(r.add(1,u),i.record(n,u),"hit"===d&&o){const t=h(o.result);t>0&&a.record(t,{kind:this.kind,op:"get"})}return"hit"!==d&&this.trackMissPrefix(t),this.maybeTraceLog("get",t,n,d),o}catch(t){this.stats.error+=1;const e=c()-s,o={kind:this.kind,op:"get",outcome:"error"};throw r.add(1,o),i.record(e,o),t}}async storeInCache(t,e,s){const i=c();try{await this.inner.storeInCache(t,e,s);const n=c()-i;this.stats.store+=1,this.stats.storeLatencyMsSum+=n,n>this.stats.storeLatencyMsMax&&(this.stats.storeLatencyMsMax=n);const d={kind:this.kind,op:"store",outcome:"ok"};r.add(1,d),o.record(n,d);const u=h(t.result);u>0&&(a.record(u,{kind:this.kind,op:"store"}),this.stats.payloadBytesSum+=u,u>this.stats.payloadBytesMax&&(this.stats.payloadBytesMax=u)),this.maybeTraceLog("store",t,n,"ok")}catch(t){throw this.stats.error+=1,r.add(1,{kind:this.kind,op:"store",outcome:"error"}),t}}isExpired(t){return this.inner.isExpired(t)}async remove(t,e){try{await this.inner.remove(t,e),this.stats.remove+=t.length,r.add(t.length,{kind:this.kind,op:"remove",outcome:"ok"})}catch(t){throw this.stats.error+=1,r.add(1,{kind:this.kind,op:"remove",outcome:"error"}),t}}async clear(){return this.inner.clear()}flushSummary(){if(!this.logger)return void(this.stats=e());const t=this.stats,s=t.hit+t.miss+t.expired,r=s>0?t.hit/s:null,i=s>0?t.getLatencyMsSum/s:0,o=t.store>0?t.storeLatencyMsSum/t.store:0,a=[...t.missesByPrefix.entries()].sort(((t,e)=>e[1]-t[1])).slice(0,10).map((([t,e])=>({prefix:t,count:e})));(s>0||t.store>0||t.remove>0)&&this.logger.info({cache:{kind:this.kind,gets:s,hits:t.hit,misses:t.miss,expired:t.expired,hitRate:r,stores:t.store,removes:t.remove,errors:t.error,avgGetMs:Number(i.toFixed(3)),maxGetMs:Number(t.getLatencyMsMax.toFixed(3)),avgStoreMs:Number(o.toFixed(3)),maxStoreMs:Number(t.storeLatencyMsMax.toFixed(3)),payloadBytesMax:t.payloadBytesMax,payloadBytesAvgStore:t.store>0?Math.round(t.payloadBytesSum/t.store):0,topMissPrefixes:a}},"nova-orm cache summary"),this.stats=e()}snapshotStats(){return this.stats}trackMissPrefix(t){const e=t.identifier??t.query;if(!e)return;const s=function(t){const e=t.split(":"),s=[];for(const t of e)if(t){if(n.test(t[0]))break;if(s.push(t),s.length>=3)break}if(0===s.length){const e=t.indexOf(" ");return e>0?t.slice(0,e):t.slice(0,32)}return s.join(":")}(e),r=this.stats.missesByPrefix,i=r.get(s);void 0!==i?r.set(s,i+1):r.size<this.maxTrackedPrefixes&&r.set(s,1)}maybeTraceLog(t,e,s,r){!this.logger||this.traceSampleRate<=0||Math.random()>=this.traceSampleRate||this.logger.trace({cache:{kind:this.kind,op:t,outcome:r,durationMs:Number(s.toFixed(3)),identifier:e.identifier}},"nova-orm cache op")}};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Logger } from '../types/logger';
|
|
2
|
+
/**
|
|
3
|
+
* Shape of TypeORM's RedisQueryResultCache once the underlying client is
|
|
4
|
+
* materialized. We reach into it via duck-typing because TypeORM does not
|
|
5
|
+
* export the type.
|
|
6
|
+
*/
|
|
7
|
+
export interface RedisQueryResultCacheLike {
|
|
8
|
+
client?: {
|
|
9
|
+
on?: (event: string, fn: (err: Error) => void) => void;
|
|
10
|
+
};
|
|
11
|
+
clientType?: string;
|
|
12
|
+
__novaErrorHandlerAttached?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* TypeORM's RedisQueryResultCache only attaches an `error` listener on the
|
|
16
|
+
* underlying redis client for `clientType === 'redis'`. With `'ioredis'` (what
|
|
17
|
+
* every hapn service uses to talk to ElastiCache Valkey) the client is an
|
|
18
|
+
* EventEmitter that crashes the host process on the first unhandled `error`
|
|
19
|
+
* event — a TLS hiccup or `MOVED` redirect during failover takes down the
|
|
20
|
+
* Lambda even with `ignoreErrors: true` set in TypeORM config.
|
|
21
|
+
*
|
|
22
|
+
* Idempotent: re-running on the same cache is a no-op.
|
|
23
|
+
*
|
|
24
|
+
* Returns true if a handler was attached on this call (useful for tests and
|
|
25
|
+
* for callers that want to observe whether anything happened).
|
|
26
|
+
*/
|
|
27
|
+
export declare function attachIoredisErrorHandler(cache: RedisQueryResultCacheLike | undefined | null, logger: Logger): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";exports.attachIoredisErrorHandler=function(e,r){return!!e&&(!e.__novaErrorHandlerAttached&&(!!e.clientType?.startsWith("ioredis")&&("function"==typeof e.client?.on&&(e.client.on("error",(e=>{r.warn({message:e.message},"Redis cache connection problem (ignored)")})),e.__novaErrorHandlerAttached=!0,!0))))};
|
package/dist/cache/index.d.ts
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
+
export { attachIoredisErrorHandler, RedisQueryResultCacheLike } from './attachIoredisErrorHandler';
|
|
2
|
+
export { CacheKind, InstrumentedQueryResultCache, InstrumentedQueryResultCacheOptions, } from './InstrumentedQueryResultCache';
|
|
1
3
|
export { LruQueryResultCache, LruQueryResultCacheConfig } from './LruQueryResultCache';
|
package/dist/cache/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var e=require("./LruQueryResultCache.js");exports.
|
|
1
|
+
"use strict";var e=require("./attachIoredisErrorHandler.js"),r=require("./InstrumentedQueryResultCache.js"),t=require("./LruQueryResultCache.js");exports.attachIoredisErrorHandler=e.attachIoredisErrorHandler,exports.InstrumentedQueryResultCache=r.InstrumentedQueryResultCache,exports.LruQueryResultCache=t.LruQueryResultCache;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var _1684483704434AddLowerCaseIndex = require('../1684483704434-addLowerCaseIndex.js');
|
|
4
|
-
var _1684483705002UpdateSecurityUserViewsV2 = require('../1684483705002-updateSecurityUserViewsV2.js');
|
|
5
4
|
var _1684483705001UpdateSecurityUserViewsV1 = require('../1684483705001-updateSecurityUserViewsV1.js');
|
|
5
|
+
var _1684483705002UpdateSecurityUserViewsV2 = require('../1684483705002-updateSecurityUserViewsV2.js');
|
|
6
6
|
var _1746434400000AddQuickSearchFulltextIndexes = require('../1746434400000-addQuickSearchFulltextIndexes.js');
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
exports.addLowerCaseIndex1684483704434 = _1684483704434AddLowerCaseIndex.addLowerCaseIndex1684483704434;
|
|
11
|
-
exports.updateSecurityUserViewsV21684483705002 = _1684483705002UpdateSecurityUserViewsV2.updateSecurityUserViewsV21684483705002;
|
|
12
11
|
exports.updateSecurityUserViewsV11684483705001 = _1684483705001UpdateSecurityUserViewsV1.updateSecurityUserViewsV11684483705001;
|
|
12
|
+
exports.updateSecurityUserViewsV21684483705002 = _1684483705002UpdateSecurityUserViewsV2.updateSecurityUserViewsV21684483705002;
|
|
13
13
|
exports.addQuickSearchFulltextIndexes1746434400000 = _1746434400000AddQuickSearchFulltextIndexes.addQuickSearchFulltextIndexes1746434400000;
|
package/dist/novaDataSource.d.ts
CHANGED
|
@@ -55,6 +55,18 @@ export declare class NovaDataSource {
|
|
|
55
55
|
* Performs connection to the database.
|
|
56
56
|
*/
|
|
57
57
|
connect(): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Wrap whatever cache provider TypeORM materialized (Redis, our LRU, or any
|
|
60
|
+
* future provider) with `InstrumentedQueryResultCache` so every consumer gets
|
|
61
|
+
* hit-rate / latency / payload-size metrics with zero callsite changes. Idempotent.
|
|
62
|
+
*/
|
|
63
|
+
private wrapQueryResultCache;
|
|
64
|
+
/**
|
|
65
|
+
* If the active cache is instrumented, emit its summary log and reset stats.
|
|
66
|
+
* Safe to call at any time; no-op when the cache isn't instrumented.
|
|
67
|
+
* Intended for per-Lambda-invocation middleware hooks.
|
|
68
|
+
*/
|
|
69
|
+
flushCacheSummary(): void;
|
|
58
70
|
/**
|
|
59
71
|
* Closes connection with the database.
|
|
60
72
|
* Once connection is closed, you cannot use repositories or perform any operations except opening connection again.
|
package/dist/novaDataSource.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var e=require("@opentelemetry/api"),r=require("typeorm")
|
|
1
|
+
"use strict";var e=require("@opentelemetry/api"),r=require("typeorm"),t=require("./cache/attachIoredisErrorHandler.js"),a=require("./cache/InstrumentedQueryResultCache.js"),s=require("./cache/LruQueryResultCache.js"),i=require("./entities/index.js"),n=require("./subscribers/index.js"),o=require("./utils/mysqlSpanProcessor.js"),c=require("./utils/queryLogger.js"),u=require("./utils/queryTags.js"),l=require("./utils/spanSubscriber.js");require("fs"),require("mysql2/promise"),require("path"),require("./types/enums.js"),require("./types/logger.js"),require("./entities/retailAlertExternalRecipient.js"),require("./utils/dateTool.js");exports.NovaDataSource=class NovaDataSource{dataSource;logger;constructor(e,t,a){this.logger=t;const s=[];for(const e in i){const r=i[e];s.push(r)}const o=[];for(const e in n){const r=n[e];o.push(r)}const u={host:"aws.connect.psdb.cloud",port:3306,database:"nova",ssl:{rejectUnauthorized:!0}};this.dataSource=new r.DataSource({type:"mysql",connectorPackage:"mysql2",synchronize:!1,logging:!0,logger:new c.QueryLogger(this.logger,e.logging),entities:s,subscribers:o,migrations:["node_modules/@spytecgps/nova-orm/dist/migration/*.js"],supportBigNumbers:!0,bigNumberStrings:!1,extra:{decimalNumbers:!0},...u,...e,replication:{master:{...u,...e},slaves:a?[{...u,...a}]:[{...u,...e}],defaultMode:e.replicationMode??"master"}})}async connect(){if(!this.dataSource.isInitialized){const r=e.trace.getTracer("@spytecgps/nova-orm");await r.startActiveSpan("nova-orm.connect",(async r=>{try{await this.dataSource.initialize(),r.setAttribute("db.system","mysql");const e=this.dataSource.options;e.database&&r.setAttribute("db.name",e.database),r.setAttribute("nova_orm.entity_count",this.dataSource.entityMetadatas.length)}catch(t){throw r.recordException(t),r.setStatus({code:e.SpanStatusCode.ERROR}),t}finally{r.end()}}))}if(o.isOtelAutoInstrumented())o.installMysqlSpanRenamer();else if(!this.dataSource.subscribers.some((e=>e._spanSubscriber))){const e=l.createSpanSubscriber(this.logger);e._spanSubscriber=!0,this.dataSource.subscribers.push(e)}this.wrapQueryResultCache()}wrapQueryResultCache(){const e=this.dataSource.queryResultCache;if(!e||e.__novaInstrumented)return;let r="unknown";e instanceof s.LruQueryResultCache?r="lru":"RedisQueryResultCache"===e.constructor?.name&&(r="redis",t.attachIoredisErrorHandler(e,this.logger));const i=new a.InstrumentedQueryResultCache(e,{kind:r,logger:this.logger});i.__novaInstrumented=!0,this.dataSource.queryResultCache=i}flushCacheSummary(){const e=this.dataSource.queryResultCache;e instanceof a.InstrumentedQueryResultCache&&e.flushSummary()}async disconnect(){try{this.dataSource.isInitialized&&await this.dataSource.destroy()}catch(e){this.logger.error({error:e},"Failed to close connection to database")}}getRepository(e){return this.dataSource.getRepository(e)}async query(e,r,t){const a=/^\s*(?:\/\*[\s\S]*?\*\/\s*)*(INSERT|UPDATE|DELETE|REPLACE|TRUNCATE|CREATE|ALTER|DROP|RENAME|GRANT|REVOKE|LOCK|UNLOCK|SET|CALL|START|COMMIT|ROLLBACK)\b/i.test(e);if(t?.usePrimary||a)return this.dataSource.query(e,r,null);const s=this.dataSource.createQueryRunner("slave");try{return await s.query(e,r)}finally{await s.release()}}async createQueryBuilder(e,r){return this.dataSource.createQueryBuilder(e,r,null)}createQueryRunner(e){return this.dataSource.createQueryRunner(e)}createQueryRunnerFromParams(e){const r=e?.queryOptions?.forceUseReplica?"slave":"master";return this.logger.trace({replicationMode:r},"Creating query runner with replication mode"),this.dataSource.createQueryRunner(r)}async safeQuery(e,r,t){try{await this.connect();return await e(this,t)}catch(e){throw this.logger.error({error:e},r?r+" ":"Error while executing query"),e}finally{t&&await t.release(),await this.disconnect()}}async safeQuery2(r,t,a){this.dataSource.isInitialized||(this.logger.info("nova-orm new connection"),await this.connect());return e.trace.getTracer("nova-orm").startActiveSpan(t??"safeQuery2",(async s=>{try{return await r(this,a)}catch(r){throw s.recordException(r),s.setStatus({code:e.SpanStatusCode.ERROR,message:r?.message}),this.logger.error({error:r},t?t+" ":"Error while executing query"),r}finally{s.end(),a&&await a.release()}}))}async safeQuery3(e,r,t,{replicationMode:a,tag:s}={}){return(async()=>{let i;try{await this.connect(),a?(this.logger.trace({replicationMode:a},"Creating query runner with replication mode"),i=this.dataSource.createQueryRunner(a)):this.logger.trace({replicationMode:this.dataSource.defaultReplicationModeForReads},"Creating query builder with default replication mode for reads");const n=this.dataSource.createQueryBuilder(e,r,i);s&&n.comment(u.queryTags({action:s}));return await t(n)}catch(e){throw this.logger.error({error:e},s?s+" ":"Error while executing query"),e}finally{i&&await i.release()}})()}async syncSchema(e=!0){this.logger.warn("Initiating schema sync"),await this.connect(),await this.dataSource.synchronize(e),await this.dataSource.runMigrations()}async clearCache(){this.dataSource.queryResultCache?(this.logger.info("Clearing query result cache"),await this.dataSource.queryResultCache.clear()):this.logger.info("No query result cache to clear")}async invalidateCacheKey(e){this.dataSource.queryResultCache&&(this.logger.info("Invalidating cache key",{key:e}),await this.dataSource.queryResultCache.remove([e]))}};
|
package/package.json
CHANGED
package/schema.sql
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
-- nova-orm-schema-checksum:
|
|
1
|
+
-- nova-orm-schema-checksum: 6c149c8020575fbb21298a8ec3655fcd
|
|
2
2
|
-- Nova ORM Schema Dump
|
|
3
|
-
-- Generated at: 2026-05-
|
|
3
|
+
-- Generated at: 2026-05-24T13:19:09.208Z
|
|
4
4
|
|
|
5
5
|
SET FOREIGN_KEY_CHECKS = 0;
|
|
6
6
|
|