payment-kit 1.27.2 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +10 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +32 -16
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +16 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +14 -2
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +1 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/models/customer.ts +23 -1
- package/api/src/store/models/payment-method.ts +4 -0
- package/api/src/store/models/price.ts +23 -14
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/run-build.js +390 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +585 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1553 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +69 -0
- package/cloudflare/wrangler.staging.json +66 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +26 -22
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/safe-did-address.tsx +75 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/customers/customers/detail.tsx +2 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
// Sequelize Model shim → D1
|
|
2
|
+
// Implements the Sequelize Model API subset used by Payment Kit
|
|
3
|
+
|
|
4
|
+
import { buildWhereClause } from './operators';
|
|
5
|
+
import { helperToSQL } from './helpers';
|
|
6
|
+
import { DataTypes } from './datatypes';
|
|
7
|
+
import { recordQuery, recordBatch } from './timing';
|
|
8
|
+
|
|
9
|
+
// Global hooks registry — keyed by class reference, avoids static property inheritance issues
|
|
10
|
+
const _hooksRegistry = new Map<any, Map<string, Map<string, Function>>>();
|
|
11
|
+
|
|
12
|
+
// Global D1 database reference, set by worker.ts at startup
|
|
13
|
+
let _db: any = null;
|
|
14
|
+
export function setDB(db: any) {
|
|
15
|
+
_db = db;
|
|
16
|
+
// PRAGMA table_info results are stable within a CF Workers isolate —
|
|
17
|
+
// schema only changes during migration deploys, so cache survives across requests.
|
|
18
|
+
}
|
|
19
|
+
export function getDB(): any {
|
|
20
|
+
if (!_db) throw new Error('D1 database not initialized. Call setDB() first.');
|
|
21
|
+
return _db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Cache of actual D1 table columns (tableName → Set<columnName>).
|
|
25
|
+
// Prevents "no such column" errors when Sequelize model defines columns
|
|
26
|
+
// that don't exist in D1 (e.g. migration not yet applied).
|
|
27
|
+
const _d1ColumnCache = new Map<string, Set<string>>();
|
|
28
|
+
|
|
29
|
+
// --- Isolate-level query cache for rarely-changing tables ---
|
|
30
|
+
// PaymentCurrency and PaymentMethod are queried on nearly every endpoint but
|
|
31
|
+
// only change via admin actions. Caching them per-isolate with TTL eliminates
|
|
32
|
+
// ~100ms D1 RTT per cached query.
|
|
33
|
+
const CACHEABLE_TABLES = new Set(['payment_currencies', 'payment_methods', 'products', 'prices']);
|
|
34
|
+
const ISOLATE_CACHE_TTL_MS = 60_000; // 60 seconds
|
|
35
|
+
const _isolateCache = new Map<string, { data: any; expiresAt: number }>();
|
|
36
|
+
|
|
37
|
+
function getIsolateCacheKey(tableName: string, method: string, options: any): string | null {
|
|
38
|
+
if (!CACHEABLE_TABLES.has(tableName)) return null;
|
|
39
|
+
// Only cache simple findAll/findOne — not writes, not complex joins
|
|
40
|
+
if (method !== 'findAll' && method !== 'findOne' && method !== 'findByPk') return null;
|
|
41
|
+
try {
|
|
42
|
+
// Stable key from table + where + attributes + order (exclude include — handled separately)
|
|
43
|
+
const w = options?.where ? JSON.stringify(options.where) : '';
|
|
44
|
+
const a = options?.attributes ? JSON.stringify(options.attributes) : '';
|
|
45
|
+
const l = options?.limit ?? '';
|
|
46
|
+
return `${tableName}:${method}:${w}:${a}:${l}`;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Invalidate isolate cache for a table (call after create/update/destroy on cacheable tables). */
|
|
53
|
+
export function invalidateIsolateCache(tableName?: string): void {
|
|
54
|
+
if (!tableName) { _isolateCache.clear(); return; }
|
|
55
|
+
for (const key of _isolateCache.keys()) {
|
|
56
|
+
if (key.startsWith(`${tableName}:`)) _isolateCache.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getD1Columns(tableName: string): Promise<Set<string>> {
|
|
61
|
+
const cached = _d1ColumnCache.get(tableName);
|
|
62
|
+
if (cached) return cached;
|
|
63
|
+
try {
|
|
64
|
+
const result = await getDB().prepare(`PRAGMA table_info("${tableName}")`).all();
|
|
65
|
+
const cols = new Set<string>((result.results || []).map((r: any) => r.name));
|
|
66
|
+
if (cols.size > 0) {
|
|
67
|
+
_d1ColumnCache.set(tableName, cols);
|
|
68
|
+
}
|
|
69
|
+
return cols;
|
|
70
|
+
} catch {
|
|
71
|
+
// If PRAGMA fails (table doesn't exist), return empty set — caller proceeds with all attrs
|
|
72
|
+
return new Set<string>();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Association info stored during associate() calls
|
|
77
|
+
interface AssociationInfo {
|
|
78
|
+
type: 'hasOne' | 'hasMany' | 'belongsTo' | 'belongsToMany';
|
|
79
|
+
target: any; // target Model class
|
|
80
|
+
foreignKey?: string;
|
|
81
|
+
sourceKey?: string;
|
|
82
|
+
as?: string;
|
|
83
|
+
through?: any;
|
|
84
|
+
otherKey?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Model registry — maps Model class to table name + attributes + associations
|
|
88
|
+
export const modelRegistry = new Map<any, { tableName: string; attributes: Record<string, any>; associations: AssociationInfo[] }>();
|
|
89
|
+
|
|
90
|
+
// Shared models registry — all Sequelize instances share the same models map.
|
|
91
|
+
// This is critical because some model files import `sequelize` from '../sequelize'
|
|
92
|
+
// (creating a different Sequelize instance than the one passed to initialize()),
|
|
93
|
+
// then reference `sequelize.models.SomeModel` for include queries.
|
|
94
|
+
export const sharedModels: Record<string, any> = {};
|
|
95
|
+
|
|
96
|
+
// Global Sequelize instance reference (set by Model.init)
|
|
97
|
+
// Needed so instance methods can call this.sequelize.models, this.sequelize.query
|
|
98
|
+
let _sequelizeInstance: any = null;
|
|
99
|
+
|
|
100
|
+
export class Model {
|
|
101
|
+
[key: string]: any;
|
|
102
|
+
|
|
103
|
+
dataValues: any = {};
|
|
104
|
+
_previousDataValues: any = {};
|
|
105
|
+
|
|
106
|
+
constructor(data?: any) {
|
|
107
|
+
if (data) {
|
|
108
|
+
Object.assign(this, data);
|
|
109
|
+
this.dataValues = { ...data };
|
|
110
|
+
this._previousDataValues = { ...data };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Schema registration (called by each model file's initialize()) ---
|
|
115
|
+
|
|
116
|
+
static init(attributes: Record<string, any>, options: any) {
|
|
117
|
+
const tableName = options.tableName || options.modelName || this.name.toLowerCase() + 's';
|
|
118
|
+
modelRegistry.set(this, {
|
|
119
|
+
tableName,
|
|
120
|
+
attributes,
|
|
121
|
+
associations: [],
|
|
122
|
+
});
|
|
123
|
+
// Store on the class for easy access
|
|
124
|
+
(this as any)._tableName = tableName;
|
|
125
|
+
(this as any)._attributes = attributes;
|
|
126
|
+
|
|
127
|
+
// Store scopes
|
|
128
|
+
if (options.scopes) this._scopes = options.scopes;
|
|
129
|
+
if (options.defaultScope) this._defaultScope = options.defaultScope;
|
|
130
|
+
|
|
131
|
+
// Register hooks from init options (e.g., Invoice has afterCreate/afterUpdate in options.hooks)
|
|
132
|
+
if (options.hooks) {
|
|
133
|
+
for (const [hookType, fn] of Object.entries(options.hooks)) {
|
|
134
|
+
if (typeof fn === 'function') {
|
|
135
|
+
this.addHook(hookType, `init_${hookType}`, fn as Function);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Store reference to sequelize instance passed in options
|
|
141
|
+
if (options.sequelize) {
|
|
142
|
+
_sequelizeInstance = options.sequelize;
|
|
143
|
+
// Register this model in sequelize.models (which points to sharedModels)
|
|
144
|
+
if (_sequelizeInstance.models) {
|
|
145
|
+
_sequelizeInstance.models[options.modelName || this.name] = this;
|
|
146
|
+
}
|
|
147
|
+
// Also register directly in sharedModels as safety net
|
|
148
|
+
sharedModels[options.modelName || this.name] = this;
|
|
149
|
+
// Set class-level sequelize reference
|
|
150
|
+
(this as any)._sequelizeInstance = options.sequelize;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Set class-level sequelize property (used by instance methods as this.sequelize)
|
|
154
|
+
Object.defineProperty(this, 'sequelize', {
|
|
155
|
+
get: () => _sequelizeInstance || { query: d1Query, models: sharedModels },
|
|
156
|
+
configurable: true,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static associate(_models: any) {
|
|
163
|
+
// Subclasses override this to call hasOne/hasMany/belongsTo
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sequelize association methods (called during associate())
|
|
167
|
+
static hasOne(target: any, options?: any) {
|
|
168
|
+
registerAssociation(this, 'hasOne', target, options);
|
|
169
|
+
}
|
|
170
|
+
static hasMany(target: any, options?: any) {
|
|
171
|
+
registerAssociation(this, 'hasMany', target, options);
|
|
172
|
+
}
|
|
173
|
+
static belongsTo(target: any, options?: any) {
|
|
174
|
+
registerAssociation(this, 'belongsTo', target, options);
|
|
175
|
+
}
|
|
176
|
+
static belongsToMany(target: any, options?: any) {
|
|
177
|
+
registerAssociation(this, 'belongsToMany', target, options);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Scopes — Sequelize Model.scope('name') returns a scoped model
|
|
181
|
+
// For simplicity, we return `this` (all columns) since D1 doesn't need column filtering
|
|
182
|
+
static _scopes: Record<string, any> = {};
|
|
183
|
+
static _defaultScope: any = {};
|
|
184
|
+
|
|
185
|
+
static scope(_scopeName?: string | string[]): typeof Model {
|
|
186
|
+
// Return this class itself — scope filtering is a no-op in CF Workers
|
|
187
|
+
// The query will return all columns regardless
|
|
188
|
+
return this;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
static unscoped(): typeof Model {
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Hook system — uses a global registry keyed by class reference to avoid
|
|
196
|
+
// prototype chain sharing issues with static properties
|
|
197
|
+
static _hooks: Map<string, Map<string, Function>> = new Map();
|
|
198
|
+
|
|
199
|
+
static addHook(hookType: string, name: string, fn: Function) {
|
|
200
|
+
const classHooks = _hooksRegistry.get(this) || new Map();
|
|
201
|
+
if (!classHooks.has(hookType)) classHooks.set(hookType, new Map());
|
|
202
|
+
classHooks.get(hookType)!.set(name, fn);
|
|
203
|
+
_hooksRegistry.set(this, classHooks);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
static removeHook(hookType: string, name: string) {
|
|
207
|
+
_hooksRegistry.get(this)?.get(hookType)?.delete(name);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Stack of async trackers for nested _runHooks calls.
|
|
211
|
+
// Hooks often call createEvent(...).catch() which is fire-and-forget — the async
|
|
212
|
+
// work (Event.create → events.emit) would complete after the request returns.
|
|
213
|
+
// We track Model.create() calls made during hook execution and await them,
|
|
214
|
+
// so events.emit fires within the request lifecycle.
|
|
215
|
+
static _hookAsyncStack: Promise<any>[][] = [];
|
|
216
|
+
|
|
217
|
+
static async _runHooks(hookType: string, ...args: any[]) {
|
|
218
|
+
const classHooks = _hooksRegistry.get(this);
|
|
219
|
+
if (!classHooks) return;
|
|
220
|
+
const hooks = classHooks.get(hookType);
|
|
221
|
+
if (!hooks || hooks.size === 0) return;
|
|
222
|
+
|
|
223
|
+
// Push a tracking frame — any Model.create/update during this hook
|
|
224
|
+
// will push its promise here (see create/update methods).
|
|
225
|
+
Model._hookAsyncStack.push([]);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
for (const [name, fn] of hooks) {
|
|
229
|
+
try {
|
|
230
|
+
const result = fn(...args);
|
|
231
|
+
if (result && typeof result.then === 'function') {
|
|
232
|
+
await result;
|
|
233
|
+
}
|
|
234
|
+
} catch (err: any) {
|
|
235
|
+
console.error(`[sequelize-d1] Hook ${hookType}:${name} error:`, err?.message || err);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Await all floating async work (e.g. createEvent → Event.create) started
|
|
240
|
+
// during this hook's synchronous execution. When these resolve, the
|
|
241
|
+
// continuations (events.emit, addToBatch, etc.) run before we return.
|
|
242
|
+
const tracked = Model._hookAsyncStack[Model._hookAsyncStack.length - 1];
|
|
243
|
+
if (tracked && tracked.length > 0) {
|
|
244
|
+
await Promise.allSettled(tracked);
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
Model._hookAsyncStack.pop();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Query methods ---
|
|
252
|
+
|
|
253
|
+
static get tableName(): string {
|
|
254
|
+
return (this as any)._tableName || this.name.toLowerCase() + 's';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Instance-level 'sequelize' property (for this.sequelize.models, this.sequelize.query)
|
|
258
|
+
get sequelize(): any {
|
|
259
|
+
return _sequelizeInstance || { query: d1Query, models: sharedModels };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
static async findAll(options?: any): Promise<any[]> {
|
|
263
|
+
// Check isolate cache for rarely-changing tables (skip if includes — those need fresh resolution)
|
|
264
|
+
const cacheKey = !options?.include?.length ? getIsolateCacheKey(this.tableName, 'findAll', options) : null;
|
|
265
|
+
if (cacheKey) {
|
|
266
|
+
const cached = _isolateCache.get(cacheKey);
|
|
267
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
268
|
+
return cached.data.map((r: any) => createInstance(this, { ...r }));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const { sql, values } = buildSelectSQL(this.tableName, options, (this as any)._attributes);
|
|
273
|
+
const _t0 = performance.now();
|
|
274
|
+
const result = await getDB().prepare(sql).bind(...values).all();
|
|
275
|
+
recordQuery(result.meta, performance.now() - _t0);
|
|
276
|
+
const rows = (result.results || []).map((r: any) => createInstance(this, r));
|
|
277
|
+
|
|
278
|
+
// Handle include (eager loading via separate queries)
|
|
279
|
+
if (options?.include?.length) {
|
|
280
|
+
await resolveIncludes(rows, options.include, this);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Apply attribute exclusions (e.g. { attributes: { exclude: ['data', 'request'] } })
|
|
284
|
+
const excl = options?.attributes?.exclude;
|
|
285
|
+
if (Array.isArray(excl) && excl.length > 0) {
|
|
286
|
+
for (const row of rows) {
|
|
287
|
+
for (const field of excl) {
|
|
288
|
+
delete row[field];
|
|
289
|
+
delete row.dataValues[field];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Store in isolate cache (raw data, not instances — instances get fresh dataValues on cache hit)
|
|
295
|
+
if (cacheKey) {
|
|
296
|
+
_isolateCache.set(cacheKey, {
|
|
297
|
+
data: rows.map((r: any) => ({ ...r.dataValues })),
|
|
298
|
+
expiresAt: Date.now() + ISOLATE_CACHE_TTL_MS,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return rows;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
static async findOne(options?: any): Promise<any | null> {
|
|
306
|
+
const results = await this.findAll({ ...options, limit: 1 });
|
|
307
|
+
return results[0] || null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
static async findByPk(id: string | number, options?: any): Promise<any | null> {
|
|
311
|
+
if (!id) return null;
|
|
312
|
+
return this.findOne({ ...options, where: { ...(options?.where || {}), id } });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
static async findAndCountAll(options?: any): Promise<{ rows: any[]; count: number }> {
|
|
316
|
+
// Batch SELECT + COUNT into a single D1 round-trip
|
|
317
|
+
const { sql: selectSql, values: selectValues } = buildSelectSQL(this.tableName, options, (this as any)._attributes);
|
|
318
|
+
const countWhere = buildWhereClause(options?.where || {});
|
|
319
|
+
const countWhereClause = countWhere.sql ? ` WHERE ${countWhere.sql}` : '';
|
|
320
|
+
const countSql = `SELECT COUNT(*) as count FROM "${this.tableName}"${countWhereClause}`;
|
|
321
|
+
|
|
322
|
+
const db = getDB();
|
|
323
|
+
const _t0 = performance.now();
|
|
324
|
+
const [countResult, selectResult] = await db.batch([
|
|
325
|
+
db.prepare(countSql).bind(...countWhere.values),
|
|
326
|
+
db.prepare(selectSql).bind(...selectValues),
|
|
327
|
+
]);
|
|
328
|
+
recordBatch([countResult, selectResult], performance.now() - _t0);
|
|
329
|
+
const count = (countResult as any).results?.[0]?.count || 0;
|
|
330
|
+
const rows = ((selectResult as any).results || []).map((r: any) => createInstance(this, r));
|
|
331
|
+
|
|
332
|
+
// Handle include (eager loading via separate queries)
|
|
333
|
+
if (options?.include?.length) {
|
|
334
|
+
await resolveIncludes(rows, options.include, this);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Apply attribute exclusions
|
|
338
|
+
const excl = options?.attributes?.exclude;
|
|
339
|
+
if (Array.isArray(excl) && excl.length > 0) {
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
for (const field of excl) {
|
|
342
|
+
delete row[field];
|
|
343
|
+
delete row.dataValues[field];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { rows, count };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
static create(data: any, _options?: any): Promise<any> {
|
|
352
|
+
const promise = this._createImpl(data, _options);
|
|
353
|
+
// Track in parent hook's async frame — captures fire-and-forget create() calls
|
|
354
|
+
// (e.g. createEvent → Event.create) so _runHooks can await them.
|
|
355
|
+
// This ensures events.emit fires before the parent create() returns,
|
|
356
|
+
// which is critical for business event chains (credit consume, auto-recharge, etc.)
|
|
357
|
+
const stack = Model._hookAsyncStack;
|
|
358
|
+
if (stack && stack.length > 0) {
|
|
359
|
+
stack[stack.length - 1].push(promise);
|
|
360
|
+
}
|
|
361
|
+
return promise;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
static async _createImpl(data: any, _options?: any): Promise<any> {
|
|
365
|
+
if (CACHEABLE_TABLES.has(this.tableName)) invalidateIsolateCache(this.tableName);
|
|
366
|
+
const attrs = Object.keys((this as any)._attributes || {});
|
|
367
|
+
const fields: string[] = [];
|
|
368
|
+
const placeholders: string[] = [];
|
|
369
|
+
const values: any[] = [];
|
|
370
|
+
|
|
371
|
+
// Filter out columns that don't exist in D1 to avoid "no such column" errors
|
|
372
|
+
const d1Cols = await getD1Columns(this.tableName);
|
|
373
|
+
|
|
374
|
+
for (const key of attrs) {
|
|
375
|
+
// Skip columns not present in D1 table (migration not applied)
|
|
376
|
+
if (d1Cols.size > 0 && !d1Cols.has(key)) continue;
|
|
377
|
+
|
|
378
|
+
const attrDef = (this as any)._attributes[key];
|
|
379
|
+
let val = data[key];
|
|
380
|
+
|
|
381
|
+
// Apply defaultValue if not provided
|
|
382
|
+
if (val === undefined && attrDef?.defaultValue !== undefined) {
|
|
383
|
+
val = typeof attrDef.defaultValue === 'function' ? attrDef.defaultValue() : attrDef.defaultValue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// CF Workers: DataTypes.NOW is a static placeholder captured at module load (Date.now()=0).
|
|
387
|
+
// Replace with actual request-time timestamp for DATE fields.
|
|
388
|
+
if (val === 'CF_NOW_PLACEHOLDER' || (val instanceof Date && val.getTime() === 0)) {
|
|
389
|
+
val = new Date().toISOString();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (val !== undefined) {
|
|
393
|
+
fields.push(`"${key}"`);
|
|
394
|
+
placeholders.push('?');
|
|
395
|
+
values.push(serializeValue(val, attrDef, key));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (fields.length === 0) return createInstance(this, data);
|
|
400
|
+
|
|
401
|
+
const db = getDB();
|
|
402
|
+
const insertSql = `INSERT INTO "${this.tableName}" (${fields.join(', ')}) VALUES (${placeholders.join(', ')})`;
|
|
403
|
+
|
|
404
|
+
// Batch INSERT + SELECT into one D1 round-trip when we know the ID
|
|
405
|
+
let created;
|
|
406
|
+
if (data.id) {
|
|
407
|
+
const selectSql = `SELECT * FROM "${this.tableName}" WHERE "id" = ?`;
|
|
408
|
+
const _t0 = performance.now();
|
|
409
|
+
const batchResult = await db.batch([
|
|
410
|
+
db.prepare(insertSql).bind(...values),
|
|
411
|
+
db.prepare(selectSql).bind(data.id),
|
|
412
|
+
]);
|
|
413
|
+
recordBatch(batchResult, performance.now() - _t0);
|
|
414
|
+
const row = batchResult[1].results?.[0];
|
|
415
|
+
created = row ? createInstance(this, row) : createInstance(this, data);
|
|
416
|
+
} else {
|
|
417
|
+
// Batch INSERT + SELECT by last_insert_rowid() into one D1 round-trip
|
|
418
|
+
const _t0 = performance.now();
|
|
419
|
+
const batchResult = await db.batch([
|
|
420
|
+
db.prepare(insertSql).bind(...values),
|
|
421
|
+
db.prepare(`SELECT * FROM "${this.tableName}" WHERE rowid = last_insert_rowid()`),
|
|
422
|
+
]);
|
|
423
|
+
recordBatch(batchResult, performance.now() - _t0);
|
|
424
|
+
const row = (batchResult[1] as any).results?.[0];
|
|
425
|
+
created = row ? createInstance(this, row) : createInstance(this, data);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Fire afterCreate hooks (must run for ALL code paths)
|
|
429
|
+
await this._runHooks('afterCreate', created, _options || {});
|
|
430
|
+
return created;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
static async update(data: any, options: any): Promise<[number]> {
|
|
434
|
+
if (CACHEABLE_TABLES.has(this.tableName)) invalidateIsolateCache(this.tableName);
|
|
435
|
+
const setClauses: string[] = [];
|
|
436
|
+
const values: any[] = [];
|
|
437
|
+
const attrs = (this as any)._attributes || {};
|
|
438
|
+
|
|
439
|
+
// Auto-set updated_at if the model has it
|
|
440
|
+
if (attrs.updated_at && data.updated_at === undefined) {
|
|
441
|
+
data.updated_at = new Date().toISOString();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Filter out columns that don't exist in D1 to avoid "no such column" errors
|
|
445
|
+
const d1Cols = await getD1Columns(this.tableName);
|
|
446
|
+
|
|
447
|
+
for (const [key, value] of Object.entries(data)) {
|
|
448
|
+
// Skip columns not present in D1 table
|
|
449
|
+
if (d1Cols.size > 0 && !d1Cols.has(key)) continue;
|
|
450
|
+
|
|
451
|
+
setClauses.push(`"${key}" = ?`);
|
|
452
|
+
values.push(serializeValue(value, attrs[key], key));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const where = buildWhereClause(options?.where || {});
|
|
456
|
+
values.push(...where.values);
|
|
457
|
+
|
|
458
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
459
|
+
const sql = `UPDATE "${this.tableName}" SET ${setClauses.join(', ')}${whereClause}`;
|
|
460
|
+
const _t0 = performance.now();
|
|
461
|
+
const result = await getDB().prepare(sql).bind(...values).run();
|
|
462
|
+
recordQuery(result.meta, performance.now() - _t0);
|
|
463
|
+
const changes = result.meta?.changes ?? result.changes ?? 0;
|
|
464
|
+
|
|
465
|
+
return [changes];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
static async destroy(options: any): Promise<number> {
|
|
469
|
+
if (CACHEABLE_TABLES.has(this.tableName)) invalidateIsolateCache(this.tableName);
|
|
470
|
+
const where = buildWhereClause(options?.where || {});
|
|
471
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
472
|
+
const sql = `DELETE FROM "${this.tableName}"${whereClause}`;
|
|
473
|
+
const _t0 = performance.now();
|
|
474
|
+
const result = await getDB().prepare(sql).bind(...where.values).run();
|
|
475
|
+
recordQuery(result.meta, performance.now() - _t0);
|
|
476
|
+
return result.meta?.changes ?? result.changes ?? 0;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
static async count(options?: any): Promise<number> {
|
|
480
|
+
const where = buildWhereClause(options?.where || {});
|
|
481
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
482
|
+
const sql = `SELECT COUNT(*) as count FROM "${this.tableName}"${whereClause}`;
|
|
483
|
+
const _t0 = performance.now();
|
|
484
|
+
const result = await getDB().prepare(sql).bind(...where.values).first();
|
|
485
|
+
recordQuery(null, performance.now() - _t0); // .first() has no meta
|
|
486
|
+
return (result as any)?.count || 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Static increment — e.g. Price.increment({ quantity_sold: 1 }, { where: { id } })
|
|
490
|
+
static async increment(fields: string | Record<string, number>, options?: any): Promise<any> {
|
|
491
|
+
const setClauses: string[] = [];
|
|
492
|
+
const values: any[] = [];
|
|
493
|
+
|
|
494
|
+
if (typeof fields === 'string') {
|
|
495
|
+
const by = options?.by ?? 1;
|
|
496
|
+
setClauses.push(`"${fields}" = "${fields}" + ?`);
|
|
497
|
+
values.push(by);
|
|
498
|
+
} else {
|
|
499
|
+
for (const [field, amount] of Object.entries(fields)) {
|
|
500
|
+
setClauses.push(`"${field}" = "${field}" + ?`);
|
|
501
|
+
values.push(amount);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const where = buildWhereClause(options?.where || {});
|
|
506
|
+
values.push(...where.values);
|
|
507
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
508
|
+
const sql = `UPDATE "${this.tableName}" SET ${setClauses.join(', ')}${whereClause}`;
|
|
509
|
+
const _t0 = performance.now();
|
|
510
|
+
const result = await getDB().prepare(sql).bind(...values).run();
|
|
511
|
+
recordQuery(result.meta, performance.now() - _t0);
|
|
512
|
+
return this;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Static decrement
|
|
516
|
+
static async decrement(fields: string | Record<string, number>, options?: any): Promise<any> {
|
|
517
|
+
const setClauses: string[] = [];
|
|
518
|
+
const values: any[] = [];
|
|
519
|
+
|
|
520
|
+
if (typeof fields === 'string') {
|
|
521
|
+
const by = options?.by ?? 1;
|
|
522
|
+
setClauses.push(`"${fields}" = "${fields}" - ?`);
|
|
523
|
+
values.push(by);
|
|
524
|
+
} else {
|
|
525
|
+
for (const [field, amount] of Object.entries(fields)) {
|
|
526
|
+
setClauses.push(`"${field}" = "${field}" - ?`);
|
|
527
|
+
values.push(amount);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const where = buildWhereClause(options?.where || {});
|
|
532
|
+
values.push(...where.values);
|
|
533
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
534
|
+
const sql = `UPDATE "${this.tableName}" SET ${setClauses.join(', ')}${whereClause}`;
|
|
535
|
+
const _t0d = performance.now();
|
|
536
|
+
const resultD = await getDB().prepare(sql).bind(...values).run();
|
|
537
|
+
recordQuery(resultD.meta, performance.now() - _t0d);
|
|
538
|
+
return this;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
static async bulkCreate(records: any[], options?: any): Promise<any[]> {
|
|
542
|
+
if (records.length === 0) return [];
|
|
543
|
+
const results: any[] = [];
|
|
544
|
+
// D1 supports batch but for simplicity do sequential
|
|
545
|
+
// For production: use db.batch([...stmts])
|
|
546
|
+
for (const data of records) {
|
|
547
|
+
results.push(await this.create(data, options));
|
|
548
|
+
}
|
|
549
|
+
return results;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
static async max(field: string, options?: any): Promise<any> {
|
|
553
|
+
const where = buildWhereClause(options?.where || {});
|
|
554
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
555
|
+
const sql = `SELECT MAX("${field}") as val FROM "${this.tableName}"${whereClause}`;
|
|
556
|
+
const result = await getDB().prepare(sql).bind(...where.values).first();
|
|
557
|
+
return (result as any)?.val;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
static async sum(field: string, options?: any): Promise<number> {
|
|
561
|
+
const where = buildWhereClause(options?.where || {});
|
|
562
|
+
const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
|
|
563
|
+
const sql = `SELECT SUM("${field}") as val FROM "${this.tableName}"${whereClause}`;
|
|
564
|
+
const result = await getDB().prepare(sql).bind(...where.values).first();
|
|
565
|
+
return (result as any)?.val || 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// --- Instance methods ---
|
|
569
|
+
|
|
570
|
+
async update(data: any, _options?: any): Promise<this> {
|
|
571
|
+
const ModelClass = this.constructor as typeof Model;
|
|
572
|
+
// Track previous values for hooks
|
|
573
|
+
this._previousDataValues = { ...this.dataValues };
|
|
574
|
+
await ModelClass.update(data, { where: { id: this.id } });
|
|
575
|
+
Object.assign(this, data);
|
|
576
|
+
this.dataValues = { ...this.dataValues, ...data };
|
|
577
|
+
|
|
578
|
+
// Fire afterUpdate hooks directly on this instance
|
|
579
|
+
// Sequelize passes options.fields (list of updated field names) to hooks
|
|
580
|
+
const hookOptions = { ...(_options || {}), fields: Object.keys(data) };
|
|
581
|
+
try {
|
|
582
|
+
await (ModelClass as any)._runHooks('afterUpdate', this, hookOptions);
|
|
583
|
+
} catch (e: any) {
|
|
584
|
+
console.error('[sequelize-d1] afterUpdate hook error:', e?.message);
|
|
585
|
+
}
|
|
586
|
+
return this;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async destroy(_options?: any): Promise<void> {
|
|
590
|
+
const ModelClass = this.constructor as typeof Model;
|
|
591
|
+
await ModelClass.destroy({ where: { id: this.id } });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async reload(_options?: any): Promise<this> {
|
|
595
|
+
const ModelClass = this.constructor as typeof Model;
|
|
596
|
+
const fresh = await ModelClass.findByPk(this.id);
|
|
597
|
+
if (fresh) Object.assign(this, fresh);
|
|
598
|
+
return this;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async save(_options?: any): Promise<this> {
|
|
602
|
+
const ModelClass = this.constructor as typeof Model;
|
|
603
|
+
const attrs = (ModelClass as any)._attributes || {};
|
|
604
|
+
const data: any = {};
|
|
605
|
+
for (const key of Object.keys(attrs)) {
|
|
606
|
+
if ((this as any)[key] !== undefined) {
|
|
607
|
+
data[key] = (this as any)[key];
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (this.id) {
|
|
611
|
+
await ModelClass.update(data, { where: { id: this.id } });
|
|
612
|
+
} else {
|
|
613
|
+
const created = await ModelClass.create(data);
|
|
614
|
+
Object.assign(this, created);
|
|
615
|
+
}
|
|
616
|
+
return this;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async increment(field: string | Record<string, number>, options?: any): Promise<this> {
|
|
620
|
+
const ModelClass = this.constructor as typeof Model;
|
|
621
|
+
const by = options?.by ?? 1;
|
|
622
|
+
if (typeof field === 'string') {
|
|
623
|
+
const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${field}" = "${field}" + ? WHERE "id" = ?`;
|
|
624
|
+
await getDB().prepare(sql).bind(by, this.id).run();
|
|
625
|
+
} else {
|
|
626
|
+
for (const [f, amount] of Object.entries(field)) {
|
|
627
|
+
const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${f}" = "${f}" + ? WHERE "id" = ?`;
|
|
628
|
+
await getDB().prepare(sql).bind(amount, this.id).run();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return this.reload();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async decrement(field: string | Record<string, number>, options?: any): Promise<this> {
|
|
635
|
+
const ModelClass = this.constructor as typeof Model;
|
|
636
|
+
const by = options?.by ?? 1;
|
|
637
|
+
if (typeof field === 'string') {
|
|
638
|
+
const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${field}" = "${field}" - ? WHERE "id" = ?`;
|
|
639
|
+
await getDB().prepare(sql).bind(by, this.id).run();
|
|
640
|
+
} else {
|
|
641
|
+
for (const [f, amount] of Object.entries(field)) {
|
|
642
|
+
const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${f}" = "${f}" - ? WHERE "id" = ?`;
|
|
643
|
+
await getDB().prepare(sql).bind(amount, this.id).run();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return this.reload();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
toJSON(): any {
|
|
650
|
+
const data: any = { ...this.dataValues };
|
|
651
|
+
// Overlay with direct instance properties — mirrors real Sequelize behavior where
|
|
652
|
+
// property assignments (e.g. doc.line_items = await Price.expand(...)) sync to dataValues
|
|
653
|
+
// via setters. Our shim lacks setters, so toJSON must prefer instance properties.
|
|
654
|
+
for (const key of Object.keys(this)) {
|
|
655
|
+
if (key === 'dataValues' || key === '_previousDataValues' || key === '_changed') continue;
|
|
656
|
+
const value = (this as any)[key];
|
|
657
|
+
if (typeof value === 'function') continue;
|
|
658
|
+
data[key] = value;
|
|
659
|
+
}
|
|
660
|
+
return data;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
get(field?: string): any {
|
|
664
|
+
if (field !== undefined) return (this as any)[field];
|
|
665
|
+
return this.toJSON();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
getDataValue(field: string): any {
|
|
669
|
+
return (this as any)[field];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
setDataValue(field: string, value: any): void {
|
|
673
|
+
(this as any)[field] = value;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Changed flag — simple stub for compatibility
|
|
677
|
+
changed(field?: string): boolean | string[] {
|
|
678
|
+
if (!this._previousDataValues) return false;
|
|
679
|
+
if (field) {
|
|
680
|
+
return this._previousDataValues[field] !== this.dataValues[field];
|
|
681
|
+
}
|
|
682
|
+
// Return list of changed fields
|
|
683
|
+
const changed: string[] = [];
|
|
684
|
+
for (const key of Object.keys(this.dataValues)) {
|
|
685
|
+
if (this._previousDataValues[key] !== this.dataValues[key]) {
|
|
686
|
+
changed.push(key);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return changed.length > 0 ? changed : false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
previous(field?: string): any {
|
|
693
|
+
if (!this._previousDataValues) return undefined;
|
|
694
|
+
if (field) return this._previousDataValues[field];
|
|
695
|
+
return this._previousDataValues;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// --- Internal helpers ---
|
|
700
|
+
|
|
701
|
+
function createInstance(ModelClass: any, data: any): any {
|
|
702
|
+
const instance = new ModelClass(deserializeRow(data, ModelClass._attributes));
|
|
703
|
+
return instance;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function serializeValue(value: any, attrDef?: any, fieldName?: string): any {
|
|
707
|
+
if (value === null || value === undefined) return null;
|
|
708
|
+
if (value instanceof Date) return value.toISOString();
|
|
709
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
710
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
711
|
+
// Prevent D1 from storing numbers as floats in TEXT columns
|
|
712
|
+
// Two checks: 1) column is TEXT/VARCHAR type, 2) field name matches amount patterns
|
|
713
|
+
if (typeof value === 'number') {
|
|
714
|
+
const isText = attrDef?.type === DataTypes.TEXT ||
|
|
715
|
+
(typeof attrDef?.type === 'object' && attrDef?.type?.type === 'TEXT');
|
|
716
|
+
const isAmount = fieldName ? isAmountField(fieldName) : false;
|
|
717
|
+
if (isText || isAmount) {
|
|
718
|
+
return String(Number.isInteger(value) ? value : value);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return value;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// JSON fields by DataType — used for deserialization
|
|
725
|
+
const JSON_TYPES = new Set(['JSON', 'JSONB']);
|
|
726
|
+
|
|
727
|
+
function isJsonField(attrDef: any, fieldName: string): boolean {
|
|
728
|
+
if (!attrDef) return false;
|
|
729
|
+
const type = attrDef.type;
|
|
730
|
+
if (!type) return false;
|
|
731
|
+
if (typeof type === 'string' && JSON_TYPES.has(type)) return true;
|
|
732
|
+
if (type === DataTypes.JSON || type === DataTypes.JSONB) return true;
|
|
733
|
+
if (typeof type === 'object') {
|
|
734
|
+
if (type.type === 'TEXT' && (type === DataTypes.JSON || type === DataTypes.JSONB)) return true;
|
|
735
|
+
// Check by type name
|
|
736
|
+
if (JSON_TYPES.has(type.type)) return false; // TEXT by itself isn't JSON
|
|
737
|
+
}
|
|
738
|
+
// Heuristic fallback for common JSON column names
|
|
739
|
+
const JSON_FIELD_PATTERNS = /metadata|images|features|vendor_config|cross_sell|currency_options|overdraft_protection|credit_config|token_balance|line_items|price_data|billing_address|customer_details|payment_details|setup_details|nft_config|vc_config|customer_dids|preference|recharge_config|vault_config|service_actions|staking_props|price_quote/;
|
|
740
|
+
return JSON_FIELD_PATTERNS.test(fieldName);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Amount/currency fields that must remain as strings for BN.js compatibility
|
|
744
|
+
const AMOUNT_FIELD_PATTERNS = /^(amount|total|subtotal|unit_amount|balance|share|minimum_payment_amount|maximum_payment_amount|remaining_amount|consumed_amount|credited_amount|max_pending_amount|min_stake_amount|billing_threshold_amount|amount_due|amount_paid|amount_remaining|amount_capturable|amount_received|amount_refunded|amount_discount|amount_shipping|amount_tax|base_amount)/;
|
|
745
|
+
function isAmountField(fieldName: string): boolean {
|
|
746
|
+
return AMOUNT_FIELD_PATTERNS.test(fieldName);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isBooleanField(attrDef: any, fieldName: string): boolean {
|
|
750
|
+
if (!attrDef) return false;
|
|
751
|
+
if (attrDef.type === DataTypes.BOOLEAN) return true;
|
|
752
|
+
// Heuristic: is_ prefix or known boolean fields
|
|
753
|
+
return fieldName.startsWith('is_') || ['active', 'livemode', 'locked', 'confirmed', 'usage_billed', 'active_now', 'collect_phone', 'collect_address'].includes(fieldName);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function deserializeRow(row: any, attributes?: Record<string, any>): any {
|
|
757
|
+
if (!row) return row;
|
|
758
|
+
const result: any = {};
|
|
759
|
+
for (const [key, value] of Object.entries(row)) {
|
|
760
|
+
const attrDef = attributes?.[key];
|
|
761
|
+
|
|
762
|
+
if (isJsonField(attrDef, key)) {
|
|
763
|
+
try {
|
|
764
|
+
result[key] = typeof value === 'string' ? JSON.parse(value as string) : value;
|
|
765
|
+
} catch {
|
|
766
|
+
result[key] = value;
|
|
767
|
+
}
|
|
768
|
+
} else if (isBooleanField(attrDef, key)) {
|
|
769
|
+
result[key] = value === 1 || value === true;
|
|
770
|
+
} else if (isAmountField(key) && value !== null && value !== undefined) {
|
|
771
|
+
// Amount fields must be strings for BN.js compatibility
|
|
772
|
+
// D1 may return numbers (50 or 50.0) or strings ("50.0")
|
|
773
|
+
let str = String(value);
|
|
774
|
+
// Remove trailing .0 (D1 float artifact)
|
|
775
|
+
if (/^\d+\.0$/.test(str)) str = str.slice(0, -2);
|
|
776
|
+
// Also handle "50.00" etc
|
|
777
|
+
if (/^\d+\.0+$/.test(str)) str = str.replace(/\.0+$/, '');
|
|
778
|
+
result[key] = str;
|
|
779
|
+
} else if (typeof value === 'number' && !Number.isInteger(value) && value === Math.floor(value)) {
|
|
780
|
+
// D1 returns integer values as floats (e.g., 50.0 instead of 50)
|
|
781
|
+
result[key] = Math.floor(value);
|
|
782
|
+
} else {
|
|
783
|
+
result[key] = value;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Convert dotted field path to SQLite json_extract
|
|
790
|
+
function fieldToSQL(field: string): string {
|
|
791
|
+
if (field.includes('.')) {
|
|
792
|
+
const parts = field.split('.');
|
|
793
|
+
const column = parts[0];
|
|
794
|
+
const jsonPath = '$.' + parts.slice(1).join('.');
|
|
795
|
+
return `json_extract("${column}", '${jsonPath}')`;
|
|
796
|
+
}
|
|
797
|
+
return `"${field}"`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function buildSelectSQL(
|
|
801
|
+
tableName: string,
|
|
802
|
+
options?: any,
|
|
803
|
+
_attributes?: Record<string, any>,
|
|
804
|
+
): { sql: string; values: any[] } {
|
|
805
|
+
const parts: string[] = [];
|
|
806
|
+
const values: any[] = [];
|
|
807
|
+
|
|
808
|
+
// SELECT — handle attributes in various forms
|
|
809
|
+
const attrClause = buildAttributesClause(options?.attributes);
|
|
810
|
+
parts.push(`SELECT ${attrClause} FROM "${tableName}"`);
|
|
811
|
+
|
|
812
|
+
// WHERE
|
|
813
|
+
const where = buildWhereClause(options?.where || {});
|
|
814
|
+
if (where.sql) {
|
|
815
|
+
parts.push(`WHERE ${where.sql}`);
|
|
816
|
+
values.push(...where.values);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// GROUP BY
|
|
820
|
+
if (options?.group) {
|
|
821
|
+
const groups = Array.isArray(options.group) ? options.group : [options.group];
|
|
822
|
+
parts.push(`GROUP BY ${groups.map((g: string) => `"${g}"`).join(', ')}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// HAVING
|
|
826
|
+
if (options?.having) {
|
|
827
|
+
const having = buildWhereClause(options.having);
|
|
828
|
+
if (having.sql) {
|
|
829
|
+
parts.push(`HAVING ${having.sql}`);
|
|
830
|
+
values.push(...having.values);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ORDER BY
|
|
835
|
+
if (options?.order) {
|
|
836
|
+
const orders = (options.order as any[]).map((o: any) => {
|
|
837
|
+
if (Array.isArray(o)) {
|
|
838
|
+
const col = typeof o[0] === 'object' ? helperToSQL(o[0]) : fieldToSQL(o[0]);
|
|
839
|
+
const dir = (o[1] || 'ASC').toUpperCase();
|
|
840
|
+
return `${col} ${dir}`;
|
|
841
|
+
}
|
|
842
|
+
if (typeof o === 'object') return helperToSQL(o);
|
|
843
|
+
return `"${o}"`;
|
|
844
|
+
});
|
|
845
|
+
parts.push(`ORDER BY ${orders.join(', ')}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// LIMIT / OFFSET
|
|
849
|
+
if (options?.limit != null) {
|
|
850
|
+
parts.push('LIMIT ?');
|
|
851
|
+
values.push(Number(options.limit));
|
|
852
|
+
}
|
|
853
|
+
if (options?.offset != null) {
|
|
854
|
+
parts.push('OFFSET ?');
|
|
855
|
+
values.push(Number(options.offset));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return { sql: parts.join(' '), values };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function buildAttributesClause(attributes: any): string {
|
|
862
|
+
if (!attributes) return '*';
|
|
863
|
+
|
|
864
|
+
// Array form: ['id', 'name'] or [[fn('COUNT', col('id')), 'count']]
|
|
865
|
+
if (Array.isArray(attributes)) {
|
|
866
|
+
if (attributes.length === 0) return '*';
|
|
867
|
+
const cols = attributes.map((a: any) => {
|
|
868
|
+
if (Array.isArray(a)) {
|
|
869
|
+
// [expr, alias]
|
|
870
|
+
return `${helperToSQL(a[0])} AS "${a[1]}"`;
|
|
871
|
+
}
|
|
872
|
+
if (typeof a === 'object') return helperToSQL(a);
|
|
873
|
+
return fieldToSQL(a);
|
|
874
|
+
});
|
|
875
|
+
return cols.join(', ');
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Object form: { exclude: ['col1'] } or { include: [[expr, alias]] }
|
|
879
|
+
if (typeof attributes === 'object') {
|
|
880
|
+
if (attributes.include) {
|
|
881
|
+
// Add extra computed columns to SELECT *
|
|
882
|
+
const extras = (attributes.include as any[]).map((a: any) => {
|
|
883
|
+
if (Array.isArray(a)) return `${helperToSQL(a[0])} AS "${a[1]}"`;
|
|
884
|
+
return helperToSQL(a);
|
|
885
|
+
});
|
|
886
|
+
return `*, ${extras.join(', ')}`;
|
|
887
|
+
}
|
|
888
|
+
// exclude: handled post-fetch (not SQL level for simplicity)
|
|
889
|
+
return '*';
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return '*';
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Apply attribute exclusions after fetch (for { exclude: [...] } form)
|
|
896
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
897
|
+
function applyAttributeExclusions(data: any, attributes: any): any {
|
|
898
|
+
if (!attributes || !attributes.exclude || !Array.isArray(attributes.exclude)) return data;
|
|
899
|
+
const result = { ...data };
|
|
900
|
+
for (const field of attributes.exclude) {
|
|
901
|
+
delete result[field];
|
|
902
|
+
}
|
|
903
|
+
return result;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// --- Include resolution ---
|
|
907
|
+
// Handles hasOne (single object), hasMany (array), belongsTo (single object)
|
|
908
|
+
|
|
909
|
+
async function resolveIncludes(rows: any[], includes: any[], SourceModel: any) {
|
|
910
|
+
if (!rows.length || !includes?.length) return;
|
|
911
|
+
|
|
912
|
+
const sourceInfo = modelRegistry.get(SourceModel);
|
|
913
|
+
|
|
914
|
+
// Resolve all includes in parallel — each include is an independent query
|
|
915
|
+
// (e.g., Customer, Meter, Subscription includes can all run concurrently).
|
|
916
|
+
// This reduces N sequential D1 round-trips to 1 parallel batch.
|
|
917
|
+
await Promise.all(
|
|
918
|
+
includes.filter(Boolean).map((inc) => {
|
|
919
|
+
if (inc.association && !inc.model) {
|
|
920
|
+
const assocInfo = sourceInfo?.associations.find((a: any) => a.as === inc.association);
|
|
921
|
+
if (assocInfo) {
|
|
922
|
+
return resolveOneInclude(rows, { ...inc, model: assocInfo.target, as: assocInfo.as, ...assocInfo }, SourceModel);
|
|
923
|
+
}
|
|
924
|
+
return Promise.resolve();
|
|
925
|
+
}
|
|
926
|
+
return resolveOneInclude(rows, inc, SourceModel);
|
|
927
|
+
})
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Batch IN-clause queries to avoid D1/SQLite bind parameter limits.
|
|
932
|
+
// D1 can fail with large IN lists (100+ values with additional WHERE params).
|
|
933
|
+
const IN_BATCH_SIZE = 100;
|
|
934
|
+
|
|
935
|
+
async function batchedInQuery(
|
|
936
|
+
targetTable: string,
|
|
937
|
+
keyColumn: string,
|
|
938
|
+
values: any[],
|
|
939
|
+
inc: any,
|
|
940
|
+
): Promise<any[]> {
|
|
941
|
+
if (values.length === 0) return [];
|
|
942
|
+
|
|
943
|
+
const db = getDB();
|
|
944
|
+
const allResults: any[] = [];
|
|
945
|
+
|
|
946
|
+
// Build extra WHERE/ORDER clauses once
|
|
947
|
+
let extraWhereSql = '';
|
|
948
|
+
const extraWhereValues: any[] = [];
|
|
949
|
+
if (inc.where) {
|
|
950
|
+
const incWhere = buildWhereClause(inc.where);
|
|
951
|
+
if (incWhere.sql) {
|
|
952
|
+
extraWhereSql = ` AND ${incWhere.sql}`;
|
|
953
|
+
extraWhereValues.push(...incWhere.values);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
let orderSql = '';
|
|
958
|
+
if (inc.order) {
|
|
959
|
+
const orderClauses = (inc.order as any[]).map((o: any) => {
|
|
960
|
+
if (Array.isArray(o)) return `"${o[0]}" ${o[1] || 'ASC'}`;
|
|
961
|
+
return typeof o === 'object' ? helperToSQL(o) : `"${o}"`;
|
|
962
|
+
});
|
|
963
|
+
orderSql = ` ORDER BY ${orderClauses.join(', ')}`;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Build prepared statements for all slices, then execute in a single db.batch() round-trip
|
|
967
|
+
const stmts: any[] = [];
|
|
968
|
+
for (let i = 0; i < values.length; i += IN_BATCH_SIZE) {
|
|
969
|
+
const slice = values.slice(i, i + IN_BATCH_SIZE);
|
|
970
|
+
const placeholders = slice.map(() => '?').join(', ');
|
|
971
|
+
const sql = `SELECT * FROM "${targetTable}" WHERE "${keyColumn}" IN (${placeholders})${extraWhereSql}${orderSql}`;
|
|
972
|
+
const bindValues = [...slice, ...extraWhereValues];
|
|
973
|
+
stmts.push(db.prepare(sql).bind(...bindValues));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (stmts.length === 1) {
|
|
977
|
+
const _t0 = performance.now();
|
|
978
|
+
const result = await stmts[0].all();
|
|
979
|
+
recordQuery(result.meta, performance.now() - _t0);
|
|
980
|
+
allResults.push(...(result.results || []));
|
|
981
|
+
} else {
|
|
982
|
+
const _t0 = performance.now();
|
|
983
|
+
const results = await db.batch(stmts);
|
|
984
|
+
recordBatch(results, performance.now() - _t0);
|
|
985
|
+
for (const result of results) {
|
|
986
|
+
allResults.push(...((result as any).results || []));
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Apply limit after collecting all batches (if specified)
|
|
991
|
+
if (inc.limit && allResults.length > inc.limit) {
|
|
992
|
+
return allResults.slice(0, inc.limit);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return allResults;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function resolveOneInclude(rows: any[], inc: any, SourceModel: any) {
|
|
999
|
+
const targetModel = inc.model;
|
|
1000
|
+
if (!targetModel) return;
|
|
1001
|
+
|
|
1002
|
+
const targetTable = targetModel._tableName || targetModel.tableName || targetModel.name.toLowerCase() + 's';
|
|
1003
|
+
const sourceInfo = modelRegistry.get(SourceModel);
|
|
1004
|
+
|
|
1005
|
+
// Find the association definition to get correct FK direction
|
|
1006
|
+
const assocDef = sourceInfo?.associations.find(
|
|
1007
|
+
(a) => a.target === targetModel && (!inc.as || a.as === inc.as),
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
const assocType = assocDef?.type || 'hasOne';
|
|
1011
|
+
const as = inc.as || assocDef?.as || targetModel.name.toLowerCase();
|
|
1012
|
+
|
|
1013
|
+
let sourceKey: string;
|
|
1014
|
+
let foreignKey: string;
|
|
1015
|
+
|
|
1016
|
+
if (assocType === 'belongsTo') {
|
|
1017
|
+
// Source has the FK pointing to Target's PK
|
|
1018
|
+
// e.g. SubscriptionItem belongsTo Subscription: SubscriptionItem.subscription_id -> Subscription.id
|
|
1019
|
+
foreignKey = inc.foreignKey || assocDef?.foreignKey || `${as}_id`;
|
|
1020
|
+
sourceKey = inc.sourceKey || assocDef?.sourceKey || 'id';
|
|
1021
|
+
|
|
1022
|
+
// Collect FK values from source rows
|
|
1023
|
+
const fkValues = [...new Set(rows.map((r) => r[foreignKey]).filter((v) => v != null))];
|
|
1024
|
+
if (fkValues.length === 0) {
|
|
1025
|
+
rows.forEach((r) => (r[as] = null));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Batched IN query to avoid D1 bind parameter limits
|
|
1030
|
+
const relResults = await batchedInQuery(targetTable, sourceKey, fkValues, inc);
|
|
1031
|
+
const relMap = new Map<string, any>();
|
|
1032
|
+
for (const r of relResults) {
|
|
1033
|
+
const instance = createInstance(targetModel, r);
|
|
1034
|
+
// Resolve nested includes if any
|
|
1035
|
+
if (inc.include?.length) {
|
|
1036
|
+
await resolveIncludes([instance], inc.include, targetModel);
|
|
1037
|
+
}
|
|
1038
|
+
relMap.set(String((r as any)[sourceKey]), instance);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
for (const row of rows) {
|
|
1042
|
+
const fkVal = row[foreignKey];
|
|
1043
|
+
row[as] = fkVal != null ? relMap.get(String(fkVal)) || null : null;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
} else if (assocType === 'hasMany') {
|
|
1047
|
+
// Source's PK is referenced by Target's FK
|
|
1048
|
+
// e.g. Invoice hasMany InvoiceItem: InvoiceItem.invoice_id -> Invoice.id
|
|
1049
|
+
sourceKey = inc.sourceKey || assocDef?.sourceKey || 'id';
|
|
1050
|
+
foreignKey = inc.foreignKey || assocDef?.foreignKey || `${SourceModel.name.toLowerCase()}_id`;
|
|
1051
|
+
|
|
1052
|
+
const sourceValues = [...new Set(rows.map((r) => r[sourceKey]).filter((v) => v != null))];
|
|
1053
|
+
if (sourceValues.length === 0) {
|
|
1054
|
+
rows.forEach((r) => (r[as] = []));
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Batched IN query to avoid D1 bind parameter limits
|
|
1059
|
+
const relResults = await batchedInQuery(targetTable, foreignKey, sourceValues, inc);
|
|
1060
|
+
|
|
1061
|
+
// Group results by foreignKey value
|
|
1062
|
+
const relMap = new Map<string, any[]>();
|
|
1063
|
+
for (const r of relResults) {
|
|
1064
|
+
const fkVal = String((r as any)[foreignKey]);
|
|
1065
|
+
if (!relMap.has(fkVal)) relMap.set(fkVal, []);
|
|
1066
|
+
const instance = createInstance(targetModel, r);
|
|
1067
|
+
relMap.get(fkVal)!.push(instance);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Resolve nested includes on all collected instances
|
|
1071
|
+
if (inc.include?.length) {
|
|
1072
|
+
const allInstances = Array.from(relMap.values()).flat();
|
|
1073
|
+
if (allInstances.length > 0) {
|
|
1074
|
+
await resolveIncludes(allInstances, inc.include, targetModel);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
for (const row of rows) {
|
|
1079
|
+
const srcVal = row[sourceKey];
|
|
1080
|
+
row[as] = srcVal != null ? relMap.get(String(srcVal)) || [] : [];
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
} else {
|
|
1084
|
+
// hasOne — Source's PK referenced by Target, but returns single result
|
|
1085
|
+
// OR: Source has a FK pointing to Target PK (using sourceKey on source, foreignKey on target)
|
|
1086
|
+
// e.g. Invoice hasOne PaymentCurrency: Invoice.currency_id -> PaymentCurrency.id
|
|
1087
|
+
// sourceKey: 'currency_id' (on Invoice), foreignKey: 'id' (on PaymentCurrency)
|
|
1088
|
+
sourceKey = inc.sourceKey || assocDef?.sourceKey || 'id';
|
|
1089
|
+
foreignKey = inc.foreignKey || assocDef?.foreignKey || 'id';
|
|
1090
|
+
|
|
1091
|
+
// The values we look up from source rows
|
|
1092
|
+
const lookupValues = [...new Set(rows.map((r) => r[sourceKey]).filter((v) => v != null))];
|
|
1093
|
+
if (lookupValues.length === 0) {
|
|
1094
|
+
rows.forEach((r) => (r[as] = inc.required ? undefined : null));
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Batched IN query to avoid D1 bind parameter limits
|
|
1099
|
+
const relResults = await batchedInQuery(targetTable, foreignKey, lookupValues, inc);
|
|
1100
|
+
|
|
1101
|
+
// Map by foreignKey value
|
|
1102
|
+
const relMap = new Map<string, any>();
|
|
1103
|
+
for (const r of relResults) {
|
|
1104
|
+
const instance = createInstance(targetModel, r);
|
|
1105
|
+
if (inc.include?.length) {
|
|
1106
|
+
await resolveIncludes([instance], inc.include, targetModel);
|
|
1107
|
+
}
|
|
1108
|
+
// For hasOne using sourceKey->foreignKey pattern, key by the FK value
|
|
1109
|
+
relMap.set(String((r as any)[foreignKey]), instance);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
for (const row of rows) {
|
|
1113
|
+
const srcVal = row[sourceKey];
|
|
1114
|
+
row[as] = srcVal != null ? relMap.get(String(srcVal)) || null : null;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Apply attribute restrictions if specified on the include
|
|
1119
|
+
if (inc.attributes) {
|
|
1120
|
+
for (const row of rows) {
|
|
1121
|
+
if (row[as] && typeof row[as] === 'object') {
|
|
1122
|
+
if (Array.isArray(row[as])) {
|
|
1123
|
+
row[as] = row[as].map((item: any) => filterAttributes(item, inc.attributes));
|
|
1124
|
+
} else {
|
|
1125
|
+
row[as] = filterAttributes(row[as], inc.attributes);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function filterAttributes(instance: any, attributes: any): any {
|
|
1133
|
+
if (!attributes || !instance) return instance;
|
|
1134
|
+
if (Array.isArray(attributes)) {
|
|
1135
|
+
const result: any = {};
|
|
1136
|
+
for (const key of attributes) {
|
|
1137
|
+
if (key in instance) result[key] = instance[key];
|
|
1138
|
+
}
|
|
1139
|
+
return Object.assign(Object.create(Object.getPrototypeOf(instance)), result);
|
|
1140
|
+
}
|
|
1141
|
+
return instance;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function registerAssociation(source: any, type: string, target: any, options?: any) {
|
|
1145
|
+
const info = modelRegistry.get(source);
|
|
1146
|
+
if (info) {
|
|
1147
|
+
info.associations.push({ type: type as any, target, ...options });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function d1Query(sql: string, options?: any): Promise<any> {
|
|
1152
|
+
const result = await getDB().prepare(sql).all();
|
|
1153
|
+
if (options?.type === 'SELECT') {
|
|
1154
|
+
return result.results || [];
|
|
1155
|
+
}
|
|
1156
|
+
return [result.results || [], result.meta];
|
|
1157
|
+
}
|