@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/attachments/lib/drivers/localDriver.ts"],
|
|
4
|
-
"sourcesContent": ["import { promises as fs } from 'fs'\nimport path from 'path'\nimport { randomUUID } from 'crypto'\nimport { resolvePartitionRoot } from '../storage'\nimport { resolveContainedPath } from '../pathContainment'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nexport class LocalStorageDriver implements StorageDriver {\n readonly key = 'local'\n\n
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY,UAAU;AAC/B,OAAO,UAAU;AACjB,SAAS,kBAAkB;AAC3B,SAAS,4BAA4B;AACrC,SAAS,4BAA4B;AAGrC,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAEO,MAAM,mBAA4C;AAAA,EAAlD;AACL,SAAS,MAAM;AAAA;AAAA,EAEf,
|
|
4
|
+
"sourcesContent": ["import { promises as fs } from 'fs'\nimport path from 'path'\nimport { randomUUID } from 'crypto'\nimport { resolvePartitionRoot } from '../storage'\nimport { resolveContainedPath } from '../pathContainment'\nimport type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nexport class LocalStorageDriver implements StorageDriver {\n readonly key = 'local'\n\n prepareStoragePath(payload: PrepareFilePayload): string {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n return path.join(orgSegment, tenantSegment, storedName).replace(/\\\\/g, '/')\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const root = resolvePartitionRoot(payload.partitionCode)\n const relativePath = payload.storagePath ?? this.prepareStoragePath(payload)\n const absolutePath = path.join(root, relativePath)\n await fs.mkdir(path.dirname(absolutePath), { recursive: true })\n await fs.writeFile(absolutePath, payload.buffer, { flag: 'wx' })\n return {\n storagePath: relativePath.replace(/\\\\/g, '/'),\n }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n const absolutePath = this.resolveAbsolutePath(partitionCode, storagePath)\n const buffer = await fs.readFile(absolutePath)\n return { buffer }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n try {\n await this.deleteStrict(partitionCode, storagePath)\n } catch {\n // Backward-compatible best-effort removal.\n }\n }\n\n async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {\n const absolutePath = this.resolveAbsolutePath(partitionCode, storagePath)\n try {\n if (typeof fs.rm === 'function') await fs.rm(absolutePath, { force: true })\n else await fs.unlink(absolutePath)\n } catch (error) {\n const code = (error as NodeJS.ErrnoException)?.code\n const message = error instanceof Error ? error.message : ''\n if (code !== 'ENOENT' && !message.includes('ENOENT')) throw error\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const filePath = this.resolveAbsolutePath(partitionCode, storagePath)\n return {\n filePath,\n cleanup: async () => {\n // no-op: local path is the real file, do not delete\n },\n }\n }\n\n private resolveAbsolutePath(partitionCode: string, storagePath: string): string {\n const root = resolvePartitionRoot(partitionCode)\n return resolveContainedPath(root, storagePath)\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,YAAY,UAAU;AAC/B,OAAO,UAAU;AACjB,SAAS,kBAAkB;AAC3B,SAAS,4BAA4B;AACrC,SAAS,4BAA4B;AAGrC,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAEO,MAAM,mBAA4C;AAAA,EAAlD;AACL,SAAS,MAAM;AAAA;AAAA,EAEf,mBAAmB,SAAqC;AACtD,UAAM,aAAa,kBAAkB,QAAQ,SAAS,IAAI;AAC1D,UAAM,gBAAgB,qBAAqB,QAAQ,YAAY,IAAI;AACnE,UAAM,WAAW,iBAAiB,QAAQ,YAAY,MAAM;AAC5D,UAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,UAAM,aAAa,GAAG,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AAC5D,WAAO,KAAK,KAAK,YAAY,eAAe,UAAU,EAAE,QAAQ,OAAO,GAAG;AAAA,EAC5E;AAAA,EAEA,MAAM,MAAM,SAAgD;AAC1D,UAAM,OAAO,qBAAqB,QAAQ,aAAa;AACvD,UAAM,eAAe,QAAQ,eAAe,KAAK,mBAAmB,OAAO;AAC3E,UAAM,eAAe,KAAK,KAAK,MAAM,YAAY;AACjD,UAAM,GAAG,MAAM,KAAK,QAAQ,YAAY,GAAG,EAAE,WAAW,KAAK,CAAC;AAC9D,UAAM,GAAG,UAAU,cAAc,QAAQ,QAAQ,EAAE,MAAM,KAAK,CAAC;AAC/D,WAAO;AAAA,MACL,aAAa,aAAa,QAAQ,OAAO,GAAG;AAAA,IAC9C;AAAA,EACF;AAAA,EAEA,MAAM,KAAK,eAAuB,aAA8C;AAC9E,UAAM,eAAe,KAAK,oBAAoB,eAAe,WAAW;AACxE,UAAM,SAAS,MAAM,GAAG,SAAS,YAAY;AAC7C,WAAO,EAAE,OAAO;AAAA,EAClB;AAAA,EAEA,MAAM,OAAO,eAAuB,aAAoC;AACtE,QAAI;AACF,YAAM,KAAK,aAAa,eAAe,WAAW;AAAA,IACpD,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEA,MAAM,aAAa,eAAuB,aAAoC;AAC5E,UAAM,eAAe,KAAK,oBAAoB,eAAe,WAAW;AACxE,QAAI;AACF,UAAI,OAAO,GAAG,OAAO,WAAY,OAAM,GAAG,GAAG,cAAc,EAAE,OAAO,KAAK,CAAC;AAAA,UACrE,OAAM,GAAG,OAAO,YAAY;AAAA,IACnC,SAAS,OAAO;AACd,YAAM,OAAQ,OAAiC;AAC/C,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,UAAI,SAAS,YAAY,CAAC,QAAQ,SAAS,QAAQ,EAAG,OAAM;AAAA,IAC9D;AAAA,EACF;AAAA,EAEA,MAAM,YACJ,eACA,aAC6D;AAC7D,UAAM,WAAW,KAAK,oBAAoB,eAAe,WAAW;AACpE,WAAO;AAAA,MACL;AAAA,MACA,SAAS,YAAY;AAAA,MAErB;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,oBAAoB,eAAuB,aAA6B;AAC9E,UAAM,OAAO,qBAAqB,aAAa;AAC/C,WAAO,qBAAqB,MAAM,WAAW;AAAA,EAC/C;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createModuleQueue } from "@open-mercato/queue";
|
|
2
|
+
const queue = createModuleQueue("attachments-quota-recovery", { concurrency: 2 });
|
|
3
|
+
async function scheduleAttachmentQuotaRecovery(payload, delayMs) {
|
|
4
|
+
await queue.enqueue(payload, { delayMs: Math.max(1e3, delayMs) });
|
|
5
|
+
}
|
|
6
|
+
export {
|
|
7
|
+
scheduleAttachmentQuotaRecovery
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=quota-recovery-queue.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/attachments/lib/quota-recovery-queue.ts"],
|
|
4
|
+
"sourcesContent": ["import { createModuleQueue } from '@open-mercato/queue'\n\nexport type AttachmentQuotaRecoveryJob = {\n reservationId: string\n tenantId: string\n organizationId: string\n}\n\nconst queue = createModuleQueue<AttachmentQuotaRecoveryJob>('attachments-quota-recovery', { concurrency: 2 })\n\nexport async function scheduleAttachmentQuotaRecovery(\n payload: AttachmentQuotaRecoveryJob,\n delayMs: number,\n): Promise<void> {\n await queue.enqueue(payload, { delayMs: Math.max(1_000, delayMs) })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,yBAAyB;AAQlC,MAAM,QAAQ,kBAA8C,8BAA8B,EAAE,aAAa,EAAE,CAAC;AAE5G,eAAsB,gCACpB,SACA,SACe;AACf,QAAM,MAAM,QAAQ,SAAS,EAAE,SAAS,KAAK,IAAI,KAAO,OAAO,EAAE,CAAC;AACpE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
import { AttachmentQuotaReservation } from "../data/entities.js";
|
|
4
|
+
import { resolveAttachmentTenantQuotaBytes } from "./upload-limits.js";
|
|
5
|
+
const DEFAULT_RESERVATION_TTL_MS = 15 * 60 * 1e3;
|
|
6
|
+
class AttachmentQuotaError extends Error {
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "AttachmentQuotaError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function parseUsage(value) {
|
|
14
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
15
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
18
|
+
}
|
|
19
|
+
throw new AttachmentQuotaError("quota_accounting_unavailable", "Storage quota accounting returned an invalid value.");
|
|
20
|
+
}
|
|
21
|
+
function isUniqueViolation(error) {
|
|
22
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "23505");
|
|
23
|
+
}
|
|
24
|
+
function changedRow(result) {
|
|
25
|
+
if (Array.isArray(result)) return result.length > 0;
|
|
26
|
+
if (!result || typeof result !== "object") return false;
|
|
27
|
+
if ("id" in result) return true;
|
|
28
|
+
if ("numUpdatedRows" in result) return Number(result.numUpdatedRows ?? 0) > 0;
|
|
29
|
+
if ("numDeletedRows" in result) return Number(result.numDeletedRows ?? 0) > 0;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function mapReservationRow(row) {
|
|
33
|
+
return {
|
|
34
|
+
id: String(row.id),
|
|
35
|
+
tenantId: String(row.tenant_id),
|
|
36
|
+
organizationId: String(row.organization_id),
|
|
37
|
+
reservedBytes: parseUsage(row.reserved_bytes),
|
|
38
|
+
actualBytes: row.actual_bytes == null ? null : parseUsage(row.actual_bytes),
|
|
39
|
+
status: row.status,
|
|
40
|
+
source: row.source,
|
|
41
|
+
storageDriver: String(row.storage_driver),
|
|
42
|
+
partitionCode: row.partition_code == null ? null : String(row.partition_code),
|
|
43
|
+
storagePath: String(row.storage_path),
|
|
44
|
+
leaseToken: String(row.lease_token),
|
|
45
|
+
uploadTokenHash: row.upload_token_hash == null ? null : String(row.upload_token_hash),
|
|
46
|
+
expiresAt: row.expires_at == null ? null : new Date(row.expires_at)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
class AttachmentQuotaService {
|
|
50
|
+
constructor(em) {
|
|
51
|
+
this.em = em;
|
|
52
|
+
}
|
|
53
|
+
async reserve(input) {
|
|
54
|
+
if (!Number.isSafeInteger(input.bytes) || input.bytes < 0) {
|
|
55
|
+
throw new AttachmentQuotaError("quota_accounting_unavailable", "Reservation bytes must be a non-negative safe integer.");
|
|
56
|
+
}
|
|
57
|
+
const id = randomUUID();
|
|
58
|
+
const leaseToken = randomUUID();
|
|
59
|
+
const expiresAt = new Date(Date.now() + Math.max(1e3, input.ttlMs ?? DEFAULT_RESERVATION_TTL_MS));
|
|
60
|
+
try {
|
|
61
|
+
await this.em.transactional(async (tx) => {
|
|
62
|
+
const db = tx.getKysely();
|
|
63
|
+
await sql`select pg_advisory_xact_lock(hashtextextended(${`attachment-quota:${input.tenantId}`}, 0))`.execute(db);
|
|
64
|
+
const attachmentUsage = await db.selectFrom("attachments").select(sql`coalesce(sum(file_size), 0)`.as("total_size")).where("tenant_id", "=", input.tenantId).executeTakeFirst();
|
|
65
|
+
const ledgerUsage = await db.selectFrom("attachment_quota_reservations").select(
|
|
66
|
+
sql`coalesce(sum(case when status = 'committed' then actual_bytes else reserved_bytes end), 0)`.as("total_size")
|
|
67
|
+
).where("tenant_id", "=", input.tenantId).executeTakeFirst();
|
|
68
|
+
const usedBytes = parseUsage(attachmentUsage?.total_size) + parseUsage(ledgerUsage?.total_size);
|
|
69
|
+
if (usedBytes + input.bytes > resolveAttachmentTenantQuotaBytes()) {
|
|
70
|
+
throw new AttachmentQuotaError("quota_exceeded", "Attachment storage quota exceeded for this tenant.");
|
|
71
|
+
}
|
|
72
|
+
const reservation = tx.create(AttachmentQuotaReservation, {
|
|
73
|
+
id,
|
|
74
|
+
tenantId: input.tenantId,
|
|
75
|
+
organizationId: input.organizationId,
|
|
76
|
+
reservedBytes: input.bytes,
|
|
77
|
+
actualBytes: null,
|
|
78
|
+
status: "reserved",
|
|
79
|
+
source: input.source,
|
|
80
|
+
storageDriver: input.storageDriver,
|
|
81
|
+
partitionCode: input.partitionCode ?? null,
|
|
82
|
+
storagePath: input.storagePath,
|
|
83
|
+
leaseToken,
|
|
84
|
+
uploadTokenHash: input.uploadTokenHash ?? null,
|
|
85
|
+
expiresAt
|
|
86
|
+
});
|
|
87
|
+
await tx.persist(reservation).flush();
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof AttachmentQuotaError) throw error;
|
|
91
|
+
if (isUniqueViolation(error)) {
|
|
92
|
+
throw new AttachmentQuotaError("quota_target_exists", "The target storage path already has an active allocation.");
|
|
93
|
+
}
|
|
94
|
+
throw new AttachmentQuotaError("quota_accounting_unavailable", "Storage quota accounting is unavailable.");
|
|
95
|
+
}
|
|
96
|
+
return { id, leaseToken, expiresAt };
|
|
97
|
+
}
|
|
98
|
+
async beginStorage(id, leaseToken, ttlMs = DEFAULT_RESERVATION_TTL_MS) {
|
|
99
|
+
await this.transition(id, leaseToken, "reserved", "storing", {
|
|
100
|
+
expires_at: new Date(Date.now() + Math.max(1e3, ttlMs))
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async markStored(id, leaseToken) {
|
|
104
|
+
await this.transition(id, leaseToken, "storing", "stored");
|
|
105
|
+
}
|
|
106
|
+
async completeAttachment(id, leaseToken, manager = this.em) {
|
|
107
|
+
const db = manager.getKysely();
|
|
108
|
+
const result = await db.deleteFrom("attachment_quota_reservations").where("id", "=", id).where("lease_token", "=", leaseToken).where("status", "=", "stored").returning("id").executeTakeFirst();
|
|
109
|
+
if (!changedRow(result)) throw new AttachmentQuotaError("quota_lease_lost", "The upload reservation lease was lost.");
|
|
110
|
+
}
|
|
111
|
+
async completeStandalone(id, leaseToken, actualBytes) {
|
|
112
|
+
if (!Number.isSafeInteger(actualBytes) || actualBytes < 0) {
|
|
113
|
+
throw new AttachmentQuotaError("quota_accounting_unavailable", "Actual bytes must be a non-negative safe integer.");
|
|
114
|
+
}
|
|
115
|
+
const db = this.em.getKysely();
|
|
116
|
+
const result = await db.updateTable("attachment_quota_reservations").set({ status: "committed", actual_bytes: actualBytes, expires_at: null, upload_token_hash: null, updated_at: /* @__PURE__ */ new Date() }).where("id", "=", id).where("lease_token", "=", leaseToken).where("status", "=", "stored").where("reserved_bytes", ">=", actualBytes).returning("id").executeTakeFirst();
|
|
117
|
+
if (!changedRow(result)) throw new AttachmentQuotaError("quota_lease_lost", "The upload reservation lease was lost.");
|
|
118
|
+
}
|
|
119
|
+
async release(id, leaseToken) {
|
|
120
|
+
const db = this.em.getKysely();
|
|
121
|
+
let query = db.deleteFrom("attachment_quota_reservations").where("id", "=", id).where("status", "!=", "committed");
|
|
122
|
+
if (leaseToken) query = query.where("lease_token", "=", leaseToken);
|
|
123
|
+
await query.execute();
|
|
124
|
+
}
|
|
125
|
+
async releaseCommittedByPath(input) {
|
|
126
|
+
const db = this.em.getKysely();
|
|
127
|
+
await db.deleteFrom("attachment_quota_reservations").where("tenant_id", "=", input.tenantId).where("storage_driver", "=", input.storageDriver).where("storage_path", "=", input.storagePath).where("status", "=", "committed").execute();
|
|
128
|
+
}
|
|
129
|
+
async findPendingByUploadTokenHash(uploadTokenHash) {
|
|
130
|
+
const db = this.em.getKysely();
|
|
131
|
+
const row = await db.selectFrom("attachment_quota_reservations").selectAll().where("upload_token_hash", "=", uploadTokenHash).where("status", "=", "reserved").where("expires_at", ">", /* @__PURE__ */ new Date()).executeTakeFirst();
|
|
132
|
+
if (!row) return null;
|
|
133
|
+
return mapReservationRow(row);
|
|
134
|
+
}
|
|
135
|
+
async claimPendingByUploadTokenHash(uploadTokenHash, ttlMs = DEFAULT_RESERVATION_TTL_MS) {
|
|
136
|
+
const db = this.em.getKysely();
|
|
137
|
+
const leaseToken = randomUUID();
|
|
138
|
+
const row = await db.updateTable("attachment_quota_reservations").set({
|
|
139
|
+
status: "storing",
|
|
140
|
+
lease_token: leaseToken,
|
|
141
|
+
expires_at: new Date(Date.now() + Math.max(1e3, ttlMs)),
|
|
142
|
+
updated_at: /* @__PURE__ */ new Date()
|
|
143
|
+
}).where("upload_token_hash", "=", uploadTokenHash).where("status", "=", "reserved").where("expires_at", ">", /* @__PURE__ */ new Date()).returningAll().executeTakeFirst();
|
|
144
|
+
return row ? mapReservationRow(row) : null;
|
|
145
|
+
}
|
|
146
|
+
async getReservation(id, scope) {
|
|
147
|
+
const db = this.em.getKysely();
|
|
148
|
+
const row = await db.selectFrom("attachment_quota_reservations").selectAll().where("id", "=", id).where("tenant_id", "=", scope.tenantId).where("organization_id", "=", scope.organizationId).executeTakeFirst();
|
|
149
|
+
return row ? mapReservationRow(row) : null;
|
|
150
|
+
}
|
|
151
|
+
async claimExpired(id, scope, recoveryTtlMs = DEFAULT_RESERVATION_TTL_MS) {
|
|
152
|
+
const db = this.em.getKysely();
|
|
153
|
+
const leaseToken = randomUUID();
|
|
154
|
+
const row = await db.updateTable("attachment_quota_reservations").set({
|
|
155
|
+
status: "recovering",
|
|
156
|
+
lease_token: leaseToken,
|
|
157
|
+
expires_at: new Date(Date.now() + Math.max(1e3, recoveryTtlMs)),
|
|
158
|
+
updated_at: /* @__PURE__ */ new Date()
|
|
159
|
+
}).where("id", "=", id).where("tenant_id", "=", scope.tenantId).where("organization_id", "=", scope.organizationId).where("status", "in", ["reserved", "storing", "stored", "recovering"]).where("expires_at", "<=", /* @__PURE__ */ new Date()).returningAll().executeTakeFirst();
|
|
160
|
+
return row ? mapReservationRow(row) : null;
|
|
161
|
+
}
|
|
162
|
+
async commitRecoveredStandalone(id, leaseToken, actualBytes) {
|
|
163
|
+
const db = this.em.getKysely();
|
|
164
|
+
const result = await db.updateTable("attachment_quota_reservations").set({ status: "committed", actual_bytes: actualBytes, expires_at: null, upload_token_hash: null, updated_at: /* @__PURE__ */ new Date() }).where("id", "=", id).where("lease_token", "=", leaseToken).where("status", "=", "recovering").where("reserved_bytes", ">=", actualBytes).returning("id").executeTakeFirst();
|
|
165
|
+
if (!changedRow(result)) throw new AttachmentQuotaError("quota_lease_lost", "The recovery reservation lease was lost.");
|
|
166
|
+
}
|
|
167
|
+
async reconcileStandaloneObjects(input) {
|
|
168
|
+
const uniqueObjects = Array.from(new Map(input.objects.map((object) => [object.path, object])).values());
|
|
169
|
+
for (const object of uniqueObjects) {
|
|
170
|
+
if (!Number.isSafeInteger(object.bytes) || object.bytes < 0) {
|
|
171
|
+
throw new AttachmentQuotaError("quota_accounting_unavailable", "Standalone storage accounting returned an invalid size.");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const db = this.em.getKysely();
|
|
175
|
+
for (let offset = 0; offset < uniqueObjects.length; offset += 500) {
|
|
176
|
+
const batch = uniqueObjects.slice(offset, offset + 500);
|
|
177
|
+
const paths = batch.map((object) => object.path);
|
|
178
|
+
if (paths.length === 0) continue;
|
|
179
|
+
const attachmentRows = await db.selectFrom("attachments").select("storage_path").where("tenant_id", "=", input.tenantId).where("storage_driver", "=", input.storageDriver).where("storage_path", "in", paths).execute();
|
|
180
|
+
const attachmentPaths = new Set(attachmentRows.map((row) => String(row.storage_path)));
|
|
181
|
+
const ledgerRows = await db.selectFrom("attachment_quota_reservations").select(["storage_path", "status"]).where("tenant_id", "=", input.tenantId).where("storage_driver", "=", input.storageDriver).where("storage_path", "in", paths).execute();
|
|
182
|
+
const ledgerPaths = new Set(ledgerRows.map((row) => String(row.storage_path)));
|
|
183
|
+
const doubleCountedPaths = ledgerRows.filter((row) => row.status === "committed" && attachmentPaths.has(String(row.storage_path))).map((row) => String(row.storage_path));
|
|
184
|
+
if (doubleCountedPaths.length > 0) {
|
|
185
|
+
await db.deleteFrom("attachment_quota_reservations").where("tenant_id", "=", input.tenantId).where("storage_driver", "=", input.storageDriver).where("storage_path", "in", doubleCountedPaths).where("status", "=", "committed").execute();
|
|
186
|
+
}
|
|
187
|
+
for (const object of batch) {
|
|
188
|
+
if (attachmentPaths.has(object.path) || ledgerPaths.has(object.path)) continue;
|
|
189
|
+
await sql`
|
|
190
|
+
insert into attachment_quota_reservations (
|
|
191
|
+
id, tenant_id, organization_id, reserved_bytes, actual_bytes, status, source,
|
|
192
|
+
storage_driver, partition_code, storage_path, lease_token, upload_token_hash,
|
|
193
|
+
expires_at, created_at, updated_at
|
|
194
|
+
)
|
|
195
|
+
select ${randomUUID()}::uuid, ${input.tenantId}::uuid, ${input.organizationId}::uuid,
|
|
196
|
+
${object.bytes}::bigint, ${object.bytes}::bigint, 'committed', 'storage_s3_upload',
|
|
197
|
+
${input.storageDriver}, null, ${object.path}, ${randomUUID()}::uuid, null,
|
|
198
|
+
null, now(), now()
|
|
199
|
+
where not exists (
|
|
200
|
+
select 1 from attachments
|
|
201
|
+
where tenant_id = ${input.tenantId}::uuid
|
|
202
|
+
and storage_driver = ${input.storageDriver}
|
|
203
|
+
and storage_path = ${object.path}
|
|
204
|
+
)
|
|
205
|
+
on conflict (tenant_id, storage_driver, storage_path) do nothing
|
|
206
|
+
`.execute(db);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async transition(id, leaseToken, from, to, extra = {}) {
|
|
211
|
+
const db = this.em.getKysely();
|
|
212
|
+
const result = await db.updateTable("attachment_quota_reservations").set({ status: to, updated_at: /* @__PURE__ */ new Date(), ...extra }).where("id", "=", id).where("lease_token", "=", leaseToken).where("status", "=", from).returning("id").executeTakeFirst();
|
|
213
|
+
if (!changedRow(result)) throw new AttachmentQuotaError("quota_lease_lost", "The upload reservation lease was lost.");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function createAttachmentQuotaService(em) {
|
|
217
|
+
return new AttachmentQuotaService(em);
|
|
218
|
+
}
|
|
219
|
+
export {
|
|
220
|
+
AttachmentQuotaError,
|
|
221
|
+
AttachmentQuotaService,
|
|
222
|
+
createAttachmentQuotaService
|
|
223
|
+
};
|
|
224
|
+
//# sourceMappingURL=quota-service.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/attachments/lib/quota-service.ts"],
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { AttachmentQuotaReservation } from '../data/entities'\nimport { resolveAttachmentTenantQuotaBytes } from './upload-limits'\n\nconst DEFAULT_RESERVATION_TTL_MS = 15 * 60 * 1000\n\nexport type AttachmentQuotaSource =\n | 'attachment'\n | 'storage_s3_upload'\n | 'storage_s3_signed'\n | 'storage_service'\n\nexport type AttachmentQuotaReservationHandle = {\n id: string\n leaseToken: string\n expiresAt: Date\n}\n\nexport type AttachmentQuotaReservationRecord = {\n id: string\n tenantId: string\n organizationId: string\n reservedBytes: number\n actualBytes: number | null\n status: 'reserved' | 'storing' | 'stored' | 'recovering' | 'committed'\n source: AttachmentQuotaSource\n storageDriver: string\n partitionCode: string | null\n storagePath: string\n leaseToken: string\n uploadTokenHash: string | null\n expiresAt: Date | null\n}\n\ntype AttachmentQuotaReservationRow = {\n id: string\n tenant_id: string\n organization_id: string\n reserved_bytes: unknown\n actual_bytes: unknown | null\n status: AttachmentQuotaReservationRecord['status']\n source: AttachmentQuotaSource\n storage_driver: string\n partition_code: string | null\n storage_path: string\n lease_token: string\n upload_token_hash: string | null\n expires_at: Date | string | null\n created_at: Date\n updated_at: Date\n}\n\ntype AttachmentUsageRow = {\n id: string\n tenant_id: string\n storage_driver: string\n storage_path: string\n file_size: unknown\n}\n\ntype AttachmentQuotaDatabase = {\n attachments: AttachmentUsageRow\n attachment_quota_reservations: AttachmentQuotaReservationRow\n}\n\nexport class AttachmentQuotaError extends Error {\n constructor(\n public readonly code:\n | 'quota_exceeded'\n | 'quota_accounting_unavailable'\n | 'quota_target_exists'\n | 'quota_lease_lost',\n message: string,\n ) {\n super(message)\n this.name = 'AttachmentQuotaError'\n }\n}\n\nfunction parseUsage(value: unknown): number {\n if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value\n if (typeof value === 'string' && value.trim() !== '') {\n const parsed = Number(value)\n if (Number.isFinite(parsed) && parsed >= 0) return parsed\n }\n throw new AttachmentQuotaError('quota_accounting_unavailable', 'Storage quota accounting returned an invalid value.')\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n return Boolean(error && typeof error === 'object' && 'code' in error && error.code === '23505')\n}\n\nfunction changedRow(result: unknown): boolean {\n if (Array.isArray(result)) return result.length > 0\n if (!result || typeof result !== 'object') return false\n if ('id' in result) return true\n if ('numUpdatedRows' in result) return Number((result as { numUpdatedRows?: unknown }).numUpdatedRows ?? 0) > 0\n if ('numDeletedRows' in result) return Number((result as { numDeletedRows?: unknown }).numDeletedRows ?? 0) > 0\n return false\n}\n\nfunction mapReservationRow(row: AttachmentQuotaReservationRow): AttachmentQuotaReservationRecord {\n return {\n id: String(row.id),\n tenantId: String(row.tenant_id),\n organizationId: String(row.organization_id),\n reservedBytes: parseUsage(row.reserved_bytes),\n actualBytes: row.actual_bytes == null ? null : parseUsage(row.actual_bytes),\n status: row.status,\n source: row.source,\n storageDriver: String(row.storage_driver),\n partitionCode: row.partition_code == null ? null : String(row.partition_code),\n storagePath: String(row.storage_path),\n leaseToken: String(row.lease_token),\n uploadTokenHash: row.upload_token_hash == null ? null : String(row.upload_token_hash),\n expiresAt: row.expires_at == null ? null : new Date(row.expires_at),\n }\n}\n\nexport class AttachmentQuotaService {\n constructor(private readonly em: EntityManager) {}\n\n async reserve(input: {\n tenantId: string\n organizationId: string\n bytes: number\n source: AttachmentQuotaSource\n storageDriver: string\n storagePath: string\n partitionCode?: string | null\n uploadTokenHash?: string | null\n ttlMs?: number\n }): Promise<AttachmentQuotaReservationHandle> {\n if (!Number.isSafeInteger(input.bytes) || input.bytes < 0) {\n throw new AttachmentQuotaError('quota_accounting_unavailable', 'Reservation bytes must be a non-negative safe integer.')\n }\n const id = randomUUID()\n const leaseToken = randomUUID()\n const expiresAt = new Date(Date.now() + Math.max(1_000, input.ttlMs ?? DEFAULT_RESERVATION_TTL_MS))\n\n try {\n await this.em.transactional(async (tx) => {\n const db = tx.getKysely<AttachmentQuotaDatabase>()\n await sql`select pg_advisory_xact_lock(hashtextextended(${`attachment-quota:${input.tenantId}`}, 0))`.execute(db)\n\n const attachmentUsage = await db\n .selectFrom('attachments')\n .select(sql<string>`coalesce(sum(file_size), 0)`.as('total_size'))\n .where('tenant_id', '=', input.tenantId)\n .executeTakeFirst()\n const ledgerUsage = await db\n .selectFrom('attachment_quota_reservations')\n .select(\n sql<string>`coalesce(sum(case when status = 'committed' then actual_bytes else reserved_bytes end), 0)`.as('total_size'),\n )\n .where('tenant_id', '=', input.tenantId)\n .executeTakeFirst()\n\n const usedBytes = parseUsage(attachmentUsage?.total_size) + parseUsage(ledgerUsage?.total_size)\n if (usedBytes + input.bytes > resolveAttachmentTenantQuotaBytes()) {\n throw new AttachmentQuotaError('quota_exceeded', 'Attachment storage quota exceeded for this tenant.')\n }\n\n const reservation = tx.create(AttachmentQuotaReservation, {\n id,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n reservedBytes: input.bytes,\n actualBytes: null,\n status: 'reserved',\n source: input.source,\n storageDriver: input.storageDriver,\n partitionCode: input.partitionCode ?? null,\n storagePath: input.storagePath,\n leaseToken,\n uploadTokenHash: input.uploadTokenHash ?? null,\n expiresAt,\n })\n await tx.persist(reservation).flush()\n })\n } catch (error) {\n if (error instanceof AttachmentQuotaError) throw error\n if (isUniqueViolation(error)) {\n throw new AttachmentQuotaError('quota_target_exists', 'The target storage path already has an active allocation.')\n }\n throw new AttachmentQuotaError('quota_accounting_unavailable', 'Storage quota accounting is unavailable.')\n }\n\n return { id, leaseToken, expiresAt }\n }\n\n async beginStorage(id: string, leaseToken: string, ttlMs = DEFAULT_RESERVATION_TTL_MS): Promise<void> {\n await this.transition(id, leaseToken, 'reserved', 'storing', {\n expires_at: new Date(Date.now() + Math.max(1_000, ttlMs)),\n })\n }\n\n async markStored(id: string, leaseToken: string): Promise<void> {\n await this.transition(id, leaseToken, 'storing', 'stored')\n }\n\n async completeAttachment(id: string, leaseToken: string, manager: EntityManager = this.em): Promise<void> {\n const db = manager.getKysely<AttachmentQuotaDatabase>()\n const result = await db\n .deleteFrom('attachment_quota_reservations')\n .where('id', '=', id)\n .where('lease_token', '=', leaseToken)\n .where('status', '=', 'stored')\n .returning('id')\n .executeTakeFirst()\n if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The upload reservation lease was lost.')\n }\n\n async completeStandalone(id: string, leaseToken: string, actualBytes: number): Promise<void> {\n if (!Number.isSafeInteger(actualBytes) || actualBytes < 0) {\n throw new AttachmentQuotaError('quota_accounting_unavailable', 'Actual bytes must be a non-negative safe integer.')\n }\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const result = await db\n .updateTable('attachment_quota_reservations')\n .set({ status: 'committed', actual_bytes: actualBytes, expires_at: null, upload_token_hash: null, updated_at: new Date() })\n .where('id', '=', id)\n .where('lease_token', '=', leaseToken)\n .where('status', '=', 'stored')\n .where('reserved_bytes', '>=', actualBytes)\n .returning('id')\n .executeTakeFirst()\n if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The upload reservation lease was lost.')\n }\n\n async release(id: string, leaseToken?: string): Promise<void> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n let query = db\n .deleteFrom('attachment_quota_reservations')\n .where('id', '=', id)\n .where('status', '!=', 'committed')\n if (leaseToken) query = query.where('lease_token', '=', leaseToken)\n await query.execute()\n }\n\n async releaseCommittedByPath(input: {\n tenantId: string\n storageDriver: string\n storagePath: string\n }): Promise<void> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n await db\n .deleteFrom('attachment_quota_reservations')\n .where('tenant_id', '=', input.tenantId)\n .where('storage_driver', '=', input.storageDriver)\n .where('storage_path', '=', input.storagePath)\n .where('status', '=', 'committed')\n .execute()\n }\n\n async findPendingByUploadTokenHash(uploadTokenHash: string): Promise<AttachmentQuotaReservationRecord | null> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const row = await db\n .selectFrom('attachment_quota_reservations')\n .selectAll()\n .where('upload_token_hash', '=', uploadTokenHash)\n .where('status', '=', 'reserved')\n .where('expires_at', '>', new Date())\n .executeTakeFirst()\n if (!row) return null\n return mapReservationRow(row)\n }\n\n async claimPendingByUploadTokenHash(\n uploadTokenHash: string,\n ttlMs = DEFAULT_RESERVATION_TTL_MS,\n ): Promise<AttachmentQuotaReservationRecord | null> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const leaseToken = randomUUID()\n const row = await db\n .updateTable('attachment_quota_reservations')\n .set({\n status: 'storing',\n lease_token: leaseToken,\n expires_at: new Date(Date.now() + Math.max(1_000, ttlMs)),\n updated_at: new Date(),\n })\n .where('upload_token_hash', '=', uploadTokenHash)\n .where('status', '=', 'reserved')\n .where('expires_at', '>', new Date())\n .returningAll()\n .executeTakeFirst()\n return row ? mapReservationRow(row) : null\n }\n\n async getReservation(\n id: string,\n scope: { tenantId: string; organizationId: string },\n ): Promise<AttachmentQuotaReservationRecord | null> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const row = await db\n .selectFrom('attachment_quota_reservations')\n .selectAll()\n .where('id', '=', id)\n .where('tenant_id', '=', scope.tenantId)\n .where('organization_id', '=', scope.organizationId)\n .executeTakeFirst()\n return row ? mapReservationRow(row) : null\n }\n\n async claimExpired(\n id: string,\n scope: { tenantId: string; organizationId: string },\n recoveryTtlMs = DEFAULT_RESERVATION_TTL_MS,\n ): Promise<AttachmentQuotaReservationRecord | null> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const leaseToken = randomUUID()\n const row = await db\n .updateTable('attachment_quota_reservations')\n .set({\n status: 'recovering',\n lease_token: leaseToken,\n expires_at: new Date(Date.now() + Math.max(1_000, recoveryTtlMs)),\n updated_at: new Date(),\n })\n .where('id', '=', id)\n .where('tenant_id', '=', scope.tenantId)\n .where('organization_id', '=', scope.organizationId)\n .where('status', 'in', ['reserved', 'storing', 'stored', 'recovering'])\n .where('expires_at', '<=', new Date())\n .returningAll()\n .executeTakeFirst()\n return row ? mapReservationRow(row) : null\n }\n\n async commitRecoveredStandalone(id: string, leaseToken: string, actualBytes: number): Promise<void> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const result = await db\n .updateTable('attachment_quota_reservations')\n .set({ status: 'committed', actual_bytes: actualBytes, expires_at: null, upload_token_hash: null, updated_at: new Date() })\n .where('id', '=', id)\n .where('lease_token', '=', leaseToken)\n .where('status', '=', 'recovering')\n .where('reserved_bytes', '>=', actualBytes)\n .returning('id')\n .executeTakeFirst()\n if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The recovery reservation lease was lost.')\n }\n\n async reconcileStandaloneObjects(input: {\n tenantId: string\n organizationId: string\n storageDriver: string\n objects: Array<{ path: string; bytes: number }>\n }): Promise<void> {\n const uniqueObjects = Array.from(new Map(input.objects.map((object) => [object.path, object])).values())\n for (const object of uniqueObjects) {\n if (!Number.isSafeInteger(object.bytes) || object.bytes < 0) {\n throw new AttachmentQuotaError('quota_accounting_unavailable', 'Standalone storage accounting returned an invalid size.')\n }\n }\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n for (let offset = 0; offset < uniqueObjects.length; offset += 500) {\n const batch = uniqueObjects.slice(offset, offset + 500)\n const paths = batch.map((object) => object.path)\n if (paths.length === 0) continue\n\n const attachmentRows = await db\n .selectFrom('attachments')\n .select('storage_path')\n .where('tenant_id', '=', input.tenantId)\n .where('storage_driver', '=', input.storageDriver)\n .where('storage_path', 'in', paths)\n .execute()\n const attachmentPaths = new Set(attachmentRows.map((row) => String(row.storage_path)))\n\n const ledgerRows = await db\n .selectFrom('attachment_quota_reservations')\n .select(['storage_path', 'status'])\n .where('tenant_id', '=', input.tenantId)\n .where('storage_driver', '=', input.storageDriver)\n .where('storage_path', 'in', paths)\n .execute()\n const ledgerPaths = new Set(ledgerRows.map((row) => String(row.storage_path)))\n\n const doubleCountedPaths = ledgerRows\n .filter((row) => row.status === 'committed' && attachmentPaths.has(String(row.storage_path)))\n .map((row) => String(row.storage_path))\n if (doubleCountedPaths.length > 0) {\n await db\n .deleteFrom('attachment_quota_reservations')\n .where('tenant_id', '=', input.tenantId)\n .where('storage_driver', '=', input.storageDriver)\n .where('storage_path', 'in', doubleCountedPaths)\n .where('status', '=', 'committed')\n .execute()\n }\n\n for (const object of batch) {\n if (attachmentPaths.has(object.path) || ledgerPaths.has(object.path)) continue\n await sql`\n insert into attachment_quota_reservations (\n id, tenant_id, organization_id, reserved_bytes, actual_bytes, status, source,\n storage_driver, partition_code, storage_path, lease_token, upload_token_hash,\n expires_at, created_at, updated_at\n )\n select ${randomUUID()}::uuid, ${input.tenantId}::uuid, ${input.organizationId}::uuid,\n ${object.bytes}::bigint, ${object.bytes}::bigint, 'committed', 'storage_s3_upload',\n ${input.storageDriver}, null, ${object.path}, ${randomUUID()}::uuid, null,\n null, now(), now()\n where not exists (\n select 1 from attachments\n where tenant_id = ${input.tenantId}::uuid\n and storage_driver = ${input.storageDriver}\n and storage_path = ${object.path}\n )\n on conflict (tenant_id, storage_driver, storage_path) do nothing\n `.execute(db)\n }\n }\n }\n\n private async transition(\n id: string,\n leaseToken: string,\n from: AttachmentQuotaReservationRecord['status'],\n to: AttachmentQuotaReservationRecord['status'],\n extra: Record<string, unknown> = {},\n ): Promise<void> {\n const db = this.em.getKysely<AttachmentQuotaDatabase>()\n const result = await db\n .updateTable('attachment_quota_reservations')\n .set({ status: to, updated_at: new Date(), ...extra })\n .where('id', '=', id)\n .where('lease_token', '=', leaseToken)\n .where('status', '=', from)\n .returning('id')\n .executeTakeFirst()\n if (!changedRow(result)) throw new AttachmentQuotaError('quota_lease_lost', 'The upload reservation lease was lost.')\n }\n}\n\nexport function createAttachmentQuotaService(em: EntityManager): AttachmentQuotaService {\n return new AttachmentQuotaService(em)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
+
class Migration20260709234741_attachments extends Migration {
|
|
3
|
+
up() {
|
|
4
|
+
this.addSql(`create table "attachment_quota_reservations" ("id" uuid not null, "tenant_id" uuid not null, "organization_id" uuid not null, "reserved_bytes" bigint not null, "actual_bytes" bigint null, "status" text not null default 'reserved', "source" text not null, "storage_driver" text not null, "partition_code" text null, "storage_path" text not null, "lease_token" uuid not null, "upload_token_hash" text null, "expires_at" timestamptz null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`);
|
|
5
|
+
this.addSql(`create index "attachment_quota_reservations_expires_idx" on "attachment_quota_reservations" ("expires_at");`);
|
|
6
|
+
this.addSql(`create index "attachment_quota_reservations_tenant_status_idx" on "attachment_quota_reservations" ("tenant_id", "status");`);
|
|
7
|
+
this.addSql(`alter table "attachment_quota_reservations" add constraint "attachment_quota_reservations_scope_path_unique" unique ("tenant_id", "storage_driver", "storage_path");`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
Migration20260709234741_attachments
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=Migration20260709234741_attachments.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/attachments/migrations/Migration20260709234741_attachments.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260709234741_attachments extends Migration {\n\n override up(): void | Promise<void> {\n this.addSql(`create table \"attachment_quota_reservations\" (\"id\" uuid not null, \"tenant_id\" uuid not null, \"organization_id\" uuid not null, \"reserved_bytes\" bigint not null, \"actual_bytes\" bigint null, \"status\" text not null default 'reserved', \"source\" text not null, \"storage_driver\" text not null, \"partition_code\" text null, \"storage_path\" text not null, \"lease_token\" uuid not null, \"upload_token_hash\" text null, \"expires_at\" timestamptz null, \"created_at\" timestamptz not null, \"updated_at\" timestamptz not null, primary key (\"id\"));`);\n this.addSql(`create index \"attachment_quota_reservations_expires_idx\" on \"attachment_quota_reservations\" (\"expires_at\");`);\n this.addSql(`create index \"attachment_quota_reservations_tenant_status_idx\" on \"attachment_quota_reservations\" (\"tenant_id\", \"status\");`);\n this.addSql(`alter table \"attachment_quota_reservations\" add constraint \"attachment_quota_reservations_scope_path_unique\" unique (\"tenant_id\", \"storage_driver\", \"storage_path\");`);\n }\n\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,4CAA4C,UAAU;AAAA,EAExD,KAA2B;AAClC,SAAK,OAAO,ghBAAghB;AAC5hB,SAAK,OAAO,6GAA6G;AACzH,SAAK,OAAO,4HAA4H;AACxI,SAAK,OAAO,sKAAsK;AAAA,EACpL;AAEF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { scheduleAttachmentQuotaRecovery } from "../lib/quota-recovery-queue.js";
|
|
2
|
+
const metadata = {
|
|
3
|
+
queue: "attachments-quota-recovery",
|
|
4
|
+
id: "attachments:quota-recovery",
|
|
5
|
+
concurrency: 2
|
|
6
|
+
};
|
|
7
|
+
async function handle(job, ctx) {
|
|
8
|
+
const quotaService = ctx.resolve("attachmentQuotaService");
|
|
9
|
+
const scope = {
|
|
10
|
+
tenantId: job.payload.tenantId,
|
|
11
|
+
organizationId: job.payload.organizationId
|
|
12
|
+
};
|
|
13
|
+
const record = await quotaService.getReservation(job.payload.reservationId, scope);
|
|
14
|
+
if (!record || record.status === "committed") return;
|
|
15
|
+
if (record.expiresAt && record.expiresAt.getTime() > Date.now()) {
|
|
16
|
+
await scheduleAttachmentQuotaRecovery(job.payload, record.expiresAt.getTime() - Date.now());
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const claimed = await quotaService.claimExpired(record.id, scope);
|
|
20
|
+
if (!claimed) return;
|
|
21
|
+
try {
|
|
22
|
+
const em = ctx.resolve("em");
|
|
23
|
+
const existingAttachment = await em.getKysely().selectFrom("attachments").select("id").where("tenant_id", "=", claimed.tenantId).where("storage_driver", "=", claimed.storageDriver).where("storage_path", "=", claimed.storagePath).executeTakeFirst();
|
|
24
|
+
if (!existingAttachment) {
|
|
25
|
+
if (!claimed.partitionCode) throw new Error("Attachment quota recovery is missing its partition code.");
|
|
26
|
+
const factory = ctx.resolve("storageDriverFactory");
|
|
27
|
+
const driver = await factory.resolveForPartition(claimed.partitionCode, {
|
|
28
|
+
tenantId: claimed.tenantId,
|
|
29
|
+
organizationId: claimed.organizationId
|
|
30
|
+
});
|
|
31
|
+
await (driver.deleteStrict?.(claimed.partitionCode, claimed.storagePath) ?? driver.delete(claimed.partitionCode, claimed.storagePath));
|
|
32
|
+
}
|
|
33
|
+
await quotaService.release(claimed.id, claimed.leaseToken);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (claimed.expiresAt) {
|
|
36
|
+
await scheduleAttachmentQuotaRecovery(job.payload, claimed.expiresAt.getTime() - Date.now());
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
handle as default,
|
|
43
|
+
metadata
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=quota-recovery.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/attachments/workers/quota-recovery.ts"],
|
|
4
|
+
"sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AttachmentQuotaService } from '../lib/quota-service'\nimport type { StorageDriverFactory } from '../lib/drivers'\nimport { scheduleAttachmentQuotaRecovery, type AttachmentQuotaRecoveryJob } from '../lib/quota-recovery-queue'\n\nexport const metadata: WorkerMeta = {\n queue: 'attachments-quota-recovery',\n id: 'attachments:quota-recovery',\n concurrency: 2,\n}\n\ntype HandlerContext = JobContext & { resolve: <T = unknown>(name: string) => T }\n\ntype AttachmentLookupDatabase = {\n attachments: {\n id: string\n tenant_id: string\n storage_driver: string\n storage_path: string\n }\n}\n\nexport default async function handle(\n job: QueuedJob<AttachmentQuotaRecoveryJob>,\n ctx: HandlerContext,\n): Promise<void> {\n const quotaService = ctx.resolve<AttachmentQuotaService>('attachmentQuotaService')\n const scope = {\n tenantId: job.payload.tenantId,\n organizationId: job.payload.organizationId,\n }\n const record = await quotaService.getReservation(job.payload.reservationId, scope)\n if (!record || record.status === 'committed') return\n if (record.expiresAt && record.expiresAt.getTime() > Date.now()) {\n await scheduleAttachmentQuotaRecovery(job.payload, record.expiresAt.getTime() - Date.now())\n return\n }\n\n const claimed = await quotaService.claimExpired(record.id, scope)\n if (!claimed) return\n try {\n const em = ctx.resolve<EntityManager>('em')\n const existingAttachment = await em.getKysely<AttachmentLookupDatabase>()\n .selectFrom('attachments')\n .select('id')\n .where('tenant_id', '=', claimed.tenantId)\n .where('storage_driver', '=', claimed.storageDriver)\n .where('storage_path', '=', claimed.storagePath)\n .executeTakeFirst()\n if (!existingAttachment) {\n if (!claimed.partitionCode) throw new Error('Attachment quota recovery is missing its partition code.')\n const factory = ctx.resolve<StorageDriverFactory>('storageDriverFactory')\n const driver = await factory.resolveForPartition(claimed.partitionCode, {\n tenantId: claimed.tenantId,\n organizationId: claimed.organizationId,\n })\n await (driver.deleteStrict?.(claimed.partitionCode, claimed.storagePath)\n ?? driver.delete(claimed.partitionCode, claimed.storagePath))\n }\n await quotaService.release(claimed.id, claimed.leaseToken)\n } catch (error) {\n if (claimed.expiresAt) {\n await scheduleAttachmentQuotaRecovery(job.payload, claimed.expiresAt.getTime() - Date.now())\n }\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "AAIA,SAAS,uCAAwE;AAE1E,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAaA,eAAO,OACL,KACA,KACe;AACf,QAAM,eAAe,IAAI,QAAgC,wBAAwB;AACjF,QAAM,QAAQ;AAAA,IACZ,UAAU,IAAI,QAAQ;AAAA,IACtB,gBAAgB,IAAI,QAAQ;AAAA,EAC9B;AACA,QAAM,SAAS,MAAM,aAAa,eAAe,IAAI,QAAQ,eAAe,KAAK;AACjF,MAAI,CAAC,UAAU,OAAO,WAAW,YAAa;AAC9C,MAAI,OAAO,aAAa,OAAO,UAAU,QAAQ,IAAI,KAAK,IAAI,GAAG;AAC/D,UAAM,gCAAgC,IAAI,SAAS,OAAO,UAAU,QAAQ,IAAI,KAAK,IAAI,CAAC;AAC1F;AAAA,EACF;AAEA,QAAM,UAAU,MAAM,aAAa,aAAa,OAAO,IAAI,KAAK;AAChE,MAAI,CAAC,QAAS;AACd,MAAI;AACF,UAAM,KAAK,IAAI,QAAuB,IAAI;AAC1C,UAAM,qBAAqB,MAAM,GAAG,UAAoC,EACrE,WAAW,aAAa,EACxB,OAAO,IAAI,EACX,MAAM,aAAa,KAAK,QAAQ,QAAQ,EACxC,MAAM,kBAAkB,KAAK,QAAQ,aAAa,EAClD,MAAM,gBAAgB,KAAK,QAAQ,WAAW,EAC9C,iBAAiB;AACpB,QAAI,CAAC,oBAAoB;AACvB,UAAI,CAAC,QAAQ,cAAe,OAAM,IAAI,MAAM,0DAA0D;AACtG,YAAM,UAAU,IAAI,QAA8B,sBAAsB;AACxE,YAAM,SAAS,MAAM,QAAQ,oBAAoB,QAAQ,eAAe;AAAA,QACtE,UAAU,QAAQ;AAAA,QAClB,gBAAgB,QAAQ;AAAA,MAC1B,CAAC;AACD,aAAO,OAAO,eAAe,QAAQ,eAAe,QAAQ,WAAW,KAClE,OAAO,OAAO,QAAQ,eAAe,QAAQ,WAAW;AAAA,IAC/D;AACA,UAAM,aAAa,QAAQ,QAAQ,IAAI,QAAQ,UAAU;AAAA,EAC3D,SAAS,OAAO;AACd,QAAI,QAAQ,WAAW;AACrB,YAAM,gCAAgC,IAAI,SAAS,QAAQ,UAAU,QAAQ,IAAI,KAAK,IAAI,CAAC;AAAA,IAC7F;AACA,UAAM;AAAA,EACR;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Role, RoleAcl, User, UserRole } from "@open-mercato/core/modules/auth/d
|
|
|
4
4
|
import { Tenant, Organization } from "@open-mercato/core/modules/directory/data/entities";
|
|
5
5
|
import { rebuildHierarchyForTenant } from "@open-mercato/core/modules/directory/lib/hierarchy";
|
|
6
6
|
import { normalizeTenantId } from "./tenantAccess.js";
|
|
7
|
-
import { computeEmailHash } from "@open-mercato/core/modules/auth/lib/emailHash";
|
|
7
|
+
import { computeEmailHash, emailHashLookupValues } from "@open-mercato/core/modules/auth/lib/emailHash";
|
|
8
8
|
import { getDefaultEncryptionMaps } from "@open-mercato/shared/modules/registry";
|
|
9
9
|
import { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
10
10
|
import { EncryptionMap } from "@open-mercato/core/modules/entities/data/entities";
|
|
@@ -92,7 +92,20 @@ async function setupInitialTenant(em, options) {
|
|
|
92
92
|
const resolvedModules = options.modules ?? tryGetModules();
|
|
93
93
|
const defaultEncryptionMaps = getDefaultEncryptionMaps(resolvedModules);
|
|
94
94
|
const mainEmail = primaryUser.email;
|
|
95
|
-
const
|
|
95
|
+
const existingUserByHash = await findOneWithDecryption(
|
|
96
|
+
em,
|
|
97
|
+
User,
|
|
98
|
+
{ emailHash: { $in: emailHashLookupValues(mainEmail) }, deletedAt: null },
|
|
99
|
+
{},
|
|
100
|
+
{ tenantId: null, organizationId: null }
|
|
101
|
+
);
|
|
102
|
+
const existingUser = existingUserByHash ?? await findOneWithDecryption(
|
|
103
|
+
em,
|
|
104
|
+
User,
|
|
105
|
+
{ email: mainEmail, deletedAt: null },
|
|
106
|
+
{},
|
|
107
|
+
{ tenantId: null, organizationId: null }
|
|
108
|
+
);
|
|
96
109
|
if (existingUser && failIfUserExists) {
|
|
97
110
|
throw new Error("USER_EXISTS");
|
|
98
111
|
}
|
|
@@ -236,6 +249,7 @@ async function setupInitialTenant(em, options) {
|
|
|
236
249
|
await tem.flush();
|
|
237
250
|
if (isTenantDataEncryptionEnabled()) {
|
|
238
251
|
for (const spec of defaultEncryptionMaps) {
|
|
252
|
+
if (spec.keyScope === "system") continue;
|
|
239
253
|
const existing = await findOneWithDecryption(tem, EncryptionMap, { entityId: spec.entityId, tenantId: tenant.id, organizationId: organization.id, deletedAt: null }, {}, { tenantId: String(tenant.id), organizationId: String(organization.id) });
|
|
240
254
|
if (!existing) {
|
|
241
255
|
tem.persist(tem.create(EncryptionMap, {
|