@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.
Files changed (115) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/bootstrap.js +16 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
  5. package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +2 -1
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +17 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +104 -8
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/data/entities.js +67 -1
  13. package/dist/modules/attachments/data/entities.js.map +2 -2
  14. package/dist/modules/attachments/di.js +7 -1
  15. package/dist/modules/attachments/di.js.map +2 -2
  16. package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
  17. package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
  18. package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
  19. package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
  20. package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
  21. package/dist/modules/attachments/lib/quota-service.js +224 -0
  22. package/dist/modules/attachments/lib/quota-service.js.map +7 -0
  23. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
  24. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
  25. package/dist/modules/attachments/workers/quota-recovery.js +45 -0
  26. package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
  27. package/dist/modules/auth/lib/setup-app.js +16 -2
  28. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  29. package/dist/modules/dashboards/services/widgetDataService.js +3 -4
  30. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  31. package/dist/modules/entities/api/definitions.js +2 -0
  32. package/dist/modules/entities/api/definitions.js.map +2 -2
  33. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  34. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  35. package/dist/modules/entities/cli.js +193 -19
  36. package/dist/modules/entities/cli.js.map +2 -2
  37. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  38. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  39. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  40. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  41. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  42. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  43. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  44. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  45. package/dist/modules/messages/api/[id]/route.js +15 -0
  46. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  47. package/dist/modules/query_index/api/reindex.js +10 -0
  48. package/dist/modules/query_index/api/reindex.js.map +2 -2
  49. package/dist/modules/query_index/cli.js +125 -1
  50. package/dist/modules/query_index/cli.js.map +2 -2
  51. package/dist/modules/query_index/lib/coverage.js +19 -7
  52. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  53. package/dist/modules/query_index/lib/reindexer.js +43 -37
  54. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  55. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  56. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  57. package/dist/modules/sales/commands/documents.js +137 -22
  58. package/dist/modules/sales/commands/documents.js.map +2 -2
  59. package/dist/modules/sales/data/validators.js +2 -0
  60. package/dist/modules/sales/data/validators.js.map +2 -2
  61. package/dist/modules/sales/workflows.js +10 -7
  62. package/dist/modules/sales/workflows.js.map +2 -2
  63. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  64. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  65. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  66. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  67. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  68. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  69. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  70. package/generated/entities.ids.generated.ts +2 -1
  71. package/generated/entity-fields-registry.ts +17 -0
  72. package/package.json +7 -7
  73. package/src/bootstrap.ts +17 -2
  74. package/src/modules/attachments/api/route.ts +114 -7
  75. package/src/modules/attachments/data/entities.ts +59 -0
  76. package/src/modules/attachments/di.ts +9 -1
  77. package/src/modules/attachments/i18n/de.json +3 -0
  78. package/src/modules/attachments/i18n/en.json +3 -0
  79. package/src/modules/attachments/i18n/es.json +3 -0
  80. package/src/modules/attachments/i18n/pl.json +3 -0
  81. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  82. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  83. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  84. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  85. package/src/modules/attachments/lib/quota-service.ts +442 -0
  86. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  87. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  88. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  89. package/src/modules/auth/lib/setup-app.ts +20 -2
  90. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  91. package/src/modules/entities/api/definitions.ts +4 -0
  92. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  93. package/src/modules/entities/cli.ts +238 -21
  94. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  95. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  96. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  97. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  98. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  99. package/src/modules/messages/api/[id]/route.ts +21 -0
  100. package/src/modules/query_index/api/reindex.ts +15 -0
  101. package/src/modules/query_index/cli.ts +180 -1
  102. package/src/modules/query_index/lib/coverage.ts +38 -8
  103. package/src/modules/query_index/lib/reindexer.ts +62 -40
  104. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  105. package/src/modules/sales/commands/documents.ts +237 -29
  106. package/src/modules/sales/data/validators.ts +2 -0
  107. package/src/modules/sales/workflows.ts +10 -7
  108. package/src/modules/workflows/i18n/de.json +1 -0
  109. package/src/modules/workflows/i18n/en.json +1 -0
  110. package/src/modules/workflows/i18n/es.json +1 -0
  111. package/src/modules/workflows/i18n/ko.json +1 -0
  112. package/src/modules/workflows/i18n/pl.json +1 -0
  113. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  114. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  115. 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
+ }