@powerhousedao/reactor-attachments 6.2.2-dev.7 → 6.2.2-dev.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +288 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +445 -10
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +211 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +851 -35
- package/dist/index.js.map +1 -1
- package/dist/{null-attachment-transport-BBhQIk5A.d.ts → null-attachment-transport-CMrO_ZKA.d.ts} +257 -10
- package/dist/null-attachment-transport-CMrO_ZKA.d.ts.map +1 -0
- package/dist/{null-attachment-transport-Drx03s02.js → null-attachment-transport-CrsUafCi.js} +361 -27
- package/dist/null-attachment-transport-CrsUafCi.js.map +1 -0
- package/package.json +12 -4
- package/dist/null-attachment-transport-BBhQIk5A.d.ts.map +0 -1
- package/dist/null-attachment-transport-Drx03s02.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as UploadTooLarge, S as SizeMismatch, _ as AttachmentPending, a as RemoteAttachmentUpload, b as InvalidAttachmentRef, c as createFetchUploadTransport, d as createRef, f as parseRef, g as AttachmentNotFound, h as AttachmentAlreadyExists, i as RemoteAttachmentUploadFactory, l as SwitchboardAttachmentTransport, m as parseAttachmentUploadTarget, n as createRemoteAttachmentService, o as RemoteReservationStore, p as parseAttachmentDownloadTarget, r as RemoteAttachmentStore, s as createXhrUploadTransport, t as NullAttachmentTransport, u as AttachmentService, v as AttachmentTransferError, x as ReservationNotFound, y as HashMismatch } from "./null-attachment-transport-CrsUafCi.js";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { Migrator, sql } from "kysely";
|
|
4
4
|
import { mkdir, rename, rm } from "node:fs/promises";
|
|
5
5
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
|
+
import { generatorTypeDefs } from "@powerhousedao/document-engineering/graphql";
|
|
9
|
+
import { constantCase, pascalCase } from "change-case";
|
|
10
|
+
import { Kind, buildASTSchema, getNamedType, isInputObjectType, isListType, isNonNullType, parse, print } from "graphql";
|
|
11
|
+
import { BaseReadModel } from "@powerhousedao/reactor";
|
|
12
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
13
|
+
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
14
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
8
15
|
//#region \0rolldown/runtime.js
|
|
9
16
|
var __defProp = Object.defineProperty;
|
|
10
17
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -91,19 +98,29 @@ async function deleteAttachmentBytes(path) {
|
|
|
91
98
|
* stream. At stream end, if the count does not equal the declaration,
|
|
92
99
|
* `SizeMismatch` is thrown. Both the `maxBytes` and `declaredSizeBytes`
|
|
93
100
|
* checks apply; `maxBytes` is evaluated first on each chunk.
|
|
101
|
+
*
|
|
102
|
+
* If `signal` aborts, the temp file is removed and the signal's reason is
|
|
103
|
+
* thrown. Nothing partial is ever returned, so a cancelled transfer cannot be
|
|
104
|
+
* committed.
|
|
94
105
|
*/
|
|
95
106
|
async function streamHashAndWrite(basePath, data, options = {}) {
|
|
96
|
-
const { maxBytes, declaredSizeBytes } = options;
|
|
107
|
+
const { maxBytes, declaredSizeBytes, signal } = options;
|
|
108
|
+
signal?.throwIfAborted();
|
|
97
109
|
const tmpDir = join(basePath, ".tmp");
|
|
98
110
|
await mkdir(tmpDir, { recursive: true });
|
|
99
111
|
const tempPath = join(tmpDir, randomUUID());
|
|
100
112
|
const hasher = createHash("sha256");
|
|
101
113
|
const writer = createWriteStream(tempPath);
|
|
102
114
|
const reader = data.getReader();
|
|
115
|
+
const onAbort = () => {
|
|
116
|
+
reader.cancel(signal?.reason).catch(() => {});
|
|
117
|
+
};
|
|
118
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
103
119
|
let sizeBytes = 0;
|
|
104
120
|
let caughtError;
|
|
105
121
|
try {
|
|
106
122
|
for (;;) {
|
|
123
|
+
signal?.throwIfAborted();
|
|
107
124
|
const { done, value } = await reader.read();
|
|
108
125
|
if (done) break;
|
|
109
126
|
sizeBytes += value.byteLength;
|
|
@@ -123,9 +140,11 @@ async function streamHashAndWrite(basePath, data, options = {}) {
|
|
|
123
140
|
writer.once("error", onError);
|
|
124
141
|
});
|
|
125
142
|
}
|
|
143
|
+
signal?.throwIfAborted();
|
|
126
144
|
} catch (err) {
|
|
127
145
|
caughtError = err instanceof Error ? err : new Error(String(err));
|
|
128
146
|
} finally {
|
|
147
|
+
signal?.removeEventListener("abort", onAbort);
|
|
129
148
|
reader.releaseLock();
|
|
130
149
|
}
|
|
131
150
|
let endError;
|
|
@@ -391,84 +410,84 @@ var KyselyReservationStore = class {
|
|
|
391
410
|
//#endregion
|
|
392
411
|
//#region src/storage/migrations/001_create_attachment_table.ts
|
|
393
412
|
var _001_create_attachment_table_exports = /* @__PURE__ */ __exportAll({
|
|
394
|
-
down: () => down$
|
|
395
|
-
up: () => up$
|
|
413
|
+
down: () => down$6,
|
|
414
|
+
up: () => up$6
|
|
396
415
|
});
|
|
397
|
-
async function up$
|
|
416
|
+
async function up$6(db) {
|
|
398
417
|
await db.schema.createTable("attachment").addColumn("hash", "text", (col) => col.primaryKey()).addColumn("mime_type", "text", (col) => col.notNull()).addColumn("file_name", "text", (col) => col.notNull()).addColumn("size_bytes", "bigint", (col) => col.notNull()).addColumn("extension", "text").addColumn("status", "text", (col) => col.notNull().defaultTo("available")).addColumn("storage_path", "text", (col) => col.notNull()).addColumn("source", "text", (col) => col.notNull().defaultTo("local")).addColumn("created_at_utc", "text", (col) => col.notNull()).addColumn("last_accessed_at_utc", "text", (col) => col.notNull()).execute();
|
|
399
418
|
await db.schema.createIndex("idx_attachment_status").on("attachment").column("status").execute();
|
|
400
419
|
await db.schema.createIndex("idx_attachment_lru").on("attachment").columns(["status", "last_accessed_at_utc"]).execute();
|
|
401
420
|
}
|
|
402
|
-
async function down$
|
|
421
|
+
async function down$6(db) {
|
|
403
422
|
await db.schema.dropTable("attachment").ifExists().execute();
|
|
404
423
|
}
|
|
405
424
|
//#endregion
|
|
406
425
|
//#region src/storage/migrations/002_create_reservation_table.ts
|
|
407
426
|
var _002_create_reservation_table_exports = /* @__PURE__ */ __exportAll({
|
|
408
|
-
down: () => down$
|
|
409
|
-
up: () => up$
|
|
427
|
+
down: () => down$5,
|
|
428
|
+
up: () => up$5
|
|
410
429
|
});
|
|
411
|
-
async function up$
|
|
430
|
+
async function up$5(db) {
|
|
412
431
|
await db.schema.createTable("attachment_reservation").addColumn("reservation_id", "text", (col) => col.primaryKey()).addColumn("mime_type", "text", (col) => col.notNull()).addColumn("file_name", "text", (col) => col.notNull()).addColumn("extension", "text").addColumn("created_at_utc", "text", (col) => col.notNull()).execute();
|
|
413
432
|
}
|
|
414
|
-
async function down$
|
|
433
|
+
async function down$5(db) {
|
|
415
434
|
await db.schema.dropTable("attachment_reservation").ifExists().execute();
|
|
416
435
|
}
|
|
417
436
|
//#endregion
|
|
418
437
|
//#region src/storage/migrations/003_add_reservation_expires_at.ts
|
|
419
438
|
var _003_add_reservation_expires_at_exports = /* @__PURE__ */ __exportAll({
|
|
420
|
-
down: () => down$
|
|
421
|
-
up: () => up$
|
|
439
|
+
down: () => down$4,
|
|
440
|
+
up: () => up$4
|
|
422
441
|
});
|
|
423
|
-
async function up$
|
|
442
|
+
async function up$4(db) {
|
|
424
443
|
await db.schema.alterTable("attachment_reservation").addColumn("expires_at_utc", "text").execute();
|
|
425
444
|
await db.updateTable("attachment_reservation").set({ expires_at_utc: sql`created_at_utc` }).where("expires_at_utc", "is", null).execute();
|
|
426
445
|
await db.schema.alterTable("attachment_reservation").alterColumn("expires_at_utc", (col) => col.setNotNull()).execute();
|
|
427
446
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
428
447
|
}
|
|
429
|
-
async function down$
|
|
448
|
+
async function down$4(db) {
|
|
430
449
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
431
450
|
await db.schema.alterTable("attachment_reservation").dropColumn("expires_at_utc").execute();
|
|
432
451
|
}
|
|
433
452
|
//#endregion
|
|
434
453
|
//#region src/storage/migrations/004_add_reservation_soft_delete.ts
|
|
435
454
|
var _004_add_reservation_soft_delete_exports = /* @__PURE__ */ __exportAll({
|
|
436
|
-
down: () => down$
|
|
437
|
-
up: () => up$
|
|
455
|
+
down: () => down$3,
|
|
456
|
+
up: () => up$3
|
|
438
457
|
});
|
|
439
|
-
async function up$
|
|
458
|
+
async function up$3(db) {
|
|
440
459
|
await db.schema.alterTable("attachment_reservation").addColumn("deleted_at_utc", "text").execute();
|
|
441
460
|
}
|
|
442
|
-
async function down$
|
|
461
|
+
async function down$3(db) {
|
|
443
462
|
await db.schema.alterTable("attachment_reservation").dropColumn("deleted_at_utc").execute();
|
|
444
463
|
}
|
|
445
464
|
//#endregion
|
|
446
465
|
//#region src/storage/migrations/005_add_reservation_active_index.ts
|
|
447
466
|
var _005_add_reservation_active_index_exports = /* @__PURE__ */ __exportAll({
|
|
448
|
-
down: () => down$
|
|
449
|
-
up: () => up$
|
|
467
|
+
down: () => down$2,
|
|
468
|
+
up: () => up$2
|
|
450
469
|
});
|
|
451
|
-
async function up$
|
|
470
|
+
async function up$2(db) {
|
|
452
471
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
453
472
|
await db.schema.createIndex("idx_reservation_expires_at_active").on("attachment_reservation").column("expires_at_utc").where(sql`deleted_at_utc IS NULL`).execute();
|
|
454
473
|
}
|
|
455
|
-
async function down$
|
|
474
|
+
async function down$2(db) {
|
|
456
475
|
await db.schema.dropIndex("idx_reservation_expires_at_active").ifExists().execute();
|
|
457
476
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
458
477
|
}
|
|
459
478
|
//#endregion
|
|
460
479
|
//#region src/storage/migrations/006_add_reservation_client_hash.ts
|
|
461
480
|
var _006_add_reservation_client_hash_exports = /* @__PURE__ */ __exportAll({
|
|
462
|
-
down: () => down,
|
|
463
|
-
up: () => up
|
|
481
|
+
down: () => down$1,
|
|
482
|
+
up: () => up$1
|
|
464
483
|
});
|
|
465
|
-
async function up(db) {
|
|
484
|
+
async function up$1(db) {
|
|
466
485
|
await db.schema.alterTable("attachment_reservation").addColumn("client_hash", "text").execute();
|
|
467
486
|
await db.schema.alterTable("attachment_reservation").addColumn("size_bytes", "bigint").execute();
|
|
468
487
|
await db.schema.alterTable("attachment_reservation").addCheckConstraint("attachment_reservation_hash_size_check", sql`client_hash is null or size_bytes is not null`).execute();
|
|
469
488
|
await db.schema.createIndex("idx_reservation_client_hash").on("attachment_reservation").column("client_hash").execute();
|
|
470
489
|
}
|
|
471
|
-
async function down(db) {
|
|
490
|
+
async function down$1(db) {
|
|
472
491
|
await db.schema.dropIndex("idx_reservation_client_hash").ifExists().execute();
|
|
473
492
|
await db.schema.alterTable("attachment_reservation").dropColumn("size_bytes").execute();
|
|
474
493
|
await db.schema.alterTable("attachment_reservation").dropColumn("client_hash").execute();
|
|
@@ -476,7 +495,7 @@ async function down(db) {
|
|
|
476
495
|
//#endregion
|
|
477
496
|
//#region src/storage/migrations/migrator.ts
|
|
478
497
|
const ATTACHMENT_SCHEMA = "attachments";
|
|
479
|
-
const migrations = {
|
|
498
|
+
const migrations$1 = {
|
|
480
499
|
"001_create_attachment_table": _001_create_attachment_table_exports,
|
|
481
500
|
"002_create_reservation_table": _002_create_reservation_table_exports,
|
|
482
501
|
"003_add_reservation_expires_at": _003_add_reservation_expires_at_exports,
|
|
@@ -484,9 +503,9 @@ const migrations = {
|
|
|
484
503
|
"005_add_reservation_active_index": _005_add_reservation_active_index_exports,
|
|
485
504
|
"006_add_reservation_client_hash": _006_add_reservation_client_hash_exports
|
|
486
505
|
};
|
|
487
|
-
var ProgrammaticMigrationProvider = class {
|
|
506
|
+
var ProgrammaticMigrationProvider$1 = class {
|
|
488
507
|
getMigrations() {
|
|
489
|
-
return Promise.resolve(migrations);
|
|
508
|
+
return Promise.resolve(migrations$1);
|
|
490
509
|
}
|
|
491
510
|
};
|
|
492
511
|
async function runAttachmentMigrations(db, schema = ATTACHMENT_SCHEMA) {
|
|
@@ -501,7 +520,7 @@ async function runAttachmentMigrations(db, schema = ATTACHMENT_SCHEMA) {
|
|
|
501
520
|
}
|
|
502
521
|
const migrator = new Migrator({
|
|
503
522
|
db: db.withSchema(schema),
|
|
504
|
-
provider: new ProgrammaticMigrationProvider(),
|
|
523
|
+
provider: new ProgrammaticMigrationProvider$1(),
|
|
505
524
|
migrationTableSchema: schema
|
|
506
525
|
});
|
|
507
526
|
let error;
|
|
@@ -555,12 +574,13 @@ var DirectAttachmentUpload = class {
|
|
|
555
574
|
this.ref = reservation.clientHash != null ? createRef(reservation.clientHash) : null;
|
|
556
575
|
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
557
576
|
}
|
|
558
|
-
async send(data) {
|
|
577
|
+
async send(data, options) {
|
|
559
578
|
if (this.reservation.clientHash != null && this.reservation.sizeBytes == null) throw new Error("hash-first reservation missing sizeBytes");
|
|
560
579
|
const declaredSizeBytes = this.reservation.clientHash != null ? this.reservation.sizeBytes ?? void 0 : void 0;
|
|
561
580
|
const { tempPath, hash, sizeBytes } = await streamHashAndWrite(this.basePath, data, {
|
|
562
581
|
maxBytes: this.maxBytes,
|
|
563
|
-
declaredSizeBytes
|
|
582
|
+
declaredSizeBytes,
|
|
583
|
+
...options?.signal ? { signal: options.signal } : {}
|
|
564
584
|
});
|
|
565
585
|
if (this.reservation.clientHash != null && hash !== this.reservation.clientHash) {
|
|
566
586
|
await rm(tempPath, { force: true });
|
|
@@ -621,12 +641,69 @@ var DirectAttachmentUploadFactory = class {
|
|
|
621
641
|
}
|
|
622
642
|
};
|
|
623
643
|
//#endregion
|
|
644
|
+
//#region src/direct/filesystem-attachment-backend.ts
|
|
645
|
+
/**
|
|
646
|
+
* Filesystem keeps byte transfer behind Switchboard. URL construction stays
|
|
647
|
+
* at the server edge, while this adapter validates that a filesystem backend
|
|
648
|
+
* can never accidentally return a direct-provider target.
|
|
649
|
+
*/
|
|
650
|
+
var FilesystemAttachmentBackend = class {
|
|
651
|
+
kind = "filesystem";
|
|
652
|
+
constructor(store, config) {
|
|
653
|
+
this.store = store;
|
|
654
|
+
this.config = config;
|
|
655
|
+
}
|
|
656
|
+
async prepareUploadTarget(reservation) {
|
|
657
|
+
const target = parseAttachmentUploadTarget(await this.config.uploadTarget(reservation));
|
|
658
|
+
if (target.kind !== "switchboard") throw new Error("Filesystem upload target must use Switchboard");
|
|
659
|
+
return target;
|
|
660
|
+
}
|
|
661
|
+
async prepareDownloadTarget(hash) {
|
|
662
|
+
const target = parseAttachmentDownloadTarget(await this.config.downloadTarget(hash));
|
|
663
|
+
if (target.kind !== "switchboard") throw new Error("Filesystem download target must use Switchboard");
|
|
664
|
+
return target;
|
|
665
|
+
}
|
|
666
|
+
exists(hash) {
|
|
667
|
+
return this.store.has(hash);
|
|
668
|
+
}
|
|
669
|
+
async health() {
|
|
670
|
+
return {
|
|
671
|
+
kind: this.kind,
|
|
672
|
+
ready: await (this.config.readiness?.() ?? true)
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/storage/s3/upload-factory.ts
|
|
678
|
+
var S3AttachmentUpload = class {
|
|
679
|
+
reservationId;
|
|
680
|
+
ref;
|
|
681
|
+
expiresAtUtc;
|
|
682
|
+
uploadTarget;
|
|
683
|
+
constructor(reservation) {
|
|
684
|
+
this.reservationId = reservation.reservationId;
|
|
685
|
+
this.ref = reservation.clientHash === null ? null : createRef(reservation.clientHash);
|
|
686
|
+
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
687
|
+
this.uploadTarget = reservation.uploadTarget;
|
|
688
|
+
}
|
|
689
|
+
async send(data) {
|
|
690
|
+
await data.cancel().catch(() => {});
|
|
691
|
+
throw new Error("S3 attachment upload must use the presigned target");
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
var S3AttachmentUploadFactory = class {
|
|
695
|
+
createUpload(reservation) {
|
|
696
|
+
return new S3AttachmentUpload(reservation);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
//#endregion
|
|
624
700
|
//#region src/attachment-builder.ts
|
|
625
701
|
var AttachmentBuilder = class {
|
|
626
702
|
transport = new NullAttachmentTransport();
|
|
627
703
|
customUploadFactory;
|
|
628
704
|
maxUploadBytes;
|
|
629
705
|
reservationSweepMs;
|
|
706
|
+
backend;
|
|
630
707
|
constructor(db, storagePath) {
|
|
631
708
|
this.db = db;
|
|
632
709
|
this.storagePath = storagePath;
|
|
@@ -639,6 +716,10 @@ var AttachmentBuilder = class {
|
|
|
639
716
|
this.customUploadFactory = factory;
|
|
640
717
|
return this;
|
|
641
718
|
}
|
|
719
|
+
withBackend(backend) {
|
|
720
|
+
this.backend = backend;
|
|
721
|
+
return this;
|
|
722
|
+
}
|
|
642
723
|
withMaxUploadBytes(maxBytes) {
|
|
643
724
|
this.maxUploadBytes = maxBytes;
|
|
644
725
|
return this;
|
|
@@ -661,8 +742,8 @@ var AttachmentBuilder = class {
|
|
|
661
742
|
const scopedDb = this.db.withSchema(ATTACHMENT_SCHEMA);
|
|
662
743
|
const store = new KyselyAttachmentStore(scopedDb, this.transport, this.storagePath);
|
|
663
744
|
const reservations = new KyselyReservationStore(scopedDb);
|
|
664
|
-
const uploadFactory = this.customUploadFactory ?? new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations, this.maxUploadBytes);
|
|
665
|
-
const service = new AttachmentService(store, reservations, uploadFactory);
|
|
745
|
+
const uploadFactory = this.customUploadFactory ?? (this.backend?.kind === "s3" ? new S3AttachmentUploadFactory() : new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations, this.maxUploadBytes));
|
|
746
|
+
const service = new AttachmentService(store, reservations, uploadFactory, this.backend);
|
|
666
747
|
let sweepTimer;
|
|
667
748
|
if (this.reservationSweepMs !== void 0) {
|
|
668
749
|
const intervalMs = this.reservationSweepMs;
|
|
@@ -682,11 +763,746 @@ var AttachmentBuilder = class {
|
|
|
682
763
|
store,
|
|
683
764
|
reservations,
|
|
684
765
|
uploadFactory,
|
|
766
|
+
...this.backend ? { backend: this.backend } : {},
|
|
685
767
|
destroy
|
|
686
768
|
};
|
|
687
769
|
}
|
|
688
770
|
};
|
|
689
771
|
//#endregion
|
|
690
|
-
|
|
772
|
+
//#region src/reference-index/attachment-schema-compiler.ts
|
|
773
|
+
const ATTACHMENT_REF_TYPE = "AttachmentRef";
|
|
774
|
+
const CODEGEN_SCALAR_NAMES = new Set([
|
|
775
|
+
"Unknown",
|
|
776
|
+
"DateTime",
|
|
777
|
+
"Address",
|
|
778
|
+
ATTACHMENT_REF_TYPE,
|
|
779
|
+
...Object.keys(generatorTypeDefs)
|
|
780
|
+
]);
|
|
781
|
+
function describeContext(context) {
|
|
782
|
+
return `document type "${context.documentType}", version ${context.version}, action "${context.actionType}"`;
|
|
783
|
+
}
|
|
784
|
+
function compilationError(context, reason) {
|
|
785
|
+
return /* @__PURE__ */ new Error(`Attachment schema compilation failed for ${describeContext(context)}: ${reason}`);
|
|
786
|
+
}
|
|
787
|
+
function extractionError(context, path, reason) {
|
|
788
|
+
return /* @__PURE__ */ new Error(`Attachment extraction failed for ${describeContext(context)} at ${path}: ${reason}`);
|
|
789
|
+
}
|
|
790
|
+
function applicableSpecification(module, context) {
|
|
791
|
+
const matches = module.documentModel.global.specifications.filter((specification) => specification.version === context.version);
|
|
792
|
+
if (matches.length !== 1) throw compilationError(context, matches.length === 0 ? "the module has no matching specification" : "the module has multiple matching specifications");
|
|
793
|
+
return matches[0];
|
|
794
|
+
}
|
|
795
|
+
function parseOperations(specification, context) {
|
|
796
|
+
return specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.map((operation) => {
|
|
797
|
+
if (operation.schema === null) return {
|
|
798
|
+
document: null,
|
|
799
|
+
operation
|
|
800
|
+
};
|
|
801
|
+
try {
|
|
802
|
+
return {
|
|
803
|
+
document: parse(operation.schema),
|
|
804
|
+
operation
|
|
805
|
+
};
|
|
806
|
+
} catch {
|
|
807
|
+
throw compilationError(context, "an operation has invalid GraphQL SDL");
|
|
808
|
+
}
|
|
809
|
+
}));
|
|
810
|
+
}
|
|
811
|
+
function selectOperation(operations, context) {
|
|
812
|
+
const matches = operations.filter(({ operation }) => operation.name !== null && constantCase(operation.name) === context.actionType);
|
|
813
|
+
if (matches.length > 1) throw compilationError(context, "multiple operations map to the action");
|
|
814
|
+
if (matches.length === 0) return null;
|
|
815
|
+
return matches[0];
|
|
816
|
+
}
|
|
817
|
+
function buildEffectiveSchema(specification, context) {
|
|
818
|
+
const scalarSchemas = Array.from(CODEGEN_SCALAR_NAMES, (name) => `scalar ${name}`);
|
|
819
|
+
const stateSchemas = Object.values(specification.state).map((state) => state.schema);
|
|
820
|
+
const operationSchemas = specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.flatMap((operation) => operation.schema === null ? [] : [operation.schema]));
|
|
821
|
+
try {
|
|
822
|
+
return buildASTSchema(dedupeTypeDefinitions(parse([
|
|
823
|
+
...scalarSchemas,
|
|
824
|
+
...stateSchemas,
|
|
825
|
+
...operationSchemas
|
|
826
|
+
].filter(Boolean).join("\n\n"))));
|
|
827
|
+
} catch {
|
|
828
|
+
throw compilationError(context, "the effective GraphQL schema is invalid");
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function dedupeTypeDefinitions(document) {
|
|
832
|
+
const seen = /* @__PURE__ */ new Map();
|
|
833
|
+
const definitions = document.definitions.filter((definition) => {
|
|
834
|
+
if (!("name" in definition) || definition.name?.value === void 0 || definition.kind === Kind.SCALAR_TYPE_DEFINITION) return true;
|
|
835
|
+
const name = definition.name.value;
|
|
836
|
+
const printed = print(definition);
|
|
837
|
+
const existing = seen.get(name);
|
|
838
|
+
if (existing === void 0) {
|
|
839
|
+
seen.set(name, printed);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
return existing !== printed;
|
|
843
|
+
});
|
|
844
|
+
return {
|
|
845
|
+
...document,
|
|
846
|
+
definitions
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function attachmentReachableTypes(definitions) {
|
|
850
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
851
|
+
let changed = true;
|
|
852
|
+
while (changed) {
|
|
853
|
+
changed = false;
|
|
854
|
+
for (const [name, definition] of definitions) {
|
|
855
|
+
if (reachable.has(name)) continue;
|
|
856
|
+
if (Object.values(definition.getFields()).some((field) => {
|
|
857
|
+
const typeName = getNamedType(field.type).name;
|
|
858
|
+
return typeName === ATTACHMENT_REF_TYPE || definitions.has(typeName) && reachable.has(typeName);
|
|
859
|
+
})) {
|
|
860
|
+
reachable.add(name);
|
|
861
|
+
changed = true;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return reachable;
|
|
866
|
+
}
|
|
867
|
+
function compileValuePlan(type, objectPlans) {
|
|
868
|
+
if (isNonNullType(type)) return {
|
|
869
|
+
...compileValuePlan(type.ofType, objectPlans),
|
|
870
|
+
required: true
|
|
871
|
+
};
|
|
872
|
+
if (isListType(type)) return {
|
|
873
|
+
item: compileValuePlan(type.ofType, objectPlans),
|
|
874
|
+
kind: "list",
|
|
875
|
+
required: false
|
|
876
|
+
};
|
|
877
|
+
const typeName = type.name;
|
|
878
|
+
if (typeName === ATTACHMENT_REF_TYPE) return {
|
|
879
|
+
kind: "attachment",
|
|
880
|
+
required: false
|
|
881
|
+
};
|
|
882
|
+
const body = objectPlans.get(typeName);
|
|
883
|
+
if (!body) throw new Error(`Internal attachment schema plan error for ${typeName}`);
|
|
884
|
+
return {
|
|
885
|
+
body,
|
|
886
|
+
kind: "object",
|
|
887
|
+
required: false
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
function compileRootPlan(rootName, definitions) {
|
|
891
|
+
const reachable = attachmentReachableTypes(definitions);
|
|
892
|
+
if (!reachable.has(rootName)) return null;
|
|
893
|
+
const objectPlans = /* @__PURE__ */ new Map();
|
|
894
|
+
for (const name of reachable) objectPlans.set(name, { fields: [] });
|
|
895
|
+
for (const name of reachable) {
|
|
896
|
+
const definition = definitions.get(name);
|
|
897
|
+
const body = objectPlans.get(name);
|
|
898
|
+
if (!definition || !body) continue;
|
|
899
|
+
for (const field of Object.values(definition.getFields())) {
|
|
900
|
+
const typeName = getNamedType(field.type).name;
|
|
901
|
+
if (typeName !== ATTACHMENT_REF_TYPE && !reachable.has(typeName)) continue;
|
|
902
|
+
body.fields.push({
|
|
903
|
+
hasDefault: field.defaultValue !== void 0,
|
|
904
|
+
name: field.name,
|
|
905
|
+
value: compileValuePlan(field.type, objectPlans)
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return objectPlans.get(rootName) ?? null;
|
|
910
|
+
}
|
|
911
|
+
function readOwnField(value, fieldName, context, path) {
|
|
912
|
+
try {
|
|
913
|
+
if (!Object.prototype.hasOwnProperty.call(value, fieldName)) return {
|
|
914
|
+
present: false,
|
|
915
|
+
value: void 0
|
|
916
|
+
};
|
|
917
|
+
return {
|
|
918
|
+
present: true,
|
|
919
|
+
value: value[fieldName]
|
|
920
|
+
};
|
|
921
|
+
} catch {
|
|
922
|
+
throw extractionError(context, path, "the declared field cannot be read");
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function extractValue(plan, value, path, context, refs, seenRefs, activeObjects) {
|
|
926
|
+
if (value === null || value === void 0) {
|
|
927
|
+
if (plan.required) throw extractionError(context, path, "a required value is missing or null");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (plan.kind === "attachment") {
|
|
931
|
+
if (typeof value !== "string") throw extractionError(context, path, "expected an AttachmentRef string");
|
|
932
|
+
try {
|
|
933
|
+
parseRef(value);
|
|
934
|
+
} catch {
|
|
935
|
+
throw extractionError(context, path, "the AttachmentRef is malformed");
|
|
936
|
+
}
|
|
937
|
+
if (!seenRefs.has(value)) {
|
|
938
|
+
seenRefs.add(value);
|
|
939
|
+
refs.push(value);
|
|
940
|
+
}
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (plan.kind === "list") {
|
|
944
|
+
if (!Array.isArray(value)) throw extractionError(context, path, "expected a list");
|
|
945
|
+
for (let index = 0; index < value.length; index += 1) extractValue(plan.item, value[index], `${path}[${index}]`, context, refs, seenRefs, activeObjects);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (typeof value !== "object" || Array.isArray(value)) throw extractionError(context, path, "expected an input object");
|
|
949
|
+
if (activeObjects.has(value)) throw extractionError(context, path, "the input value contains a cycle");
|
|
950
|
+
activeObjects.add(value);
|
|
951
|
+
try {
|
|
952
|
+
const record = value;
|
|
953
|
+
for (const field of plan.body.fields) {
|
|
954
|
+
const fieldPath = `${path}.${field.name}`;
|
|
955
|
+
const result = readOwnField(record, field.name, context, fieldPath);
|
|
956
|
+
if ((!result.present || result.value === void 0) && field.hasDefault) continue;
|
|
957
|
+
extractValue(field.value, result.value, fieldPath, context, refs, seenRefs, activeObjects);
|
|
958
|
+
}
|
|
959
|
+
} finally {
|
|
960
|
+
activeObjects.delete(value);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
var SchemaCompiledAttachmentExtractor = class {
|
|
964
|
+
constructor(context, rootPlan) {
|
|
965
|
+
this.context = context;
|
|
966
|
+
this.rootPlan = rootPlan;
|
|
967
|
+
}
|
|
968
|
+
extract(action) {
|
|
969
|
+
if (action.type !== this.context.actionType) throw extractionError(this.context, "input", "the action type does not match the compiled schema");
|
|
970
|
+
if (!this.rootPlan) return [];
|
|
971
|
+
if (action.input === null || typeof action.input !== "object" || Array.isArray(action.input)) throw extractionError(this.context, "input", "expected an input object");
|
|
972
|
+
const refs = [];
|
|
973
|
+
const seenRefs = /* @__PURE__ */ new Set();
|
|
974
|
+
const activeObjects = /* @__PURE__ */ new WeakSet();
|
|
975
|
+
activeObjects.add(action.input);
|
|
976
|
+
try {
|
|
977
|
+
const input = action.input;
|
|
978
|
+
for (const field of this.rootPlan.fields) {
|
|
979
|
+
const path = `input.${field.name}`;
|
|
980
|
+
const result = readOwnField(input, field.name, this.context, path);
|
|
981
|
+
if ((!result.present || result.value === void 0) && field.hasDefault) continue;
|
|
982
|
+
extractValue(field.value, result.value, path, this.context, refs, seenRefs, activeObjects);
|
|
983
|
+
}
|
|
984
|
+
} finally {
|
|
985
|
+
activeObjects.delete(action.input);
|
|
986
|
+
}
|
|
987
|
+
return refs;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
function compileExtractor(module, actionType) {
|
|
991
|
+
const context = {
|
|
992
|
+
actionType,
|
|
993
|
+
documentType: module.documentModel.global.id,
|
|
994
|
+
version: module.version ?? 1
|
|
995
|
+
};
|
|
996
|
+
const specification = applicableSpecification(module, context);
|
|
997
|
+
const selected = selectOperation(parseOperations(specification, context), context);
|
|
998
|
+
if (selected === null || selected.operation.schema === null) return new SchemaCompiledAttachmentExtractor(context, null);
|
|
999
|
+
const effectiveSchema = buildEffectiveSchema(specification, context);
|
|
1000
|
+
const operationName = selected.operation.name;
|
|
1001
|
+
if (operationName === null) throw compilationError(context, "the operation has no name");
|
|
1002
|
+
const rootName = `${pascalCase(operationName)}Input`;
|
|
1003
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
1004
|
+
for (const type of Object.values(effectiveSchema.getTypeMap())) if (isInputObjectType(type) && !type.name.startsWith("__")) definitions.set(type.name, type);
|
|
1005
|
+
if (!(selected.document?.definitions.filter((definition) => (definition.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION || definition.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION) && definition.name.value === rootName))?.length || !definitions.has(rootName)) throw compilationError(context, `the operation does not declare its expected root input "${rootName}"`);
|
|
1006
|
+
return new SchemaCompiledAttachmentExtractor(context, compileRootPlan(rootName, definitions));
|
|
1007
|
+
}
|
|
1008
|
+
var AttachmentSchemaCompiler = class {
|
|
1009
|
+
cache = /* @__PURE__ */ new WeakMap();
|
|
1010
|
+
forModuleAction(module, actionType) {
|
|
1011
|
+
let moduleCache = this.cache.get(module);
|
|
1012
|
+
if (!moduleCache) {
|
|
1013
|
+
moduleCache = /* @__PURE__ */ new Map();
|
|
1014
|
+
this.cache.set(module, moduleCache);
|
|
1015
|
+
}
|
|
1016
|
+
const cached = moduleCache.get(actionType);
|
|
1017
|
+
if (cached) return cached;
|
|
1018
|
+
const compiled = compileExtractor(module, actionType);
|
|
1019
|
+
moduleCache.set(actionType, compiled);
|
|
1020
|
+
return compiled;
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/read-models/attachment-reference/kysely-attachment-reference-store.ts
|
|
1025
|
+
var KyselyAttachmentReferenceStore = class {
|
|
1026
|
+
constructor(db) {
|
|
1027
|
+
this.db = db;
|
|
1028
|
+
}
|
|
1029
|
+
async hasReference(documentId, ref) {
|
|
1030
|
+
return await this.db.selectFrom("attachment_reference").select("document_id").where("document_id", "=", documentId).where("attachment_ref", "=", ref).executeTakeFirst() !== void 0;
|
|
1031
|
+
}
|
|
1032
|
+
async addReferences(references) {
|
|
1033
|
+
if (references.length === 0) return;
|
|
1034
|
+
await this.db.insertInto("attachment_reference").values(references.map((reference) => ({
|
|
1035
|
+
document_id: reference.documentId,
|
|
1036
|
+
attachment_ref: reference.ref,
|
|
1037
|
+
attachment_hash: parseRef(reference.ref).hash,
|
|
1038
|
+
first_operation_id: reference.operationId,
|
|
1039
|
+
branch: reference.branch,
|
|
1040
|
+
scope: reference.scope,
|
|
1041
|
+
first_seen_ordinal: reference.ordinal,
|
|
1042
|
+
created_at_utc: (/* @__PURE__ */ new Date()).toISOString()
|
|
1043
|
+
}))).onConflict((oc) => oc.columns(["document_id", "attachment_ref"]).doNothing()).execute();
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
//#endregion
|
|
1047
|
+
//#region src/read-models/attachment-reference/storage/migrations/001_create_attachment_reference_table.ts
|
|
1048
|
+
var _001_create_attachment_reference_table_exports = /* @__PURE__ */ __exportAll({
|
|
1049
|
+
down: () => down,
|
|
1050
|
+
up: () => up
|
|
1051
|
+
});
|
|
1052
|
+
async function up(db) {
|
|
1053
|
+
await db.schema.createTable("attachment_reference").addColumn("document_id", "text", (col) => col.notNull()).addColumn("attachment_ref", "text", (col) => col.notNull()).addColumn("attachment_hash", "text", (col) => col.notNull()).addColumn("first_operation_id", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("first_seen_ordinal", "integer", (col) => col.notNull()).addColumn("created_at_utc", "text", (col) => col.notNull()).addUniqueConstraint("unique_attachment_reference_document_ref", ["document_id", "attachment_ref"]).execute();
|
|
1054
|
+
await db.schema.createIndex("idx_attachment_reference_ref").on("attachment_reference").column("attachment_ref").execute();
|
|
1055
|
+
await db.schema.createIndex("idx_attachment_reference_hash").on("attachment_reference").column("attachment_hash").execute();
|
|
1056
|
+
}
|
|
1057
|
+
async function down(db) {
|
|
1058
|
+
await db.schema.dropTable("attachment_reference").ifExists().execute();
|
|
1059
|
+
}
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/read-models/attachment-reference/storage/migrations/migrator.ts
|
|
1062
|
+
const ATTACHMENT_REFERENCE_SCHEMA = "attachment_reference_read_model";
|
|
1063
|
+
const ATTACHMENT_REFERENCE_MIGRATION_TABLE = "kysely_migration_attachment_reference_read_model";
|
|
1064
|
+
const ATTACHMENT_REFERENCE_MIGRATION_LOCK_TABLE = "kysely_migration_attachment_reference_read_model_lock";
|
|
1065
|
+
const migrations = { "001_create_attachment_reference_table": _001_create_attachment_reference_table_exports };
|
|
1066
|
+
var ProgrammaticMigrationProvider = class {
|
|
1067
|
+
getMigrations() {
|
|
1068
|
+
return Promise.resolve(migrations);
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
function createMigrator(db, schema) {
|
|
1072
|
+
return new Migrator({
|
|
1073
|
+
db: db.withSchema(schema),
|
|
1074
|
+
provider: new ProgrammaticMigrationProvider(),
|
|
1075
|
+
migrationTableSchema: schema,
|
|
1076
|
+
migrationTableName: ATTACHMENT_REFERENCE_MIGRATION_TABLE,
|
|
1077
|
+
migrationLockTableName: ATTACHMENT_REFERENCE_MIGRATION_LOCK_TABLE
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
function toResult(error, results) {
|
|
1081
|
+
const migrationsExecuted = results?.map((result) => result.migrationName) ?? [];
|
|
1082
|
+
if (error) return {
|
|
1083
|
+
success: false,
|
|
1084
|
+
migrationsExecuted,
|
|
1085
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Unknown migration error")
|
|
1086
|
+
};
|
|
1087
|
+
return {
|
|
1088
|
+
success: true,
|
|
1089
|
+
migrationsExecuted
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
async function runAttachmentReferenceMigrations(db, schema = ATTACHMENT_REFERENCE_SCHEMA) {
|
|
1093
|
+
try {
|
|
1094
|
+
await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(schema)}`.execute(db);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return {
|
|
1097
|
+
success: false,
|
|
1098
|
+
migrationsExecuted: [],
|
|
1099
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Failed to create schema")
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const { error, results } = await createMigrator(db, schema).migrateToLatest();
|
|
1104
|
+
return toResult(error, results);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
return toResult(error, []);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
async function rollbackAttachmentReferenceMigration(db, schema = ATTACHMENT_REFERENCE_SCHEMA) {
|
|
1110
|
+
try {
|
|
1111
|
+
const { error, results } = await createMigrator(db, schema).migrateDown();
|
|
1112
|
+
return toResult(error, results);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
return toResult(error, []);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
async function getAttachmentReferenceMigrationStatus(db, schema = ATTACHMENT_REFERENCE_SCHEMA) {
|
|
1118
|
+
return await createMigrator(db, schema).getMigrations();
|
|
1119
|
+
}
|
|
1120
|
+
//#endregion
|
|
1121
|
+
//#region src/read-models/attachment-reference/index-builder.ts
|
|
1122
|
+
var AttachmentReferenceIndexBuilder = class {
|
|
1123
|
+
constructor(db) {
|
|
1124
|
+
this.db = db;
|
|
1125
|
+
}
|
|
1126
|
+
async build() {
|
|
1127
|
+
const result = await runAttachmentReferenceMigrations(this.db);
|
|
1128
|
+
if (!result.success && result.error) throw result.error;
|
|
1129
|
+
return { store: new KyselyAttachmentReferenceStore(this.db.withSchema(ATTACHMENT_REFERENCE_SCHEMA)) };
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region src/read-models/attachment-reference/attachment-reference-read-model.ts
|
|
1134
|
+
const ATTACHMENT_REFERENCE_READ_MODEL_ID = "attachment-reference-read-model";
|
|
1135
|
+
var AttachmentReferenceReadModel = class extends BaseReadModel {
|
|
1136
|
+
indexingQueue = Promise.resolve();
|
|
1137
|
+
constructor(db, operationIndex, writeCache, consistencyTracker, documentModelRegistry, schemaCompiler, referenceWriter) {
|
|
1138
|
+
super(db, operationIndex, writeCache, consistencyTracker, {
|
|
1139
|
+
readModelId: ATTACHMENT_REFERENCE_READ_MODEL_ID,
|
|
1140
|
+
rebuildStateOnInit: false
|
|
1141
|
+
});
|
|
1142
|
+
this.documentModelRegistry = documentModelRegistry;
|
|
1143
|
+
this.schemaCompiler = schemaCompiler;
|
|
1144
|
+
this.referenceWriter = referenceWriter;
|
|
1145
|
+
}
|
|
1146
|
+
indexOperations(items) {
|
|
1147
|
+
return this.enqueue(() => this.indexOperationsInOrdinalOrder(items));
|
|
1148
|
+
}
|
|
1149
|
+
init() {
|
|
1150
|
+
return this.enqueue(async () => {
|
|
1151
|
+
const viewState = await this.loadState();
|
|
1152
|
+
if (viewState !== void 0) this.lastOrdinal = viewState;
|
|
1153
|
+
else await this.initializeState();
|
|
1154
|
+
let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
|
|
1155
|
+
while (page.results.length > 0) {
|
|
1156
|
+
await this.indexOperationsInOrdinalOrder(page.results);
|
|
1157
|
+
if (!page.next) break;
|
|
1158
|
+
page = await page.next();
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
enqueue(work) {
|
|
1163
|
+
const result = this.indexingQueue.then(work);
|
|
1164
|
+
this.indexingQueue = result.catch(() => void 0);
|
|
1165
|
+
return result;
|
|
1166
|
+
}
|
|
1167
|
+
async commitOperations(items) {
|
|
1168
|
+
const references = [];
|
|
1169
|
+
for (const { operation, context } of items) {
|
|
1170
|
+
if (operation.error !== void 0) continue;
|
|
1171
|
+
const module = this.documentModelRegistry.getModule(context.documentType);
|
|
1172
|
+
const refs = this.schemaCompiler.forModuleAction(module, operation.action.type).extract(operation.action);
|
|
1173
|
+
for (const ref of refs) references.push({
|
|
1174
|
+
documentId: context.documentId,
|
|
1175
|
+
ref,
|
|
1176
|
+
operationId: operation.id,
|
|
1177
|
+
branch: context.branch,
|
|
1178
|
+
scope: context.scope,
|
|
1179
|
+
ordinal: context.ordinal
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
if (references.length > 0) await this.referenceWriter.addReferences(references);
|
|
1183
|
+
}
|
|
1184
|
+
async indexOperationsInOrdinalOrder(incoming) {
|
|
1185
|
+
const pending = this.sortAndDedupe(incoming.filter(({ context }) => context.ordinal > this.lastOrdinal));
|
|
1186
|
+
if (pending.length === 0) return;
|
|
1187
|
+
const incomingMax = pending[pending.length - 1].context.ordinal;
|
|
1188
|
+
let candidates = pending;
|
|
1189
|
+
if (!this.isContiguousThrough(candidates, incomingMax)) {
|
|
1190
|
+
const replayed = await this.loadThroughOrdinal(incomingMax);
|
|
1191
|
+
candidates = this.sortAndDedupe([...replayed, ...pending]);
|
|
1192
|
+
}
|
|
1193
|
+
const contiguous = [];
|
|
1194
|
+
let expectedOrdinal = this.lastOrdinal + 1;
|
|
1195
|
+
for (const item of candidates) {
|
|
1196
|
+
const ordinal = item.context.ordinal;
|
|
1197
|
+
if (ordinal < expectedOrdinal) continue;
|
|
1198
|
+
if (ordinal > expectedOrdinal || ordinal > incomingMax) break;
|
|
1199
|
+
contiguous.push(item);
|
|
1200
|
+
expectedOrdinal++;
|
|
1201
|
+
}
|
|
1202
|
+
if (expectedOrdinal <= incomingMax) throw new Error(`Attachment reference read model cannot advance past missing ordinal ${expectedOrdinal}`);
|
|
1203
|
+
const previousOrdinal = this.lastOrdinal;
|
|
1204
|
+
try {
|
|
1205
|
+
await super.indexOperations(contiguous);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
this.lastOrdinal = previousOrdinal;
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
isContiguousThrough(items, maxOrdinal) {
|
|
1212
|
+
let expectedOrdinal = this.lastOrdinal + 1;
|
|
1213
|
+
for (const item of items) {
|
|
1214
|
+
if (item.context.ordinal !== expectedOrdinal) return false;
|
|
1215
|
+
expectedOrdinal++;
|
|
1216
|
+
}
|
|
1217
|
+
return expectedOrdinal > maxOrdinal;
|
|
1218
|
+
}
|
|
1219
|
+
async loadThroughOrdinal(maxOrdinal) {
|
|
1220
|
+
const operations = [];
|
|
1221
|
+
let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
|
|
1222
|
+
for (;;) {
|
|
1223
|
+
for (const item of page.results) if (item.context.ordinal <= maxOrdinal) operations.push(item);
|
|
1224
|
+
if (page.results.some(({ context }) => context.ordinal >= maxOrdinal) || !page.next) break;
|
|
1225
|
+
page = await page.next();
|
|
1226
|
+
}
|
|
1227
|
+
return operations;
|
|
1228
|
+
}
|
|
1229
|
+
sortAndDedupe(items) {
|
|
1230
|
+
const byOrdinal = /* @__PURE__ */ new Map();
|
|
1231
|
+
for (const item of items) byOrdinal.set(item.context.ordinal, item);
|
|
1232
|
+
return [...byOrdinal.values()].sort((left, right) => left.context.ordinal - right.context.ordinal);
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/storage/s3/config.ts
|
|
1237
|
+
const DEFAULT_S3_ATTACHMENT_PREFIX = "attachments";
|
|
1238
|
+
const DEFAULT_S3_UPLOAD_TTL_SECONDS = 900;
|
|
1239
|
+
const DEFAULT_S3_DOWNLOAD_TTL_SECONDS = 300;
|
|
1240
|
+
const MAX_S3_PRESIGN_TTL_SECONDS = 604800;
|
|
1241
|
+
function required(env, name) {
|
|
1242
|
+
const value = env[name];
|
|
1243
|
+
if (value === void 0 || value.trim().length === 0) throw new Error(`${name} is required and must not be blank`);
|
|
1244
|
+
if (value.trim() !== value) throw new Error(`${name} must not have leading or trailing whitespace`);
|
|
1245
|
+
return value;
|
|
1246
|
+
}
|
|
1247
|
+
const LOOPBACK_HOSTNAMES = new Set([
|
|
1248
|
+
"127.0.0.1",
|
|
1249
|
+
"localhost",
|
|
1250
|
+
"[::1]"
|
|
1251
|
+
]);
|
|
1252
|
+
/** Plain HTTP is allowed only for loopback hosts (local S3 emulators). */
|
|
1253
|
+
function isLoopbackHost(endpoint) {
|
|
1254
|
+
return LOOPBACK_HOSTNAMES.has(endpoint.hostname.toLowerCase());
|
|
1255
|
+
}
|
|
1256
|
+
function parseEndpoint(value) {
|
|
1257
|
+
const message = "PH_ATTACHMENT_S3_ENDPOINT must be a valid HTTPS URL (HTTP is allowed only for loopback hosts)";
|
|
1258
|
+
let endpoint;
|
|
1259
|
+
try {
|
|
1260
|
+
endpoint = new URL(value);
|
|
1261
|
+
} catch {
|
|
1262
|
+
throw new Error(message);
|
|
1263
|
+
}
|
|
1264
|
+
const protocolAllowed = endpoint.protocol === "https:" || endpoint.protocol === "http:" && isLoopbackHost(endpoint);
|
|
1265
|
+
if (value.trim() !== value || !protocolAllowed || endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "") throw new Error(message);
|
|
1266
|
+
return endpoint.toString().replace(/\/$/, "");
|
|
1267
|
+
}
|
|
1268
|
+
function parseBoolean(env, name, defaultValue) {
|
|
1269
|
+
const value = env[name];
|
|
1270
|
+
if (value === void 0) return defaultValue;
|
|
1271
|
+
if (value === "true") return true;
|
|
1272
|
+
if (value === "false") return false;
|
|
1273
|
+
throw new Error(`${name} must be either true or false`);
|
|
1274
|
+
}
|
|
1275
|
+
function parseTtl(env, name, defaultValue) {
|
|
1276
|
+
const value = env[name];
|
|
1277
|
+
if (value === void 0) return defaultValue;
|
|
1278
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${name} must be a positive integer`);
|
|
1279
|
+
const seconds = Number(value);
|
|
1280
|
+
if (!Number.isSafeInteger(seconds) || seconds > 604800) throw new Error(`${name} must be between 1 and ${MAX_S3_PRESIGN_TTL_SECONDS}`);
|
|
1281
|
+
return seconds;
|
|
1282
|
+
}
|
|
1283
|
+
function normalizeS3AttachmentPrefix(prefix) {
|
|
1284
|
+
if (prefix.trim() !== prefix || prefix.startsWith("/") || prefix.includes("\\")) throw new Error("S3_ATTACHMENT_PREFIX is unsafe");
|
|
1285
|
+
const normalized = prefix.replace(/\/+$/, "");
|
|
1286
|
+
if (normalized.length === 0) throw new Error("S3_ATTACHMENT_PREFIX must not be blank");
|
|
1287
|
+
const segments = normalized.split("/");
|
|
1288
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment))) throw new Error("S3_ATTACHMENT_PREFIX contains an unsafe segment");
|
|
1289
|
+
return segments.join("/");
|
|
1290
|
+
}
|
|
1291
|
+
function parseAttachmentStorageConfig(env = process.env) {
|
|
1292
|
+
const selector = env.PH_ATTACHMENT_STORAGE;
|
|
1293
|
+
if (selector === void 0 || selector === "filesystem") return { kind: "filesystem" };
|
|
1294
|
+
if (selector !== "s3") throw new Error("PH_ATTACHMENT_STORAGE must be either filesystem or s3");
|
|
1295
|
+
return {
|
|
1296
|
+
kind: "s3",
|
|
1297
|
+
s3: {
|
|
1298
|
+
endpoint: parseEndpoint(required(env, "PH_ATTACHMENT_S3_ENDPOINT")),
|
|
1299
|
+
region: required(env, "PH_ATTACHMENT_S3_REGION"),
|
|
1300
|
+
bucket: required(env, "PH_ATTACHMENT_S3_BUCKET"),
|
|
1301
|
+
accessKeyId: required(env, "PH_ATTACHMENT_S3_ACCESS_KEY_ID"),
|
|
1302
|
+
secretAccessKey: required(env, "PH_ATTACHMENT_S3_SECRET_ACCESS_KEY"),
|
|
1303
|
+
prefix: normalizeS3AttachmentPrefix(env.S3_ATTACHMENT_PREFIX ?? "attachments"),
|
|
1304
|
+
forcePathStyle: parseBoolean(env, "PH_ATTACHMENT_S3_FORCE_PATH_STYLE", false),
|
|
1305
|
+
uploadTtlSeconds: parseTtl(env, "PH_ATTACHMENT_S3_UPLOAD_TTL_SECONDS", 900),
|
|
1306
|
+
downloadTtlSeconds: parseTtl(env, "PH_ATTACHMENT_S3_DOWNLOAD_TTL_SECONDS", 300)
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/storage/s3/keying.ts
|
|
1312
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
1313
|
+
function validateHash(hash) {
|
|
1314
|
+
if (!SHA256_HEX.test(hash)) throw new Error("Attachment hash must be 64 lowercase hexadecimal characters");
|
|
1315
|
+
}
|
|
1316
|
+
function deriveS3AttachmentKey(hash, prefix) {
|
|
1317
|
+
validateHash(hash);
|
|
1318
|
+
if (prefix.startsWith("/") || prefix.endsWith("/") || prefix.includes("\\") || prefix.split("/").some((segment) => segment.length === 0 || segment === "." || segment === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment))) throw new Error("S3 attachment prefix must be normalized and safe");
|
|
1319
|
+
return `${prefix}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
|
|
1320
|
+
}
|
|
1321
|
+
function sha256HexToBase64(hash) {
|
|
1322
|
+
validateHash(hash);
|
|
1323
|
+
return Buffer$1.from(hash, "hex").toString("base64");
|
|
1324
|
+
}
|
|
1325
|
+
//#endregion
|
|
1326
|
+
//#region src/storage/s3/primitives.ts
|
|
1327
|
+
const defaultPresigner = (client, command, expiresInSeconds) => getSignedUrl(client, command, {
|
|
1328
|
+
expiresIn: expiresInSeconds,
|
|
1329
|
+
unhoistableHeaders: new Set(["x-amz-checksum-sha256"])
|
|
1330
|
+
});
|
|
1331
|
+
var S3AttachmentPrimitives = class {
|
|
1332
|
+
client;
|
|
1333
|
+
presign;
|
|
1334
|
+
now;
|
|
1335
|
+
constructor(config, dependencies = {}) {
|
|
1336
|
+
this.config = config;
|
|
1337
|
+
this.client = dependencies.client ?? new S3Client({
|
|
1338
|
+
endpoint: config.endpoint,
|
|
1339
|
+
region: config.region,
|
|
1340
|
+
forcePathStyle: config.forcePathStyle,
|
|
1341
|
+
credentials: {
|
|
1342
|
+
accessKeyId: config.accessKeyId,
|
|
1343
|
+
secretAccessKey: config.secretAccessKey
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
this.presign = dependencies.presign ?? defaultPresigner;
|
|
1347
|
+
this.now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
1348
|
+
}
|
|
1349
|
+
buildHeadObjectCommand(hash) {
|
|
1350
|
+
return new HeadObjectCommand({
|
|
1351
|
+
Bucket: this.config.bucket,
|
|
1352
|
+
Key: deriveS3AttachmentKey(hash, this.config.prefix)
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
buildPutObjectCommand(hash, mimeType) {
|
|
1356
|
+
if (mimeType.trim().length === 0 || mimeType.includes("\r") || mimeType.includes("\n")) throw new Error("Attachment MIME type must not be blank or contain newlines");
|
|
1357
|
+
return new PutObjectCommand({
|
|
1358
|
+
Bucket: this.config.bucket,
|
|
1359
|
+
Key: deriveS3AttachmentKey(hash, this.config.prefix),
|
|
1360
|
+
ContentType: mimeType,
|
|
1361
|
+
ChecksumSHA256: sha256HexToBase64(hash)
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
buildGetObjectCommand(hash) {
|
|
1365
|
+
return new GetObjectCommand({
|
|
1366
|
+
Bucket: this.config.bucket,
|
|
1367
|
+
Key: deriveS3AttachmentKey(hash, this.config.prefix)
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
headObject(hash) {
|
|
1371
|
+
return this.client.send(this.buildHeadObjectCommand(hash));
|
|
1372
|
+
}
|
|
1373
|
+
async createUploadTarget(hash, mimeType, ttlSeconds = this.config.uploadTtlSeconds) {
|
|
1374
|
+
const checksum = sha256HexToBase64(hash);
|
|
1375
|
+
return {
|
|
1376
|
+
kind: "presigned-put",
|
|
1377
|
+
method: "PUT",
|
|
1378
|
+
url: await this.presign(this.client, this.buildPutObjectCommand(hash, mimeType), ttlSeconds),
|
|
1379
|
+
headers: {
|
|
1380
|
+
"content-type": mimeType,
|
|
1381
|
+
"x-amz-checksum-sha256": checksum
|
|
1382
|
+
},
|
|
1383
|
+
expiresAtUtc: new Date(this.now().getTime() + ttlSeconds * 1e3).toISOString()
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
async createDownloadTarget(hash, ttlSeconds = this.config.downloadTtlSeconds) {
|
|
1387
|
+
return {
|
|
1388
|
+
kind: "presigned-get",
|
|
1389
|
+
method: "GET",
|
|
1390
|
+
url: await this.presign(this.client, this.buildGetObjectCommand(hash), ttlSeconds),
|
|
1391
|
+
headers: {},
|
|
1392
|
+
expiresAtUtc: new Date(this.now().getTime() + ttlSeconds * 1e3).toISOString()
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
function createS3AttachmentPrimitives(config, dependencies = {}) {
|
|
1397
|
+
return new S3AttachmentPrimitives(config, dependencies);
|
|
1398
|
+
}
|
|
1399
|
+
//#endregion
|
|
1400
|
+
//#region src/storage/s3/backend.ts
|
|
1401
|
+
const READINESS_PROBE_HASH = "0".repeat(64);
|
|
1402
|
+
const OBJECT_NOT_FOUND_NAMES = new Set([
|
|
1403
|
+
"NotFound",
|
|
1404
|
+
"NoSuchKey",
|
|
1405
|
+
"NoSuchObject"
|
|
1406
|
+
]);
|
|
1407
|
+
function isObjectNotFound(error) {
|
|
1408
|
+
if (typeof error !== "object" || error === null) return false;
|
|
1409
|
+
const providerError = error;
|
|
1410
|
+
if (providerError.$metadata?.httpStatusCode !== 404) return false;
|
|
1411
|
+
return [providerError.name, providerError.code].some((code) => typeof code === "string" && OBJECT_NOT_FOUND_NAMES.has(code));
|
|
1412
|
+
}
|
|
1413
|
+
function requireHashFirstReservation(reservation) {
|
|
1414
|
+
if (reservation.clientHash === null) throw new Error("S3 attachment reservations require a client hash");
|
|
1415
|
+
deriveS3AttachmentKey(reservation.clientHash, "validation");
|
|
1416
|
+
if (reservation.sizeBytes === null || !Number.isSafeInteger(reservation.sizeBytes) || reservation.sizeBytes <= 0) throw new Error("S3 attachment reservation sizeBytes must be a positive safe integer");
|
|
1417
|
+
}
|
|
1418
|
+
/** Server-only S3 capability. Callers complete authorization before download. */
|
|
1419
|
+
var S3AttachmentBackend = class {
|
|
1420
|
+
kind = "s3";
|
|
1421
|
+
primitives;
|
|
1422
|
+
now;
|
|
1423
|
+
uploadTtlSeconds;
|
|
1424
|
+
downloadTtlSeconds;
|
|
1425
|
+
constructor(db, config, dependencies = {}) {
|
|
1426
|
+
this.db = db;
|
|
1427
|
+
this.config = config;
|
|
1428
|
+
this.primitives = new S3AttachmentPrimitives(config, dependencies);
|
|
1429
|
+
this.now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
1430
|
+
this.uploadTtlSeconds = dependencies.uploadTtlSeconds ?? config.uploadTtlSeconds;
|
|
1431
|
+
this.downloadTtlSeconds = dependencies.downloadTtlSeconds ?? config.downloadTtlSeconds;
|
|
1432
|
+
}
|
|
1433
|
+
async prepareUploadTarget(reservation) {
|
|
1434
|
+
requireHashFirstReservation(reservation);
|
|
1435
|
+
const hash = reservation.clientHash;
|
|
1436
|
+
const now = this.now().toISOString();
|
|
1437
|
+
const storagePath = deriveS3AttachmentKey(hash, this.config.prefix);
|
|
1438
|
+
try {
|
|
1439
|
+
await this.db.insertInto("attachment").values({
|
|
1440
|
+
hash,
|
|
1441
|
+
mime_type: reservation.mimeType,
|
|
1442
|
+
file_name: reservation.fileName,
|
|
1443
|
+
size_bytes: reservation.sizeBytes,
|
|
1444
|
+
extension: reservation.extension,
|
|
1445
|
+
status: "available",
|
|
1446
|
+
storage_path: storagePath,
|
|
1447
|
+
source: "local",
|
|
1448
|
+
created_at_utc: reservation.createdAtUtc,
|
|
1449
|
+
last_accessed_at_utc: now
|
|
1450
|
+
}).onConflict((conflict) => conflict.column("hash").doUpdateSet({
|
|
1451
|
+
mime_type: reservation.mimeType,
|
|
1452
|
+
file_name: reservation.fileName,
|
|
1453
|
+
size_bytes: reservation.sizeBytes,
|
|
1454
|
+
extension: reservation.extension,
|
|
1455
|
+
status: "available",
|
|
1456
|
+
storage_path: storagePath,
|
|
1457
|
+
last_accessed_at_utc: now
|
|
1458
|
+
})).execute();
|
|
1459
|
+
} catch {
|
|
1460
|
+
throw new Error("S3 attachment metadata registration failed");
|
|
1461
|
+
}
|
|
1462
|
+
let target;
|
|
1463
|
+
try {
|
|
1464
|
+
target = await this.primitives.createUploadTarget(hash, reservation.mimeType, this.uploadTtlSeconds);
|
|
1465
|
+
} catch {
|
|
1466
|
+
throw new Error("S3 attachment upload target preparation failed");
|
|
1467
|
+
}
|
|
1468
|
+
return target;
|
|
1469
|
+
}
|
|
1470
|
+
async prepareDownloadTarget(hash, ttlSeconds) {
|
|
1471
|
+
try {
|
|
1472
|
+
return await this.primitives.createDownloadTarget(hash, ttlSeconds ?? this.downloadTtlSeconds);
|
|
1473
|
+
} catch {
|
|
1474
|
+
throw new Error("S3 attachment download target preparation failed");
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async exists(hash) {
|
|
1478
|
+
if (!await this.db.selectFrom("attachment").select("hash").where("hash", "=", hash).executeTakeFirst()) return false;
|
|
1479
|
+
try {
|
|
1480
|
+
await this.primitives.headObject(hash);
|
|
1481
|
+
return true;
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
if (isObjectNotFound(error)) return false;
|
|
1484
|
+
throw new Error("S3 attachment existence check failed");
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
async health() {
|
|
1488
|
+
try {
|
|
1489
|
+
await this.primitives.headObject(READINESS_PROBE_HASH);
|
|
1490
|
+
return {
|
|
1491
|
+
kind: this.kind,
|
|
1492
|
+
ready: true
|
|
1493
|
+
};
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
return {
|
|
1496
|
+
kind: this.kind,
|
|
1497
|
+
ready: isObjectNotFound(error)
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
function createS3AttachmentBackend(db, config, dependencies = {}) {
|
|
1503
|
+
return new S3AttachmentBackend(db, config, dependencies);
|
|
1504
|
+
}
|
|
1505
|
+
//#endregion
|
|
1506
|
+
export { ATTACHMENT_REFERENCE_MIGRATION_LOCK_TABLE, ATTACHMENT_REFERENCE_MIGRATION_TABLE, ATTACHMENT_REFERENCE_READ_MODEL_ID, ATTACHMENT_REFERENCE_SCHEMA, ATTACHMENT_SCHEMA, AttachmentAlreadyExists, AttachmentBuilder, AttachmentNotFound, AttachmentPending, AttachmentReferenceIndexBuilder, AttachmentReferenceReadModel, AttachmentSchemaCompiler, AttachmentService, AttachmentTransferError, DEFAULT_RESERVATION_TTL_MS, DEFAULT_S3_ATTACHMENT_PREFIX, DEFAULT_S3_DOWNLOAD_TTL_SECONDS, DEFAULT_S3_UPLOAD_TTL_SECONDS, DirectAttachmentUpload, DirectAttachmentUploadFactory, FilesystemAttachmentBackend, HashMismatch, InvalidAttachmentRef, KyselyAttachmentReferenceStore, KyselyAttachmentStore, KyselyReservationStore, MAX_S3_PRESIGN_TTL_SECONDS, NullAttachmentTransport, RemoteAttachmentStore, RemoteAttachmentUpload, RemoteAttachmentUploadFactory, RemoteReservationStore, ReservationNotFound, S3AttachmentBackend, S3AttachmentPrimitives, S3AttachmentUploadFactory, SizeMismatch, SwitchboardAttachmentTransport, UploadTooLarge, createFetchUploadTransport, createRef, createRemoteAttachmentService, createS3AttachmentBackend, createS3AttachmentPrimitives, createXhrUploadTransport, deriveS3AttachmentKey, getAttachmentReferenceMigrationStatus, normalizeS3AttachmentPrefix, parseAttachmentDownloadTarget, parseAttachmentStorageConfig, parseAttachmentUploadTarget, parseRef, rollbackAttachmentReferenceMigration, runAttachmentMigrations, runAttachmentReferenceMigrations, sha256HexToBase64 };
|
|
691
1507
|
|
|
692
1508
|
//# sourceMappingURL=index.js.map
|