@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a
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/.turbo/turbo-build.log +2 -2
- package/dist/bootstrap.js +16 -1
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
- package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +17 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/route.js +104 -8
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +67 -1
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/di.js +7 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
- package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
- package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
- package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/attachments/lib/quota-service.js +224 -0
- package/dist/modules/attachments/lib/quota-service.js.map +7 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
- package/dist/modules/attachments/workers/quota-recovery.js +45 -0
- package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
- package/dist/modules/auth/lib/setup-app.js +16 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/dashboards/services/widgetDataService.js +3 -4
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +2 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/cli.js +193 -19
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
- package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +15 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +10 -0
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/cli.js +125 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +19 -7
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +43 -37
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +137 -22
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +2 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/workflows.js +10 -7
- package/dist/modules/sales/workflows.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +27 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/attachment_quota_reservation/index.ts +15 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +17 -0
- package/package.json +7 -7
- package/src/bootstrap.ts +17 -2
- package/src/modules/attachments/api/route.ts +114 -7
- package/src/modules/attachments/data/entities.ts +59 -0
- package/src/modules/attachments/di.ts +9 -1
- package/src/modules/attachments/i18n/de.json +3 -0
- package/src/modules/attachments/i18n/en.json +3 -0
- package/src/modules/attachments/i18n/es.json +3 -0
- package/src/modules/attachments/i18n/pl.json +3 -0
- package/src/modules/attachments/lib/drivers/index.ts +1 -1
- package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
- package/src/modules/attachments/lib/drivers/types.ts +5 -0
- package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
- package/src/modules/attachments/lib/quota-service.ts +442 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
- package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
- package/src/modules/attachments/workers/quota-recovery.ts +68 -0
- package/src/modules/auth/lib/setup-app.ts +20 -2
- package/src/modules/dashboards/services/widgetDataService.ts +3 -6
- package/src/modules/entities/api/definitions.ts +4 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
- package/src/modules/entities/cli.ts +238 -21
- package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
- package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/messages/api/[id]/route.ts +21 -0
- package/src/modules/query_index/api/reindex.ts +15 -0
- package/src/modules/query_index/cli.ts +180 -1
- package/src/modules/query_index/lib/coverage.ts +38 -8
- package/src/modules/query_index/lib/reindexer.ts +62 -40
- package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
- package/src/modules/sales/commands/documents.ts +237 -29
- package/src/modules/sales/data/validators.ts +2 -0
- package/src/modules/sales/workflows.ts +10 -7
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/ko.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/lib/activity-executor.ts +40 -1
- package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
- package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { sql } from 'kysely'
|
|
4
|
+
import { AttachmentQuotaReservation } from '../data/entities'
|
|
5
|
+
import { resolveAttachmentTenantQuotaBytes } from './upload-limits'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_RESERVATION_TTL_MS = 15 * 60 * 1000
|
|
8
|
+
|
|
9
|
+
export type AttachmentQuotaSource =
|
|
10
|
+
| 'attachment'
|
|
11
|
+
| 'storage_s3_upload'
|
|
12
|
+
| 'storage_s3_signed'
|
|
13
|
+
| 'storage_service'
|
|
14
|
+
|
|
15
|
+
export type AttachmentQuotaReservationHandle = {
|
|
16
|
+
id: string
|
|
17
|
+
leaseToken: string
|
|
18
|
+
expiresAt: Date
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AttachmentQuotaReservationRecord = {
|
|
22
|
+
id: string
|
|
23
|
+
tenantId: string
|
|
24
|
+
organizationId: string
|
|
25
|
+
reservedBytes: number
|
|
26
|
+
actualBytes: number | null
|
|
27
|
+
status: 'reserved' | 'storing' | 'stored' | 'recovering' | 'committed'
|
|
28
|
+
source: AttachmentQuotaSource
|
|
29
|
+
storageDriver: string
|
|
30
|
+
partitionCode: string | null
|
|
31
|
+
storagePath: string
|
|
32
|
+
leaseToken: string
|
|
33
|
+
uploadTokenHash: string | null
|
|
34
|
+
expiresAt: Date | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type AttachmentQuotaReservationRow = {
|
|
38
|
+
id: string
|
|
39
|
+
tenant_id: string
|
|
40
|
+
organization_id: string
|
|
41
|
+
reserved_bytes: unknown
|
|
42
|
+
actual_bytes: unknown | null
|
|
43
|
+
status: AttachmentQuotaReservationRecord['status']
|
|
44
|
+
source: AttachmentQuotaSource
|
|
45
|
+
storage_driver: string
|
|
46
|
+
partition_code: string | null
|
|
47
|
+
storage_path: string
|
|
48
|
+
lease_token: string
|
|
49
|
+
upload_token_hash: string | null
|
|
50
|
+
expires_at: Date | string | null
|
|
51
|
+
created_at: Date
|
|
52
|
+
updated_at: Date
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type AttachmentUsageRow = {
|
|
56
|
+
id: string
|
|
57
|
+
tenant_id: string
|
|
58
|
+
storage_driver: string
|
|
59
|
+
storage_path: string
|
|
60
|
+
file_size: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type AttachmentQuotaDatabase = {
|
|
64
|
+
attachments: AttachmentUsageRow
|
|
65
|
+
attachment_quota_reservations: AttachmentQuotaReservationRow
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class AttachmentQuotaError extends Error {
|
|
69
|
+
constructor(
|
|
70
|
+
public readonly code:
|
|
71
|
+
| 'quota_exceeded'
|
|
72
|
+
| 'quota_accounting_unavailable'
|
|
73
|
+
| 'quota_target_exists'
|
|
74
|
+
| 'quota_lease_lost',
|
|
75
|
+
message: string,
|
|
76
|
+
) {
|
|
77
|
+
super(message)
|
|
78
|
+
this.name = 'AttachmentQuotaError'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseUsage(value: unknown): number {
|
|
83
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
|
84
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
85
|
+
const parsed = Number(value)
|
|
86
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
|
87
|
+
}
|
|
88
|
+
throw new AttachmentQuotaError('quota_accounting_unavailable', 'Storage quota accounting returned an invalid value.')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
92
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === '23505')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function changedRow(result: unknown): boolean {
|
|
96
|
+
if (Array.isArray(result)) return result.length > 0
|
|
97
|
+
if (!result || typeof result !== 'object') return false
|
|
98
|
+
if ('id' in result) return true
|
|
99
|
+
if ('numUpdatedRows' in result) return Number((result as { numUpdatedRows?: unknown }).numUpdatedRows ?? 0) > 0
|
|
100
|
+
if ('numDeletedRows' in result) return Number((result as { numDeletedRows?: unknown }).numDeletedRows ?? 0) > 0
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mapReservationRow(row: AttachmentQuotaReservationRow): AttachmentQuotaReservationRecord {
|
|
105
|
+
return {
|
|
106
|
+
id: String(row.id),
|
|
107
|
+
tenantId: String(row.tenant_id),
|
|
108
|
+
organizationId: String(row.organization_id),
|
|
109
|
+
reservedBytes: parseUsage(row.reserved_bytes),
|
|
110
|
+
actualBytes: row.actual_bytes == null ? null : parseUsage(row.actual_bytes),
|
|
111
|
+
status: row.status,
|
|
112
|
+
source: row.source,
|
|
113
|
+
storageDriver: String(row.storage_driver),
|
|
114
|
+
partitionCode: row.partition_code == null ? null : String(row.partition_code),
|
|
115
|
+
storagePath: String(row.storage_path),
|
|
116
|
+
leaseToken: String(row.lease_token),
|
|
117
|
+
uploadTokenHash: row.upload_token_hash == null ? null : String(row.upload_token_hash),
|
|
118
|
+
expiresAt: row.expires_at == null ? null : new Date(row.expires_at),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class AttachmentQuotaService {
|
|
123
|
+
constructor(private readonly em: EntityManager) {}
|
|
124
|
+
|
|
125
|
+
async reserve(input: {
|
|
126
|
+
tenantId: string
|
|
127
|
+
organizationId: string
|
|
128
|
+
bytes: number
|
|
129
|
+
source: AttachmentQuotaSource
|
|
130
|
+
storageDriver: string
|
|
131
|
+
storagePath: string
|
|
132
|
+
partitionCode?: string | null
|
|
133
|
+
uploadTokenHash?: string | null
|
|
134
|
+
ttlMs?: number
|
|
135
|
+
}): Promise<AttachmentQuotaReservationHandle> {
|
|
136
|
+
if (!Number.isSafeInteger(input.bytes) || input.bytes < 0) {
|
|
137
|
+
throw new AttachmentQuotaError('quota_accounting_unavailable', 'Reservation bytes must be a non-negative safe integer.')
|
|
138
|
+
}
|
|
139
|
+
const id = randomUUID()
|
|
140
|
+
const leaseToken = randomUUID()
|
|
141
|
+
const expiresAt = new Date(Date.now() + Math.max(1_000, input.ttlMs ?? DEFAULT_RESERVATION_TTL_MS))
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await this.em.transactional(async (tx) => {
|
|
145
|
+
const db = tx.getKysely<AttachmentQuotaDatabase>()
|
|
146
|
+
await sql`select pg_advisory_xact_lock(hashtextextended(${`attachment-quota:${input.tenantId}`}, 0))`.execute(db)
|
|
147
|
+
|
|
148
|
+
const attachmentUsage = await db
|
|
149
|
+
.selectFrom('attachments')
|
|
150
|
+
.select(sql<string>`coalesce(sum(file_size), 0)`.as('total_size'))
|
|
151
|
+
.where('tenant_id', '=', input.tenantId)
|
|
152
|
+
.executeTakeFirst()
|
|
153
|
+
const ledgerUsage = await db
|
|
154
|
+
.selectFrom('attachment_quota_reservations')
|
|
155
|
+
.select(
|
|
156
|
+
sql<string>`coalesce(sum(case when status = 'committed' then actual_bytes else reserved_bytes end), 0)`.as('total_size'),
|
|
157
|
+
)
|
|
158
|
+
.where('tenant_id', '=', input.tenantId)
|
|
159
|
+
.executeTakeFirst()
|
|
160
|
+
|
|
161
|
+
const usedBytes = parseUsage(attachmentUsage?.total_size) + parseUsage(ledgerUsage?.total_size)
|
|
162
|
+
if (usedBytes + input.bytes > resolveAttachmentTenantQuotaBytes()) {
|
|
163
|
+
throw new AttachmentQuotaError('quota_exceeded', 'Attachment storage quota exceeded for this tenant.')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const reservation = tx.create(AttachmentQuotaReservation, {
|
|
167
|
+
id,
|
|
168
|
+
tenantId: input.tenantId,
|
|
169
|
+
organizationId: input.organizationId,
|
|
170
|
+
reservedBytes: input.bytes,
|
|
171
|
+
actualBytes: null,
|
|
172
|
+
status: 'reserved',
|
|
173
|
+
source: input.source,
|
|
174
|
+
storageDriver: input.storageDriver,
|
|
175
|
+
partitionCode: input.partitionCode ?? null,
|
|
176
|
+
storagePath: input.storagePath,
|
|
177
|
+
leaseToken,
|
|
178
|
+
uploadTokenHash: input.uploadTokenHash ?? null,
|
|
179
|
+
expiresAt,
|
|
180
|
+
})
|
|
181
|
+
await tx.persist(reservation).flush()
|
|
182
|
+
})
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof AttachmentQuotaError) throw error
|
|
185
|
+
if (isUniqueViolation(error)) {
|
|
186
|
+
throw new AttachmentQuotaError('quota_target_exists', 'The target storage path already has an active allocation.')
|
|
187
|
+
}
|
|
188
|
+
throw new AttachmentQuotaError('quota_accounting_unavailable', 'Storage quota accounting is unavailable.')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { id, leaseToken, expiresAt }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async beginStorage(id: string, leaseToken: string, ttlMs = DEFAULT_RESERVATION_TTL_MS): Promise<void> {
|
|
195
|
+
await this.transition(id, leaseToken, 'reserved', 'storing', {
|
|
196
|
+
expires_at: new Date(Date.now() + Math.max(1_000, ttlMs)),
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async markStored(id: string, leaseToken: string): Promise<void> {
|
|
201
|
+
await this.transition(id, leaseToken, 'storing', 'stored')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async completeAttachment(id: string, leaseToken: string, manager: EntityManager = this.em): Promise<void> {
|
|
205
|
+
const db = manager.getKysely<AttachmentQuotaDatabase>()
|
|
206
|
+
const result = await db
|
|
207
|
+
.deleteFrom('attachment_quota_reservations')
|
|
208
|
+
.where('id', '=', id)
|
|
209
|
+
.where('lease_token', '=', leaseToken)
|
|
210
|
+
.where('status', '=', 'stored')
|
|
211
|
+
.returning('id')
|
|
212
|
+
.executeTakeFirst()
|
|
213
|
+
if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The upload reservation lease was lost.')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async completeStandalone(id: string, leaseToken: string, actualBytes: number): Promise<void> {
|
|
217
|
+
if (!Number.isSafeInteger(actualBytes) || actualBytes < 0) {
|
|
218
|
+
throw new AttachmentQuotaError('quota_accounting_unavailable', 'Actual bytes must be a non-negative safe integer.')
|
|
219
|
+
}
|
|
220
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
221
|
+
const result = await db
|
|
222
|
+
.updateTable('attachment_quota_reservations')
|
|
223
|
+
.set({ status: 'committed', actual_bytes: actualBytes, expires_at: null, upload_token_hash: null, updated_at: new Date() })
|
|
224
|
+
.where('id', '=', id)
|
|
225
|
+
.where('lease_token', '=', leaseToken)
|
|
226
|
+
.where('status', '=', 'stored')
|
|
227
|
+
.where('reserved_bytes', '>=', actualBytes)
|
|
228
|
+
.returning('id')
|
|
229
|
+
.executeTakeFirst()
|
|
230
|
+
if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The upload reservation lease was lost.')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async release(id: string, leaseToken?: string): Promise<void> {
|
|
234
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
235
|
+
let query = db
|
|
236
|
+
.deleteFrom('attachment_quota_reservations')
|
|
237
|
+
.where('id', '=', id)
|
|
238
|
+
.where('status', '!=', 'committed')
|
|
239
|
+
if (leaseToken) query = query.where('lease_token', '=', leaseToken)
|
|
240
|
+
await query.execute()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async releaseCommittedByPath(input: {
|
|
244
|
+
tenantId: string
|
|
245
|
+
storageDriver: string
|
|
246
|
+
storagePath: string
|
|
247
|
+
}): Promise<void> {
|
|
248
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
249
|
+
await db
|
|
250
|
+
.deleteFrom('attachment_quota_reservations')
|
|
251
|
+
.where('tenant_id', '=', input.tenantId)
|
|
252
|
+
.where('storage_driver', '=', input.storageDriver)
|
|
253
|
+
.where('storage_path', '=', input.storagePath)
|
|
254
|
+
.where('status', '=', 'committed')
|
|
255
|
+
.execute()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async findPendingByUploadTokenHash(uploadTokenHash: string): Promise<AttachmentQuotaReservationRecord | null> {
|
|
259
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
260
|
+
const row = await db
|
|
261
|
+
.selectFrom('attachment_quota_reservations')
|
|
262
|
+
.selectAll()
|
|
263
|
+
.where('upload_token_hash', '=', uploadTokenHash)
|
|
264
|
+
.where('status', '=', 'reserved')
|
|
265
|
+
.where('expires_at', '>', new Date())
|
|
266
|
+
.executeTakeFirst()
|
|
267
|
+
if (!row) return null
|
|
268
|
+
return mapReservationRow(row)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async claimPendingByUploadTokenHash(
|
|
272
|
+
uploadTokenHash: string,
|
|
273
|
+
ttlMs = DEFAULT_RESERVATION_TTL_MS,
|
|
274
|
+
): Promise<AttachmentQuotaReservationRecord | null> {
|
|
275
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
276
|
+
const leaseToken = randomUUID()
|
|
277
|
+
const row = await db
|
|
278
|
+
.updateTable('attachment_quota_reservations')
|
|
279
|
+
.set({
|
|
280
|
+
status: 'storing',
|
|
281
|
+
lease_token: leaseToken,
|
|
282
|
+
expires_at: new Date(Date.now() + Math.max(1_000, ttlMs)),
|
|
283
|
+
updated_at: new Date(),
|
|
284
|
+
})
|
|
285
|
+
.where('upload_token_hash', '=', uploadTokenHash)
|
|
286
|
+
.where('status', '=', 'reserved')
|
|
287
|
+
.where('expires_at', '>', new Date())
|
|
288
|
+
.returningAll()
|
|
289
|
+
.executeTakeFirst()
|
|
290
|
+
return row ? mapReservationRow(row) : null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async getReservation(
|
|
294
|
+
id: string,
|
|
295
|
+
scope: { tenantId: string; organizationId: string },
|
|
296
|
+
): Promise<AttachmentQuotaReservationRecord | null> {
|
|
297
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
298
|
+
const row = await db
|
|
299
|
+
.selectFrom('attachment_quota_reservations')
|
|
300
|
+
.selectAll()
|
|
301
|
+
.where('id', '=', id)
|
|
302
|
+
.where('tenant_id', '=', scope.tenantId)
|
|
303
|
+
.where('organization_id', '=', scope.organizationId)
|
|
304
|
+
.executeTakeFirst()
|
|
305
|
+
return row ? mapReservationRow(row) : null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async claimExpired(
|
|
309
|
+
id: string,
|
|
310
|
+
scope: { tenantId: string; organizationId: string },
|
|
311
|
+
recoveryTtlMs = DEFAULT_RESERVATION_TTL_MS,
|
|
312
|
+
): Promise<AttachmentQuotaReservationRecord | null> {
|
|
313
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
314
|
+
const leaseToken = randomUUID()
|
|
315
|
+
const row = await db
|
|
316
|
+
.updateTable('attachment_quota_reservations')
|
|
317
|
+
.set({
|
|
318
|
+
status: 'recovering',
|
|
319
|
+
lease_token: leaseToken,
|
|
320
|
+
expires_at: new Date(Date.now() + Math.max(1_000, recoveryTtlMs)),
|
|
321
|
+
updated_at: new Date(),
|
|
322
|
+
})
|
|
323
|
+
.where('id', '=', id)
|
|
324
|
+
.where('tenant_id', '=', scope.tenantId)
|
|
325
|
+
.where('organization_id', '=', scope.organizationId)
|
|
326
|
+
.where('status', 'in', ['reserved', 'storing', 'stored', 'recovering'])
|
|
327
|
+
.where('expires_at', '<=', new Date())
|
|
328
|
+
.returningAll()
|
|
329
|
+
.executeTakeFirst()
|
|
330
|
+
return row ? mapReservationRow(row) : null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async commitRecoveredStandalone(id: string, leaseToken: string, actualBytes: number): Promise<void> {
|
|
334
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
335
|
+
const result = await db
|
|
336
|
+
.updateTable('attachment_quota_reservations')
|
|
337
|
+
.set({ status: 'committed', actual_bytes: actualBytes, expires_at: null, upload_token_hash: null, updated_at: new Date() })
|
|
338
|
+
.where('id', '=', id)
|
|
339
|
+
.where('lease_token', '=', leaseToken)
|
|
340
|
+
.where('status', '=', 'recovering')
|
|
341
|
+
.where('reserved_bytes', '>=', actualBytes)
|
|
342
|
+
.returning('id')
|
|
343
|
+
.executeTakeFirst()
|
|
344
|
+
if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The recovery reservation lease was lost.')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async reconcileStandaloneObjects(input: {
|
|
348
|
+
tenantId: string
|
|
349
|
+
organizationId: string
|
|
350
|
+
storageDriver: string
|
|
351
|
+
objects: Array<{ path: string; bytes: number }>
|
|
352
|
+
}): Promise<void> {
|
|
353
|
+
const uniqueObjects = Array.from(new Map(input.objects.map((object) => [object.path, object])).values())
|
|
354
|
+
for (const object of uniqueObjects) {
|
|
355
|
+
if (!Number.isSafeInteger(object.bytes) || object.bytes < 0) {
|
|
356
|
+
throw new AttachmentQuotaError('quota_accounting_unavailable', 'Standalone storage accounting returned an invalid size.')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
360
|
+
for (let offset = 0; offset < uniqueObjects.length; offset += 500) {
|
|
361
|
+
const batch = uniqueObjects.slice(offset, offset + 500)
|
|
362
|
+
const paths = batch.map((object) => object.path)
|
|
363
|
+
if (paths.length === 0) continue
|
|
364
|
+
|
|
365
|
+
const attachmentRows = await db
|
|
366
|
+
.selectFrom('attachments')
|
|
367
|
+
.select('storage_path')
|
|
368
|
+
.where('tenant_id', '=', input.tenantId)
|
|
369
|
+
.where('storage_driver', '=', input.storageDriver)
|
|
370
|
+
.where('storage_path', 'in', paths)
|
|
371
|
+
.execute()
|
|
372
|
+
const attachmentPaths = new Set(attachmentRows.map((row) => String(row.storage_path)))
|
|
373
|
+
|
|
374
|
+
const ledgerRows = await db
|
|
375
|
+
.selectFrom('attachment_quota_reservations')
|
|
376
|
+
.select(['storage_path', 'status'])
|
|
377
|
+
.where('tenant_id', '=', input.tenantId)
|
|
378
|
+
.where('storage_driver', '=', input.storageDriver)
|
|
379
|
+
.where('storage_path', 'in', paths)
|
|
380
|
+
.execute()
|
|
381
|
+
const ledgerPaths = new Set(ledgerRows.map((row) => String(row.storage_path)))
|
|
382
|
+
|
|
383
|
+
const doubleCountedPaths = ledgerRows
|
|
384
|
+
.filter((row) => row.status === 'committed' && attachmentPaths.has(String(row.storage_path)))
|
|
385
|
+
.map((row) => String(row.storage_path))
|
|
386
|
+
if (doubleCountedPaths.length > 0) {
|
|
387
|
+
await db
|
|
388
|
+
.deleteFrom('attachment_quota_reservations')
|
|
389
|
+
.where('tenant_id', '=', input.tenantId)
|
|
390
|
+
.where('storage_driver', '=', input.storageDriver)
|
|
391
|
+
.where('storage_path', 'in', doubleCountedPaths)
|
|
392
|
+
.where('status', '=', 'committed')
|
|
393
|
+
.execute()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const object of batch) {
|
|
397
|
+
if (attachmentPaths.has(object.path) || ledgerPaths.has(object.path)) continue
|
|
398
|
+
await sql`
|
|
399
|
+
insert into attachment_quota_reservations (
|
|
400
|
+
id, tenant_id, organization_id, reserved_bytes, actual_bytes, status, source,
|
|
401
|
+
storage_driver, partition_code, storage_path, lease_token, upload_token_hash,
|
|
402
|
+
expires_at, created_at, updated_at
|
|
403
|
+
)
|
|
404
|
+
select ${randomUUID()}::uuid, ${input.tenantId}::uuid, ${input.organizationId}::uuid,
|
|
405
|
+
${object.bytes}::bigint, ${object.bytes}::bigint, 'committed', 'storage_s3_upload',
|
|
406
|
+
${input.storageDriver}, null, ${object.path}, ${randomUUID()}::uuid, null,
|
|
407
|
+
null, now(), now()
|
|
408
|
+
where not exists (
|
|
409
|
+
select 1 from attachments
|
|
410
|
+
where tenant_id = ${input.tenantId}::uuid
|
|
411
|
+
and storage_driver = ${input.storageDriver}
|
|
412
|
+
and storage_path = ${object.path}
|
|
413
|
+
)
|
|
414
|
+
on conflict (tenant_id, storage_driver, storage_path) do nothing
|
|
415
|
+
`.execute(db)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async transition(
|
|
421
|
+
id: string,
|
|
422
|
+
leaseToken: string,
|
|
423
|
+
from: AttachmentQuotaReservationRecord['status'],
|
|
424
|
+
to: AttachmentQuotaReservationRecord['status'],
|
|
425
|
+
extra: Record<string, unknown> = {},
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
const db = this.em.getKysely<AttachmentQuotaDatabase>()
|
|
428
|
+
const result = await db
|
|
429
|
+
.updateTable('attachment_quota_reservations')
|
|
430
|
+
.set({ status: to, updated_at: new Date(), ...extra })
|
|
431
|
+
.where('id', '=', id)
|
|
432
|
+
.where('lease_token', '=', leaseToken)
|
|
433
|
+
.where('status', '=', from)
|
|
434
|
+
.returning('id')
|
|
435
|
+
.executeTakeFirst()
|
|
436
|
+
if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The upload reservation lease was lost.')
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function createAttachmentQuotaService(em: EntityManager): AttachmentQuotaService {
|
|
441
|
+
return new AttachmentQuotaService(em)
|
|
442
|
+
}
|