@getstrata/core 2.2.31 → 2.2.33

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/index.js CHANGED
@@ -3660,1836 +3660,1034 @@ function resolveScimTenantFromToken(token) {
3660
3660
  return null;
3661
3661
  }
3662
3662
 
3663
- // ../../src/core/tenant/resolveTenant.ts
3664
- init_repositoryConnection();
3665
- init_tenancyConfig();
3666
- async function resolveTenant(tenantId) {
3667
- if (!isTenancyEnabled()) {
3668
- return {
3669
- id: tenantId,
3670
- slug: "default"
3671
- };
3672
- }
3673
- const rows = await repositoryConnection`
3674
- SELECT id, slug
3675
- FROM tenant
3676
- WHERE id = ${tenantId}
3677
- LIMIT 1
3678
- `;
3679
- const row = rows[0];
3680
- return row ? { id: row.id, slug: row.slug } : null;
3663
+ // ../../src/core/database/baseRepository.ts
3664
+ init_eventBus();
3665
+
3666
+ // ../../src/core/events/index.ts
3667
+ init_deferredModelEvents();
3668
+ init_eventBus();
3669
+ function modelEventName(tableName, action) {
3670
+ return `${tableName}.${action}`;
3681
3671
  }
3682
3672
 
3683
- // ../../src/core/auth/scimAuthMiddleware.ts
3684
- init_tenantDatabaseScope();
3685
- function createScimAuthMiddleware() {
3686
- return async (request, next) => {
3687
- const authorization = request.headers.get("authorization");
3688
- if (!authorization?.startsWith("Bearer ")) {
3689
- return jsonScimError("SCIM bearer token required.", 401);
3690
- }
3691
- const token = authorization.slice("Bearer ".length).trim();
3692
- const tenantId = resolveScimTenantFromToken(token);
3693
- if (tenantId === null) {
3694
- return jsonScimError("Invalid SCIM bearer token.", 401);
3695
- }
3696
- const tenant = await resolveTenant(tenantId);
3697
- if (!tenant) {
3698
- return jsonScimError("SCIM tenant not found.", 401);
3699
- }
3700
- return await runWithTenantDatabase(tenant, async () => {
3701
- bindDatabaseConnection(getActiveDatabaseConnection(getDefaultDatabasePool()));
3702
- try {
3703
- return await next();
3704
- } finally {
3705
- resetBoundDatabaseConnection();
3706
- }
3707
- });
3673
+ // ../../src/core/pagination/index.ts
3674
+ function buildPaginationMeta(input) {
3675
+ const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
3676
+ return {
3677
+ page: input.page,
3678
+ per_page: input.perPage,
3679
+ total: input.total,
3680
+ last_page: lastPage
3708
3681
  };
3709
3682
  }
3710
- function jsonScimError(detail, status) {
3711
- return Response.json({
3712
- schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
3713
- detail,
3714
- status: String(status)
3715
- }, {
3716
- status,
3717
- headers: { "content-type": "application/scim+json" }
3683
+
3684
+ // ../../src/core/database/baseRepository.ts
3685
+ init_connectionContext();
3686
+ init_dialect();
3687
+
3688
+ // ../../src/core/database/keyset.ts
3689
+ init_dialect();
3690
+ var KEYSET_ALIAS_PREFIX = "__strata_keyset_";
3691
+ function validateKeyset(table, options) {
3692
+ if (!Number.isInteger(options.perPage) || options.perPage < 1 || options.perPage > 1000)
3693
+ throw new RangeError("Keyset page size must be between 1 and 1000.");
3694
+ if (!Array.isArray(options.orderBy) || options.orderBy.length < 1 || options.orderBy.length > 8)
3695
+ throw new TypeError("Keyset ordering requires between 1 and 8 columns.");
3696
+ if (table.columns.some((column) => column.toLowerCase().startsWith(KEYSET_ALIAS_PREFIX)))
3697
+ throw new TypeError("Table columns conflict with reserved keyset aliases.");
3698
+ const seen = new Set;
3699
+ const order = Array.from(options.orderBy).map(({ column, direction }) => {
3700
+ if (!table.columns.includes(column) || seen.has(column) || !["asc", "desc"].includes(direction))
3701
+ throw new TypeError("Invalid or duplicate keyset ordering column/direction.");
3702
+ seen.add(column);
3703
+ return { column, direction };
3718
3704
  });
3719
- }
3720
- // ../../src/core/auth/tokenAbilityChecker.ts
3721
- function tokenCan(user, ability) {
3722
- if (!user) {
3723
- return false;
3705
+ if (order.at(-1)?.column !== table.primaryKey)
3706
+ throw new TypeError("Keyset ordering must end with the table primary key.");
3707
+ if (options.cursor !== undefined) {
3708
+ const cursor = options.cursor;
3709
+ if (cursor?.version !== 1 || !Array.isArray(cursor.order) || cursor.order.length !== order.length || !Array.isArray(cursor.values) || cursor.values.length !== order.length || Array.from(cursor.order).some((item, i) => !item || item.column !== order[i]?.column || item.direction !== order[i]?.direction) || Array.from(cursor.values).some((value) => typeof value !== "string" || value.length > 4096))
3710
+ throw new TypeError("Invalid keyset cursor or ordering mismatch.");
3724
3711
  }
3725
- const abilities = user.abilities ?? [];
3726
- return abilities.includes("*") || abilities.includes(ability);
3712
+ return order;
3727
3713
  }
3728
- function createTokenAbilityChecker() {
3729
- return {
3730
- tokenCan,
3731
- requireAbility(user, ability) {
3732
- if (!tokenCan(user, ability)) {
3733
- throw new ForbiddenError(`Missing ability: ${ability}`);
3714
+ function keysetBoundary(order, cursor) {
3715
+ if (!cursor)
3716
+ return [];
3717
+ if (currentSqlDialect().driver === "pgsql" && order.length > 1 && order.every((item) => item.direction === order[0]?.direction)) {
3718
+ return [
3719
+ {
3720
+ kind: "and",
3721
+ compareRow: {
3722
+ columns: order.map((item) => item.column),
3723
+ operator: order[0]?.direction === "asc" ? "gt" : "lt",
3724
+ values: [...cursor.values]
3725
+ }
3734
3726
  }
3727
+ ];
3728
+ }
3729
+ const branches = order.map((item, i) => {
3730
+ const where = {};
3731
+ order.slice(0, i).forEach((prefix, j) => {
3732
+ where[prefix.column] = cursor.values[j];
3733
+ });
3734
+ where[item.column] = { [item.direction === "asc" ? "gt" : "lt"]: cursor.values[i] };
3735
+ return { kind: i === 0 ? "and" : "or", where };
3736
+ });
3737
+ return [{ kind: "and", group: branches }];
3738
+ }
3739
+
3740
+ // ../../src/core/database/query.ts
3741
+ init_dialect();
3742
+ function quoteIdentifier(identifier) {
3743
+ return currentSqlDialect().quoteIdentifier(identifier);
3744
+ }
3745
+ function returningSuffix(columns) {
3746
+ return currentSqlDialect().returningClause(columns);
3747
+ }
3748
+ function qualifyColumn(tableName, column) {
3749
+ return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
3750
+ }
3751
+ function resolveQualifiedColumn(defaultTable, columnName) {
3752
+ if (columnName.includes(".")) {
3753
+ const [table, column] = columnName.split(".", 2);
3754
+ if (!table || !column) {
3755
+ throw new Error(`Invalid qualified column: ${columnName}`);
3735
3756
  }
3736
- };
3757
+ return qualifyColumn(table, column);
3758
+ }
3759
+ return qualifyColumn(defaultTable, columnName);
3737
3760
  }
3738
- // ../../src/core/cache/redisCacheStore.ts
3739
- var {RedisClient } = globalThis.Bun;
3740
- var CACHE_SCRIPT = `
3741
- local expected = {'hash', 'zset', 'zset', 'hash', 'zset'}
3742
- for i, key in ipairs(KEYS) do
3743
- local kind = redis.call('TYPE', key).ok
3744
- if kind ~= 'none' and kind ~= expected[i] then return redis.error_reply('Unexpected cache key type') end
3745
- end
3746
- -- Redis eviction can remove one container without its companions. Never read
3747
- -- orphaned values or leases after losing the expiry/order/generation indexes.
3748
- local entries = redis.call('HLEN', KEYS[1])
3749
- local order = redis.call('ZCARD', KEYS[3])
3750
- if redis.call('ZCARD', KEYS[2]) ~= order or entries ~= (order > 0 and order + 1 or 0) then
3751
- redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
3752
- end
3753
- if redis.call('HLEN', KEYS[4]) ~= redis.call('ZCARD', KEYS[5]) then
3754
- redis.call('DEL', KEYS[4], KEYS[5])
3755
- end
3756
- local r = cjson.decode(ARGV[1])
3757
- local time = redis.call('TIME')
3758
- local now = tonumber(time[1]) * 1000 + tonumber(time[2]) / 1000
3759
- local function removeEntry(key)
3760
- local removed = redis.call('HDEL', KEYS[1], 'e:' .. key)
3761
- redis.call('ZREM', KEYS[2], key)
3762
- redis.call('ZREM', KEYS[3], key)
3763
- return removed
3764
- end
3765
- local function removeMeta(field)
3766
- redis.call('HDEL', KEYS[4], field)
3767
- redis.call('ZREM', KEYS[5], field)
3768
- end
3769
- for _, key in ipairs(redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, r.capacity)) do removeEntry(key) end
3770
- for _, field in ipairs(redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, r.metadataLimit)) do removeMeta(field) end
3771
- local function expireContainers()
3772
- local last = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
3773
- if #last == 0 then
3774
- redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
3775
- elseif last[2] == 'inf' then
3776
- for i = 1, 3 do redis.call('PERSIST', KEYS[i]) end
3777
- else
3778
- local ttl = math.max(1, math.ceil(tonumber(last[2]) - now))
3779
- for i = 1, 3 do redis.call('PEXPIRE', KEYS[i], ttl) end
3780
- end
3781
- for i = 4, 5 do redis.call('PEXPIRE', KEYS[i], r.metadataTtlMs) end
3782
- end
3783
- local function response(value)
3784
- expireContainers()
3785
- return cjson.encode(value)
3786
- end
3787
- local function addTags(existing, added)
3788
- local seen = {}
3789
- local result = {}
3790
- for _, tags in ipairs({existing, added}) do
3791
- for _, tag in ipairs(tags) do
3792
- if not seen[tag] then seen[tag] = true; table.insert(result, tag) end
3793
- end
3794
- end
3795
- if #result > 32 then error('Cache entries support at most 32 tags') end
3796
- return result
3797
- end
3798
- local function touch(key)
3799
- local sequence = redis.call('HINCRBY', KEYS[1], '_sequence', 1)
3800
- redis.call('ZADD', KEYS[3], sequence, key)
3801
- end
3802
- local function read(key, tags)
3803
- local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
3804
- if not raw then return nil end
3805
- local entry = cjson.decode(raw)
3806
- if #tags > 0 then
3807
- entry.tags = addTags(entry.tags, tags)
3808
- redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
3809
- end
3810
- touch(key)
3811
- return entry.payload
3812
- end
3813
- local function write(key, payload, tags)
3814
- local entry = {payload=payload, tags=tags}
3815
- redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
3816
- redis.call('ZADD', KEYS[2], r.ttlMs == 0 and '+inf' or now + r.ttlMs, key)
3817
- touch(key)
3818
- local overflow = redis.call('ZCARD', KEYS[3]) - r.capacity
3819
- if overflow > 0 then
3820
- for _, victim in ipairs(redis.call('ZRANGE', KEYS[3], 0, overflow - 1)) do removeEntry(victim) end
3821
- end
3822
- end
3823
- local leaseField = 'l:' .. r.key
3824
- local function lease()
3825
- local raw = redis.call('HGET', KEYS[4], leaseField)
3826
- if not raw then return nil end
3827
- local value = cjson.decode(raw)
3828
- if value.expires <= now or value.deadline <= now then removeMeta(leaseField); return nil end
3829
- return value
3830
- end
3831
- local function metadataSpace(count)
3832
- if redis.call('HLEN', KEYS[4]) + count > r.metadataLimit then
3833
- -- Pressure sheds generation/lease state. Every old owner is fenced; cached
3834
- -- entries remain valid because invalidation removes matching entries atomically.
3835
- redis.call('DEL', KEYS[4], KEYS[5])
3836
- end
3837
- end
3838
- local function putMeta(field, value, expires)
3839
- redis.call('HSET', KEYS[4], field, value)
3840
- redis.call('ZADD', KEYS[5], expires, field)
3841
- end
3842
- local function scopes()
3843
- local fields = {}
3844
- for _, tag in ipairs(r.tags) do table.insert(fields, 'g:tag:' .. tag) end
3845
- for _, prefix in ipairs(r.prefixes) do table.insert(fields, 'g:prefix:' .. prefix) end
3846
- return fields
3847
- end
3848
- local function matches(key, tags)
3849
- if r.op == 'invalidate' then return key == r.key end
3850
- if r.op == 'prefix' then return key == r.key or string.sub(key, 1, #r.key + 1) == r.key .. '?' end
3851
- if r.op == 'tags' then
3852
- for _, existing in ipairs(tags) do
3853
- for _, wanted in ipairs(r.tags) do if wanted == existing then return true end end
3854
- end
3855
- end
3856
- return false
3857
- end
3858
- if r.op == 'clear' then
3859
- redis.call('DEL', unpack(KEYS))
3860
- return cjson.encode({removed=0})
3861
- elseif r.op == 'get' then
3862
- local payload = read(r.key, r.tags)
3863
- return response(payload and {kind='value', payload=payload} or {kind='missing'})
3864
- elseif r.op == 'set' then
3865
- local old = redis.call('HGET', KEYS[1], 'e:' .. r.key)
3866
- local tags = old and cjson.decode(old).tags or {}
3867
- write(r.key, r.payload, tags)
3868
- removeMeta(leaseField)
3869
- return response({written=true})
3870
- elseif r.op == 'attach' then
3871
- read(r.key, r.tags)
3872
- return response({written=true})
3873
- elseif r.op == 'size' then
3874
- return response({size=redis.call('ZCARD', KEYS[3])})
3875
- elseif r.op == 'tags' or r.op == 'prefix' or r.op == 'invalidate' then
3876
- local generationFields = {}
3877
- if r.op == 'tags' then
3878
- for _, tag in ipairs(r.tags) do table.insert(generationFields, 'g:tag:' .. tag) end
3879
- elseif r.op == 'prefix' then table.insert(generationFields, 'g:prefix:' .. r.key) end
3880
- metadataSpace(#generationFields)
3881
- for _, field in ipairs(generationFields) do putMeta(field, r.owner, now + r.metadataTtlMs) end
3882
- local removed = 0
3883
- for _, key in ipairs(redis.call('ZRANGE', KEYS[3], 0, -1)) do
3884
- local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
3885
- if raw and matches(key, cjson.decode(raw).tags) then removed = removed + removeEntry(key) end
3886
- end
3887
- for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do
3888
- if string.sub(field, 1, 2) == 'l:' then
3889
- local value = cjson.decode(redis.call('HGET', KEYS[4], field))
3890
- if matches(string.sub(field, 3), value.tags) then removeMeta(field) end
3891
- end
3892
- end
3893
- return response({removed=removed})
3894
- elseif r.op == 'claim' then
3895
- local payload = read(r.key, r.tags)
3896
- if payload then return response({kind='value', payload=payload}) end
3897
- local current = lease()
3898
- if current then
3899
- local shared = addTags(current.tags, r.tags)
3900
- if #shared == #current.tags then return response({kind='wait'}) end
3901
- -- A newly requested tag must not inherit a fill started before that tag's
3902
- -- invalidation. Restart with the union of tags and a new owner.
3903
- r.tags = shared
3904
- removeMeta(leaseField)
3905
- end
3906
- local active = 0
3907
- for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do if string.sub(field, 1, 2) == 'l:' then active = active + 1 end end
3908
- if active >= r.capacity then return response({kind='wait'}) end
3909
- local fields = scopes()
3910
- metadataSpace(#fields + 1)
3911
- local generations = {}
3912
- for _, field in ipairs(fields) do
3913
- local token = redis.call('HGET', KEYS[4], field) or r.owner
3914
- putMeta(field, token, now + r.metadataTtlMs)
3915
- generations[field] = token
3916
- end
3917
- local value = {owner=r.owner, tags=r.tags, generations=generations, deadline=now + r.fillTimeoutMs, expires=now + r.leaseMs}
3918
- putMeta(leaseField, cjson.encode(value), value.expires)
3919
- return response({kind='owner'})
3920
- elseif r.op == 'renew' or r.op == 'publish' or r.op == 'release' then
3921
- local current = lease()
3922
- if not current or current.owner ~= r.owner then return response({owned=false}) end
3923
- if r.op == 'release' then removeMeta(leaseField); return response({owned=true}) end
3924
- for field, token in pairs(current.generations) do
3925
- if redis.call('HGET', KEYS[4], field) ~= token then removeMeta(leaseField); return response({owned=false}) end
3926
- end
3927
- if r.op == 'renew' then
3928
- current.expires = math.min(current.deadline, now + r.leaseMs)
3929
- putMeta(leaseField, cjson.encode(current), current.expires)
3930
- else
3931
- write(r.key, r.payload, current.tags)
3932
- removeMeta(leaseField)
3933
- end
3934
- return response({owned=true})
3935
- end
3936
- return redis.error_reply('Unknown cache operation')
3937
- `;
3938
-
3939
- class RedisCacheStore {
3940
- redisUrl;
3941
- ttlMs;
3942
- maxEntries;
3943
- client;
3944
- keys;
3945
- options;
3946
- fills = new Set;
3947
- closed = false;
3948
- constructor(redisUrl, ttlMs, maxEntries, options = {}) {
3949
- this.redisUrl = redisUrl;
3950
- this.ttlMs = ttlMs;
3951
- this.maxEntries = maxEntries;
3952
- if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
3953
- throw new RangeError("ttlMs must be a non-negative safe integer.");
3954
- if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 1e4)
3955
- throw new RangeError("maxEntries must be between 1 and 10000.");
3956
- this.options = {
3957
- commandTimeoutMs: 5000,
3958
- leaseMs: 1e4,
3959
- fillTimeoutMs: 30000,
3960
- waitTimeoutMs: 35000,
3961
- pollMs: 25,
3962
- metadataTtlMs: 60000,
3963
- metadataLimit: 4096,
3964
- ...options
3965
- };
3966
- for (const [key, value] of Object.entries(this.options))
3967
- if (!Number.isSafeInteger(value) || value < 1)
3968
- throw new RangeError(`${key} must be a positive safe integer.`);
3969
- if (this.options.leaseMs < 30 || this.options.fillTimeoutMs < this.options.leaseMs || this.options.metadataTtlMs < this.options.fillTimeoutMs + this.options.leaseMs || this.options.metadataLimit < 66 || this.options.metadataLimit > 1e5)
3970
- throw new RangeError("Invalid Redis cache lease, deadline or metadata bounds.");
3971
- const namespace = namespacedRedisKey("cache:v3:");
3972
- const slot = new Bun.CryptoHasher("sha256").update(namespace).digest("hex");
3973
- this.keys = ["entries", "expiry", "order", "metadata", "metadata-expiry"].map((name) => `${namespace}{${slot}}:${name}`);
3974
- this.client = this.createClient();
3761
+ function parseQualifiedColumn(reference) {
3762
+ const [table, column] = reference.split(".", 2);
3763
+ if (!table || !column) {
3764
+ throw new Error(`Join columns must be qualified as table.column: ${reference}`);
3975
3765
  }
3976
- createClient() {
3977
- return new RedisClient(this.redisUrl, {
3978
- connectionTimeout: this.options.commandTimeoutMs,
3979
- maxRetries: 1
3980
- });
3766
+ return { table, column };
3767
+ }
3768
+ function normalizeDirection(direction = "ASC") {
3769
+ return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
3770
+ }
3771
+ function isQueryOperator(value) {
3772
+ return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
3773
+ }
3774
+ function pushParam(values, value) {
3775
+ values.push(value);
3776
+ return currentSqlDialect().placeholder(values.length);
3777
+ }
3778
+ var SUPPORTED_OPERATORS = new Set([
3779
+ "eq",
3780
+ "ne",
3781
+ "in",
3782
+ "notIn",
3783
+ "gt",
3784
+ "gte",
3785
+ "lt",
3786
+ "lte",
3787
+ "isNull",
3788
+ "ilike",
3789
+ "tsMatch"
3790
+ ]);
3791
+ function buildInClause(column, values, params, negated = false) {
3792
+ if (values.length === 0) {
3793
+ return negated ? "1 = 1" : "1 = 0";
3981
3794
  }
3982
- close() {
3983
- this.closed = true;
3984
- for (const fill of this.fills)
3985
- fill.abort(new Error("Redis cache store is closed."));
3986
- this.client?.close();
3987
- this.client = undefined;
3795
+ const placeholders = values.map((value) => pushParam(params, value)).join(", ");
3796
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
3797
+ }
3798
+ function buildOperatorClauses(column, operator, params) {
3799
+ const clauses = [];
3800
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
3801
+ if (unsupported.length > 0) {
3802
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
3988
3803
  }
3989
- async get(key) {
3990
- const result = await this.execute("get", key);
3991
- return result.kind === "value" ? JSON.parse(result.payload ?? "null") : undefined;
3804
+ if (operator.isNull === true) {
3805
+ clauses.push(`${column} IS NULL`);
3992
3806
  }
3993
- async set(key, value, ttlMs) {
3994
- await this.execute("set", key, { payload: this.serialize(value), ttlMs: ttlMs ?? this.ttlMs });
3807
+ if (operator.isNull === false) {
3808
+ clauses.push(`${column} IS NOT NULL`);
3995
3809
  }
3996
- async getOrSet(key, loader, ttlMs) {
3997
- return this.getOrSetTagged(key, loader, [], ttlMs);
3810
+ if (operator.eq !== undefined) {
3811
+ if (operator.eq === null) {
3812
+ clauses.push(`${column} IS NULL`);
3813
+ } else {
3814
+ clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
3815
+ }
3998
3816
  }
3999
- async getOrSetTagged(key, loader, tags, ttlMs) {
4000
- const owner = crypto.randomUUID();
4001
- const end = performance.now() + this.options.waitTimeoutMs;
4002
- while (!this.closed) {
4003
- const claim = await this.execute("claim", key, { owner, tags, ttlMs: ttlMs ?? this.ttlMs });
4004
- if (claim.kind === "value")
4005
- return JSON.parse(claim.payload ?? "null");
4006
- if (claim.kind === "owner")
4007
- return this.fill(key, owner, loader, ttlMs);
4008
- if (performance.now() >= end)
4009
- throw new Error("Redis cache fill wait deadline exceeded.");
4010
- await Bun.sleep(this.options.pollMs);
3817
+ if (operator.ne !== undefined) {
3818
+ if (operator.ne === null) {
3819
+ clauses.push(`${column} IS NOT NULL`);
3820
+ } else {
3821
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
4011
3822
  }
4012
- throw new Error("Redis cache store is closed.");
4013
3823
  }
4014
- async attachTags(key, tags) {
4015
- await this.execute("attach", key, { tags });
3824
+ if (operator.in !== undefined) {
3825
+ clauses.push(buildInClause(column, operator.in, params));
4016
3826
  }
4017
- async flushTags(tags) {
4018
- return (await this.execute("tags", "", { tags })).removed ?? 0;
3827
+ if (operator.notIn !== undefined) {
3828
+ clauses.push(buildInClause(column, operator.notIn, params, true));
4019
3829
  }
4020
- async invalidate(key) {
4021
- return ((await this.execute("invalidate", key)).removed ?? 0) > 0;
3830
+ if (operator.gt !== undefined) {
3831
+ clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
4022
3832
  }
4023
- async invalidateByPrefix(prefix) {
4024
- return (await this.execute("prefix", prefix)).removed ?? 0;
3833
+ if (operator.gte !== undefined) {
3834
+ clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
4025
3835
  }
4026
- async clear() {
4027
- await this.execute("clear", "");
3836
+ if (operator.lt !== undefined) {
3837
+ clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
4028
3838
  }
4029
- async size() {
4030
- return (await this.execute("size", "")).size ?? 0;
3839
+ if (operator.lte !== undefined) {
3840
+ clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
4031
3841
  }
4032
- async fill(key, owner, loader, ttlMs) {
4033
- if (this.fills.size >= this.maxEntries) {
4034
- await this.execute("release", key, { owner });
4035
- throw new Error("Redis cache local fill capacity exceeded.");
4036
- }
4037
- const controller = new AbortController;
4038
- this.fills.add(controller);
4039
- let done = false;
4040
- let renewing = false;
4041
- const renew = setInterval(() => {
4042
- if (renewing || done)
4043
- return;
4044
- renewing = true;
4045
- this.execute("renew", key, { owner }).then((result) => {
4046
- if (!result.owned)
4047
- clearInterval(renew);
4048
- }).catch((error) => {
4049
- if (!done)
4050
- controller.abort(error);
4051
- }).finally(() => {
4052
- renewing = false;
4053
- });
4054
- }, Math.max(10, Math.floor(this.options.leaseMs / 3)));
4055
- const timeout = setTimeout(() => controller.abort(new Error("Redis cache fill deadline exceeded.")), this.options.fillTimeoutMs);
4056
- let rejectAbort;
4057
- const aborted = new Promise((_, reject) => {
4058
- rejectAbort = () => reject(controller.signal.reason);
4059
- controller.signal.addEventListener("abort", rejectAbort, { once: true });
4060
- });
4061
- try {
4062
- const value = await Promise.race([
4063
- Promise.resolve().then(() => loader(controller.signal)),
4064
- aborted
4065
- ]);
4066
- controller.signal.throwIfAborted();
4067
- done = true;
4068
- clearInterval(renew);
4069
- await this.execute("publish", key, {
4070
- owner,
4071
- payload: this.serialize(value),
4072
- ttlMs: ttlMs ?? this.ttlMs
4073
- });
4074
- return value;
4075
- } finally {
4076
- done = true;
4077
- clearInterval(renew);
4078
- clearTimeout(timeout);
4079
- controller.signal.removeEventListener("abort", rejectAbort);
4080
- this.fills.delete(controller);
4081
- if (!this.closed)
4082
- await this.execute("release", key, { owner }).catch(() => {});
4083
- }
4084
- }
4085
- serialize(value) {
4086
- const payload = JSON.stringify(value);
4087
- if (typeof payload !== "string")
4088
- throw new TypeError("Redis cache values must be JSON serializable.");
4089
- return payload;
4090
- }
4091
- async execute(op, key, extra = {}) {
4092
- if (this.closed)
4093
- throw new Error("Redis cache store is closed.");
4094
- if (Buffer.byteLength(key) > 4096)
4095
- throw new RangeError("Invalid Redis cache identity.");
4096
- const tags = Array.from(new Set(extra.tags ?? []));
4097
- if (tags.length > 32 || tags.some((tag) => typeof tag !== "string" || Buffer.byteLength(tag) > 1024))
4098
- throw new RangeError("Redis cache supports at most 32 tags of 1024 bytes each.");
4099
- const prefixes = [key];
4100
- for (let i = 0;i < key.length; i++)
4101
- if (key[i] === "?")
4102
- prefixes.push(key.slice(0, i));
4103
- if (prefixes.length > 33)
4104
- throw new RangeError("Redis cache identities support at most 32 query separators.");
4105
- const ttlMs = extra.ttlMs ?? this.ttlMs;
4106
- if (typeof ttlMs !== "number" || !Number.isSafeInteger(ttlMs) || ttlMs < 0)
4107
- throw new RangeError("ttlMs must be a non-negative safe integer.");
4108
- if (!this.client)
4109
- this.client = this.createClient();
4110
- const client = this.client;
4111
- const retire = (reason) => {
4112
- if (this.client !== client)
4113
- return;
4114
- this.client = undefined;
4115
- for (const fill of this.fills)
4116
- fill.abort(reason);
4117
- client.close();
4118
- };
4119
- let timer;
4120
- const timeout = new Promise((_, reject) => {
4121
- timer = setTimeout(() => {
4122
- const error = new Error("Redis cache command deadline exceeded.");
4123
- reject(error);
4124
- retire(error);
4125
- }, this.options.commandTimeoutMs);
4126
- });
4127
- try {
4128
- const raw = await Promise.race([
4129
- client.send("EVAL", [
4130
- CACHE_SCRIPT,
4131
- String(this.keys.length),
4132
- ...this.keys,
4133
- JSON.stringify({
4134
- op,
4135
- key,
4136
- capacity: this.maxEntries,
4137
- owner: crypto.randomUUID(),
4138
- ...this.options,
4139
- ...extra,
4140
- tags,
4141
- prefixes,
4142
- ttlMs
4143
- })
4144
- ]),
4145
- timeout
4146
- ]);
4147
- if (this.closed || this.client !== client)
4148
- throw new Error("Redis cache connection was retired.");
4149
- return JSON.parse(String(raw));
4150
- } catch (error) {
4151
- if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && error.code.startsWith("ERR_REDIS_CONNECTION"))
4152
- retire(error);
4153
- throw error;
4154
- } finally {
4155
- clearTimeout(timer);
3842
+ if (operator.ilike !== undefined) {
3843
+ clauses.push(`${column} ${currentSqlDialect().ilikeOperator()} ${pushParam(params, operator.ilike)}`);
3844
+ }
3845
+ if (operator.tsMatch !== undefined) {
3846
+ if (currentSqlDialect().driver !== "pgsql") {
3847
+ throw new Error("Full-text search (tsMatch) is only available on PostgreSQL.");
4156
3848
  }
3849
+ clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
4157
3850
  }
3851
+ return clauses;
4158
3852
  }
4159
- var redisCacheStore_default = RedisCacheStore;
4160
-
4161
- // ../../src/core/cache/simpleCache.ts
4162
- class SimpleCache {
4163
- ttlMs;
4164
- maxEntries;
4165
- cache = new Map;
4166
- inflight = new Map;
4167
- tagIndex = new Map;
4168
- keyTags = new Map;
4169
- constructor(ttlMs = 3600000, maxEntries = 100) {
4170
- this.ttlMs = ttlMs;
4171
- this.maxEntries = maxEntries;
4172
- if (!Number.isFinite(ttlMs) || ttlMs < 0) {
4173
- throw new RangeError("ttlMs must be a non-negative number.");
3853
+ function appendWhereParts(tableName, where, params) {
3854
+ const clauses = [];
3855
+ for (const [columnName, filterValue] of Object.entries(where)) {
3856
+ if (filterValue === undefined) {
3857
+ continue;
4174
3858
  }
4175
- if (!Number.isInteger(maxEntries) || maxEntries < 1) {
4176
- throw new RangeError("maxEntries must be a positive integer.");
3859
+ const column = resolveQualifiedColumn(tableName, columnName);
3860
+ if (Array.isArray(filterValue)) {
3861
+ clauses.push(buildInClause(column, filterValue, params));
3862
+ continue;
3863
+ }
3864
+ if (isQueryOperator(filterValue)) {
3865
+ clauses.push(...buildOperatorClauses(column, filterValue, params));
3866
+ continue;
3867
+ }
3868
+ if (filterValue === null) {
3869
+ clauses.push(`${column} IS NULL`);
3870
+ continue;
4177
3871
  }
3872
+ clauses.push(`${column} = ${pushParam(params, filterValue)}`);
4178
3873
  }
4179
- get(key) {
4180
- return this.getFreshEntry(key)?.value;
3874
+ return clauses.join(" AND ");
3875
+ }
3876
+ function buildWhereClause(tableName, where = {}) {
3877
+ const params = [];
3878
+ const body = appendWhereParts(tableName, where, params);
3879
+ return {
3880
+ clause: body.length > 0 ? ` WHERE ${body}` : "",
3881
+ params
3882
+ };
3883
+ }
3884
+ function remapExistsSql(sql, existsParams, params) {
3885
+ const offset = params.length;
3886
+ params.push(...existsParams);
3887
+ return sql.replace(/\$(\d+)/g, (_match, index) => `$${offset + Number(index)}`);
3888
+ }
3889
+ function buildWhereNodeClause(tableName, node, params) {
3890
+ if ("compareRow" in node) {
3891
+ const { columns, operator, values } = node.compareRow;
3892
+ if (!Array.isArray(columns) || !columns.length || !Array.isArray(values) || columns.length !== values.length || !["lt", "gt"].includes(operator) || Array.from(columns).some((column) => typeof column !== "string" || !column.length))
3893
+ throw new TypeError("Invalid row comparison columns, values or operator.");
3894
+ return `(${columns.map((column) => qualifyColumn(tableName, column)).join(", ")}) ${operator === "lt" ? "<" : ">"} (${values.map((value) => pushParam(params, value)).join(", ")})`;
4181
3895
  }
4182
- set(key, value, ttlMs) {
4183
- const now = Date.now();
4184
- const resolvedTtlMs = ttlMs ?? this.ttlMs;
4185
- this.cache.set(key, {
4186
- value,
4187
- expiresAt: now + resolvedTtlMs,
4188
- lastAccessedAt: now
4189
- });
4190
- this.evictOverflow();
3896
+ if ("exists" in node) {
3897
+ const body = remapExistsSql(node.exists.sql, node.exists.params, params);
3898
+ return `${node.exists.not ? "NOT " : ""}EXISTS (${body})`;
4191
3899
  }
4192
- async getOrSet(key, loader, ttlMs) {
4193
- this.pruneExpired();
4194
- const cachedEntry = this.getFreshEntry(key);
4195
- if (cachedEntry) {
4196
- return cachedEntry.value;
4197
- }
4198
- const inflightRequest = this.inflight.get(key);
4199
- if (inflightRequest) {
4200
- return inflightRequest;
4201
- }
4202
- const pendingRequest = loader().then((value) => {
4203
- this.set(key, value, ttlMs);
4204
- return value;
4205
- }).finally(() => {
4206
- this.inflight.delete(key);
4207
- });
4208
- this.inflight.set(key, pendingRequest);
4209
- return pendingRequest;
3900
+ if ("where" in node) {
3901
+ return appendWhereParts(tableName, node.where, params);
4210
3902
  }
4211
- attachTags(key, tags) {
4212
- if (tags.length === 0) {
4213
- return;
4214
- }
4215
- let tagsForKey = this.keyTags.get(key);
4216
- if (!tagsForKey) {
4217
- tagsForKey = new Set;
4218
- this.keyTags.set(key, tagsForKey);
4219
- }
4220
- for (const tag of tags) {
4221
- tagsForKey.add(tag);
4222
- let keysForTag = this.tagIndex.get(tag);
4223
- if (!keysForTag) {
4224
- keysForTag = new Set;
4225
- this.tagIndex.set(tag, keysForTag);
4226
- }
4227
- keysForTag.add(key);
4228
- }
3903
+ const grouped = buildWhereGroupClause(tableName, node.group, params);
3904
+ if (!grouped) {
3905
+ return "";
4229
3906
  }
4230
- flushTags(tags) {
4231
- const keysToRemove = new Set;
4232
- for (const tag of tags) {
4233
- const keys = this.tagIndex.get(tag);
4234
- if (!keys) {
4235
- continue;
4236
- }
4237
- for (const key of keys) {
4238
- keysToRemove.add(key);
4239
- }
4240
- }
4241
- let removed = 0;
4242
- for (const key of keysToRemove) {
4243
- if (this.invalidate(key)) {
4244
- removed += 1;
4245
- }
3907
+ return grouped.includes(" OR ") ? `(${grouped})` : grouped;
3908
+ }
3909
+ function buildWhereGroupClause(tableName, nodes, params) {
3910
+ let result = "";
3911
+ for (const node of nodes) {
3912
+ const part = buildWhereNodeClause(tableName, node, params);
3913
+ if (!part) {
3914
+ continue;
4246
3915
  }
4247
- for (const tag of tags) {
4248
- this.tagIndex.delete(tag);
3916
+ if (!result) {
3917
+ result = part;
3918
+ continue;
4249
3919
  }
4250
- return removed;
3920
+ result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
4251
3921
  }
4252
- invalidate(key) {
4253
- const removed = this.cache.delete(key);
4254
- if (removed) {
4255
- this.detachKeyFromTags(key);
4256
- }
4257
- return removed;
4258
- }
4259
- invalidateByPrefix(prefix) {
4260
- let removed = 0;
4261
- for (const key of [...this.cache.keys()]) {
4262
- if (key === prefix || key.startsWith(`${prefix}?`)) {
4263
- if (this.invalidate(key)) {
4264
- removed += 1;
4265
- }
4266
- }
4267
- }
4268
- return removed;
4269
- }
4270
- clear() {
4271
- this.cache.clear();
4272
- this.inflight.clear();
4273
- this.tagIndex.clear();
4274
- this.keyTags.clear();
4275
- }
4276
- size() {
4277
- this.pruneExpired();
4278
- return this.cache.size;
4279
- }
4280
- detachKeyFromTags(key) {
4281
- const tags = this.keyTags.get(key);
4282
- if (!tags) {
4283
- return;
4284
- }
4285
- for (const tag of tags) {
4286
- const keys = this.tagIndex.get(tag);
4287
- if (!keys) {
4288
- continue;
4289
- }
4290
- keys.delete(key);
4291
- if (keys.size === 0) {
4292
- this.tagIndex.delete(tag);
4293
- }
4294
- }
4295
- this.keyTags.delete(key);
4296
- }
4297
- getFreshEntry(key) {
4298
- const entry = this.cache.get(key);
4299
- if (!entry) {
4300
- return;
4301
- }
4302
- if (entry.expiresAt <= Date.now()) {
4303
- this.invalidate(key);
4304
- return;
4305
- }
4306
- entry.lastAccessedAt = Date.now();
4307
- return entry;
4308
- }
4309
- pruneExpired() {
4310
- const now = Date.now();
4311
- for (const [key, entry] of this.cache.entries()) {
4312
- if (entry.expiresAt <= now) {
4313
- this.invalidate(key);
4314
- }
4315
- }
4316
- }
4317
- evictOverflow() {
4318
- while (this.cache.size > this.maxEntries) {
4319
- let oldestKey;
4320
- let oldestAccessTime = Number.POSITIVE_INFINITY;
4321
- for (const [key, entry] of this.cache.entries()) {
4322
- if (entry.lastAccessedAt < oldestAccessTime) {
4323
- oldestAccessTime = entry.lastAccessedAt;
4324
- oldestKey = key;
4325
- }
4326
- }
4327
- if (!oldestKey) {
4328
- return;
4329
- }
4330
- this.invalidate(oldestKey);
4331
- }
3922
+ if (!result) {
3923
+ return "";
4332
3924
  }
3925
+ return result;
4333
3926
  }
4334
- var simpleCache_default = SimpleCache;
4335
-
4336
- // ../../src/core/cache/simpleCacheStore.ts
4337
- class SimpleCacheStore {
4338
- cache;
4339
- constructor(cache) {
4340
- this.cache = cache;
3927
+ function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
3928
+ const nodes = [];
3929
+ if (Object.keys(where).length > 0) {
3930
+ nodes.push({ kind: "and", where });
4341
3931
  }
4342
- get(key) {
4343
- return Promise.resolve(this.cache.get(key));
3932
+ nodes.push(...whereNodes);
3933
+ const combined = buildWhereGroupClause(tableName, nodes, params);
3934
+ return {
3935
+ clause: combined ? ` WHERE ${combined}` : "",
3936
+ params
3937
+ };
3938
+ }
3939
+ function resolveSoftDeleteColumn(table) {
3940
+ if (!table.softDeletes) {
3941
+ return null;
4344
3942
  }
4345
- set(key, value, ttlMs) {
4346
- this.cache.set(key, value, ttlMs);
4347
- return Promise.resolve();
3943
+ if (table.softDeletes === true) {
3944
+ return "deleted_at";
4348
3945
  }
4349
- getOrSet(key, loader, ttlMs) {
4350
- return this.cache.getOrSet(key, loader, ttlMs);
3946
+ return table.softDeletes.column ?? "deleted_at";
3947
+ }
3948
+ function appendSoftDeleteScope(table, options, clauses) {
3949
+ const column = resolveSoftDeleteColumn(table);
3950
+ if (!column) {
3951
+ return;
4351
3952
  }
4352
- attachTags(key, tags) {
4353
- this.cache.attachTags(key, tags);
4354
- return Promise.resolve();
3953
+ const qualifiedColumn = qualifyColumn(table.name, column);
3954
+ if (options.onlyTrashed) {
3955
+ clauses.push(`${qualifiedColumn} IS NOT NULL`);
3956
+ return;
4355
3957
  }
4356
- flushTags(tags) {
4357
- return Promise.resolve(this.cache.flushTags(tags));
3958
+ if (!options.withTrashed) {
3959
+ clauses.push(`${qualifiedColumn} IS NULL`);
4358
3960
  }
4359
- invalidate(key) {
4360
- return Promise.resolve(this.cache.invalidate(key));
3961
+ }
3962
+ function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
3963
+ const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
3964
+ const softDeleteClauses = [];
3965
+ appendSoftDeleteScope(table, options, softDeleteClauses);
3966
+ if (softDeleteClauses.length === 0) {
3967
+ return { clause, params: whereParams };
4361
3968
  }
4362
- invalidateByPrefix(prefix) {
4363
- return Promise.resolve(this.cache.invalidateByPrefix(prefix));
3969
+ const base = clause.replace(/^ WHERE /, "");
3970
+ const scope = softDeleteClauses.join(" AND ");
3971
+ return {
3972
+ clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
3973
+ params: whereParams
3974
+ };
3975
+ }
3976
+ function isQueryOrder(value) {
3977
+ return "column" in value;
3978
+ }
3979
+ function normalizeOrderBy(orderBy) {
3980
+ if (!orderBy) {
3981
+ return [];
4364
3982
  }
4365
- clear() {
4366
- this.cache.clear();
4367
- return Promise.resolve();
3983
+ if (Array.isArray(orderBy)) {
3984
+ return orderBy;
4368
3985
  }
4369
- size() {
4370
- return Promise.resolve(this.cache.size());
3986
+ if (isQueryOrder(orderBy)) {
3987
+ return [orderBy];
4371
3988
  }
3989
+ return Object.entries(orderBy).map(([column, direction]) => ({
3990
+ column,
3991
+ direction
3992
+ }));
4372
3993
  }
4373
- var simpleCacheStore_default = SimpleCacheStore;
4374
-
4375
- // ../../src/core/cache/createCacheStore.ts
4376
- function createCacheStore(options) {
4377
- if (options.driver === "redis") {
4378
- if (!options.redisUrl) {
4379
- throw new Error('CACHE_DRIVER="redis" requires REDIS_URL to be set.');
3994
+ function buildOrderByClause(tableName, orderBy) {
3995
+ const parts = normalizeOrderBy(orderBy).map(({ column, direction, nulls }) => {
3996
+ if (nulls !== undefined && nulls !== "first" && nulls !== "last") {
3997
+ throw new TypeError("Invalid null ordering: expected first or last.");
4380
3998
  }
4381
- return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries, options.redis);
3999
+ const reference = resolveQualifiedColumn(tableName, column);
4000
+ const ordered = `${reference} ${normalizeDirection(direction)}`;
4001
+ if (nulls === undefined)
4002
+ return ordered;
4003
+ if (currentSqlDialect().driver === "mysql") {
4004
+ return `(${reference} IS NULL) ${nulls === "first" ? "DESC" : "ASC"}, ${ordered}`;
4005
+ }
4006
+ return `${ordered} NULLS ${nulls.toUpperCase()}`;
4007
+ });
4008
+ return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
4009
+ }
4010
+ function buildGroupByClause(tableName, groupBy) {
4011
+ if (!groupBy) {
4012
+ return "";
4382
4013
  }
4383
- return new simpleCacheStore_default(new simpleCache_default(options.ttlMs, options.maxEntries));
4014
+ const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
4015
+ const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
4016
+ return parts.length > 0 ? ` GROUP BY ${parts.join(", ")}` : "";
4384
4017
  }
4385
- // ../../src/core/cache/taggedCache.ts
4386
- class TaggedCache {
4387
- store;
4388
- tags;
4389
- constructor(store, tags) {
4390
- this.store = store;
4391
- this.tags = tags;
4018
+ function buildHavingClause(tableName, having, params) {
4019
+ if (!having) {
4020
+ return "";
4392
4021
  }
4393
- async remember(key, callback, ttlMs) {
4394
- if (this.store.getOrSetTagged)
4395
- return this.store.getOrSetTagged(key, callback, this.tags, ttlMs);
4396
- const value = await this.store.getOrSet(key, callback, ttlMs);
4397
- await this.store.attachTags(key, this.tags);
4398
- return value;
4022
+ const body = appendWhereParts(tableName, having, params);
4023
+ return body.length > 0 ? ` HAVING ${body}` : "";
4024
+ }
4025
+ function buildJoinClause(joins = []) {
4026
+ return joins.map((join) => {
4027
+ const joinType = join.type === "left" ? "LEFT JOIN" : "INNER JOIN";
4028
+ const onClause = join.on.map(({ left, right }) => `${qualifyColumn(left.table, left.column)} = ${qualifyColumn(right.table, right.column)}`).join(" AND ");
4029
+ return ` ${joinType} ${quoteIdentifier(join.table)} ON ${onClause}`;
4030
+ }).join("");
4031
+ }
4032
+ function buildLimitClause(limit) {
4033
+ if (limit === undefined) {
4034
+ return "";
4399
4035
  }
4400
- async flush() {
4401
- return this.store.flushTags(this.tags);
4036
+ if (!Number.isInteger(limit) || limit <= 0) {
4037
+ throw new Error("Query limit must be a positive integer.");
4402
4038
  }
4039
+ return ` LIMIT ${limit}`;
4403
4040
  }
4404
- var taggedCache_default = TaggedCache;
4405
-
4406
- // ../../src/core/cache/repository.ts
4407
- class CacheRepository {
4408
- store;
4409
- constructor(store) {
4410
- this.store = store;
4041
+ function buildOffsetClause(offset) {
4042
+ if (offset === undefined) {
4043
+ return "";
4411
4044
  }
4412
- async close() {
4413
- await this.store.close?.();
4045
+ if (!Number.isInteger(offset) || offset < 0) {
4046
+ throw new Error("Query offset must be a non-negative integer.");
4414
4047
  }
4415
- async get(key) {
4416
- return this.store.get(key);
4048
+ return ` OFFSET ${offset}`;
4049
+ }
4050
+ function buildReturningColumns(table) {
4051
+ return table.columns.map((column) => qualifyColumn(table.name, column)).join(", ");
4052
+ }
4053
+ function buildSelectList(table, select, params = []) {
4054
+ if (!select || select.length === 0) {
4055
+ return buildReturningColumns(table);
4417
4056
  }
4418
- async remember(key, callback, ttlMs) {
4419
- return this.store.getOrSet(key, callback, ttlMs);
4420
- }
4421
- async forget(key) {
4422
- return this.store.invalidate(key);
4423
- }
4424
- async flush() {
4425
- await this.store.clear();
4426
- }
4427
- tags(...names) {
4428
- return new taggedCache_default(this.store, names);
4429
- }
4430
- async getOrSet(key, loader, ttlMs) {
4431
- return this.remember(key, loader, ttlMs);
4432
- }
4433
- async invalidate(key) {
4434
- return this.forget(key);
4435
- }
4436
- async invalidateByPrefix(prefix) {
4437
- return this.store.invalidateByPrefix(prefix);
4438
- }
4439
- async clear() {
4440
- await this.flush();
4441
- }
4442
- async size() {
4443
- return this.store.size();
4444
- }
4445
- }
4446
- var repository_default = CacheRepository;
4447
- // ../../src/core/cache/tags.ts
4448
- var CACHE_TAGS = {
4449
- organizations: "organizations",
4450
- projects: "projects",
4451
- tasks: "tasks",
4452
- comments: "comments",
4453
- attachments: "attachments",
4454
- reports: "reports",
4455
- users: "users",
4456
- departments: "departments",
4457
- positions: "positions",
4458
- applications: "applications",
4459
- careers: "careers",
4460
- offers: "offers"
4461
- };
4462
- // ../../src/core/crypto/fieldEncryptionRotation.ts
4463
- import { createHash as createHash3, randomUUID } from "crypto";
4464
-
4465
- // ../../src/core/database/baseRepository.ts
4466
- init_eventBus();
4467
-
4468
- // ../../src/core/events/index.ts
4469
- init_deferredModelEvents();
4470
- init_eventBus();
4471
- function modelEventName(tableName, action) {
4472
- return `${tableName}.${action}`;
4473
- }
4474
-
4475
- // ../../src/core/pagination/index.ts
4476
- function buildPaginationMeta(input) {
4477
- const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
4478
- return {
4479
- page: input.page,
4480
- per_page: input.perPage,
4481
- total: input.total,
4482
- last_page: lastPage
4483
- };
4057
+ return select.map((item) => {
4058
+ if (item.kind === "column") {
4059
+ const column = qualifyColumn(item.table, item.column);
4060
+ return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
4061
+ }
4062
+ if (item.kind === "textColumn") {
4063
+ const dialect = currentSqlDialect();
4064
+ const column = qualifyColumn(item.table, item.column);
4065
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
4066
+ return `${text} AS ${quoteIdentifier(item.as)}`;
4067
+ }
4068
+ if (item.kind === "literalText") {
4069
+ return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
4070
+ }
4071
+ if (item.kind === "subqueryCount") {
4072
+ const body = remapExistsSql(item.sql, item.params, params);
4073
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
4074
+ }
4075
+ const column = qualifyColumn(item.table, item.column);
4076
+ const placeholder = pushParam(params, item.query);
4077
+ return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
4078
+ }).join(", ");
4484
4079
  }
4485
-
4486
- // ../../src/core/database/baseRepository.ts
4487
- init_connectionContext();
4488
- init_dialect();
4489
-
4490
- // ../../src/core/database/keyset.ts
4491
- init_dialect();
4492
- var KEYSET_ALIAS_PREFIX = "__strata_keyset_";
4493
- function validateKeyset(table, options) {
4494
- if (!Number.isInteger(options.perPage) || options.perPage < 1 || options.perPage > 1000)
4495
- throw new RangeError("Keyset page size must be between 1 and 1000.");
4496
- if (!Array.isArray(options.orderBy) || options.orderBy.length < 1 || options.orderBy.length > 8)
4497
- throw new TypeError("Keyset ordering requires between 1 and 8 columns.");
4498
- if (table.columns.some((column) => column.toLowerCase().startsWith(KEYSET_ALIAS_PREFIX)))
4499
- throw new TypeError("Table columns conflict with reserved keyset aliases.");
4500
- const seen = new Set;
4501
- const order = Array.from(options.orderBy).map(({ column, direction }) => {
4502
- if (!table.columns.includes(column) || seen.has(column) || !["asc", "desc"].includes(direction))
4503
- throw new TypeError("Invalid or duplicate keyset ordering column/direction.");
4504
- seen.add(column);
4505
- return { column, direction };
4080
+ function getDefinedColumnEntries(table, values, options = {}) {
4081
+ const record = values;
4082
+ const excluded = new Set(options.exclude ?? []);
4083
+ return table.columns.flatMap((column) => {
4084
+ if (excluded.has(column) || !Object.hasOwn(record, column)) {
4085
+ return [];
4086
+ }
4087
+ const value = record[column];
4088
+ if (value === undefined) {
4089
+ return [];
4090
+ }
4091
+ return [[column, value]];
4506
4092
  });
4507
- if (order.at(-1)?.column !== table.primaryKey)
4508
- throw new TypeError("Keyset ordering must end with the table primary key.");
4509
- if (options.cursor !== undefined) {
4510
- const cursor = options.cursor;
4511
- if (cursor?.version !== 1 || !Array.isArray(cursor.order) || cursor.order.length !== order.length || !Array.isArray(cursor.values) || cursor.values.length !== order.length || Array.from(cursor.order).some((item, i) => !item || item.column !== order[i]?.column || item.direction !== order[i]?.direction) || Array.from(cursor.values).some((value) => typeof value !== "string" || value.length > 4096))
4512
- throw new TypeError("Invalid keyset cursor or ordering mismatch.");
4513
- }
4514
- return order;
4515
4093
  }
4516
- function keysetBoundary(order, cursor) {
4517
- if (!cursor)
4518
- return [];
4519
- if (currentSqlDialect().driver === "pgsql" && order.length > 1 && order.every((item) => item.direction === order[0]?.direction)) {
4520
- return [
4521
- {
4522
- kind: "and",
4523
- compareRow: {
4524
- columns: order.map((item) => item.column),
4525
- operator: order[0]?.direction === "asc" ? "gt" : "lt",
4526
- values: [...cursor.values]
4527
- }
4528
- }
4529
- ];
4094
+ function buildSelectLock(table, options) {
4095
+ const lock = options.lock;
4096
+ if (!lock)
4097
+ return "";
4098
+ const driver = currentSqlDialect().driver;
4099
+ if (driver === "sqlite")
4100
+ throw new Error("Row locks are not supported on SQLite; use an explicit write transaction.");
4101
+ if (lock.mode !== "update" && lock.mode !== "share")
4102
+ throw new TypeError("Invalid row lock mode.");
4103
+ if (lock.wait !== undefined && !["wait", "nowait", "skipLocked"].includes(lock.wait))
4104
+ throw new TypeError("Invalid row lock wait policy.");
4105
+ if (options.groupBy || options.having)
4106
+ throw new Error("Row locks cannot be combined with grouped/aggregate queries.");
4107
+ let target = "";
4108
+ if (lock.of !== undefined) {
4109
+ if (driver !== "pgsql")
4110
+ throw new Error("Row lock targets are supported only on PostgreSQL.");
4111
+ const tables = [table.name, ...(options.joins ?? []).map((join) => join.table)];
4112
+ if (!lock.of.length || lock.of.some((name) => !tables.includes(name)))
4113
+ throw new TypeError("Row lock targets must name tables in this query.");
4114
+ target = ` OF ${lock.of.map(quoteIdentifier).join(", ")}`;
4530
4115
  }
4531
- const branches = order.map((item, i) => {
4532
- const where = {};
4533
- order.slice(0, i).forEach((prefix, j) => {
4534
- where[prefix.column] = cursor.values[j];
4535
- });
4536
- where[item.column] = { [item.direction === "asc" ? "gt" : "lt"]: cursor.values[i] };
4537
- return { kind: i === 0 ? "and" : "or", where };
4538
- });
4539
- return [{ kind: "and", group: branches }];
4116
+ const wait = lock.wait === "nowait" ? " NOWAIT" : lock.wait === "skipLocked" ? " SKIP LOCKED" : "";
4117
+ return ` FOR ${lock.mode === "update" ? "UPDATE" : "SHARE"}${target}${wait}`;
4540
4118
  }
4541
-
4542
- // ../../src/core/database/query.ts
4543
- init_dialect();
4544
- function quoteIdentifier(identifier) {
4545
- return currentSqlDialect().quoteIdentifier(identifier);
4119
+ function buildSelectQuery(table, options = {}, whereNodes = []) {
4120
+ const params = [];
4121
+ const columns = buildSelectList(table, options.select, params);
4122
+ const { clause } = buildQueryWhereClause(table, options, whereNodes, params);
4123
+ const joins = buildJoinClause(options.joins);
4124
+ const groupBy = buildGroupByClause(table.name, options.groupBy);
4125
+ const havingClause = buildHavingClause(table.name, options.having, params);
4126
+ const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
4127
+ const limit = buildLimitClause(options.limit);
4128
+ const offset = buildOffsetClause(options.offset);
4129
+ const lock = buildSelectLock(table, options);
4130
+ return {
4131
+ text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}${lock}`,
4132
+ params
4133
+ };
4546
4134
  }
4547
- function returningSuffix(columns) {
4548
- return currentSqlDialect().returningClause(columns);
4135
+ function buildCountQuery(table, where = {}, options = {}, whereNodes = []) {
4136
+ const params = [];
4137
+ const { clause, params: whereParams } = buildQueryWhereClause(table, {
4138
+ where,
4139
+ withTrashed: options.withTrashed,
4140
+ onlyTrashed: options.onlyTrashed
4141
+ }, whereNodes);
4142
+ params.push(...whereParams);
4143
+ const joins = buildJoinClause(options.joins);
4144
+ const groupBy = buildGroupByClause(table.name, options.groupBy);
4145
+ return {
4146
+ text: `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}`,
4147
+ params
4148
+ };
4549
4149
  }
4550
- function qualifyColumn(tableName, column) {
4551
- return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
4150
+ function buildProjectionQuery(table, expression, alias, options = {}, whereNodes = []) {
4151
+ assertSafeProjectionExpression(expression);
4152
+ const params = [];
4153
+ const { clause, params: whereParams } = buildQueryWhereClause(table, options, whereNodes);
4154
+ params.push(...whereParams);
4155
+ const joins = buildJoinClause(options.joins);
4156
+ const groupBy = buildGroupByClause(table.name, options.groupBy);
4157
+ const orderBy = buildOrderByClause(table.name, options.orderBy);
4158
+ const limit = buildLimitClause(options.limit);
4159
+ return {
4160
+ text: `SELECT ${expression} AS ${quoteIdentifier(alias)} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${orderBy}${limit}`,
4161
+ params
4162
+ };
4552
4163
  }
4553
- function resolveQualifiedColumn(defaultTable, columnName) {
4554
- if (columnName.includes(".")) {
4555
- const [table, column] = columnName.split(".", 2);
4556
- if (!table || !column) {
4557
- throw new Error(`Invalid qualified column: ${columnName}`);
4558
- }
4559
- return qualifyColumn(table, column);
4164
+ var SAFE_PROJECTION_EXPRESSION = /^(COUNT\(\*\)|(?:"[\w_]+"(?:\."[\w_]+")?)|(?:AVG|SUM|MIN|MAX)\((?:"[\w_]+"(?:\."[\w_]+")?)\))$/;
4165
+ function assertSafeProjectionExpression(expression) {
4166
+ if (!SAFE_PROJECTION_EXPRESSION.test(expression.trim())) {
4167
+ throw new Error(`Unsafe projection expression: ${expression}`);
4560
4168
  }
4561
- return qualifyColumn(defaultTable, columnName);
4562
4169
  }
4563
- function parseQualifiedColumn(reference) {
4564
- const [table, column] = reference.split(".", 2);
4565
- if (!table || !column) {
4566
- throw new Error(`Join columns must be qualified as table.column: ${reference}`);
4567
- }
4568
- return { table, column };
4170
+ function buildGroupedSumQuery(table, groupColumn, column, options = {}, whereNodes = options.whereNodes ?? []) {
4171
+ if (!table.columns.includes(groupColumn) || !table.columns.includes(column))
4172
+ throw new TypeError("Grouped sums require declared columns.");
4173
+ for (const key of Object.keys(options))
4174
+ if (!["where", "whereNodes", "withTrashed", "onlyTrashed"].includes(key))
4175
+ throw new TypeError("Grouped sums do not support joins, selection, ordering, pagination, grouping or locks.");
4176
+ const group = qualifyColumn(table.name, groupColumn);
4177
+ const total = currentSqlDialect().castToText(`SUM(${qualifyColumn(table.name, column)})`);
4178
+ const { clause, params } = buildQueryWhereClause(table, options, whereNodes);
4179
+ return {
4180
+ text: `SELECT ${group} AS ${quoteIdentifier("value")}, ${total} AS ${quoteIdentifier("sum")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${group} ORDER BY ${group} ASC`,
4181
+ params
4182
+ };
4569
4183
  }
4570
- function normalizeDirection(direction = "ASC") {
4571
- return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
4572
- }
4573
- function isQueryOperator(value) {
4574
- return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
4575
- }
4576
- function pushParam(values, value) {
4577
- values.push(value);
4578
- return currentSqlDialect().placeholder(values.length);
4184
+ function buildGroupedCountQuery(table, column, where = {}, options = {}) {
4185
+ const qualifiedColumn = qualifyColumn(table.name, column);
4186
+ const { clause, params } = buildQueryWhereClause(table, {
4187
+ where,
4188
+ ...options
4189
+ });
4190
+ return {
4191
+ text: `SELECT ${qualifiedColumn} AS ${quoteIdentifier("value")}, COUNT(*) AS ${quoteIdentifier("count")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${qualifiedColumn} ORDER BY ${qualifiedColumn} ASC`,
4192
+ params
4193
+ };
4579
4194
  }
4580
- var SUPPORTED_OPERATORS = new Set([
4581
- "eq",
4582
- "ne",
4583
- "in",
4584
- "notIn",
4585
- "gt",
4586
- "gte",
4587
- "lt",
4588
- "lte",
4589
- "isNull",
4590
- "ilike",
4591
- "tsMatch"
4592
- ]);
4593
- function buildInClause(column, values, params, negated = false) {
4594
- if (values.length === 0) {
4595
- return negated ? "1 = 1" : "1 = 0";
4195
+ function buildInsertQuery(table, values) {
4196
+ const entries = getDefinedColumnEntries(table, values);
4197
+ if (entries.length === 0) {
4198
+ throw new Error(`Cannot insert into ${table.name} without any column values.`);
4596
4199
  }
4597
- const placeholders = values.map((value) => pushParam(params, value)).join(", ");
4598
- return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
4200
+ const params = [];
4201
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
4202
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
4203
+ const returningColumns = buildReturningColumns(table);
4204
+ return {
4205
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${returningSuffix(returningColumns)}`,
4206
+ params
4207
+ };
4599
4208
  }
4600
- function buildOperatorClauses(column, operator, params) {
4601
- const clauses = [];
4602
- const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
4603
- if (unsupported.length > 0) {
4604
- throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
4605
- }
4606
- if (operator.isNull === true) {
4607
- clauses.push(`${column} IS NULL`);
4608
- }
4609
- if (operator.isNull === false) {
4610
- clauses.push(`${column} IS NOT NULL`);
4611
- }
4612
- if (operator.eq !== undefined) {
4613
- if (operator.eq === null) {
4614
- clauses.push(`${column} IS NULL`);
4615
- } else {
4616
- clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
4617
- }
4618
- }
4619
- if (operator.ne !== undefined) {
4620
- if (operator.ne === null) {
4621
- clauses.push(`${column} IS NOT NULL`);
4622
- } else {
4623
- clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
4624
- }
4625
- }
4626
- if (operator.in !== undefined) {
4627
- clauses.push(buildInClause(column, operator.in, params));
4628
- }
4629
- if (operator.notIn !== undefined) {
4630
- clauses.push(buildInClause(column, operator.notIn, params, true));
4631
- }
4632
- if (operator.gt !== undefined) {
4633
- clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
4634
- }
4635
- if (operator.gte !== undefined) {
4636
- clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
4637
- }
4638
- if (operator.lt !== undefined) {
4639
- clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
4640
- }
4641
- if (operator.lte !== undefined) {
4642
- clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
4643
- }
4644
- if (operator.ilike !== undefined) {
4645
- clauses.push(`${column} ${currentSqlDialect().ilikeOperator()} ${pushParam(params, operator.ilike)}`);
4646
- }
4647
- if (operator.tsMatch !== undefined) {
4648
- if (currentSqlDialect().driver !== "pgsql") {
4649
- throw new Error("Full-text search (tsMatch) is only available on PostgreSQL.");
4650
- }
4651
- clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
4209
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
4210
+ const entries = getDefinedColumnEntries(table, values);
4211
+ if (entries.length === 0) {
4212
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
4652
4213
  }
4653
- return clauses;
4654
- }
4655
- function appendWhereParts(tableName, where, params) {
4656
- const clauses = [];
4657
- for (const [columnName, filterValue] of Object.entries(where)) {
4658
- if (filterValue === undefined) {
4659
- continue;
4660
- }
4661
- const column = resolveQualifiedColumn(tableName, columnName);
4662
- if (Array.isArray(filterValue)) {
4663
- clauses.push(buildInClause(column, filterValue, params));
4664
- continue;
4665
- }
4666
- if (isQueryOperator(filterValue)) {
4667
- clauses.push(...buildOperatorClauses(column, filterValue, params));
4668
- continue;
4669
- }
4670
- if (filterValue === null) {
4671
- clauses.push(`${column} IS NULL`);
4672
- continue;
4673
- }
4674
- clauses.push(`${column} = ${pushParam(params, filterValue)}`);
4214
+ if (conflictColumns.length === 0) {
4215
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
4675
4216
  }
4676
- return clauses.join(" AND ");
4677
- }
4678
- function buildWhereClause(tableName, where = {}) {
4217
+ const insertable = new Set(entries.map(([column]) => column));
4218
+ const conflict = new Set(conflictColumns);
4219
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
4679
4220
  const params = [];
4680
- const body = appendWhereParts(tableName, where, params);
4221
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
4222
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
4223
+ const returningColumns = buildReturningColumns(table);
4224
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
4681
4225
  return {
4682
- clause: body.length > 0 ? ` WHERE ${body}` : "",
4226
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
4683
4227
  params
4684
4228
  };
4685
4229
  }
4686
- function remapExistsSql(sql, existsParams, params) {
4687
- const offset = params.length;
4688
- params.push(...existsParams);
4689
- return sql.replace(/\$(\d+)/g, (_match, index) => `$${offset + Number(index)}`);
4690
- }
4691
- function buildWhereNodeClause(tableName, node, params) {
4692
- if ("compareRow" in node) {
4693
- const { columns, operator, values } = node.compareRow;
4694
- if (!Array.isArray(columns) || !columns.length || !Array.isArray(values) || columns.length !== values.length || !["lt", "gt"].includes(operator) || Array.from(columns).some((column) => typeof column !== "string" || !column.length))
4695
- throw new TypeError("Invalid row comparison columns, values or operator.");
4696
- return `(${columns.map((column) => qualifyColumn(tableName, column)).join(", ")}) ${operator === "lt" ? "<" : ">"} (${values.map((value) => pushParam(params, value)).join(", ")})`;
4697
- }
4698
- if ("exists" in node) {
4699
- const body = remapExistsSql(node.exists.sql, node.exists.params, params);
4700
- return `${node.exists.not ? "NOT " : ""}EXISTS (${body})`;
4230
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
4231
+ if (!Number.isFinite(amount)) {
4232
+ throw new Error("Increment amount must be a finite number.");
4701
4233
  }
4702
- if ("where" in node) {
4703
- return appendWhereParts(tableName, node.where, params);
4234
+ if (!table.columns.includes(column)) {
4235
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
4704
4236
  }
4705
- const grouped = buildWhereGroupClause(tableName, node.group, params);
4706
- if (!grouped) {
4707
- return "";
4237
+ const params = [];
4238
+ const target = quoteIdentifier(column);
4239
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
4240
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
4241
+ exclude: [table.primaryKey, column]
4242
+ })) {
4243
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
4708
4244
  }
4709
- return grouped.includes(" OR ") ? `(${grouped})` : grouped;
4245
+ const primaryKeyPlaceholder = pushParam(params, id);
4246
+ const returningColumns = buildReturningColumns(table);
4247
+ const scopeClauses = [];
4248
+ appendSoftDeleteScope(table, {}, scopeClauses);
4249
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
4250
+ return {
4251
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
4252
+ params
4253
+ };
4710
4254
  }
4711
- function buildWhereGroupClause(tableName, nodes, params) {
4712
- let result = "";
4713
- for (const node of nodes) {
4714
- const part = buildWhereNodeClause(tableName, node, params);
4715
- if (!part) {
4716
- continue;
4717
- }
4718
- if (!result) {
4719
- result = part;
4720
- continue;
4721
- }
4722
- result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
4723
- }
4724
- if (!result) {
4725
- return "";
4255
+ function buildUpdateQuery(table, id, changes) {
4256
+ const entries = getDefinedColumnEntries(table, changes, {
4257
+ exclude: [table.primaryKey]
4258
+ });
4259
+ if (entries.length === 0) {
4260
+ throw new Error(`Cannot update ${table.name} without any changed column values.`);
4726
4261
  }
4727
- return result;
4262
+ const params = [];
4263
+ const setClause = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`).join(", ");
4264
+ const primaryKeyPlaceholder = pushParam(params, id);
4265
+ const returningColumns = buildReturningColumns(table);
4266
+ const scopeClauses = [];
4267
+ appendSoftDeleteScope(table, {}, scopeClauses);
4268
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
4269
+ return {
4270
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${setClause} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
4271
+ params
4272
+ };
4728
4273
  }
4729
- function buildAdvancedWhereClause(tableName, where = {}, whereNodes = [], params = []) {
4730
- const nodes = [];
4731
- if (Object.keys(where).length > 0) {
4732
- nodes.push({ kind: "and", where });
4274
+ function buildSoftDeleteByIdQuery(table, id, deletedAt) {
4275
+ const deletedAtColumn = resolveSoftDeleteColumn(table);
4276
+ if (!deletedAtColumn) {
4277
+ throw new Error(`Table ${table.name} does not support soft deletes.`);
4733
4278
  }
4734
- nodes.push(...whereNodes);
4735
- const combined = buildWhereGroupClause(tableName, nodes, params);
4279
+ const returningColumns = buildReturningColumns(table);
4280
+ const scopeClauses = [];
4281
+ appendSoftDeleteScope(table, {}, scopeClauses);
4282
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
4283
+ const params = [];
4284
+ const deletedAtPlaceholder = pushParam(params, deletedAt);
4285
+ const idPlaceholder = pushParam(params, id);
4736
4286
  return {
4737
- clause: combined ? ` WHERE ${combined}` : "",
4287
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = ${deletedAtPlaceholder} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
4738
4288
  params
4739
4289
  };
4740
4290
  }
4741
- function resolveSoftDeleteColumn(table) {
4742
- if (!table.softDeletes) {
4743
- return null;
4744
- }
4745
- if (table.softDeletes === true) {
4746
- return "deleted_at";
4291
+ function buildRestoreByIdQuery(table, id) {
4292
+ const deletedAtColumn = resolveSoftDeleteColumn(table);
4293
+ if (!deletedAtColumn) {
4294
+ throw new Error(`Table ${table.name} does not support soft deletes.`);
4747
4295
  }
4748
- return table.softDeletes.column ?? "deleted_at";
4296
+ const returningColumns = buildReturningColumns(table);
4297
+ const params = [];
4298
+ const deletedAtPlaceholder = pushParam(params, null);
4299
+ const idPlaceholder = pushParam(params, id);
4300
+ return {
4301
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = ${deletedAtPlaceholder} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder} AND ${qualifyColumn(table.name, deletedAtColumn)} IS NOT NULL${returningSuffix(returningColumns)}`,
4302
+ params
4303
+ };
4749
4304
  }
4750
- function appendSoftDeleteScope(table, options, clauses) {
4751
- const column = resolveSoftDeleteColumn(table);
4752
- if (!column) {
4753
- return;
4754
- }
4755
- const qualifiedColumn = qualifyColumn(table.name, column);
4756
- if (options.onlyTrashed) {
4757
- clauses.push(`${qualifiedColumn} IS NOT NULL`);
4758
- return;
4759
- }
4760
- if (!options.withTrashed) {
4761
- clauses.push(`${qualifiedColumn} IS NULL`);
4762
- }
4305
+ function buildDeleteByIdQuery(table, id) {
4306
+ const params = [];
4307
+ const idPlaceholder = pushParam(params, id);
4308
+ const returning = returningSuffix(`${quoteIdentifier(table.primaryKey)} AS ${quoteIdentifier("deleted_id")}`);
4309
+ return {
4310
+ text: `DELETE FROM ${quoteIdentifier(table.name)} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder}${returning}`,
4311
+ params
4312
+ };
4763
4313
  }
4764
- function buildQueryWhereClause(table, options = {}, whereNodes = [], params = []) {
4765
- const { clause, params: whereParams } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes, params);
4766
- const softDeleteClauses = [];
4767
- appendSoftDeleteScope(table, options, softDeleteClauses);
4768
- if (softDeleteClauses.length === 0) {
4769
- return { clause, params: whereParams };
4314
+ function buildConditionalWriteQuery(table, operation, changes, options, returning) {
4315
+ for (const key of [
4316
+ "joins",
4317
+ "groupBy",
4318
+ "having",
4319
+ "select",
4320
+ "orderBy",
4321
+ "limit",
4322
+ "offset",
4323
+ "lock"
4324
+ ]) {
4325
+ if (options[key] !== undefined)
4326
+ throw new Error(`Conditional writes do not support ${key}.`);
4770
4327
  }
4771
- const base = clause.replace(/^ WHERE /, "");
4772
- const scope = softDeleteClauses.join(" AND ");
4328
+ const params = [];
4329
+ const predicate = buildAdvancedWhereClause(table.name, options.where ?? {}, options.whereNodes ?? []);
4330
+ if (!predicate.clause)
4331
+ throw new Error("Conditional writes require an explicit predicate.");
4332
+ if (returning !== undefined && currentSqlDialect().driver === "mysql")
4333
+ throw new Error("Mutation RETURNING is not supported on MySQL.");
4334
+ const columns = returning ?? [table.primaryKey];
4335
+ if (!columns.length || columns.some((column) => !table.columns.includes(column)))
4336
+ throw new Error("RETURNING columns must be nonempty declared table columns.");
4337
+ const deletedAt = resolveSoftDeleteColumn(table);
4338
+ const values = operation === "delete" && deletedAt ? { [deletedAt]: new Date } : changes;
4339
+ let prefix;
4340
+ if (operation === "update" || deletedAt) {
4341
+ const entries = Object.entries(values).filter(([, value]) => value !== undefined);
4342
+ if (!entries.length)
4343
+ throw new Error("Conditional updates require at least one value.");
4344
+ if (entries.some(([column]) => column === table.primaryKey || !table.columns.includes(column)))
4345
+ throw new Error("Conditional updates require declared non-primary-key columns.");
4346
+ const assignments = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`);
4347
+ prefix = `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")}`;
4348
+ } else
4349
+ prefix = `DELETE FROM ${quoteIdentifier(table.name)}`;
4350
+ const { clause } = buildQueryWhereClause(table, options, options.whereNodes ?? [], params);
4773
4351
  return {
4774
- clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
4775
- params: whereParams
4352
+ text: prefix + clause + returningSuffix(columns.map(quoteIdentifier).join(", ")),
4353
+ params
4776
4354
  };
4777
4355
  }
4778
- function isQueryOrder(value) {
4779
- return "column" in value;
4780
- }
4781
- function normalizeOrderBy(orderBy) {
4782
- if (!orderBy) {
4783
- return [];
4784
- }
4785
- if (Array.isArray(orderBy)) {
4786
- return orderBy;
4787
- }
4788
- if (isQueryOrder(orderBy)) {
4789
- return [orderBy];
4790
- }
4791
- return Object.entries(orderBy).map(([column, direction]) => ({
4792
- column,
4793
- direction
4794
- }));
4356
+
4357
+ // ../../src/core/database/relationships.ts
4358
+ function hasMany(definition) {
4359
+ return {
4360
+ type: "hasMany",
4361
+ ...definition
4362
+ };
4795
4363
  }
4796
- function buildOrderByClause(tableName, orderBy) {
4797
- const parts = normalizeOrderBy(orderBy).map(({ column, direction, nulls }) => {
4798
- if (nulls !== undefined && nulls !== "first" && nulls !== "last") {
4799
- throw new TypeError("Invalid null ordering: expected first or last.");
4800
- }
4801
- const reference = resolveQualifiedColumn(tableName, column);
4802
- const ordered = `${reference} ${normalizeDirection(direction)}`;
4803
- if (nulls === undefined)
4804
- return ordered;
4805
- if (currentSqlDialect().driver === "mysql") {
4806
- return `(${reference} IS NULL) ${nulls === "first" ? "DESC" : "ASC"}, ${ordered}`;
4807
- }
4808
- return `${ordered} NULLS ${nulls.toUpperCase()}`;
4809
- });
4810
- return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
4364
+ function hasOne(definition) {
4365
+ return {
4366
+ type: "hasOne",
4367
+ ...definition
4368
+ };
4811
4369
  }
4812
- function buildGroupByClause(tableName, groupBy) {
4813
- if (!groupBy) {
4814
- return "";
4815
- }
4816
- const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
4817
- const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
4818
- return parts.length > 0 ? ` GROUP BY ${parts.join(", ")}` : "";
4370
+ function belongsTo(definition) {
4371
+ return {
4372
+ type: "belongsTo",
4373
+ ...definition
4374
+ };
4819
4375
  }
4820
- function buildHavingClause(tableName, having, params) {
4821
- if (!having) {
4822
- return "";
4823
- }
4824
- const body = appendWhereParts(tableName, having, params);
4825
- return body.length > 0 ? ` HAVING ${body}` : "";
4376
+ function hasManyThrough(definition) {
4377
+ return {
4378
+ type: "hasManyThrough",
4379
+ throughParentKey: "__through_parent_id",
4380
+ ...definition
4381
+ };
4826
4382
  }
4827
- function buildJoinClause(joins = []) {
4828
- return joins.map((join) => {
4829
- const joinType = join.type === "left" ? "LEFT JOIN" : "INNER JOIN";
4830
- const onClause = join.on.map(({ left, right }) => `${qualifyColumn(left.table, left.column)} = ${qualifyColumn(right.table, right.column)}`).join(" AND ");
4831
- return ` ${joinType} ${quoteIdentifier(join.table)} ON ${onClause}`;
4832
- }).join("");
4383
+ function belongsToMany(definition) {
4384
+ return {
4385
+ type: "belongsToMany",
4386
+ ...definition
4387
+ };
4833
4388
  }
4834
- function buildLimitClause(limit) {
4835
- if (limit === undefined) {
4389
+ function relationMatchKey(value) {
4390
+ if (value === null || value === undefined) {
4836
4391
  return "";
4837
4392
  }
4838
- if (!Number.isInteger(limit) || limit <= 0) {
4839
- throw new Error("Query limit must be a positive integer.");
4393
+ if (typeof value === "bigint") {
4394
+ return value.toString();
4840
4395
  }
4841
- return ` LIMIT ${limit}`;
4842
- }
4843
- function buildOffsetClause(offset) {
4844
- if (offset === undefined) {
4845
- return "";
4396
+ if (typeof value === "number" && Number.isFinite(value)) {
4397
+ return String(value);
4846
4398
  }
4847
- if (!Number.isInteger(offset) || offset < 0) {
4848
- throw new Error("Query offset must be a non-negative integer.");
4399
+ if (typeof value === "string" && /^-?\d+$/.test(value)) {
4400
+ return BigInt(value).toString();
4849
4401
  }
4850
- return ` OFFSET ${offset}`;
4402
+ return String(value);
4851
4403
  }
4852
- function buildReturningColumns(table) {
4853
- return table.columns.map((column) => qualifyColumn(table.name, column)).join(", ");
4404
+ var relationLookupCache = new WeakMap;
4405
+ function indexedRelationLookup(map) {
4406
+ const cached = relationLookupCache.get(map);
4407
+ if (cached) {
4408
+ return cached;
4409
+ }
4410
+ const indexed = new Map;
4411
+ for (const [existing, value] of map) {
4412
+ indexed.set(relationMatchKey(existing), value);
4413
+ }
4414
+ relationLookupCache.set(map, indexed);
4415
+ return indexed;
4854
4416
  }
4855
- function buildSelectList(table, select, params = []) {
4856
- if (!select || select.length === 0) {
4857
- return buildReturningColumns(table);
4417
+ function getByRelationKey(map, key) {
4418
+ if (map.has(key)) {
4419
+ return map.get(key);
4858
4420
  }
4859
- return select.map((item) => {
4860
- if (item.kind === "column") {
4861
- const column = qualifyColumn(item.table, item.column);
4862
- return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
4863
- }
4864
- if (item.kind === "textColumn") {
4865
- const dialect = currentSqlDialect();
4866
- const column = qualifyColumn(item.table, item.column);
4867
- const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
4868
- return `${text} AS ${quoteIdentifier(item.as)}`;
4421
+ const want = relationMatchKey(key);
4422
+ if (want === "") {
4423
+ return;
4424
+ }
4425
+ return indexedRelationLookup(map).get(want);
4426
+ }
4427
+ function indexHasManyRelation(parents, children, relation) {
4428
+ const groups = new Map;
4429
+ const originalKeys = new Map;
4430
+ for (const parent of parents) {
4431
+ const key = relationMatchKey(parent[relation.localKey]);
4432
+ if (!groups.has(key)) {
4433
+ groups.set(key, []);
4434
+ originalKeys.set(key, parent[relation.localKey]);
4869
4435
  }
4870
- if (item.kind === "literalText") {
4871
- return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
4436
+ }
4437
+ for (const child of children) {
4438
+ const group = groups.get(relationMatchKey(child[relation.foreignKey]));
4439
+ if (!group) {
4440
+ continue;
4872
4441
  }
4873
- if (item.kind === "subqueryCount") {
4874
- const body = remapExistsSql(item.sql, item.params, params);
4875
- return `(${body}) AS ${quoteIdentifier(item.as)}`;
4442
+ group.push(child);
4443
+ }
4444
+ const result = new Map;
4445
+ for (const [key, group] of groups) {
4446
+ const original = originalKeys.get(key);
4447
+ if (original !== undefined) {
4448
+ result.set(original, group);
4876
4449
  }
4877
- const column = qualifyColumn(item.table, item.column);
4878
- const placeholder = pushParam(params, item.query);
4879
- return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
4880
- }).join(", ");
4450
+ }
4451
+ return result;
4881
4452
  }
4882
- function getDefinedColumnEntries(table, values, options = {}) {
4883
- const record = values;
4884
- const excluded = new Set(options.exclude ?? []);
4885
- return table.columns.flatMap((column) => {
4886
- if (excluded.has(column) || !Object.hasOwn(record, column)) {
4887
- return [];
4888
- }
4889
- const value = record[column];
4890
- if (value === undefined) {
4891
- return [];
4892
- }
4893
- return [[column, value]];
4894
- });
4453
+ function indexHasOneRelation(parents, children, relation) {
4454
+ const grouped = indexHasManyRelation(parents, children, relation);
4455
+ const result = new Map;
4456
+ for (const parent of parents) {
4457
+ const matches = getByRelationKey(grouped, parent[relation.localKey]) ?? [];
4458
+ result.set(parent[relation.localKey], matches[0]);
4459
+ }
4460
+ return result;
4895
4461
  }
4896
- function buildSelectLock(table, options) {
4897
- const lock = options.lock;
4898
- if (!lock)
4899
- return "";
4900
- const driver = currentSqlDialect().driver;
4901
- if (driver === "sqlite")
4902
- throw new Error("Row locks are not supported on SQLite; use an explicit write transaction.");
4903
- if (lock.mode !== "update" && lock.mode !== "share")
4904
- throw new TypeError("Invalid row lock mode.");
4905
- if (lock.wait !== undefined && !["wait", "nowait", "skipLocked"].includes(lock.wait))
4906
- throw new TypeError("Invalid row lock wait policy.");
4907
- if (options.groupBy || options.having)
4908
- throw new Error("Row locks cannot be combined with grouped/aggregate queries.");
4909
- let target = "";
4910
- if (lock.of !== undefined) {
4911
- if (driver !== "pgsql")
4912
- throw new Error("Row lock targets are supported only on PostgreSQL.");
4913
- const tables = [table.name, ...(options.joins ?? []).map((join) => join.table)];
4914
- if (!lock.of.length || lock.of.some((name) => !tables.includes(name)))
4915
- throw new TypeError("Row lock targets must name tables in this query.");
4916
- target = ` OF ${lock.of.map(quoteIdentifier).join(", ")}`;
4462
+ function indexBelongsToRelation(children, parents, relation) {
4463
+ const parentsById = new Map;
4464
+ for (const parent of parents) {
4465
+ parentsById.set(relationMatchKey(parent[relation.ownerKey]), parent);
4917
4466
  }
4918
- const wait = lock.wait === "nowait" ? " NOWAIT" : lock.wait === "skipLocked" ? " SKIP LOCKED" : "";
4919
- return ` FOR ${lock.mode === "update" ? "UPDATE" : "SHARE"}${target}${wait}`;
4920
- }
4921
- function buildSelectQuery(table, options = {}, whereNodes = []) {
4922
- const params = [];
4923
- const columns = buildSelectList(table, options.select, params);
4924
- const { clause } = buildQueryWhereClause(table, options, whereNodes, params);
4925
- const joins = buildJoinClause(options.joins);
4926
- const groupBy = buildGroupByClause(table.name, options.groupBy);
4927
- const havingClause = buildHavingClause(table.name, options.having, params);
4928
- const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
4929
- const limit = buildLimitClause(options.limit);
4930
- const offset = buildOffsetClause(options.offset);
4931
- const lock = buildSelectLock(table, options);
4932
- return {
4933
- text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}${lock}`,
4934
- params
4935
- };
4936
- }
4937
- function buildCountQuery(table, where = {}, options = {}, whereNodes = []) {
4938
- const params = [];
4939
- const { clause, params: whereParams } = buildQueryWhereClause(table, {
4940
- where,
4941
- withTrashed: options.withTrashed,
4942
- onlyTrashed: options.onlyTrashed
4943
- }, whereNodes);
4944
- params.push(...whereParams);
4945
- const joins = buildJoinClause(options.joins);
4946
- const groupBy = buildGroupByClause(table.name, options.groupBy);
4947
- return {
4948
- text: `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}`,
4949
- params
4950
- };
4951
- }
4952
- function buildProjectionQuery(table, expression, alias, options = {}, whereNodes = []) {
4953
- assertSafeProjectionExpression(expression);
4954
- const params = [];
4955
- const { clause, params: whereParams } = buildQueryWhereClause(table, options, whereNodes);
4956
- params.push(...whereParams);
4957
- const joins = buildJoinClause(options.joins);
4958
- const groupBy = buildGroupByClause(table.name, options.groupBy);
4959
- const orderBy = buildOrderByClause(table.name, options.orderBy);
4960
- const limit = buildLimitClause(options.limit);
4961
- return {
4962
- text: `SELECT ${expression} AS ${quoteIdentifier(alias)} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${orderBy}${limit}`,
4963
- params
4964
- };
4467
+ const result = new Map;
4468
+ for (const child of children) {
4469
+ const foreignKey = child[relation.foreignKey];
4470
+ const parent = parentsById.get(relationMatchKey(foreignKey));
4471
+ if (parent) {
4472
+ result.set(foreignKey, parent);
4473
+ }
4474
+ }
4475
+ return result;
4965
4476
  }
4966
- var SAFE_PROJECTION_EXPRESSION = /^(COUNT\(\*\)|(?:"[\w_]+"(?:\."[\w_]+")?)|(?:AVG|SUM|MIN|MAX)\((?:"[\w_]+"(?:\."[\w_]+")?)\))$/;
4967
- function assertSafeProjectionExpression(expression) {
4968
- if (!SAFE_PROJECTION_EXPRESSION.test(expression.trim())) {
4969
- throw new Error(`Unsafe projection expression: ${expression}`);
4477
+ function indexBelongsToManyRelation(parents, pivotRows, relatedRows, relation) {
4478
+ const relatedById = new Map;
4479
+ for (const related of relatedRows) {
4480
+ relatedById.set(relationMatchKey(related[relation.relatedKey]), related);
4481
+ }
4482
+ const groups = new Map;
4483
+ const originalKeys = new Map;
4484
+ for (const parent of parents) {
4485
+ const key = relationMatchKey(parent[relation.parentKey]);
4486
+ if (!groups.has(key)) {
4487
+ groups.set(key, []);
4488
+ originalKeys.set(key, parent[relation.parentKey]);
4489
+ }
4490
+ }
4491
+ for (const pivot of pivotRows) {
4492
+ const group = groups.get(relationMatchKey(pivot[relation.foreignPivotKey]));
4493
+ const related = relatedById.get(relationMatchKey(pivot[relation.relatedPivotKey]));
4494
+ if (!group || !related) {
4495
+ continue;
4496
+ }
4497
+ group.push(related);
4498
+ }
4499
+ const result = new Map;
4500
+ for (const [key, group] of groups) {
4501
+ const original = originalKeys.get(key);
4502
+ if (original !== undefined) {
4503
+ result.set(original, group);
4504
+ }
4970
4505
  }
4506
+ return result;
4971
4507
  }
4972
- function buildGroupedSumQuery(table, groupColumn, column, options = {}, whereNodes = options.whereNodes ?? []) {
4973
- if (!table.columns.includes(groupColumn) || !table.columns.includes(column))
4974
- throw new TypeError("Grouped sums require declared columns.");
4975
- for (const key of Object.keys(options))
4976
- if (!["where", "whereNodes", "withTrashed", "onlyTrashed"].includes(key))
4977
- throw new TypeError("Grouped sums do not support joins, selection, ordering, pagination, grouping or locks.");
4978
- const group = qualifyColumn(table.name, groupColumn);
4979
- const total = currentSqlDialect().castToText(`SUM(${qualifyColumn(table.name, column)})`);
4980
- const { clause, params } = buildQueryWhereClause(table, options, whereNodes);
4508
+ function morphMany(definition) {
4981
4509
  return {
4982
- text: `SELECT ${group} AS ${quoteIdentifier("value")}, ${total} AS ${quoteIdentifier("sum")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${group} ORDER BY ${group} ASC`,
4983
- params
4510
+ type: "morphMany",
4511
+ ...definition
4984
4512
  };
4985
4513
  }
4986
- function buildGroupedCountQuery(table, column, where = {}, options = {}) {
4987
- const qualifiedColumn = qualifyColumn(table.name, column);
4988
- const { clause, params } = buildQueryWhereClause(table, {
4989
- where,
4990
- ...options
4991
- });
4514
+ function morphOne(definition) {
4992
4515
  return {
4993
- text: `SELECT ${qualifiedColumn} AS ${quoteIdentifier("value")}, COUNT(*) AS ${quoteIdentifier("count")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${qualifiedColumn} ORDER BY ${qualifiedColumn} ASC`,
4994
- params
4516
+ type: "morphOne",
4517
+ ...definition
4995
4518
  };
4996
4519
  }
4997
- function buildInsertQuery(table, values) {
4998
- const entries = getDefinedColumnEntries(table, values);
4999
- if (entries.length === 0) {
5000
- throw new Error(`Cannot insert into ${table.name} without any column values.`);
5001
- }
5002
- const params = [];
5003
- const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
5004
- const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
5005
- const returningColumns = buildReturningColumns(table);
4520
+ function morphTo(definition) {
5006
4521
  return {
5007
- text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${returningSuffix(returningColumns)}`,
5008
- params
4522
+ type: "morphTo",
4523
+ ...definition
5009
4524
  };
5010
4525
  }
5011
- function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
5012
- const entries = getDefinedColumnEntries(table, values);
5013
- if (entries.length === 0) {
5014
- throw new Error(`Cannot upsert into ${table.name} without any column values.`);
4526
+ function indexMorphManyRelation(parents, children, relation) {
4527
+ const groups = new Map;
4528
+ const originalKeys = new Map;
4529
+ for (const parent of parents) {
4530
+ const key = relationMatchKey(parent[relation.localKey]);
4531
+ if (!groups.has(key)) {
4532
+ groups.set(key, []);
4533
+ originalKeys.set(key, parent[relation.localKey]);
4534
+ }
5015
4535
  }
5016
- if (conflictColumns.length === 0) {
5017
- throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
4536
+ for (const child of children) {
4537
+ if (child[relation.morphTypeKey] !== relation.morphType) {
4538
+ continue;
4539
+ }
4540
+ const group = groups.get(relationMatchKey(child[relation.morphIdKey]));
4541
+ if (!group) {
4542
+ continue;
4543
+ }
4544
+ group.push(child);
5018
4545
  }
5019
- const insertable = new Set(entries.map(([column]) => column));
5020
- const conflict = new Set(conflictColumns);
5021
- const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
5022
- const params = [];
5023
- const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
5024
- const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
5025
- const returningColumns = buildReturningColumns(table);
5026
- const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
5027
- return {
5028
- text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
5029
- params
5030
- };
4546
+ const result = new Map;
4547
+ for (const [key, group] of groups) {
4548
+ const original = originalKeys.get(key);
4549
+ if (original !== undefined) {
4550
+ result.set(original, group);
4551
+ }
4552
+ }
4553
+ return result;
5031
4554
  }
5032
- function buildIncrementQuery(table, id, column, amount, extra = {}) {
5033
- if (!Number.isFinite(amount)) {
5034
- throw new Error("Increment amount must be a finite number.");
4555
+ function indexMorphOneRelation(parents, children, relation) {
4556
+ const grouped = indexMorphManyRelation(parents, children, relation);
4557
+ const result = new Map;
4558
+ for (const parent of parents) {
4559
+ const matches = getByRelationKey(grouped, parent[relation.localKey]) ?? [];
4560
+ result.set(parent[relation.localKey], matches[0]);
5035
4561
  }
5036
- if (!table.columns.includes(column)) {
5037
- throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
4562
+ return result;
4563
+ }
4564
+ function indexHasManyThroughRelation(parents, children, relation) {
4565
+ const throughKey = relation.throughParentKey ?? "__through_parent_id";
4566
+ const grouped = new Map;
4567
+ for (const child of children) {
4568
+ const key = relationMatchKey(child[throughKey]);
4569
+ if (key === "") {
4570
+ continue;
4571
+ }
4572
+ const existing = grouped.get(key) ?? [];
4573
+ const { [throughKey]: _through, ...far } = child;
4574
+ existing.push(far);
4575
+ grouped.set(key, existing);
5038
4576
  }
5039
- const params = [];
5040
- const target = quoteIdentifier(column);
5041
- const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
5042
- for (const [name, value] of getDefinedColumnEntries(table, extra, {
5043
- exclude: [table.primaryKey, column]
5044
- })) {
5045
- assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
4577
+ const result = new Map;
4578
+ for (const parent of parents) {
4579
+ const key = relationMatchKey(parent[relation.localKey]);
4580
+ result.set(parent[relation.localKey], grouped.get(key) ?? []);
5046
4581
  }
5047
- const primaryKeyPlaceholder = pushParam(params, id);
5048
- const returningColumns = buildReturningColumns(table);
5049
- const scopeClauses = [];
5050
- appendSoftDeleteScope(table, {}, scopeClauses);
5051
- const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
5052
- return {
5053
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
5054
- params
5055
- };
4582
+ return result;
5056
4583
  }
5057
- function buildUpdateQuery(table, id, changes) {
5058
- const entries = getDefinedColumnEntries(table, changes, {
5059
- exclude: [table.primaryKey]
5060
- });
5061
- if (entries.length === 0) {
5062
- throw new Error(`Cannot update ${table.name} without any changed column values.`);
5063
- }
5064
- const params = [];
5065
- const setClause = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`).join(", ");
5066
- const primaryKeyPlaceholder = pushParam(params, id);
5067
- const returningColumns = buildReturningColumns(table);
5068
- const scopeClauses = [];
5069
- appendSoftDeleteScope(table, {}, scopeClauses);
5070
- const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
5071
- return {
5072
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${setClause} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
5073
- params
5074
- };
5075
- }
5076
- function buildSoftDeleteByIdQuery(table, id, deletedAt) {
5077
- const deletedAtColumn = resolveSoftDeleteColumn(table);
5078
- if (!deletedAtColumn) {
5079
- throw new Error(`Table ${table.name} does not support soft deletes.`);
5080
- }
5081
- const returningColumns = buildReturningColumns(table);
5082
- const scopeClauses = [];
5083
- appendSoftDeleteScope(table, {}, scopeClauses);
5084
- const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
5085
- const params = [];
5086
- const deletedAtPlaceholder = pushParam(params, deletedAt);
5087
- const idPlaceholder = pushParam(params, id);
5088
- return {
5089
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = ${deletedAtPlaceholder} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
5090
- params
5091
- };
5092
- }
5093
- function buildRestoreByIdQuery(table, id) {
5094
- const deletedAtColumn = resolveSoftDeleteColumn(table);
5095
- if (!deletedAtColumn) {
5096
- throw new Error(`Table ${table.name} does not support soft deletes.`);
5097
- }
5098
- const returningColumns = buildReturningColumns(table);
5099
- const params = [];
5100
- const deletedAtPlaceholder = pushParam(params, null);
5101
- const idPlaceholder = pushParam(params, id);
5102
- return {
5103
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = ${deletedAtPlaceholder} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder} AND ${qualifyColumn(table.name, deletedAtColumn)} IS NOT NULL${returningSuffix(returningColumns)}`,
5104
- params
5105
- };
5106
- }
5107
- function buildDeleteByIdQuery(table, id) {
5108
- const params = [];
5109
- const idPlaceholder = pushParam(params, id);
5110
- const returning = returningSuffix(`${quoteIdentifier(table.primaryKey)} AS ${quoteIdentifier("deleted_id")}`);
5111
- return {
5112
- text: `DELETE FROM ${quoteIdentifier(table.name)} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder}${returning}`,
5113
- params
5114
- };
5115
- }
5116
- function buildConditionalWriteQuery(table, operation, changes, options, returning) {
5117
- for (const key of [
5118
- "joins",
5119
- "groupBy",
5120
- "having",
5121
- "select",
5122
- "orderBy",
5123
- "limit",
5124
- "offset",
5125
- "lock"
5126
- ]) {
5127
- if (options[key] !== undefined)
5128
- throw new Error(`Conditional writes do not support ${key}.`);
4584
+ function indexMorphToRelation(children, parentsByType, relation) {
4585
+ const result = new Map;
4586
+ for (const child of children) {
4587
+ const morphType = String(child[relation.morphTypeKey]);
4588
+ const parents = parentsByType.get(morphType);
4589
+ if (!parents) {
4590
+ continue;
4591
+ }
4592
+ const parent = getByRelationKey(parents, child[relation.morphIdKey]);
4593
+ if (parent) {
4594
+ result.set(child[relation.morphIdKey], parent);
4595
+ }
5129
4596
  }
5130
- const params = [];
5131
- const predicate = buildAdvancedWhereClause(table.name, options.where ?? {}, options.whereNodes ?? []);
5132
- if (!predicate.clause)
5133
- throw new Error("Conditional writes require an explicit predicate.");
5134
- if (returning !== undefined && currentSqlDialect().driver === "mysql")
5135
- throw new Error("Mutation RETURNING is not supported on MySQL.");
5136
- const columns = returning ?? [table.primaryKey];
5137
- if (!columns.length || columns.some((column) => !table.columns.includes(column)))
5138
- throw new Error("RETURNING columns must be nonempty declared table columns.");
5139
- const deletedAt = resolveSoftDeleteColumn(table);
5140
- const values = operation === "delete" && deletedAt ? { [deletedAt]: new Date } : changes;
5141
- let prefix;
5142
- if (operation === "update" || deletedAt) {
5143
- const entries = Object.entries(values).filter(([, value]) => value !== undefined);
5144
- if (!entries.length)
5145
- throw new Error("Conditional updates require at least one value.");
5146
- if (entries.some(([column]) => column === table.primaryKey || !table.columns.includes(column)))
5147
- throw new Error("Conditional updates require declared non-primary-key columns.");
5148
- const assignments = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`);
5149
- prefix = `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")}`;
5150
- } else
5151
- prefix = `DELETE FROM ${quoteIdentifier(table.name)}`;
5152
- const { clause } = buildQueryWhereClause(table, options, options.whereNodes ?? [], params);
5153
- return {
5154
- text: prefix + clause + returningSuffix(columns.map(quoteIdentifier).join(", ")),
5155
- params
5156
- };
4597
+ return result;
5157
4598
  }
5158
4599
 
5159
- // ../../src/core/database/relationships.ts
5160
- function hasMany(definition) {
5161
- return {
5162
- type: "hasMany",
5163
- ...definition
5164
- };
5165
- }
5166
- function hasOne(definition) {
5167
- return {
5168
- type: "hasOne",
5169
- ...definition
5170
- };
5171
- }
5172
- function belongsTo(definition) {
5173
- return {
5174
- type: "belongsTo",
5175
- ...definition
5176
- };
5177
- }
5178
- function hasManyThrough(definition) {
5179
- return {
5180
- type: "hasManyThrough",
5181
- throughParentKey: "__through_parent_id",
5182
- ...definition
5183
- };
5184
- }
5185
- function belongsToMany(definition) {
5186
- return {
5187
- type: "belongsToMany",
5188
- ...definition
5189
- };
5190
- }
5191
- function relationMatchKey(value) {
5192
- if (value === null || value === undefined) {
5193
- return "";
5194
- }
5195
- if (typeof value === "bigint") {
5196
- return value.toString();
4600
+ // ../../src/core/database/baseRepository.ts
4601
+ init_repositoryConnection();
4602
+
4603
+ // ../../src/core/database/pluck.ts
4604
+ function uniqueColumnSelect(table, columns) {
4605
+ const seen = new Set;
4606
+ const select = [];
4607
+ for (const column of columns) {
4608
+ if (seen.has(column)) {
4609
+ continue;
4610
+ }
4611
+ seen.add(column);
4612
+ select.push({ kind: "column", table, column, as: column });
5197
4613
  }
5198
- if (typeof value === "number" && Number.isFinite(value)) {
5199
- return String(value);
4614
+ return select;
4615
+ }
4616
+ function projectPluck(rows, column, keyBy) {
4617
+ if (keyBy === undefined) {
4618
+ return rows.map((row) => row[column]);
5200
4619
  }
5201
- if (typeof value === "string" && /^-?\d+$/.test(value)) {
5202
- return BigInt(value).toString();
4620
+ const keyed = new Map;
4621
+ for (const row of rows) {
4622
+ keyed.set(row[keyBy], row[column]);
5203
4623
  }
5204
- return String(value);
4624
+ return keyed;
5205
4625
  }
5206
- var relationLookupCache = new WeakMap;
5207
- function indexedRelationLookup(map) {
5208
- const cached = relationLookupCache.get(map);
5209
- if (cached) {
5210
- return cached;
4626
+
4627
+ // ../../src/core/database/whereBuilder.ts
4628
+ class WhereBuilder {
4629
+ nodes = [];
4630
+ where(where) {
4631
+ this.nodes.push({ kind: "and", where });
4632
+ return this;
5211
4633
  }
5212
- const indexed = new Map;
5213
- for (const [existing, value] of map) {
5214
- indexed.set(relationMatchKey(existing), value);
4634
+ orWhere(where) {
4635
+ this.nodes.push({ kind: "or", where });
4636
+ return this;
5215
4637
  }
5216
- relationLookupCache.set(map, indexed);
5217
- return indexed;
5218
- }
5219
- function getByRelationKey(map, key) {
5220
- if (map.has(key)) {
5221
- return map.get(key);
4638
+ whereGroup(fn) {
4639
+ const nested = new WhereBuilder;
4640
+ fn(nested);
4641
+ if (nested.nodes.length > 0) {
4642
+ this.nodes.push({ kind: "and", group: nested.nodes });
4643
+ }
4644
+ return this;
5222
4645
  }
5223
- const want = relationMatchKey(key);
5224
- if (want === "") {
5225
- return;
4646
+ orWhereGroup(fn) {
4647
+ const nested = new WhereBuilder;
4648
+ fn(nested);
4649
+ if (nested.nodes.length > 0) {
4650
+ this.nodes.push({ kind: "or", group: nested.nodes });
4651
+ }
4652
+ return this;
5226
4653
  }
5227
- return indexedRelationLookup(map).get(want);
5228
4654
  }
5229
- function indexHasManyRelation(parents, children, relation) {
5230
- const groups = new Map;
5231
- const originalKeys = new Map;
5232
- for (const parent of parents) {
5233
- const key = relationMatchKey(parent[relation.localKey]);
5234
- if (!groups.has(key)) {
5235
- groups.set(key, []);
5236
- originalKeys.set(key, parent[relation.localKey]);
5237
- }
4655
+
4656
+ // ../../src/core/database/repositoryQuery.ts
4657
+ class RepositoryQuery {
4658
+ repository;
4659
+ whereClause;
4660
+ queryOptions;
4661
+ eagerLoads = [];
4662
+ whereNodes = [];
4663
+ protectedWhere = [];
4664
+ constructor(repository, whereClause = {}, queryOptions = {}) {
4665
+ this.repository = repository;
4666
+ this.whereClause = whereClause;
4667
+ this.queryOptions = queryOptions;
5238
4668
  }
5239
- for (const child of children) {
5240
- const group = groups.get(relationMatchKey(child[relation.foreignKey]));
5241
- if (!group) {
5242
- continue;
4669
+ where(input) {
4670
+ if (typeof input === "function") {
4671
+ const builder = new WhereBuilder;
4672
+ input(builder);
4673
+ if (builder.nodes.length > 0)
4674
+ this.whereNodes.push({ kind: "and", group: builder.nodes });
4675
+ return this;
5243
4676
  }
5244
- group.push(child);
4677
+ this.whereNodes.push({ kind: "and", where: { ...input } });
4678
+ return this;
5245
4679
  }
5246
- const result = new Map;
5247
- for (const [key, group] of groups) {
5248
- const original = originalKeys.get(key);
5249
- if (original !== undefined) {
5250
- result.set(original, group);
4680
+ protectWhere() {
4681
+ const nodes = [];
4682
+ if (Object.keys(this.whereClause).length > 0) {
4683
+ nodes.push({ kind: "and", where: this.whereClause });
5251
4684
  }
5252
- }
5253
- return result;
5254
- }
5255
- function indexHasOneRelation(parents, children, relation) {
5256
- const grouped = indexHasManyRelation(parents, children, relation);
5257
- const result = new Map;
5258
- for (const parent of parents) {
5259
- const matches = getByRelationKey(grouped, parent[relation.localKey]) ?? [];
5260
- result.set(parent[relation.localKey], matches[0]);
5261
- }
5262
- return result;
5263
- }
5264
- function indexBelongsToRelation(children, parents, relation) {
5265
- const parentsById = new Map;
5266
- for (const parent of parents) {
5267
- parentsById.set(relationMatchKey(parent[relation.ownerKey]), parent);
5268
- }
5269
- const result = new Map;
5270
- for (const child of children) {
5271
- const foreignKey = child[relation.foreignKey];
5272
- const parent = parentsById.get(relationMatchKey(foreignKey));
5273
- if (parent) {
5274
- result.set(foreignKey, parent);
5275
- }
5276
- }
5277
- return result;
5278
- }
5279
- function indexBelongsToManyRelation(parents, pivotRows, relatedRows, relation) {
5280
- const relatedById = new Map;
5281
- for (const related of relatedRows) {
5282
- relatedById.set(relationMatchKey(related[relation.relatedKey]), related);
5283
- }
5284
- const groups = new Map;
5285
- const originalKeys = new Map;
5286
- for (const parent of parents) {
5287
- const key = relationMatchKey(parent[relation.parentKey]);
5288
- if (!groups.has(key)) {
5289
- groups.set(key, []);
5290
- originalKeys.set(key, parent[relation.parentKey]);
5291
- }
5292
- }
5293
- for (const pivot of pivotRows) {
5294
- const group = groups.get(relationMatchKey(pivot[relation.foreignPivotKey]));
5295
- const related = relatedById.get(relationMatchKey(pivot[relation.relatedPivotKey]));
5296
- if (!group || !related) {
5297
- continue;
5298
- }
5299
- group.push(related);
5300
- }
5301
- const result = new Map;
5302
- for (const [key, group] of groups) {
5303
- const original = originalKeys.get(key);
5304
- if (original !== undefined) {
5305
- result.set(original, group);
5306
- }
5307
- }
5308
- return result;
5309
- }
5310
- function morphMany(definition) {
5311
- return {
5312
- type: "morphMany",
5313
- ...definition
5314
- };
5315
- }
5316
- function morphOne(definition) {
5317
- return {
5318
- type: "morphOne",
5319
- ...definition
5320
- };
5321
- }
5322
- function morphTo(definition) {
5323
- return {
5324
- type: "morphTo",
5325
- ...definition
5326
- };
5327
- }
5328
- function indexMorphManyRelation(parents, children, relation) {
5329
- const groups = new Map;
5330
- const originalKeys = new Map;
5331
- for (const parent of parents) {
5332
- const key = relationMatchKey(parent[relation.localKey]);
5333
- if (!groups.has(key)) {
5334
- groups.set(key, []);
5335
- originalKeys.set(key, parent[relation.localKey]);
5336
- }
5337
- }
5338
- for (const child of children) {
5339
- if (child[relation.morphTypeKey] !== relation.morphType) {
5340
- continue;
5341
- }
5342
- const group = groups.get(relationMatchKey(child[relation.morphIdKey]));
5343
- if (!group) {
5344
- continue;
5345
- }
5346
- group.push(child);
5347
- }
5348
- const result = new Map;
5349
- for (const [key, group] of groups) {
5350
- const original = originalKeys.get(key);
5351
- if (original !== undefined) {
5352
- result.set(original, group);
5353
- }
5354
- }
5355
- return result;
5356
- }
5357
- function indexMorphOneRelation(parents, children, relation) {
5358
- const grouped = indexMorphManyRelation(parents, children, relation);
5359
- const result = new Map;
5360
- for (const parent of parents) {
5361
- const matches = getByRelationKey(grouped, parent[relation.localKey]) ?? [];
5362
- result.set(parent[relation.localKey], matches[0]);
5363
- }
5364
- return result;
5365
- }
5366
- function indexHasManyThroughRelation(parents, children, relation) {
5367
- const throughKey = relation.throughParentKey ?? "__through_parent_id";
5368
- const grouped = new Map;
5369
- for (const child of children) {
5370
- const key = relationMatchKey(child[throughKey]);
5371
- if (key === "") {
5372
- continue;
5373
- }
5374
- const existing = grouped.get(key) ?? [];
5375
- const { [throughKey]: _through, ...far } = child;
5376
- existing.push(far);
5377
- grouped.set(key, existing);
5378
- }
5379
- const result = new Map;
5380
- for (const parent of parents) {
5381
- const key = relationMatchKey(parent[relation.localKey]);
5382
- result.set(parent[relation.localKey], grouped.get(key) ?? []);
5383
- }
5384
- return result;
5385
- }
5386
- function indexMorphToRelation(children, parentsByType, relation) {
5387
- const result = new Map;
5388
- for (const child of children) {
5389
- const morphType = String(child[relation.morphTypeKey]);
5390
- const parents = parentsByType.get(morphType);
5391
- if (!parents) {
5392
- continue;
5393
- }
5394
- const parent = getByRelationKey(parents, child[relation.morphIdKey]);
5395
- if (parent) {
5396
- result.set(child[relation.morphIdKey], parent);
5397
- }
5398
- }
5399
- return result;
5400
- }
5401
-
5402
- // ../../src/core/database/baseRepository.ts
5403
- init_repositoryConnection();
5404
-
5405
- // ../../src/core/database/pluck.ts
5406
- function uniqueColumnSelect(table, columns) {
5407
- const seen = new Set;
5408
- const select = [];
5409
- for (const column of columns) {
5410
- if (seen.has(column)) {
5411
- continue;
5412
- }
5413
- seen.add(column);
5414
- select.push({ kind: "column", table, column, as: column });
5415
- }
5416
- return select;
5417
- }
5418
- function projectPluck(rows, column, keyBy) {
5419
- if (keyBy === undefined) {
5420
- return rows.map((row) => row[column]);
5421
- }
5422
- const keyed = new Map;
5423
- for (const row of rows) {
5424
- keyed.set(row[keyBy], row[column]);
5425
- }
5426
- return keyed;
5427
- }
5428
-
5429
- // ../../src/core/database/whereBuilder.ts
5430
- class WhereBuilder {
5431
- nodes = [];
5432
- where(where) {
5433
- this.nodes.push({ kind: "and", where });
5434
- return this;
5435
- }
5436
- orWhere(where) {
5437
- this.nodes.push({ kind: "or", where });
5438
- return this;
5439
- }
5440
- whereGroup(fn) {
5441
- const nested = new WhereBuilder;
5442
- fn(nested);
5443
- if (nested.nodes.length > 0) {
5444
- this.nodes.push({ kind: "and", group: nested.nodes });
5445
- }
5446
- return this;
5447
- }
5448
- orWhereGroup(fn) {
5449
- const nested = new WhereBuilder;
5450
- fn(nested);
5451
- if (nested.nodes.length > 0) {
5452
- this.nodes.push({ kind: "or", group: nested.nodes });
5453
- }
5454
- return this;
5455
- }
5456
- }
5457
-
5458
- // ../../src/core/database/repositoryQuery.ts
5459
- class RepositoryQuery {
5460
- repository;
5461
- whereClause;
5462
- queryOptions;
5463
- eagerLoads = [];
5464
- whereNodes = [];
5465
- protectedWhere = [];
5466
- constructor(repository, whereClause = {}, queryOptions = {}) {
5467
- this.repository = repository;
5468
- this.whereClause = whereClause;
5469
- this.queryOptions = queryOptions;
5470
- }
5471
- where(input) {
5472
- if (typeof input === "function") {
5473
- const builder = new WhereBuilder;
5474
- input(builder);
5475
- if (builder.nodes.length > 0)
5476
- this.whereNodes.push({ kind: "and", group: builder.nodes });
5477
- return this;
5478
- }
5479
- this.whereNodes.push({ kind: "and", where: { ...input } });
5480
- return this;
5481
- }
5482
- protectWhere() {
5483
- const nodes = [];
5484
- if (Object.keys(this.whereClause).length > 0) {
5485
- nodes.push({ kind: "and", where: this.whereClause });
5486
- }
5487
- nodes.push(...this.whereNodes);
5488
- if (nodes.length > 0)
5489
- this.protectedWhere.push({ kind: "and", group: nodes });
5490
- this.whereClause = {};
5491
- this.whereNodes.length = 0;
5492
- return this;
4685
+ nodes.push(...this.whereNodes);
4686
+ if (nodes.length > 0)
4687
+ this.protectedWhere.push({ kind: "and", group: nodes });
4688
+ this.whereClause = {};
4689
+ this.whereNodes.length = 0;
4690
+ return this;
5493
4691
  }
5494
4692
  orWhere(input) {
5495
4693
  if (typeof input === "function") {
@@ -6114,356 +5312,1159 @@ class BaseRepository {
6114
5312
  }
6115
5313
  };
6116
5314
  }
6117
- async findById(id) {
6118
- return await this.firstOrNull({
6119
- [this.table.primaryKey]: id
5315
+ async findById(id) {
5316
+ return await this.firstOrNull({
5317
+ [this.table.primaryKey]: id
5318
+ });
5319
+ }
5320
+ async findByIdOrThrow(id, errorFactory) {
5321
+ const record = await this.findById(id);
5322
+ if (record) {
5323
+ return record;
5324
+ }
5325
+ throw errorFactory?.(id) ?? new Error(`${this.table.name} ${String(id)} was not found.`);
5326
+ }
5327
+ async findByIds(ids) {
5328
+ const uniqueIds = [...new Set(ids)];
5329
+ if (uniqueIds.length === 0) {
5330
+ return [];
5331
+ }
5332
+ return await this.findWhere({
5333
+ [this.table.primaryKey]: uniqueIds
5334
+ });
5335
+ }
5336
+ async firstOrNull(where, options = {}) {
5337
+ const [record] = await this.findAll({ ...options, where, limit: 1 });
5338
+ return record ?? null;
5339
+ }
5340
+ async upsert(values, conflictColumns, updateColumns) {
5341
+ return await withDatabaseErrorHandling(async () => {
5342
+ const { text, params } = buildUpsertQuery(this.table, values, conflictColumns, updateColumns);
5343
+ const [record] = await this.connection.unsafe(text, params);
5344
+ return record ?? null;
5345
+ });
5346
+ }
5347
+ async incrementById(id, column, amount = 1, extra = {}) {
5348
+ return await withDatabaseErrorHandling(async () => {
5349
+ const { text, params } = buildIncrementQuery(this.table, id, column, amount, extra);
5350
+ const [record] = await this.connection.unsafe(text, params);
5351
+ return record ?? null;
5352
+ });
5353
+ }
5354
+ async decrementById(id, column, amount = 1, extra = {}) {
5355
+ return await this.incrementById(id, column, -amount, extra);
5356
+ }
5357
+ async create(values) {
5358
+ this.assertTransactionalEvent("created");
5359
+ return await this.runModelMutation("created", () => withDatabaseErrorHandling(async () => {
5360
+ const { text, params } = buildInsertQuery(this.table, values);
5361
+ const [record] = await this.connection.unsafe(text, params);
5362
+ if (!record) {
5363
+ throw new Error(`Insert into ${this.table.name} did not return a record.`);
5364
+ }
5365
+ const entity = record;
5366
+ await dispatchModelEvent(modelEventName(this.table.name, "created"), entity);
5367
+ return entity;
5368
+ }));
5369
+ }
5370
+ async updateWhere(changes, options) {
5371
+ return this.executeConditionalWrite("update", changes, options);
5372
+ }
5373
+ async deleteWhere(options) {
5374
+ return this.executeConditionalWrite("delete", {}, options);
5375
+ }
5376
+ async updateWhereReturning(changes, columns, options) {
5377
+ const { text, params } = buildConditionalWriteQuery(this.table, "update", changes, options, columns);
5378
+ return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
5379
+ }
5380
+ async executeConditionalWrite(operation, changes, options) {
5381
+ const { text, params } = buildConditionalWriteQuery(this.table, operation, changes, options);
5382
+ return withDatabaseErrorHandling(async () => {
5383
+ const rows = await this.connection.unsafe(text, params);
5384
+ if (currentSqlDialect().driver !== "mysql")
5385
+ return rows.length;
5386
+ const affected = rows[0]?.affectedRows;
5387
+ if (!Number.isSafeInteger(affected) || affected === undefined || affected < 0)
5388
+ throw new Error("MySQL conditional writes require affectedRows metadata from the framework adapter.");
5389
+ return affected;
5390
+ });
5391
+ }
5392
+ async updateById(id, changes) {
5393
+ this.assertTransactionalEvent("updated");
5394
+ return await this.runModelMutation("updated", () => withDatabaseErrorHandling(async () => {
5395
+ const { text, params } = buildUpdateQuery(this.table, id, changes);
5396
+ const [record] = await this.connection.unsafe(text, params);
5397
+ const entity = record ?? null;
5398
+ if (entity) {
5399
+ await dispatchModelEvent(modelEventName(this.table.name, "updated"), entity);
5400
+ }
5401
+ return entity;
5402
+ }));
5403
+ }
5404
+ async updateByIdOrThrow(id, changes, errorFactory) {
5405
+ const record = await this.updateById(id, changes);
5406
+ if (record) {
5407
+ return record;
5408
+ }
5409
+ throw errorFactory?.(id) ?? new Error(`${this.table.name} ${String(id)} was not found.`);
5410
+ }
5411
+ async deleteById(id) {
5412
+ if (resolveSoftDeleteColumn(this.table)) {
5413
+ return await this.softDeleteById(id);
5414
+ }
5415
+ return await this.forceDeleteById(id);
5416
+ }
5417
+ async softDeleteById(id) {
5418
+ this.assertTransactionalEvent("deleted");
5419
+ return await this.runModelMutation("deleted", () => withDatabaseErrorHandling(async () => {
5420
+ const { text, params } = buildSoftDeleteByIdQuery(this.table, id, new Date);
5421
+ const [record] = await this.connection.unsafe(text, params);
5422
+ if (!record) {
5423
+ return false;
5424
+ }
5425
+ await dispatchModelEvent(modelEventName(this.table.name, "deleted"), record);
5426
+ return true;
5427
+ }));
5428
+ }
5429
+ async forceDeleteById(id) {
5430
+ this.assertTransactionalEvent("force-deleted");
5431
+ return await this.runModelMutation("force-deleted", () => withDatabaseErrorHandling(async () => {
5432
+ const { text, params } = buildDeleteByIdQuery(this.table, id);
5433
+ const [row] = await this.connection.unsafe(text, params);
5434
+ if (!row) {
5435
+ return false;
5436
+ }
5437
+ await dispatchModelEvent(modelEventName(this.table.name, "force-deleted"), {
5438
+ id
5439
+ });
5440
+ return true;
5441
+ }));
5442
+ }
5443
+ async restoreById(id) {
5444
+ this.assertTransactionalEvent("restored");
5445
+ return await this.runModelMutation("restored", () => withDatabaseErrorHandling(async () => {
5446
+ const { text, params } = buildRestoreByIdQuery(this.table, id);
5447
+ const [record] = await this.connection.unsafe(text, params);
5448
+ if (!record) {
5449
+ return null;
5450
+ }
5451
+ const entity = record;
5452
+ await dispatchModelEvent(modelEventName(this.table.name, "restored"), entity);
5453
+ return entity;
5454
+ }));
5455
+ }
5456
+ primaryKeySeekDirection(options) {
5457
+ if (options.joins?.length || options.groupBy || options.having || options.select?.length || options.offset !== undefined || options.limit !== undefined || options.lock) {
5458
+ return null;
5459
+ }
5460
+ const orders = listOrders(options.orderBy ?? this.table.defaultOrderBy);
5461
+ const order = orders[0];
5462
+ if (orders.length !== 1 || !order || order.nulls || order.column !== this.table.primaryKey) {
5463
+ return null;
5464
+ }
5465
+ return order.direction;
5466
+ }
5467
+ withConnection(connection) {
5468
+ const clone = Object.create(Object.getPrototypeOf(this));
5469
+ Object.assign(clone, this);
5470
+ clone.connection = connection;
5471
+ return clone;
5472
+ }
5473
+ getConnection() {
5474
+ return this.connection;
5475
+ }
5476
+ getTable() {
5477
+ return this.table;
5478
+ }
5479
+ query(where = {}) {
5480
+ return new RepositoryQuery(this, where);
5481
+ }
5482
+ async findWhere(where, options = {}) {
5483
+ return await this.findAll({ ...options, where });
5484
+ }
5485
+ async countWhere(where = {}, options = {}, whereNodes = []) {
5486
+ const { text, params } = buildCountQuery(this.table, where, options, whereNodes);
5487
+ const [row] = await this.connection.unsafe(text, params);
5488
+ return Number(row?.count ?? 0);
5489
+ }
5490
+ async averageColumn(column, where = {}) {
5491
+ const qualifiedColumn = qualifyColumn(this.table.name, column);
5492
+ return await this.averageExpression(`AVG(${qualifiedColumn})`, "value", where);
5493
+ }
5494
+ async averageExpression(expression, alias, where = {}) {
5495
+ const { text, params } = buildProjectionQuery(this.table, expression, alias, { where });
5496
+ const [row] = await this.connection.unsafe(text, params);
5497
+ return Number(row?.[alias] ?? 0);
5498
+ }
5499
+ async sumBy(groupColumn, column, options = {}) {
5500
+ const { whereNodes, ...queryOptions } = options;
5501
+ const { text, params } = buildGroupedSumQuery(this.table, groupColumn, column, queryOptions, whereNodes ?? []);
5502
+ return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
5503
+ }
5504
+ async sum(column, where = {}) {
5505
+ return await this.aggregateColumn("SUM", column, where);
5506
+ }
5507
+ async avg(column, where = {}) {
5508
+ return await this.aggregateColumn("AVG", column, where);
5509
+ }
5510
+ async min(column, where = {}) {
5511
+ return await this.aggregateColumn("MIN", column, where);
5512
+ }
5513
+ async max(column, where = {}) {
5514
+ return await this.aggregateColumn("MAX", column, where);
5515
+ }
5516
+ async aggregateColumn(fn, column, where) {
5517
+ const qualifiedColumn = qualifyColumn(this.table.name, column);
5518
+ return await this.averageExpression(`${fn}(${qualifiedColumn})`, "value", where);
5519
+ }
5520
+ async pluckNumberValues(expression, alias, options = {}) {
5521
+ const { text, params } = buildProjectionQuery(this.table, expression, alias, options);
5522
+ const rows = await this.connection.unsafe(text, params);
5523
+ return rows.flatMap((row) => {
5524
+ const value = row[alias];
5525
+ return value === null || value === undefined ? [] : [Number(value)];
5526
+ });
5527
+ }
5528
+ async countGroupedBy(column, where = {}) {
5529
+ const { text, params } = buildGroupedCountQuery(this.table, column, where);
5530
+ const rows = await this.connection.unsafe(text, params);
5531
+ return rows.map(({ value, count }) => ({
5532
+ value,
5533
+ count: Number(count)
5534
+ }));
5535
+ }
5536
+ async findByHasManyRelation(relation, parentId, options = {}) {
5537
+ return await this.findWhere({
5538
+ [relation.foreignKey]: parentId
5539
+ }, options);
5540
+ }
5541
+ async loadHasManyForParents(parents, relation, options = {}) {
5542
+ if (parents.length === 0) {
5543
+ return indexHasManyRelation(parents, [], relation);
5544
+ }
5545
+ const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
5546
+ const children = await this.findWhere({
5547
+ [relation.foreignKey]: parentIds
5548
+ }, options);
5549
+ return indexHasManyRelation(parents, children, relation);
5550
+ }
5551
+ async findHasManyThrough(parentId, relation, options = {}) {
5552
+ const grouped = await this.loadHasManyThroughForParents([{ [relation.localKey]: parentId }], relation, options);
5553
+ return getByRelationKey(grouped, parentId) ?? [];
5554
+ }
5555
+ async loadHasManyThroughForParents(parents, relation, options = {}) {
5556
+ if (parents.length === 0) {
5557
+ return indexHasManyThroughRelation(parents, [], relation);
5558
+ }
5559
+ if (options.joins?.some((join) => join.table === relation.throughTable)) {
5560
+ throw new Error("A related model scope joins the through table twice; use an explicitly aliased correlated predicate instead.");
5561
+ }
5562
+ const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
5563
+ const throughParentKey = relation.throughParentKey ?? "__through_parent_id";
5564
+ const farTable = this.table.name;
5565
+ const columns = this.table.columns.map((column) => `${qualifyColumn(farTable, column)}`).join(", ");
5566
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5567
+ const { text: extraWhere, params: extraParams } = this.buildThroughWhere(options, parentIds);
5568
+ const sql = `SELECT ${columns}, ${qualifyColumn(relation.throughTable, relation.firstKey)} AS ${throughParentKey} FROM ${quoteIdentifier(farTable)} INNER JOIN ${quoteIdentifier(relation.throughTable)} ON ${qualifyColumn(relation.throughTable, relation.secondLocalKey)} = ${qualifyColumn(farTable, relation.secondKey)}${buildJoinClause(options.joins)} WHERE ${qualifyColumn(relation.throughTable, relation.firstKey)} IN (${placeholders})${extraWhere}`;
5569
+ const children = await this.connection.unsafe(sql, [
5570
+ ...parentIds,
5571
+ ...extraParams
5572
+ ]);
5573
+ return indexHasManyThroughRelation(parents, children, relation);
5574
+ }
5575
+ buildThroughWhere(options, parentIds) {
5576
+ const parameters = [...parentIds];
5577
+ const { clause } = buildQueryWhereClause(this.table, options, options.whereNodes, parameters);
5578
+ return {
5579
+ text: clause ? ` AND (${clause.replace(/^ WHERE /, "")})` : "",
5580
+ params: parameters.slice(parentIds.length)
5581
+ };
5582
+ }
5583
+ async loadBelongsToForParents(children, relation, parentRepository, options = {}) {
5584
+ if (children.length === 0) {
5585
+ return new Map;
5586
+ }
5587
+ const ownerIds = [...new Set(children.map((child) => child[relation.foreignKey]))];
5588
+ const parents = await parentRepository.withConnection(this.connection).findWhere({
5589
+ [relation.ownerKey]: ownerIds
5590
+ }, options);
5591
+ return indexBelongsToRelation(children, parents, relation);
5592
+ }
5593
+ async loadMorphManyForParents(parents, relation, options = {}) {
5594
+ if (parents.length === 0) {
5595
+ return indexMorphManyRelation(parents, [], relation);
5596
+ }
5597
+ const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
5598
+ const children = await this.findWhere({
5599
+ [relation.morphTypeKey]: relation.morphType,
5600
+ [relation.morphIdKey]: parentIds
5601
+ }, options);
5602
+ return indexMorphManyRelation(parents, children, relation);
5603
+ }
5604
+ async loadMorphOneForParents(parents, relation, options = {}) {
5605
+ const grouped = await this.loadMorphManyForParents(parents, relation, options);
5606
+ const result = new Map;
5607
+ for (const parent of parents) {
5608
+ const matches = getByRelationKey(grouped, parent[relation.localKey]) ?? [];
5609
+ result.set(parent[relation.localKey], matches[0]);
5610
+ }
5611
+ return result;
5612
+ }
5613
+ async loadMorphToForChildren(children, relation, repositoriesByType, options = {}) {
5614
+ if (children.length === 0) {
5615
+ return new Map;
5616
+ }
5617
+ const idsByType = new Map;
5618
+ for (const child of children) {
5619
+ const morphType = String(child[relation.morphTypeKey]);
5620
+ const morphId = child[relation.morphIdKey];
5621
+ const ids = idsByType.get(morphType) ?? new Set;
5622
+ ids.add(morphId);
5623
+ idsByType.set(morphType, ids);
5624
+ }
5625
+ const parentsByType = new Map;
5626
+ await Promise.all([...idsByType.entries()].map(async ([morphType, ids]) => {
5627
+ const repository = repositoriesByType.get(morphType);
5628
+ if (!repository) {
5629
+ return;
5630
+ }
5631
+ const ownerKey = repository.getTable().primaryKey;
5632
+ const parents = await repository.withConnection(this.connection).findWhere({
5633
+ [ownerKey]: [...ids]
5634
+ }, options);
5635
+ const indexed = new Map;
5636
+ for (const parent of parents) {
5637
+ indexed.set(relationMatchKey(parent[ownerKey]), parent);
5638
+ }
5639
+ parentsByType.set(morphType, indexed);
5640
+ }));
5641
+ return indexMorphToRelation(children, parentsByType, relation);
5642
+ }
5643
+ async loadBelongsToManyForParents(parents, relation, relatedRepository, options = {}) {
5644
+ if (parents.length === 0) {
5645
+ return indexBelongsToManyRelation(parents, [], [], relation);
5646
+ }
5647
+ const parentIds = [...new Set(parents.map((parent) => parent[relation.parentKey]))];
5648
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5649
+ const pivotRows = await this.connection.unsafe(`SELECT * FROM ${relation.pivotTable} WHERE ${String(relation.foreignPivotKey)} IN (${placeholders})`, parentIds);
5650
+ if (pivotRows.length === 0) {
5651
+ return indexBelongsToManyRelation(parents, [], [], relation);
5652
+ }
5653
+ const relatedIds = [
5654
+ ...new Set(pivotRows.map((row) => row[relation.relatedPivotKey]))
5655
+ ];
5656
+ const relatedRows = await relatedRepository.withConnection(this.connection).findWhere({
5657
+ [relation.relatedKey]: relatedIds
5658
+ }, options);
5659
+ return indexBelongsToManyRelation(parents, pivotRows, relatedRows, relation);
5660
+ }
5661
+ }
5662
+ var baseRepository_default = BaseRepository;
5663
+
5664
+ // ../../src/core/database/table.ts
5665
+ function defineTable(definition) {
5666
+ return definition;
5667
+ }
5668
+
5669
+ // ../../src/core/tenant/resolveTenant.ts
5670
+ init_tenancyConfig();
5671
+ var tenants = new BaseRepository(defineTable({
5672
+ name: "tenant",
5673
+ primaryKey: "id",
5674
+ columns: ["id", "slug"]
5675
+ }));
5676
+ async function resolveTenant(tenantId) {
5677
+ if (!isTenancyEnabled()) {
5678
+ return {
5679
+ id: tenantId,
5680
+ slug: "default"
5681
+ };
5682
+ }
5683
+ const row = await tenants.findById(tenantId);
5684
+ return row ? { id: row.id, slug: row.slug } : null;
5685
+ }
5686
+
5687
+ // ../../src/core/auth/scimAuthMiddleware.ts
5688
+ init_tenantDatabaseScope();
5689
+ function createScimAuthMiddleware() {
5690
+ return async (request, next) => {
5691
+ const authorization = request.headers.get("authorization");
5692
+ if (!authorization?.startsWith("Bearer ")) {
5693
+ return jsonScimError("SCIM bearer token required.", 401);
5694
+ }
5695
+ const token = authorization.slice("Bearer ".length).trim();
5696
+ const tenantId = resolveScimTenantFromToken(token);
5697
+ if (tenantId === null) {
5698
+ return jsonScimError("Invalid SCIM bearer token.", 401);
5699
+ }
5700
+ const tenant = await resolveTenant(tenantId);
5701
+ if (!tenant) {
5702
+ return jsonScimError("SCIM tenant not found.", 401);
5703
+ }
5704
+ return await runWithTenantDatabase(tenant, async () => {
5705
+ bindDatabaseConnection(getActiveDatabaseConnection(getDefaultDatabasePool()));
5706
+ try {
5707
+ return await next();
5708
+ } finally {
5709
+ resetBoundDatabaseConnection();
5710
+ }
5711
+ });
5712
+ };
5713
+ }
5714
+ function jsonScimError(detail, status) {
5715
+ return Response.json({
5716
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
5717
+ detail,
5718
+ status: String(status)
5719
+ }, {
5720
+ status,
5721
+ headers: { "content-type": "application/scim+json" }
5722
+ });
5723
+ }
5724
+ // ../../src/core/auth/tokenAbilityChecker.ts
5725
+ function tokenCan(user, ability) {
5726
+ if (!user) {
5727
+ return false;
5728
+ }
5729
+ const abilities = user.abilities ?? [];
5730
+ return abilities.includes("*") || abilities.includes(ability);
5731
+ }
5732
+ function createTokenAbilityChecker() {
5733
+ return {
5734
+ tokenCan,
5735
+ requireAbility(user, ability) {
5736
+ if (!tokenCan(user, ability)) {
5737
+ throw new ForbiddenError(`Missing ability: ${ability}`);
5738
+ }
5739
+ }
5740
+ };
5741
+ }
5742
+ // ../../src/core/cache/redisCacheStore.ts
5743
+ var {RedisClient } = globalThis.Bun;
5744
+ var CACHE_SCRIPT = `
5745
+ local expected = {'hash', 'zset', 'zset', 'hash', 'zset'}
5746
+ for i, key in ipairs(KEYS) do
5747
+ local kind = redis.call('TYPE', key).ok
5748
+ if kind ~= 'none' and kind ~= expected[i] then return redis.error_reply('Unexpected cache key type') end
5749
+ end
5750
+ -- Redis eviction can remove one container without its companions. Never read
5751
+ -- orphaned values or leases after losing the expiry/order/generation indexes.
5752
+ local entries = redis.call('HLEN', KEYS[1])
5753
+ local order = redis.call('ZCARD', KEYS[3])
5754
+ if redis.call('ZCARD', KEYS[2]) ~= order or entries ~= (order > 0 and order + 1 or 0) then
5755
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
5756
+ end
5757
+ if redis.call('HLEN', KEYS[4]) ~= redis.call('ZCARD', KEYS[5]) then
5758
+ redis.call('DEL', KEYS[4], KEYS[5])
5759
+ end
5760
+ local r = cjson.decode(ARGV[1])
5761
+ local time = redis.call('TIME')
5762
+ local now = tonumber(time[1]) * 1000 + tonumber(time[2]) / 1000
5763
+ local function removeEntry(key)
5764
+ local removed = redis.call('HDEL', KEYS[1], 'e:' .. key)
5765
+ redis.call('ZREM', KEYS[2], key)
5766
+ redis.call('ZREM', KEYS[3], key)
5767
+ return removed
5768
+ end
5769
+ local function removeMeta(field)
5770
+ redis.call('HDEL', KEYS[4], field)
5771
+ redis.call('ZREM', KEYS[5], field)
5772
+ end
5773
+ for _, key in ipairs(redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, r.capacity)) do removeEntry(key) end
5774
+ for _, field in ipairs(redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, r.metadataLimit)) do removeMeta(field) end
5775
+ local function expireContainers()
5776
+ local last = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
5777
+ if #last == 0 then
5778
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
5779
+ elseif last[2] == 'inf' then
5780
+ for i = 1, 3 do redis.call('PERSIST', KEYS[i]) end
5781
+ else
5782
+ local ttl = math.max(1, math.ceil(tonumber(last[2]) - now))
5783
+ for i = 1, 3 do redis.call('PEXPIRE', KEYS[i], ttl) end
5784
+ end
5785
+ for i = 4, 5 do redis.call('PEXPIRE', KEYS[i], r.metadataTtlMs) end
5786
+ end
5787
+ local function response(value)
5788
+ expireContainers()
5789
+ return cjson.encode(value)
5790
+ end
5791
+ local function addTags(existing, added)
5792
+ local seen = {}
5793
+ local result = {}
5794
+ for _, tags in ipairs({existing, added}) do
5795
+ for _, tag in ipairs(tags) do
5796
+ if not seen[tag] then seen[tag] = true; table.insert(result, tag) end
5797
+ end
5798
+ end
5799
+ if #result > 32 then error('Cache entries support at most 32 tags') end
5800
+ return result
5801
+ end
5802
+ local function touch(key)
5803
+ local sequence = redis.call('HINCRBY', KEYS[1], '_sequence', 1)
5804
+ redis.call('ZADD', KEYS[3], sequence, key)
5805
+ end
5806
+ local function read(key, tags)
5807
+ local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
5808
+ if not raw then return nil end
5809
+ local entry = cjson.decode(raw)
5810
+ if #tags > 0 then
5811
+ entry.tags = addTags(entry.tags, tags)
5812
+ redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
5813
+ end
5814
+ touch(key)
5815
+ return entry.payload
5816
+ end
5817
+ local function write(key, payload, tags)
5818
+ local entry = {payload=payload, tags=tags}
5819
+ redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
5820
+ redis.call('ZADD', KEYS[2], r.ttlMs == 0 and '+inf' or now + r.ttlMs, key)
5821
+ touch(key)
5822
+ local overflow = redis.call('ZCARD', KEYS[3]) - r.capacity
5823
+ if overflow > 0 then
5824
+ for _, victim in ipairs(redis.call('ZRANGE', KEYS[3], 0, overflow - 1)) do removeEntry(victim) end
5825
+ end
5826
+ end
5827
+ local leaseField = 'l:' .. r.key
5828
+ local function lease()
5829
+ local raw = redis.call('HGET', KEYS[4], leaseField)
5830
+ if not raw then return nil end
5831
+ local value = cjson.decode(raw)
5832
+ if value.expires <= now or value.deadline <= now then removeMeta(leaseField); return nil end
5833
+ return value
5834
+ end
5835
+ local function metadataSpace(count)
5836
+ if redis.call('HLEN', KEYS[4]) + count > r.metadataLimit then
5837
+ -- Pressure sheds generation/lease state. Every old owner is fenced; cached
5838
+ -- entries remain valid because invalidation removes matching entries atomically.
5839
+ redis.call('DEL', KEYS[4], KEYS[5])
5840
+ end
5841
+ end
5842
+ local function putMeta(field, value, expires)
5843
+ redis.call('HSET', KEYS[4], field, value)
5844
+ redis.call('ZADD', KEYS[5], expires, field)
5845
+ end
5846
+ local function scopes()
5847
+ local fields = {}
5848
+ for _, tag in ipairs(r.tags) do table.insert(fields, 'g:tag:' .. tag) end
5849
+ for _, prefix in ipairs(r.prefixes) do table.insert(fields, 'g:prefix:' .. prefix) end
5850
+ return fields
5851
+ end
5852
+ local function matches(key, tags)
5853
+ if r.op == 'invalidate' then return key == r.key end
5854
+ if r.op == 'prefix' then return key == r.key or string.sub(key, 1, #r.key + 1) == r.key .. '?' end
5855
+ if r.op == 'tags' then
5856
+ for _, existing in ipairs(tags) do
5857
+ for _, wanted in ipairs(r.tags) do if wanted == existing then return true end end
5858
+ end
5859
+ end
5860
+ return false
5861
+ end
5862
+ if r.op == 'clear' then
5863
+ redis.call('DEL', unpack(KEYS))
5864
+ return cjson.encode({removed=0})
5865
+ elseif r.op == 'get' then
5866
+ local payload = read(r.key, r.tags)
5867
+ return response(payload and {kind='value', payload=payload} or {kind='missing'})
5868
+ elseif r.op == 'set' then
5869
+ local old = redis.call('HGET', KEYS[1], 'e:' .. r.key)
5870
+ local tags = old and cjson.decode(old).tags or {}
5871
+ write(r.key, r.payload, tags)
5872
+ removeMeta(leaseField)
5873
+ return response({written=true})
5874
+ elseif r.op == 'attach' then
5875
+ read(r.key, r.tags)
5876
+ return response({written=true})
5877
+ elseif r.op == 'size' then
5878
+ return response({size=redis.call('ZCARD', KEYS[3])})
5879
+ elseif r.op == 'tags' or r.op == 'prefix' or r.op == 'invalidate' then
5880
+ local generationFields = {}
5881
+ if r.op == 'tags' then
5882
+ for _, tag in ipairs(r.tags) do table.insert(generationFields, 'g:tag:' .. tag) end
5883
+ elseif r.op == 'prefix' then table.insert(generationFields, 'g:prefix:' .. r.key) end
5884
+ metadataSpace(#generationFields)
5885
+ for _, field in ipairs(generationFields) do putMeta(field, r.owner, now + r.metadataTtlMs) end
5886
+ local removed = 0
5887
+ for _, key in ipairs(redis.call('ZRANGE', KEYS[3], 0, -1)) do
5888
+ local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
5889
+ if raw and matches(key, cjson.decode(raw).tags) then removed = removed + removeEntry(key) end
5890
+ end
5891
+ for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do
5892
+ if string.sub(field, 1, 2) == 'l:' then
5893
+ local value = cjson.decode(redis.call('HGET', KEYS[4], field))
5894
+ if matches(string.sub(field, 3), value.tags) then removeMeta(field) end
5895
+ end
5896
+ end
5897
+ return response({removed=removed})
5898
+ elseif r.op == 'claim' then
5899
+ local payload = read(r.key, r.tags)
5900
+ if payload then return response({kind='value', payload=payload}) end
5901
+ local current = lease()
5902
+ if current then
5903
+ local shared = addTags(current.tags, r.tags)
5904
+ if #shared == #current.tags then return response({kind='wait'}) end
5905
+ -- A newly requested tag must not inherit a fill started before that tag's
5906
+ -- invalidation. Restart with the union of tags and a new owner.
5907
+ r.tags = shared
5908
+ removeMeta(leaseField)
5909
+ end
5910
+ local active = 0
5911
+ for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do if string.sub(field, 1, 2) == 'l:' then active = active + 1 end end
5912
+ if active >= r.capacity then return response({kind='wait'}) end
5913
+ local fields = scopes()
5914
+ metadataSpace(#fields + 1)
5915
+ local generations = {}
5916
+ for _, field in ipairs(fields) do
5917
+ local token = redis.call('HGET', KEYS[4], field) or r.owner
5918
+ putMeta(field, token, now + r.metadataTtlMs)
5919
+ generations[field] = token
5920
+ end
5921
+ local value = {owner=r.owner, tags=r.tags, generations=generations, deadline=now + r.fillTimeoutMs, expires=now + r.leaseMs}
5922
+ putMeta(leaseField, cjson.encode(value), value.expires)
5923
+ return response({kind='owner'})
5924
+ elseif r.op == 'renew' or r.op == 'publish' or r.op == 'release' then
5925
+ local current = lease()
5926
+ if not current or current.owner ~= r.owner then return response({owned=false}) end
5927
+ if r.op == 'release' then removeMeta(leaseField); return response({owned=true}) end
5928
+ for field, token in pairs(current.generations) do
5929
+ if redis.call('HGET', KEYS[4], field) ~= token then removeMeta(leaseField); return response({owned=false}) end
5930
+ end
5931
+ if r.op == 'renew' then
5932
+ current.expires = math.min(current.deadline, now + r.leaseMs)
5933
+ putMeta(leaseField, cjson.encode(current), current.expires)
5934
+ else
5935
+ write(r.key, r.payload, current.tags)
5936
+ removeMeta(leaseField)
5937
+ end
5938
+ return response({owned=true})
5939
+ end
5940
+ return redis.error_reply('Unknown cache operation')
5941
+ `;
5942
+
5943
+ class RedisCacheStore {
5944
+ redisUrl;
5945
+ ttlMs;
5946
+ maxEntries;
5947
+ client;
5948
+ keys;
5949
+ options;
5950
+ fills = new Set;
5951
+ closed = false;
5952
+ constructor(redisUrl, ttlMs, maxEntries, options = {}) {
5953
+ this.redisUrl = redisUrl;
5954
+ this.ttlMs = ttlMs;
5955
+ this.maxEntries = maxEntries;
5956
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
5957
+ throw new RangeError("ttlMs must be a non-negative safe integer.");
5958
+ if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 1e4)
5959
+ throw new RangeError("maxEntries must be between 1 and 10000.");
5960
+ this.options = {
5961
+ commandTimeoutMs: 5000,
5962
+ leaseMs: 1e4,
5963
+ fillTimeoutMs: 30000,
5964
+ waitTimeoutMs: 35000,
5965
+ pollMs: 25,
5966
+ metadataTtlMs: 60000,
5967
+ metadataLimit: 4096,
5968
+ ...options
5969
+ };
5970
+ for (const [key, value] of Object.entries(this.options))
5971
+ if (!Number.isSafeInteger(value) || value < 1)
5972
+ throw new RangeError(`${key} must be a positive safe integer.`);
5973
+ if (this.options.leaseMs < 30 || this.options.fillTimeoutMs < this.options.leaseMs || this.options.metadataTtlMs < this.options.fillTimeoutMs + this.options.leaseMs || this.options.metadataLimit < 66 || this.options.metadataLimit > 1e5)
5974
+ throw new RangeError("Invalid Redis cache lease, deadline or metadata bounds.");
5975
+ const namespace = namespacedRedisKey("cache:v3:");
5976
+ const slot = new Bun.CryptoHasher("sha256").update(namespace).digest("hex");
5977
+ this.keys = ["entries", "expiry", "order", "metadata", "metadata-expiry"].map((name) => `${namespace}{${slot}}:${name}`);
5978
+ this.client = this.createClient();
5979
+ }
5980
+ createClient() {
5981
+ return new RedisClient(this.redisUrl, {
5982
+ connectionTimeout: this.options.commandTimeoutMs,
5983
+ maxRetries: 1
6120
5984
  });
6121
5985
  }
6122
- async findByIdOrThrow(id, errorFactory) {
6123
- const record = await this.findById(id);
6124
- if (record) {
6125
- return record;
5986
+ close() {
5987
+ this.closed = true;
5988
+ for (const fill of this.fills)
5989
+ fill.abort(new Error("Redis cache store is closed."));
5990
+ this.client?.close();
5991
+ this.client = undefined;
5992
+ }
5993
+ async get(key) {
5994
+ const result = await this.execute("get", key);
5995
+ return result.kind === "value" ? JSON.parse(result.payload ?? "null") : undefined;
5996
+ }
5997
+ async set(key, value, ttlMs) {
5998
+ await this.execute("set", key, { payload: this.serialize(value), ttlMs: ttlMs ?? this.ttlMs });
5999
+ }
6000
+ async getOrSet(key, loader, ttlMs) {
6001
+ return this.getOrSetTagged(key, loader, [], ttlMs);
6002
+ }
6003
+ async getOrSetTagged(key, loader, tags, ttlMs) {
6004
+ const owner = crypto.randomUUID();
6005
+ const end = performance.now() + this.options.waitTimeoutMs;
6006
+ while (!this.closed) {
6007
+ const claim = await this.execute("claim", key, { owner, tags, ttlMs: ttlMs ?? this.ttlMs });
6008
+ if (claim.kind === "value")
6009
+ return JSON.parse(claim.payload ?? "null");
6010
+ if (claim.kind === "owner")
6011
+ return this.fill(key, owner, loader, ttlMs);
6012
+ if (performance.now() >= end)
6013
+ throw new Error("Redis cache fill wait deadline exceeded.");
6014
+ await Bun.sleep(this.options.pollMs);
6126
6015
  }
6127
- throw errorFactory?.(id) ?? new Error(`${this.table.name} ${String(id)} was not found.`);
6016
+ throw new Error("Redis cache store is closed.");
6128
6017
  }
6129
- async findByIds(ids) {
6130
- const uniqueIds = [...new Set(ids)];
6131
- if (uniqueIds.length === 0) {
6132
- return [];
6018
+ async attachTags(key, tags) {
6019
+ await this.execute("attach", key, { tags });
6020
+ }
6021
+ async flushTags(tags) {
6022
+ return (await this.execute("tags", "", { tags })).removed ?? 0;
6023
+ }
6024
+ async invalidate(key) {
6025
+ return ((await this.execute("invalidate", key)).removed ?? 0) > 0;
6026
+ }
6027
+ async invalidateByPrefix(prefix) {
6028
+ return (await this.execute("prefix", prefix)).removed ?? 0;
6029
+ }
6030
+ async clear() {
6031
+ await this.execute("clear", "");
6032
+ }
6033
+ async size() {
6034
+ return (await this.execute("size", "")).size ?? 0;
6035
+ }
6036
+ async fill(key, owner, loader, ttlMs) {
6037
+ if (this.fills.size >= this.maxEntries) {
6038
+ await this.execute("release", key, { owner });
6039
+ throw new Error("Redis cache local fill capacity exceeded.");
6133
6040
  }
6134
- return await this.findWhere({
6135
- [this.table.primaryKey]: uniqueIds
6041
+ const controller = new AbortController;
6042
+ this.fills.add(controller);
6043
+ let done = false;
6044
+ let renewing = false;
6045
+ const renew = setInterval(() => {
6046
+ if (renewing || done)
6047
+ return;
6048
+ renewing = true;
6049
+ this.execute("renew", key, { owner }).then((result) => {
6050
+ if (!result.owned)
6051
+ clearInterval(renew);
6052
+ }).catch((error) => {
6053
+ if (!done)
6054
+ controller.abort(error);
6055
+ }).finally(() => {
6056
+ renewing = false;
6057
+ });
6058
+ }, Math.max(10, Math.floor(this.options.leaseMs / 3)));
6059
+ const timeout = setTimeout(() => controller.abort(new Error("Redis cache fill deadline exceeded.")), this.options.fillTimeoutMs);
6060
+ let rejectAbort;
6061
+ const aborted = new Promise((_, reject) => {
6062
+ rejectAbort = () => reject(controller.signal.reason);
6063
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
6136
6064
  });
6065
+ try {
6066
+ const value = await Promise.race([
6067
+ Promise.resolve().then(() => loader(controller.signal)),
6068
+ aborted
6069
+ ]);
6070
+ controller.signal.throwIfAborted();
6071
+ done = true;
6072
+ clearInterval(renew);
6073
+ await this.execute("publish", key, {
6074
+ owner,
6075
+ payload: this.serialize(value),
6076
+ ttlMs: ttlMs ?? this.ttlMs
6077
+ });
6078
+ return value;
6079
+ } finally {
6080
+ done = true;
6081
+ clearInterval(renew);
6082
+ clearTimeout(timeout);
6083
+ controller.signal.removeEventListener("abort", rejectAbort);
6084
+ this.fills.delete(controller);
6085
+ if (!this.closed)
6086
+ await this.execute("release", key, { owner }).catch(() => {});
6087
+ }
6137
6088
  }
6138
- async firstOrNull(where, options = {}) {
6139
- const [record] = await this.findAll({ ...options, where, limit: 1 });
6140
- return record ?? null;
6089
+ serialize(value) {
6090
+ const payload = JSON.stringify(value);
6091
+ if (typeof payload !== "string")
6092
+ throw new TypeError("Redis cache values must be JSON serializable.");
6093
+ return payload;
6141
6094
  }
6142
- async upsert(values, conflictColumns, updateColumns) {
6143
- return await withDatabaseErrorHandling(async () => {
6144
- const { text, params } = buildUpsertQuery(this.table, values, conflictColumns, updateColumns);
6145
- const [record] = await this.connection.unsafe(text, params);
6146
- return record ?? null;
6095
+ async execute(op, key, extra = {}) {
6096
+ if (this.closed)
6097
+ throw new Error("Redis cache store is closed.");
6098
+ if (Buffer.byteLength(key) > 4096)
6099
+ throw new RangeError("Invalid Redis cache identity.");
6100
+ const tags = Array.from(new Set(extra.tags ?? []));
6101
+ if (tags.length > 32 || tags.some((tag) => typeof tag !== "string" || Buffer.byteLength(tag) > 1024))
6102
+ throw new RangeError("Redis cache supports at most 32 tags of 1024 bytes each.");
6103
+ const prefixes = [key];
6104
+ for (let i = 0;i < key.length; i++)
6105
+ if (key[i] === "?")
6106
+ prefixes.push(key.slice(0, i));
6107
+ if (prefixes.length > 33)
6108
+ throw new RangeError("Redis cache identities support at most 32 query separators.");
6109
+ const ttlMs = extra.ttlMs ?? this.ttlMs;
6110
+ if (typeof ttlMs !== "number" || !Number.isSafeInteger(ttlMs) || ttlMs < 0)
6111
+ throw new RangeError("ttlMs must be a non-negative safe integer.");
6112
+ if (!this.client)
6113
+ this.client = this.createClient();
6114
+ const client = this.client;
6115
+ const retire = (reason) => {
6116
+ if (this.client !== client)
6117
+ return;
6118
+ this.client = undefined;
6119
+ for (const fill of this.fills)
6120
+ fill.abort(reason);
6121
+ client.close();
6122
+ };
6123
+ let timer;
6124
+ const timeout = new Promise((_, reject) => {
6125
+ timer = setTimeout(() => {
6126
+ const error = new Error("Redis cache command deadline exceeded.");
6127
+ reject(error);
6128
+ retire(error);
6129
+ }, this.options.commandTimeoutMs);
6130
+ });
6131
+ try {
6132
+ const raw = await Promise.race([
6133
+ client.send("EVAL", [
6134
+ CACHE_SCRIPT,
6135
+ String(this.keys.length),
6136
+ ...this.keys,
6137
+ JSON.stringify({
6138
+ op,
6139
+ key,
6140
+ capacity: this.maxEntries,
6141
+ owner: crypto.randomUUID(),
6142
+ ...this.options,
6143
+ ...extra,
6144
+ tags,
6145
+ prefixes,
6146
+ ttlMs
6147
+ })
6148
+ ]),
6149
+ timeout
6150
+ ]);
6151
+ if (this.closed || this.client !== client)
6152
+ throw new Error("Redis cache connection was retired.");
6153
+ return JSON.parse(String(raw));
6154
+ } catch (error) {
6155
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && error.code.startsWith("ERR_REDIS_CONNECTION"))
6156
+ retire(error);
6157
+ throw error;
6158
+ } finally {
6159
+ clearTimeout(timer);
6160
+ }
6161
+ }
6162
+ }
6163
+ var redisCacheStore_default = RedisCacheStore;
6164
+
6165
+ // ../../src/core/cache/simpleCache.ts
6166
+ class SimpleCache {
6167
+ ttlMs;
6168
+ maxEntries;
6169
+ cache = new Map;
6170
+ inflight = new Map;
6171
+ tagIndex = new Map;
6172
+ keyTags = new Map;
6173
+ constructor(ttlMs = 3600000, maxEntries = 100) {
6174
+ this.ttlMs = ttlMs;
6175
+ this.maxEntries = maxEntries;
6176
+ if (!Number.isFinite(ttlMs) || ttlMs < 0) {
6177
+ throw new RangeError("ttlMs must be a non-negative number.");
6178
+ }
6179
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
6180
+ throw new RangeError("maxEntries must be a positive integer.");
6181
+ }
6182
+ }
6183
+ get(key) {
6184
+ return this.getFreshEntry(key)?.value;
6185
+ }
6186
+ set(key, value, ttlMs) {
6187
+ const now = Date.now();
6188
+ const resolvedTtlMs = ttlMs ?? this.ttlMs;
6189
+ this.cache.set(key, {
6190
+ value,
6191
+ expiresAt: now + resolvedTtlMs,
6192
+ lastAccessedAt: now
6147
6193
  });
6194
+ this.evictOverflow();
6148
6195
  }
6149
- async incrementById(id, column, amount = 1, extra = {}) {
6150
- return await withDatabaseErrorHandling(async () => {
6151
- const { text, params } = buildIncrementQuery(this.table, id, column, amount, extra);
6152
- const [record] = await this.connection.unsafe(text, params);
6153
- return record ?? null;
6196
+ async getOrSet(key, loader, ttlMs) {
6197
+ this.pruneExpired();
6198
+ const cachedEntry = this.getFreshEntry(key);
6199
+ if (cachedEntry) {
6200
+ return cachedEntry.value;
6201
+ }
6202
+ const inflightRequest = this.inflight.get(key);
6203
+ if (inflightRequest) {
6204
+ return inflightRequest;
6205
+ }
6206
+ const pendingRequest = loader().then((value) => {
6207
+ this.set(key, value, ttlMs);
6208
+ return value;
6209
+ }).finally(() => {
6210
+ this.inflight.delete(key);
6154
6211
  });
6212
+ this.inflight.set(key, pendingRequest);
6213
+ return pendingRequest;
6155
6214
  }
6156
- async decrementById(id, column, amount = 1, extra = {}) {
6157
- return await this.incrementById(id, column, -amount, extra);
6215
+ attachTags(key, tags) {
6216
+ if (tags.length === 0) {
6217
+ return;
6218
+ }
6219
+ let tagsForKey = this.keyTags.get(key);
6220
+ if (!tagsForKey) {
6221
+ tagsForKey = new Set;
6222
+ this.keyTags.set(key, tagsForKey);
6223
+ }
6224
+ for (const tag of tags) {
6225
+ tagsForKey.add(tag);
6226
+ let keysForTag = this.tagIndex.get(tag);
6227
+ if (!keysForTag) {
6228
+ keysForTag = new Set;
6229
+ this.tagIndex.set(tag, keysForTag);
6230
+ }
6231
+ keysForTag.add(key);
6232
+ }
6158
6233
  }
6159
- async create(values) {
6160
- this.assertTransactionalEvent("created");
6161
- return await this.runModelMutation("created", () => withDatabaseErrorHandling(async () => {
6162
- const { text, params } = buildInsertQuery(this.table, values);
6163
- const [record] = await this.connection.unsafe(text, params);
6164
- if (!record) {
6165
- throw new Error(`Insert into ${this.table.name} did not return a record.`);
6234
+ flushTags(tags) {
6235
+ const keysToRemove = new Set;
6236
+ for (const tag of tags) {
6237
+ const keys = this.tagIndex.get(tag);
6238
+ if (!keys) {
6239
+ continue;
6166
6240
  }
6167
- const entity = record;
6168
- await dispatchModelEvent(modelEventName(this.table.name, "created"), entity);
6169
- return entity;
6170
- }));
6241
+ for (const key of keys) {
6242
+ keysToRemove.add(key);
6243
+ }
6244
+ }
6245
+ let removed = 0;
6246
+ for (const key of keysToRemove) {
6247
+ if (this.invalidate(key)) {
6248
+ removed += 1;
6249
+ }
6250
+ }
6251
+ for (const tag of tags) {
6252
+ this.tagIndex.delete(tag);
6253
+ }
6254
+ return removed;
6171
6255
  }
6172
- async updateWhere(changes, options) {
6173
- return this.executeConditionalWrite("update", changes, options);
6256
+ invalidate(key) {
6257
+ const removed = this.cache.delete(key);
6258
+ if (removed) {
6259
+ this.detachKeyFromTags(key);
6260
+ }
6261
+ return removed;
6174
6262
  }
6175
- async deleteWhere(options) {
6176
- return this.executeConditionalWrite("delete", {}, options);
6263
+ invalidateByPrefix(prefix) {
6264
+ let removed = 0;
6265
+ for (const key of [...this.cache.keys()]) {
6266
+ if (key === prefix || key.startsWith(`${prefix}?`)) {
6267
+ if (this.invalidate(key)) {
6268
+ removed += 1;
6269
+ }
6270
+ }
6271
+ }
6272
+ return removed;
6177
6273
  }
6178
- async updateWhereReturning(changes, columns, options) {
6179
- const { text, params } = buildConditionalWriteQuery(this.table, "update", changes, options, columns);
6180
- return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
6274
+ clear() {
6275
+ this.cache.clear();
6276
+ this.inflight.clear();
6277
+ this.tagIndex.clear();
6278
+ this.keyTags.clear();
6181
6279
  }
6182
- async executeConditionalWrite(operation, changes, options) {
6183
- const { text, params } = buildConditionalWriteQuery(this.table, operation, changes, options);
6184
- return withDatabaseErrorHandling(async () => {
6185
- const rows = await this.connection.unsafe(text, params);
6186
- if (currentSqlDialect().driver !== "mysql")
6187
- return rows.length;
6188
- const affected = rows[0]?.affectedRows;
6189
- if (!Number.isSafeInteger(affected) || affected === undefined || affected < 0)
6190
- throw new Error("MySQL conditional writes require affectedRows metadata from the framework adapter.");
6191
- return affected;
6192
- });
6280
+ size() {
6281
+ this.pruneExpired();
6282
+ return this.cache.size;
6193
6283
  }
6194
- async updateById(id, changes) {
6195
- this.assertTransactionalEvent("updated");
6196
- return await this.runModelMutation("updated", () => withDatabaseErrorHandling(async () => {
6197
- const { text, params } = buildUpdateQuery(this.table, id, changes);
6198
- const [record] = await this.connection.unsafe(text, params);
6199
- const entity = record ?? null;
6200
- if (entity) {
6201
- await dispatchModelEvent(modelEventName(this.table.name, "updated"), entity);
6284
+ detachKeyFromTags(key) {
6285
+ const tags = this.keyTags.get(key);
6286
+ if (!tags) {
6287
+ return;
6288
+ }
6289
+ for (const tag of tags) {
6290
+ const keys = this.tagIndex.get(tag);
6291
+ if (!keys) {
6292
+ continue;
6293
+ }
6294
+ keys.delete(key);
6295
+ if (keys.size === 0) {
6296
+ this.tagIndex.delete(tag);
6202
6297
  }
6203
- return entity;
6204
- }));
6205
- }
6206
- async updateByIdOrThrow(id, changes, errorFactory) {
6207
- const record = await this.updateById(id, changes);
6208
- if (record) {
6209
- return record;
6210
6298
  }
6211
- throw errorFactory?.(id) ?? new Error(`${this.table.name} ${String(id)} was not found.`);
6299
+ this.keyTags.delete(key);
6212
6300
  }
6213
- async deleteById(id) {
6214
- if (resolveSoftDeleteColumn(this.table)) {
6215
- return await this.softDeleteById(id);
6301
+ getFreshEntry(key) {
6302
+ const entry = this.cache.get(key);
6303
+ if (!entry) {
6304
+ return;
6216
6305
  }
6217
- return await this.forceDeleteById(id);
6306
+ if (entry.expiresAt <= Date.now()) {
6307
+ this.invalidate(key);
6308
+ return;
6309
+ }
6310
+ entry.lastAccessedAt = Date.now();
6311
+ return entry;
6218
6312
  }
6219
- async softDeleteById(id) {
6220
- this.assertTransactionalEvent("deleted");
6221
- return await this.runModelMutation("deleted", () => withDatabaseErrorHandling(async () => {
6222
- const { text, params } = buildSoftDeleteByIdQuery(this.table, id, new Date);
6223
- const [record] = await this.connection.unsafe(text, params);
6224
- if (!record) {
6225
- return false;
6313
+ pruneExpired() {
6314
+ const now = Date.now();
6315
+ for (const [key, entry] of this.cache.entries()) {
6316
+ if (entry.expiresAt <= now) {
6317
+ this.invalidate(key);
6226
6318
  }
6227
- await dispatchModelEvent(modelEventName(this.table.name, "deleted"), record);
6228
- return true;
6229
- }));
6319
+ }
6230
6320
  }
6231
- async forceDeleteById(id) {
6232
- this.assertTransactionalEvent("force-deleted");
6233
- return await this.runModelMutation("force-deleted", () => withDatabaseErrorHandling(async () => {
6234
- const { text, params } = buildDeleteByIdQuery(this.table, id);
6235
- const [row] = await this.connection.unsafe(text, params);
6236
- if (!row) {
6237
- return false;
6321
+ evictOverflow() {
6322
+ while (this.cache.size > this.maxEntries) {
6323
+ let oldestKey;
6324
+ let oldestAccessTime = Number.POSITIVE_INFINITY;
6325
+ for (const [key, entry] of this.cache.entries()) {
6326
+ if (entry.lastAccessedAt < oldestAccessTime) {
6327
+ oldestAccessTime = entry.lastAccessedAt;
6328
+ oldestKey = key;
6329
+ }
6238
6330
  }
6239
- await dispatchModelEvent(modelEventName(this.table.name, "force-deleted"), {
6240
- id
6241
- });
6242
- return true;
6243
- }));
6244
- }
6245
- async restoreById(id) {
6246
- this.assertTransactionalEvent("restored");
6247
- return await this.runModelMutation("restored", () => withDatabaseErrorHandling(async () => {
6248
- const { text, params } = buildRestoreByIdQuery(this.table, id);
6249
- const [record] = await this.connection.unsafe(text, params);
6250
- if (!record) {
6251
- return null;
6331
+ if (!oldestKey) {
6332
+ return;
6252
6333
  }
6253
- const entity = record;
6254
- await dispatchModelEvent(modelEventName(this.table.name, "restored"), entity);
6255
- return entity;
6256
- }));
6257
- }
6258
- primaryKeySeekDirection(options) {
6259
- if (options.joins?.length || options.groupBy || options.having || options.select?.length || options.offset !== undefined || options.limit !== undefined || options.lock) {
6260
- return null;
6261
- }
6262
- const orders = listOrders(options.orderBy ?? this.table.defaultOrderBy);
6263
- const order = orders[0];
6264
- if (orders.length !== 1 || !order || order.nulls || order.column !== this.table.primaryKey) {
6265
- return null;
6334
+ this.invalidate(oldestKey);
6266
6335
  }
6267
- return order.direction;
6268
6336
  }
6269
- withConnection(connection) {
6270
- const clone = Object.create(Object.getPrototypeOf(this));
6271
- Object.assign(clone, this);
6272
- clone.connection = connection;
6273
- return clone;
6337
+ }
6338
+ var simpleCache_default = SimpleCache;
6339
+
6340
+ // ../../src/core/cache/simpleCacheStore.ts
6341
+ class SimpleCacheStore {
6342
+ cache;
6343
+ constructor(cache) {
6344
+ this.cache = cache;
6274
6345
  }
6275
- getConnection() {
6276
- return this.connection;
6346
+ get(key) {
6347
+ return Promise.resolve(this.cache.get(key));
6277
6348
  }
6278
- getTable() {
6279
- return this.table;
6349
+ set(key, value, ttlMs) {
6350
+ this.cache.set(key, value, ttlMs);
6351
+ return Promise.resolve();
6280
6352
  }
6281
- query(where = {}) {
6282
- return new RepositoryQuery(this, where);
6353
+ getOrSet(key, loader, ttlMs) {
6354
+ return this.cache.getOrSet(key, loader, ttlMs);
6283
6355
  }
6284
- async findWhere(where, options = {}) {
6285
- return await this.findAll({ ...options, where });
6356
+ attachTags(key, tags) {
6357
+ this.cache.attachTags(key, tags);
6358
+ return Promise.resolve();
6286
6359
  }
6287
- async countWhere(where = {}, options = {}, whereNodes = []) {
6288
- const { text, params } = buildCountQuery(this.table, where, options, whereNodes);
6289
- const [row] = await this.connection.unsafe(text, params);
6290
- return Number(row?.count ?? 0);
6360
+ flushTags(tags) {
6361
+ return Promise.resolve(this.cache.flushTags(tags));
6291
6362
  }
6292
- async averageColumn(column, where = {}) {
6293
- const qualifiedColumn = qualifyColumn(this.table.name, column);
6294
- return await this.averageExpression(`AVG(${qualifiedColumn})`, "value", where);
6363
+ invalidate(key) {
6364
+ return Promise.resolve(this.cache.invalidate(key));
6295
6365
  }
6296
- async averageExpression(expression, alias, where = {}) {
6297
- const { text, params } = buildProjectionQuery(this.table, expression, alias, { where });
6298
- const [row] = await this.connection.unsafe(text, params);
6299
- return Number(row?.[alias] ?? 0);
6366
+ invalidateByPrefix(prefix) {
6367
+ return Promise.resolve(this.cache.invalidateByPrefix(prefix));
6300
6368
  }
6301
- async sumBy(groupColumn, column, options = {}) {
6302
- const { whereNodes, ...queryOptions } = options;
6303
- const { text, params } = buildGroupedSumQuery(this.table, groupColumn, column, queryOptions, whereNodes ?? []);
6304
- return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
6369
+ clear() {
6370
+ this.cache.clear();
6371
+ return Promise.resolve();
6305
6372
  }
6306
- async sum(column, where = {}) {
6307
- return await this.aggregateColumn("SUM", column, where);
6373
+ size() {
6374
+ return Promise.resolve(this.cache.size());
6308
6375
  }
6309
- async avg(column, where = {}) {
6310
- return await this.aggregateColumn("AVG", column, where);
6376
+ }
6377
+ var simpleCacheStore_default = SimpleCacheStore;
6378
+
6379
+ // ../../src/core/cache/createCacheStore.ts
6380
+ function createCacheStore(options) {
6381
+ if (options.driver === "redis") {
6382
+ if (!options.redisUrl) {
6383
+ throw new Error('CACHE_DRIVER="redis" requires REDIS_URL to be set.');
6384
+ }
6385
+ return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries, options.redis);
6311
6386
  }
6312
- async min(column, where = {}) {
6313
- return await this.aggregateColumn("MIN", column, where);
6387
+ return new simpleCacheStore_default(new simpleCache_default(options.ttlMs, options.maxEntries));
6388
+ }
6389
+ // ../../src/core/cache/taggedCache.ts
6390
+ class TaggedCache {
6391
+ store;
6392
+ tags;
6393
+ constructor(store, tags) {
6394
+ this.store = store;
6395
+ this.tags = tags;
6314
6396
  }
6315
- async max(column, where = {}) {
6316
- return await this.aggregateColumn("MAX", column, where);
6397
+ async remember(key, callback, ttlMs) {
6398
+ if (this.store.getOrSetTagged)
6399
+ return this.store.getOrSetTagged(key, callback, this.tags, ttlMs);
6400
+ const value = await this.store.getOrSet(key, callback, ttlMs);
6401
+ await this.store.attachTags(key, this.tags);
6402
+ return value;
6317
6403
  }
6318
- async aggregateColumn(fn, column, where) {
6319
- const qualifiedColumn = qualifyColumn(this.table.name, column);
6320
- return await this.averageExpression(`${fn}(${qualifiedColumn})`, "value", where);
6404
+ async flush() {
6405
+ return this.store.flushTags(this.tags);
6321
6406
  }
6322
- async pluckNumberValues(expression, alias, options = {}) {
6323
- const { text, params } = buildProjectionQuery(this.table, expression, alias, options);
6324
- const rows = await this.connection.unsafe(text, params);
6325
- return rows.flatMap((row) => {
6326
- const value = row[alias];
6327
- return value === null || value === undefined ? [] : [Number(value)];
6328
- });
6407
+ }
6408
+ var taggedCache_default = TaggedCache;
6409
+
6410
+ // ../../src/core/cache/repository.ts
6411
+ class CacheRepository {
6412
+ store;
6413
+ constructor(store) {
6414
+ this.store = store;
6329
6415
  }
6330
- async countGroupedBy(column, where = {}) {
6331
- const { text, params } = buildGroupedCountQuery(this.table, column, where);
6332
- const rows = await this.connection.unsafe(text, params);
6333
- return rows.map(({ value, count }) => ({
6334
- value,
6335
- count: Number(count)
6336
- }));
6416
+ async close() {
6417
+ await this.store.close?.();
6337
6418
  }
6338
- async findByHasManyRelation(relation, parentId, options = {}) {
6339
- return await this.findWhere({
6340
- [relation.foreignKey]: parentId
6341
- }, options);
6419
+ async get(key) {
6420
+ return this.store.get(key);
6342
6421
  }
6343
- async loadHasManyForParents(parents, relation, options = {}) {
6344
- if (parents.length === 0) {
6345
- return indexHasManyRelation(parents, [], relation);
6346
- }
6347
- const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
6348
- const children = await this.findWhere({
6349
- [relation.foreignKey]: parentIds
6350
- }, options);
6351
- return indexHasManyRelation(parents, children, relation);
6422
+ async remember(key, callback, ttlMs) {
6423
+ return this.store.getOrSet(key, callback, ttlMs);
6352
6424
  }
6353
- async findHasManyThrough(parentId, relation, options = {}) {
6354
- const grouped = await this.loadHasManyThroughForParents([{ [relation.localKey]: parentId }], relation, options);
6355
- return getByRelationKey(grouped, parentId) ?? [];
6425
+ async forget(key) {
6426
+ return this.store.invalidate(key);
6356
6427
  }
6357
- async loadHasManyThroughForParents(parents, relation, options = {}) {
6358
- if (parents.length === 0) {
6359
- return indexHasManyThroughRelation(parents, [], relation);
6360
- }
6361
- if (options.joins?.some((join) => join.table === relation.throughTable)) {
6362
- throw new Error("A related model scope joins the through table twice; use an explicitly aliased correlated predicate instead.");
6363
- }
6364
- const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
6365
- const throughParentKey = relation.throughParentKey ?? "__through_parent_id";
6366
- const farTable = this.table.name;
6367
- const columns = this.table.columns.map((column) => `${qualifyColumn(farTable, column)}`).join(", ");
6368
- const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
6369
- const { text: extraWhere, params: extraParams } = this.buildThroughWhere(options, parentIds);
6370
- const sql = `SELECT ${columns}, ${qualifyColumn(relation.throughTable, relation.firstKey)} AS ${throughParentKey} FROM ${quoteIdentifier(farTable)} INNER JOIN ${quoteIdentifier(relation.throughTable)} ON ${qualifyColumn(relation.throughTable, relation.secondLocalKey)} = ${qualifyColumn(farTable, relation.secondKey)}${buildJoinClause(options.joins)} WHERE ${qualifyColumn(relation.throughTable, relation.firstKey)} IN (${placeholders})${extraWhere}`;
6371
- const children = await this.connection.unsafe(sql, [
6372
- ...parentIds,
6373
- ...extraParams
6374
- ]);
6375
- return indexHasManyThroughRelation(parents, children, relation);
6428
+ async flush() {
6429
+ await this.store.clear();
6376
6430
  }
6377
- buildThroughWhere(options, parentIds) {
6378
- const parameters = [...parentIds];
6379
- const { clause } = buildQueryWhereClause(this.table, options, options.whereNodes, parameters);
6380
- return {
6381
- text: clause ? ` AND (${clause.replace(/^ WHERE /, "")})` : "",
6382
- params: parameters.slice(parentIds.length)
6383
- };
6431
+ tags(...names) {
6432
+ return new taggedCache_default(this.store, names);
6384
6433
  }
6385
- async loadBelongsToForParents(children, relation, parentRepository, options = {}) {
6386
- if (children.length === 0) {
6387
- return new Map;
6388
- }
6389
- const ownerIds = [...new Set(children.map((child) => child[relation.foreignKey]))];
6390
- const parents = await parentRepository.withConnection(this.connection).findWhere({
6391
- [relation.ownerKey]: ownerIds
6392
- }, options);
6393
- return indexBelongsToRelation(children, parents, relation);
6434
+ async getOrSet(key, loader, ttlMs) {
6435
+ return this.remember(key, loader, ttlMs);
6394
6436
  }
6395
- async loadMorphManyForParents(parents, relation, options = {}) {
6396
- if (parents.length === 0) {
6397
- return indexMorphManyRelation(parents, [], relation);
6398
- }
6399
- const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
6400
- const children = await this.findWhere({
6401
- [relation.morphTypeKey]: relation.morphType,
6402
- [relation.morphIdKey]: parentIds
6403
- }, options);
6404
- return indexMorphManyRelation(parents, children, relation);
6437
+ async invalidate(key) {
6438
+ return this.forget(key);
6405
6439
  }
6406
- async loadMorphOneForParents(parents, relation, options = {}) {
6407
- const grouped = await this.loadMorphManyForParents(parents, relation, options);
6408
- const result = new Map;
6409
- for (const parent of parents) {
6410
- const matches = getByRelationKey(grouped, parent[relation.localKey]) ?? [];
6411
- result.set(parent[relation.localKey], matches[0]);
6412
- }
6413
- return result;
6440
+ async invalidateByPrefix(prefix) {
6441
+ return this.store.invalidateByPrefix(prefix);
6414
6442
  }
6415
- async loadMorphToForChildren(children, relation, repositoriesByType, options = {}) {
6416
- if (children.length === 0) {
6417
- return new Map;
6418
- }
6419
- const idsByType = new Map;
6420
- for (const child of children) {
6421
- const morphType = String(child[relation.morphTypeKey]);
6422
- const morphId = child[relation.morphIdKey];
6423
- const ids = idsByType.get(morphType) ?? new Set;
6424
- ids.add(morphId);
6425
- idsByType.set(morphType, ids);
6426
- }
6427
- const parentsByType = new Map;
6428
- await Promise.all([...idsByType.entries()].map(async ([morphType, ids]) => {
6429
- const repository = repositoriesByType.get(morphType);
6430
- if (!repository) {
6431
- return;
6432
- }
6433
- const ownerKey = repository.getTable().primaryKey;
6434
- const parents = await repository.withConnection(this.connection).findWhere({
6435
- [ownerKey]: [...ids]
6436
- }, options);
6437
- const indexed = new Map;
6438
- for (const parent of parents) {
6439
- indexed.set(relationMatchKey(parent[ownerKey]), parent);
6440
- }
6441
- parentsByType.set(morphType, indexed);
6442
- }));
6443
- return indexMorphToRelation(children, parentsByType, relation);
6443
+ async clear() {
6444
+ await this.flush();
6444
6445
  }
6445
- async loadBelongsToManyForParents(parents, relation, relatedRepository, options = {}) {
6446
- if (parents.length === 0) {
6447
- return indexBelongsToManyRelation(parents, [], [], relation);
6448
- }
6449
- const parentIds = [...new Set(parents.map((parent) => parent[relation.parentKey]))];
6450
- const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
6451
- const pivotRows = await this.connection.unsafe(`SELECT * FROM ${relation.pivotTable} WHERE ${String(relation.foreignPivotKey)} IN (${placeholders})`, parentIds);
6452
- if (pivotRows.length === 0) {
6453
- return indexBelongsToManyRelation(parents, [], [], relation);
6454
- }
6455
- const relatedIds = [
6456
- ...new Set(pivotRows.map((row) => row[relation.relatedPivotKey]))
6457
- ];
6458
- const relatedRows = await relatedRepository.withConnection(this.connection).findWhere({
6459
- [relation.relatedKey]: relatedIds
6460
- }, options);
6461
- return indexBelongsToManyRelation(parents, pivotRows, relatedRows, relation);
6446
+ async size() {
6447
+ return this.store.size();
6462
6448
  }
6463
6449
  }
6464
- var baseRepository_default = BaseRepository;
6465
-
6450
+ var repository_default = CacheRepository;
6451
+ // ../../src/core/cache/tags.ts
6452
+ var CACHE_TAGS = {
6453
+ organizations: "organizations",
6454
+ projects: "projects",
6455
+ tasks: "tasks",
6456
+ comments: "comments",
6457
+ attachments: "attachments",
6458
+ reports: "reports",
6459
+ users: "users",
6460
+ departments: "departments",
6461
+ positions: "positions",
6462
+ applications: "applications",
6463
+ careers: "careers",
6464
+ offers: "offers"
6465
+ };
6466
6466
  // ../../src/core/crypto/fieldEncryptionRotation.ts
6467
+ import { createHash as createHash3, randomUUID } from "crypto";
6467
6468
  init_boundConnection();
6468
6469
 
6469
6470
  // ../../src/core/database/migrations/rollbackGuard.ts
@@ -7128,11 +7129,6 @@ function createSchemaBuilder(db, driver) {
7128
7129
  }
7129
7130
  });
7130
7131
  }
7131
- // ../../src/core/database/table.ts
7132
- function defineTable(definition) {
7133
- return definition;
7134
- }
7135
-
7136
7132
  // ../../src/core/crypto/fieldEncryptionRotation.ts
7137
7133
  init_transaction();
7138
7134
 
@@ -15929,7 +15925,6 @@ init_tenantDatabaseScope();
15929
15925
 
15930
15926
  // ../../src/core/tenant/tenantMiddleware.ts
15931
15927
  import { createHash as createHash8 } from "crypto";
15932
- init_repositoryConnection();
15933
15928
  init_databaseTenantContext();
15934
15929
 
15935
15930
  // ../../src/core/tenant/publicTenantHost.ts
@@ -15985,18 +15980,18 @@ var DEFAULT_TENANT = {
15985
15980
  id: 1,
15986
15981
  slug: "default"
15987
15982
  };
15983
+ var userTenants = new BaseRepository(defineTable({
15984
+ name: "users",
15985
+ primaryKey: "id",
15986
+ columns: ["id", "tenant_id"]
15987
+ }));
15988
15988
  async function resolveUserTenantId(userId) {
15989
15989
  if (!isTenancyEnabled()) {
15990
15990
  return DEFAULT_TENANT.id;
15991
15991
  }
15992
15992
  return await runWithMigrationBypassForIdentifier(userId, async () => {
15993
- const rows = await repositoryConnection`
15994
- SELECT tenant_id
15995
- FROM users
15996
- WHERE id = ${userId}
15997
- LIMIT 1
15998
- `;
15999
- return rows[0]?.tenant_id ?? DEFAULT_TENANT.id;
15993
+ const tenantId = await userTenants.query().where({ id: userId }).value("tenant_id");
15994
+ return tenantId ?? DEFAULT_TENANT.id;
16000
15995
  });
16001
15996
  }
16002
15997
  function auditChecksum(payload) {