From 71e7bfde97d0eaf221b11bf4179cac2b94c5c484 Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Sat, 19 Sep 2026 12:37:34 +0200 Subject: [PATCH] feat: retention interval --- project/server/make/dev.mk | 1 + project/server/src/config/index.ts | 2 + .../scheduled/compaction/optimize.module.ts | 11 + .../scheduled/compaction/optimize.service.ts | 45 ++++ .../compaction/orphaned/attached.module.ts | 11 + .../orphaned/attached.service.test.ts | 200 ++++++++++++++++++ .../compaction/orphaned/attached.service.ts | 107 ++++++++++ .../compaction/orphaned/submission.module.ts | 11 + .../orphaned/submission.service.test.ts | 102 +++++++++ .../compaction/orphaned/submission.service.ts | 33 +++ .../scheduled/compaction/vacuum.module.ts | 11 + .../scheduled/compaction/vacuum.service.ts | 38 ++++ .../scheduled/retention/revocation.module.ts | 11 + .../retention/revocation.service.test.ts | 98 +++++++++ .../scheduled/retention/revocation.service.ts | 41 ++++ .../src/layer/scheduler/scheduler.module.ts | 20 ++ project/server/src/repl-nest.ts | 2 + .../migration/staging/20260914142229.sql | 33 +++ .../migration/staging/20260916152325.sql | 4 + .../query/staging/snapshot-delete.sql | 77 +++++++ .../database/query/staging/snapshot-get.sql | 39 ++++ .../database/schema/staging/01_snapshot.sql | 6 +- project/server/src/service/snapshot/index.ts | 82 +++++-- project/server/src/utility/timed.ts | 9 + 24 files changed, 977 insertions(+), 17 deletions(-) create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/optimize.module.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/optimize.service.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.module.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.test.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.module.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.test.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/vacuum.module.ts create mode 100644 project/server/src/layer/scheduler/scheduled/compaction/vacuum.service.ts create mode 100644 project/server/src/layer/scheduler/scheduled/retention/revocation.module.ts create mode 100644 project/server/src/layer/scheduler/scheduled/retention/revocation.service.test.ts create mode 100644 project/server/src/layer/scheduler/scheduled/retention/revocation.service.ts create mode 100644 project/server/src/service/database/migration/staging/20260914142229.sql create mode 100644 project/server/src/service/database/migration/staging/20260916152325.sql create mode 100644 project/server/src/utility/timed.ts diff --git a/project/server/make/dev.mk b/project/server/make/dev.mk index 8b23727c..eb900708 100644 --- a/project/server/make/dev.mk +++ b/project/server/make/dev.mk @@ -53,6 +53,7 @@ repl: build repl-nest: build @ \ SIGNING_VOUCHER=$(shell $(call secret,voucher,signing-key)) \ + SCHEDULER_ENABLE='false' \ node --enable-source-maps $(SERVER_OUT_REPL_NEST) start-container: diff --git a/project/server/src/config/index.ts b/project/server/src/config/index.ts index 829b85cb..76b224db 100644 --- a/project/server/src/config/index.ts +++ b/project/server/src/config/index.ts @@ -52,6 +52,8 @@ export const config = () => /** how long a voucher is valid for — in seconds */ ttl: env.integer(required("SNAPSHOT_VOUCHER_TTL", floor(60 * 60 * 2))), }, + /** subsequent days with no submissions after which all data pertaining to a submitting instance is deleted */ + revokeAfter: env.integer(required("SNAPSHOT_REVOKE_AFTER", floor(60))), defer: { target: env.choice(Schema.Enums(SnapshotDeferTarget))( required("SNAPSHOT_DEFER_TARGET", SnapshotDeferTarget.None), diff --git a/project/server/src/layer/scheduler/scheduled/compaction/optimize.module.ts b/project/server/src/layer/scheduler/scheduled/compaction/optimize.module.ts new file mode 100644 index 00000000..00051a23 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/optimize.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { ModuleDatabase } from "../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionOptimize } from "./optimize.service"; + +@Module({ + imports: [ModuleDatabase], + providers: [ServiceSchedulerScheduledCompactionOptimize], + exports: [ServiceSchedulerScheduledCompactionOptimize], +}) +export class ModuleSchedulerScheduledCompactionOptimize {} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/optimize.service.ts b/project/server/src/layer/scheduler/scheduled/compaction/optimize.service.ts new file mode 100644 index 00000000..cd606a58 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/optimize.service.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { DatabaseStaging } from "../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionVacuum } from "./vacuum.service"; + +import type { IDatabase } from "../../../../service/database"; +import type { SchedulerScheduled } from "../../../../service/scheduler/base"; + +@Injectable() +export class ServiceSchedulerScheduledCompactionOptimize + implements + SchedulerScheduled +{ + static readonly id = Symbol("ServiceSchedulerScheduledCompactionOptimize"); + + static readonly prerequisites = [ + ServiceSchedulerScheduledCompactionVacuum.id, + ]; + static readonly schedule = { + minute: "0", + hour: "0", + } as const; + + constructor(@Inject(DatabaseStaging) private db: IDatabase<"staging">) {} + + async run(): Promise { + await this.db.begin("w", async (t) => { + t.run({ + /* + https://sqlite.org/lang_analyze.html#periodically_run_pragma_optimize_ + "The PRAGMA optimize command will normally only consider running ANALYZE on tables that have been previously queried by the same database connection or that do not have entries in the sqlite_stat1 table. + However, if the 0x10000 bit is added to the argument, PRAGMA optimize will examine all tables to see if they can benefit from ANALYZE, not just those that have been recently queried." + */ + query: "pragma optimize=0x10000;", + name: "Optimize", + parameters: [], + database: "staging", + connectionMode: "w", + integerMode: "number", + resultMode: "one", + rowMode: "object", + }); + }); + } +} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.module.ts b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.module.ts new file mode 100644 index 00000000..94a5f43b --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { ModuleDatabase } from "../../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionOrphanedAttached } from "./attached.service"; + +@Module({ + imports: [ModuleDatabase], + providers: [ServiceSchedulerScheduledCompactionOrphanedAttached], + exports: [ServiceSchedulerScheduledCompactionOrphanedAttached], +}) +export class ModuleSchedulerScheduledCompactionOrphanedAttached {} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.test.ts b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.test.ts new file mode 100644 index 00000000..46cfdc3d --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.test.ts @@ -0,0 +1,200 @@ +import { randomBytes } from "node:crypto"; +import { type TestContext, test } from "node:test"; + +import { testDatabase } from "../../../../../service/database/utility"; +import { StubIntrospection } from "../../../../../service/introspect/stub"; +import { Snapshot } from "../../../../../service/snapshot"; +import { Voucher } from "../../../../../service/voucher"; +import { floor } from "../../../../../type/codec/integer"; +import { uuid } from "../../../../../type/codec/uuid"; +import { unroll } from "../../../../../utility/iterable"; +import { omit } from "../../../../../utility/omit"; +import { ServiceSchedulerScheduledCompactionOrphanedAttached } from "./attached.service"; + +import type { IDatabase } from "../../../../../service/database"; + +const buildSnapshot = (database: IDatabase<"staging">) => + new Snapshot( + database, + new StubIntrospection(), + new Voucher(randomBytes(64).toString()), + { + voucher: { + expectedAfter: floor(60 * 60 * 23), + ttl: floor(60 * 60 * 2), + }, + }, + ); + +const device1 = { + entry_type: null, + has_configuration_url: false, + hw_version: null, + manufacturer: "Signify Netherlands B.V.", + model: "Hue white lamp", + model_id: "LWB010", + sw_version: "1.116.3", + via_device: null, +} as const; + +const device2 = { + entry_type: null, + has_configuration_url: false, + hw_version: null, + manufacturer: "Signify Netherlands B.V.", + model: "Hue green lamp", + model_id: "LWB011", + sw_version: "1.116.4", + via_device: null, +} as const; + +const entity1 = { + assumed_state: false, + domain: "light", + entity_category: null, + has_entity_name: true, + original_device_class: null, + unit_of_measurement: null, +} as const; + +test("orphaned attachable", async (t: TestContext) => { + await using database = await testDatabase("staging", true); + + const snapshot = buildSnapshot(database); + const service = new ServiceSchedulerScheduledCompactionOrphanedAttached( + database, + ); + + const subjectA = uuid(); + { + const created = await snapshot.create(snapshot.voucher.initial(subjectA)); + t.assert.strictEqual(created.kind, "success"); + + await snapshot.attach.device(created.handle, "foo", device1, [entity1]); + await snapshot.attach.device(created.handle, "bar", device2, [entity1]); + + await snapshot.finalize( + created.handle, + { + version: 1, + hash: Buffer.alloc(32, 0xaa), + }, + "2025.3.1", + ); + } + + const subjectB = uuid(); + { + const created = await snapshot.create(snapshot.voucher.initial(subjectB)); + t.assert.strictEqual(created.kind, "success"); + + await snapshot.attach.device(created.handle, "foo", device1, []); + + await snapshot.finalize( + created.handle, + { + version: 1, + hash: Buffer.alloc(32, 0xab), + }, + "2025.3.1", + ); + } + + t.assert.deepStrictEqual( + (await unroll(snapshot.staging.devices({ integration: "foo" }))).map( + (item) => omit(item, "id"), + ), + [ + { + integration: "foo", + manufacturer: device1.manufacturer, + model: device1.model, + modelId: device1.model_id, + }, + ], + ); + t.assert.deepStrictEqual( + (await unroll(snapshot.staging.devices({ integration: "bar" }))).map( + (item) => omit(item, "id"), + ), + [ + { + integration: "bar", + manufacturer: device2.manufacturer, + model: device2.model, + modelId: device2.model_id, + }, + ], + ); + t.assert.deepStrictEqual( + (await unroll(snapshot.staging.entities({ domain: "light" }))).map((item) => + omit(item, "id"), + ), + [ + { + domain: entity1.domain, + assumedState: entity1.assumed_state, + hasName: entity1.has_entity_name, + category: entity1.entity_category ?? undefined, + originalDeviceClass: entity1.original_device_class ?? undefined, + unitOfMeasurement: entity1.unit_of_measurement ?? undefined, + }, + ], + ); + + let attributionA; + { + const all = await unroll( + snapshot.staging.attribution.submissions({ subject: subjectA }), + ); + t.assert.deepEqual(all.length, 1); + attributionA = all[0]; + } + + await snapshot.delete(attributionA.submissionId); + + await service.run(); + + t.assert.deepStrictEqual( + (await unroll(snapshot.staging.devices({ integration: "foo" }))).map( + (item) => omit(item, "id"), + ), + [ + { + integration: "foo", + manufacturer: device1.manufacturer, + model: device1.model, + modelId: device1.model_id, + }, + ], + ); + t.assert.deepStrictEqual( + await unroll(snapshot.staging.devices({ integration: "bar" })), + [], + ); + t.assert.deepStrictEqual( + await unroll(snapshot.staging.entities({ domain: "light" })), + [], + ); + + let attributionB; + { + const all = await unroll( + snapshot.staging.attribution.submissions({ subject: subjectB }), + ); + t.assert.deepEqual(all.length, 1); + attributionB = all[0]; + } + await snapshot.delete(attributionB.submissionId); + + await service.run(); + + t.assert.deepStrictEqual( + await unroll(snapshot.staging.devices({ integration: "foo" })), + [], + ); + t.assert.deepStrictEqual( + await unroll(snapshot.staging.devices({ integration: "bar" })), + [], + ); +}); diff --git a/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.ts b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.ts new file mode 100644 index 00000000..89915cac --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/attached.service.ts @@ -0,0 +1,107 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { logger as parentLogger } from "../../../../../logger"; +import { + deleteOrphanedDevice, + deleteOrphanedDevicePermutation, + deleteOrphanedDevicePermutationLink, + deleteOrphanedEntity, + deleteOrphanedEntitySet, + deleteOrphanedEntitySetContent, +} from "../../../../../service/database/query/staging/snapshot-delete"; +import { formatNs } from "../../../../../utility/format"; +import { timed } from "../../../../../utility/timed"; +import { DatabaseStaging } from "../../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionOrphanedSubmission } from "./submission.service"; + +import type { IDatabase } from "../../../../../service/database"; +import type { SchedulerScheduled } from "../../../../../service/scheduler/base"; + +const logger = parentLogger.child({ + label: "scheduler-scheduled-compaction-orphaned-attached", +}); + +@Injectable() +/** required in addition to {@link ServiceSchedulerScheduledCompactionOrphanedSubmission} + * the former deletes submissions and accompanying attributions, but not actual devices / device permutations / entities that have become orphaned */ +export class ServiceSchedulerScheduledCompactionOrphanedAttached + implements + SchedulerScheduled< + typeof ServiceSchedulerScheduledCompactionOrphanedAttached + > +{ + static readonly id = Symbol( + "ServiceSchedulerScheduledCompactionOrphanedAttached", + ); + + static readonly prerequisites = [ + ServiceSchedulerScheduledCompactionOrphanedSubmission.id, + ]; + + constructor(@Inject(DatabaseStaging) private db: IDatabase<"staging">) {} + + async run(): Promise { + await this.db.begin("w", async (t) => { + // run before device permutation deletion to prevent expensive cascading deletes + { + const took = await timed(() => + t.run(deleteOrphanedEntitySetContent.bind.anonymous([])), + ); + logger.info(`pruned set entity content in ${formatNs(took)}s`, { + took, + table: "snapshot_submission_set_content_entity_device_permutation", + }); + } + + { + const took = await timed(() => + t.run(deleteOrphanedEntitySet.bind.anonymous([])), + ); + logger.info(`pruned set entity descriptors in ${formatNs(took)}s`, { + took, + table: "snapshot_submission_set_entity_device_permutation", + }); + } + + { + const took = await timed(() => + t.run(deleteOrphanedEntity.bind.anonymous([])), + ); + logger.info(`pruned entities in ${formatNs(took)}s`, { + took, + table: "snapshot_submission_entity", + }); + } + + { + const took = await timed(() => + t.run(deleteOrphanedDevicePermutationLink.bind.anonymous([])), + ); + logger.info(`pruned device permutation links in ${formatNs(took)}s`, { + took, + table: "snapshot_submission_device_permutation_link", + }); + } + + { + const took = await timed(() => + t.run(deleteOrphanedDevicePermutation.bind.anonymous([])), + ); + logger.info(`pruned device permutations in ${formatNs(took)}s`, { + took, + table: "snapshot_submission_device_permutation", + }); + } + + { + const took = await timed(() => + t.run(deleteOrphanedDevice.bind.anonymous([])), + ); + logger.info(`pruned devices in ${formatNs(took)}s`, { + took, + table: "snapshot_submission_device", + }); + } + }); + } +} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.module.ts b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.module.ts new file mode 100644 index 00000000..8468f9b7 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { ModuleDatabase } from "../../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionOrphanedSubmission } from "./submission.service"; + +@Module({ + imports: [ModuleDatabase], + providers: [ServiceSchedulerScheduledCompactionOrphanedSubmission], + exports: [ServiceSchedulerScheduledCompactionOrphanedSubmission], +}) +export class ModuleSchedulerScheduledCompactionOrphanedSubmission {} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.test.ts b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.test.ts new file mode 100644 index 00000000..d279b1e7 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.test.ts @@ -0,0 +1,102 @@ +import { randomBytes } from "node:crypto"; +import { type TestContext, test } from "node:test"; + +import { testDatabase } from "../../../../../service/database/utility"; +import { StubIntrospection } from "../../../../../service/introspect/stub"; +import { Snapshot } from "../../../../../service/snapshot"; +import { Voucher } from "../../../../../service/voucher"; +import { floor } from "../../../../../type/codec/integer"; +import { uuid } from "../../../../../type/codec/uuid"; +import { floorTime } from "../../../../../utility/floor-time"; +import { unroll } from "../../../../../utility/iterable"; +import { ServiceSchedulerScheduledCompactionOrphanedSubmission } from "./submission.service"; + +import type { IDatabase } from "../../../../../service/database"; + +const buildSnapshot = (database: IDatabase<"staging">) => + new Snapshot( + database, + new StubIntrospection(), + new Voucher(randomBytes(64).toString()), + { + voucher: { + expectedAfter: floor(60 * 60 * 23), + ttl: floor(60 * 60 * 2), + }, + }, + ); + +test("orphaned submission", async (t: TestContext) => { + await using database = await testDatabase("staging", true); + + const snapshot = buildSnapshot(database); + + const service = new ServiceSchedulerScheduledCompactionOrphanedSubmission( + database, + ); + + t.mock.timers.enable({ apis: ["Date"], now: floorTime() }); + + const attributions = []; + for (let i = 0; i < 2; i++) { + const subject = uuid(); + const created = await snapshot.create(snapshot.voucher.initial(subject)); + t.assert.strictEqual(created.kind, "success"); + + await snapshot.finalize( + created.handle, + { version: 1, hash: randomBytes(32) }, + "2026.5.0", + ); + + let attribution; + { + const attributions = await unroll( + snapshot.staging.attribution.submissions({ subject }), + ); + t.assert.strictEqual(attributions.length, 1); + attribution = attributions[0]; + } + + attributions.push(attribution); + + // otherwise submissions share a creation time (which they are ordered by when listed) + t.mock.timers.tick(1000); + } + + const [first, second] = attributions; + + t.assert.deepStrictEqual( + ( + await unroll( + snapshot.staging.submissions({ a: new Date(0), b: new Date() }), + ) + ).map((submission) => submission.id), + [second.submissionId, first.submissionId], + ); + + // no other attribution references the submission, it is orphaned once the attribution is gone + await snapshot.staging.attribution.delete(second.id); + + await service.run(); + + t.assert.deepStrictEqual( + ( + await unroll( + snapshot.staging.submissions({ a: new Date(0), b: new Date() }), + ) + ).map((submission) => submission.id), + [first.submissionId], + ); + + await snapshot.staging.attribution.delete(first.id); + + await service.run(); + + t.assert.deepStrictEqual( + await unroll( + snapshot.staging.submissions({ a: new Date(0), b: new Date() }), + ), + [], + ); +}); diff --git a/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.ts b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.ts new file mode 100644 index 00000000..31acc662 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/orphaned/submission.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { deleteOrphanedSubmission } from "../../../../../service/database/query/staging/snapshot-delete"; +import { DatabaseStaging } from "../../../../database/database.module"; +import { ServiceSchedulerScheduledRetentionRevocation } from "../../retention/revocation.service"; + +import type { IDatabase } from "../../../../../service/database"; +import type { SchedulerScheduled } from "../../../../../service/scheduler/base"; + +@Injectable() +/** submission can become attributions are removed by {@link ServiceSchedulerScheduledRetentionRevocation} */ +export class ServiceSchedulerScheduledCompactionOrphanedSubmission + implements + SchedulerScheduled< + typeof ServiceSchedulerScheduledCompactionOrphanedSubmission + > +{ + static readonly id = Symbol( + "ServiceSchedulerScheduledCompactionOrphanedSubmission", + ); + + static readonly prerequisites = [ + ServiceSchedulerScheduledRetentionRevocation.id, + ]; + + constructor(@Inject(DatabaseStaging) private db: IDatabase<"staging">) {} + + async run(): Promise { + await this.db.begin("w", async (t) => { + await t.run(deleteOrphanedSubmission.bind.anonymous([])); + }); + } +} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/vacuum.module.ts b/project/server/src/layer/scheduler/scheduled/compaction/vacuum.module.ts new file mode 100644 index 00000000..16f376e8 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/vacuum.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { ModuleDatabase } from "../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionVacuum } from "./vacuum.service"; + +@Module({ + imports: [ModuleDatabase], + providers: [ServiceSchedulerScheduledCompactionVacuum], + exports: [ServiceSchedulerScheduledCompactionVacuum], +}) +export class ModuleSchedulerScheduledCompactionVacuum {} diff --git a/project/server/src/layer/scheduler/scheduled/compaction/vacuum.service.ts b/project/server/src/layer/scheduler/scheduled/compaction/vacuum.service.ts new file mode 100644 index 00000000..5d22234f --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/compaction/vacuum.service.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { DatabaseStaging } from "../../../database/database.module"; +import { ServiceSchedulerScheduledCompactionOrphanedAttached } from "./orphaned/attached.service"; +import { ServiceSchedulerScheduledCompactionOrphanedSubmission } from "./orphaned/submission.service"; + +import type { IDatabase } from "../../../../service/database"; +import type { SchedulerScheduled } from "../../../../service/scheduler/base"; + +@Injectable() +export class ServiceSchedulerScheduledCompactionVacuum + implements + SchedulerScheduled +{ + static readonly id = Symbol("ServiceSchedulerScheduledCompactionVacuum"); + + static readonly prerequisites = [ + ServiceSchedulerScheduledCompactionOrphanedSubmission.id, + ServiceSchedulerScheduledCompactionOrphanedAttached.id, + ]; + + constructor(@Inject(DatabaseStaging) private db: IDatabase<"staging">) {} + + async run(): Promise { + await this.db.begin("w", async (t) => { + t.run({ + query: "vacuum full;", + name: "Vacuum", + parameters: [], + database: "staging", + connectionMode: "w", + integerMode: "number", + resultMode: "one", + rowMode: "object", + }); + }); + } +} diff --git a/project/server/src/layer/scheduler/scheduled/retention/revocation.module.ts b/project/server/src/layer/scheduler/scheduled/retention/revocation.module.ts new file mode 100644 index 00000000..f38c4546 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/retention/revocation.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { ModuleDatabase } from "../../../database/database.module"; +import { ServiceSchedulerScheduledRetentionRevocation } from "./revocation.service"; + +@Module({ + imports: [ModuleDatabase], + providers: [ServiceSchedulerScheduledRetentionRevocation], + exports: [ServiceSchedulerScheduledRetentionRevocation], +}) +export class ModuleSchedulerScheduledRetentionRevocation {} diff --git a/project/server/src/layer/scheduler/scheduled/retention/revocation.service.test.ts b/project/server/src/layer/scheduler/scheduled/retention/revocation.service.test.ts new file mode 100644 index 00000000..364ed853 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/retention/revocation.service.test.ts @@ -0,0 +1,98 @@ +import { randomBytes } from "node:crypto"; +import { type TestContext, test } from "node:test"; + +import { addSeconds, subDays, subSeconds } from "date-fns"; + +import { testDatabase } from "../../../../service/database/utility"; +import { StubIntrospection } from "../../../../service/introspect/stub"; +import { Snapshot } from "../../../../service/snapshot"; +import { Voucher } from "../../../../service/voucher"; +import { floor } from "../../../../type/codec/integer"; +import { uuid } from "../../../../type/codec/uuid"; +import { floorTime } from "../../../../utility/floor-time"; +import { unroll } from "../../../../utility/iterable"; +import { ServiceSchedulerScheduledRetentionRevocation } from "./revocation.service"; + +import type { IDatabase } from "../../../../service/database"; + +/** subsequent days without submissions after which a subject is revoked */ +const revokeAfter = floor(60); + +const buildSnapshot = (database: IDatabase<"staging">) => + new Snapshot( + database, + new StubIntrospection(), + new Voucher(randomBytes(64).toString()), + { + voucher: { + expectedAfter: floor(60 * 60 * 23), + ttl: floor(60 * 60 * 2), + }, + }, + ); + +test("revocation", async (t: TestContext) => { + await using database = await testDatabase("staging", true); + + const snapshot = buildSnapshot(database); + + const now = floorTime(); + t.mock.timers.enable({ apis: ["Date"], now }); + + const cutoff = subDays(now, revokeAfter); + + const submissions = [ + // last submitted after the cutoff elapsed + { subject: uuid(), at: subSeconds(cutoff, 1) }, + // last submitted just before the cutoff elapsed + { subject: uuid(), at: addSeconds(cutoff, 1) }, + // last submitted recently + { subject: uuid(), at: now }, + ] as const; + + for (const submission of submissions) { + // attribution is always created with current time, not provided time + // → mock to use the current time + t.mock.timers.setTime(submission.at.getTime()); + + const created = await snapshot.create( + snapshot.voucher.initial(submission.subject), + ); + t.assert.strictEqual(created.kind, "success"); + + await snapshot.finalize( + created.handle, + { version: 1, hash: randomBytes(32) }, + "2026.5.0", + ); + } + + t.mock.timers.reset(); + + const [revoked, boundary, recent] = submissions; + + t.assert.deepStrictEqual( + ( + await unroll( + snapshot.staging.attribution.submissions({ a: new Date(0), b: now }), + ) + ).map((item) => item.subject), + [recent, boundary, revoked].map((item) => item.subject), + ); + + const service = new ServiceSchedulerScheduledRetentionRevocation( + { snapshot: { revokeAfter } }, + database, + ); + + await service.run(); + + t.assert.deepStrictEqual( + ( + await unroll( + snapshot.staging.attribution.submissions({ a: new Date(0), b: now }), + ) + ).map((item) => item.subject), + [recent, boundary].map((item) => item.subject), + ); +}); diff --git a/project/server/src/layer/scheduler/scheduled/retention/revocation.service.ts b/project/server/src/layer/scheduler/scheduled/retention/revocation.service.ts new file mode 100644 index 00000000..c5255818 --- /dev/null +++ b/project/server/src/layer/scheduler/scheduled/retention/revocation.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { subDays } from "date-fns"; +import type { PickDeep } from "type-fest"; + +import { deleteAttributionSubmissionFromRevokedSubjectByCutoff } from "../../../../service/database/query/staging/snapshot-delete"; +import { floor } from "../../../../type/codec/integer"; +import { Config } from "../../../config/config.module"; +import { DatabaseStaging } from "../../../database/database.module"; + +import type { IDatabase } from "../../../../service/database"; +import type { SchedulerScheduled } from "../../../../service/scheduler/base"; + +@Injectable() +export class ServiceSchedulerScheduledRetentionRevocation + implements + SchedulerScheduled +{ + static readonly id = Symbol("ServiceSchedulerScheduledRetentionRevocation"); + + static readonly prerequisites = []; + static readonly schedule = { + minute: "0", + hour: "15", + } as const; + + constructor( + @Inject(Config) private config: PickDeep, + @Inject(DatabaseStaging) private db: IDatabase<"staging">, + ) {} + + async run(): Promise { + const cutoff = subDays(new Date(), this.config.snapshot.revokeAfter); + await this.db.begin("w", async (t) => { + t.run( + deleteAttributionSubmissionFromRevokedSubjectByCutoff.bind.named({ + cutoff: floor(cutoff.getTime() / 1000), + }), + ); + }); + } +} diff --git a/project/server/src/layer/scheduler/scheduler.module.ts b/project/server/src/layer/scheduler/scheduler.module.ts index aace8137..9bb2fbbb 100644 --- a/project/server/src/layer/scheduler/scheduler.module.ts +++ b/project/server/src/layer/scheduler/scheduler.module.ts @@ -1,10 +1,20 @@ import { Module } from "@nestjs/common"; import { ModuleIntrospection } from "../introspection/introspection.module"; +import { ModuleSchedulerScheduledCompactionOptimize } from "./scheduled/compaction/optimize.module"; +import { ServiceSchedulerScheduledCompactionOptimize } from "./scheduled/compaction/optimize.service"; +import { ModuleSchedulerScheduledCompactionOrphanedAttached } from "./scheduled/compaction/orphaned/attached.module"; +import { ServiceSchedulerScheduledCompactionOrphanedAttached } from "./scheduled/compaction/orphaned/attached.service"; +import { ModuleSchedulerScheduledCompactionOrphanedSubmission } from "./scheduled/compaction/orphaned/submission.module"; +import { ServiceSchedulerScheduledCompactionOrphanedSubmission } from "./scheduled/compaction/orphaned/submission.service"; +import { ModuleSchedulerScheduledCompactionVacuum } from "./scheduled/compaction/vacuum.module"; +import { ServiceSchedulerScheduledCompactionVacuum } from "./scheduled/compaction/vacuum.service"; import { ModuleSchedulerScheduledDeriveDevice } from "./scheduled/derive/device.module"; import { ServiceSchedulerScheduledDeriveDevice } from "./scheduled/derive/device.service"; import { ModuleSchedulerScheduledDeriveSubject } from "./scheduled/derive/subject.module"; import { ServiceSchedulerScheduledDeriveSubject } from "./scheduled/derive/subject.service"; +import { ModuleSchedulerScheduledRetentionRevocation } from "./scheduled/retention/revocation.module"; +import { ServiceSchedulerScheduledRetentionRevocation } from "./scheduled/retention/revocation.service"; import { SchedulerScheduled } from "./scheduler.registry"; import { ServiceScheduler } from "./scheduler.service"; @@ -15,6 +25,11 @@ import type { SchedulerScheduledInstance } from "../../service/scheduler/base"; ModuleIntrospection, ModuleSchedulerScheduledDeriveDevice, ModuleSchedulerScheduledDeriveSubject, + ModuleSchedulerScheduledRetentionRevocation, + ModuleSchedulerScheduledCompactionOrphanedSubmission, + ModuleSchedulerScheduledCompactionOrphanedAttached, + ModuleSchedulerScheduledCompactionVacuum, + ModuleSchedulerScheduledCompactionOptimize, ], providers: [ { @@ -25,6 +40,11 @@ import type { SchedulerScheduledInstance } from "../../service/scheduler/base"; inject: [ ServiceSchedulerScheduledDeriveDevice, ServiceSchedulerScheduledDeriveSubject, + ServiceSchedulerScheduledRetentionRevocation, + ServiceSchedulerScheduledCompactionOrphanedSubmission, + ServiceSchedulerScheduledCompactionOrphanedAttached, + ServiceSchedulerScheduledCompactionVacuum, + ServiceSchedulerScheduledCompactionOptimize, ], }, ServiceScheduler, diff --git a/project/server/src/repl-nest.ts b/project/server/src/repl-nest.ts index 43c8f9e2..deb6be1e 100644 --- a/project/server/src/repl-nest.ts +++ b/project/server/src/repl-nest.ts @@ -1,8 +1,10 @@ import { repl } from "@nestjs/core"; import { ModuleApp } from "./layer/app.module"; +import { unroll } from "./utility/iterable"; void (async () => { const r = await repl(ModuleApp); + Object.assign(r.context, { unroll }); r.setupHistory(".nestjs_repl_history", () => {}); })(); diff --git a/project/server/src/service/database/migration/staging/20260914142229.sql b/project/server/src/service/database/migration/staging/20260914142229.sql new file mode 100644 index 00000000..7faf69f7 --- /dev/null +++ b/project/server/src/service/database/migration/staging/20260914142229.sql @@ -0,0 +1,33 @@ +-- preflight:begin +pragma foreign_keys=off; +-- preflight:end + +-- "on delete cascade" was missing from "snapshot_submission_device_id", so removing a device left its permutations behind as orphans +create table _snapshot_submission_device_permutation ( + -- synthetic identifier + id text not null primary key, + snapshot_submission_device_id text not null references snapshot_submission_device(id) on delete cascade, + entry_type text, + -- boolean + has_configuration_url integer, + version_sw text, + version_hw text +) strict, without rowid; + +insert into _snapshot_submission_device_permutation select * from snapshot_submission_device_permutation; +drop table snapshot_submission_device_permutation; +-- foreign key enforcement is disabled, so "references snapshot_submission_device_permutation" clauses of dependent +-- tables are left untouched by the rename and keep pointing at the table below +alter table _snapshot_submission_device_permutation rename to snapshot_submission_device_permutation; + +create unique index snapshot_submission_device_permutation_composite_idx on snapshot_submission_device_permutation( + snapshot_submission_device_id, + coalesce(entry_type, ''), + coalesce(has_configuration_url, -1), + coalesce(version_sw, ''), + coalesce(version_hw, '') +); + +-- postflight:begin +pragma foreign_keys=on; +-- postflight:end diff --git a/project/server/src/service/database/migration/staging/20260916152325.sql b/project/server/src/service/database/migration/staging/20260916152325.sql new file mode 100644 index 00000000..b15b8e51 --- /dev/null +++ b/project/server/src/service/database/migration/staging/20260916152325.sql @@ -0,0 +1,4 @@ +create index snapshot_submission_attribution_device_permutation_link_snapshot_submission_device_permutation_link_id_idx on snapshot_submission_attribution_device_permutation_link(snapshot_submission_device_permutation_link_id); +create index snapshot_submission_attribution_set_entity_device_permutation_snapshot_submission_set_entity_device_permutation_id_idx on snapshot_submission_attribution_set_entity_device_permutation (snapshot_submission_set_entity_device_permutation_id); + +create index snapshot_submission_set_entity_device_permutation_snapshot_submission_device_permutation_id_idx on snapshot_submission_set_entity_device_permutation(snapshot_submission_device_permutation_id); diff --git a/project/server/src/service/database/query/staging/snapshot-delete.sql b/project/server/src/service/database/query/staging/snapshot-delete.sql index a80fe5f2..54b2061b 100644 --- a/project/server/src/service/database/query/staging/snapshot-delete.sql +++ b/project/server/src/service/database/query/staging/snapshot-delete.sql @@ -1,2 +1,79 @@ -- name: DeleteSnapshot :exec delete from snapshot_submission where id = @submissionId; + +-- name: DeleteAttributionSubmission :exec +delete from snapshot_submission_attribution_submission where id = ?; + +-- name: DeleteAttributionSubmissionFromRevokedSubjectByCutoff :exec +with revoked as ( + select + subject, + max(created_at) max_created_at + from + snapshot_submission_attribution_submission + group by 1 +) +delete from snapshot_submission_attribution_submission where subject in ( + select + subject + from + revoked + where + max_created_at < cast(@cutoff as integer) +); + +-- name: DeleteOrphanedDevice :exec +delete from snapshot_submission_device where id not in ( + select + snapshot_submission_device_id + from + snapshot_submission_attribution_device +); + +-- name: DeleteOrphanedDevicePermutationLink :exec +delete from snapshot_submission_device_permutation_link where id not in ( + select + snapshot_submission_device_permutation_link_id + from + snapshot_submission_attribution_device_permutation_link +); + +-- name: DeleteOrphanedDevicePermutation :exec +delete from snapshot_submission_device_permutation where id not in ( + select + snapshot_submission_device_permutation_id + from + snapshot_submission_attribution_device_permutation +); + +-- name: DeleteOrphanedEntitySetContent :exec +delete from snapshot_submission_set_content_entity_device_permutation where snapshot_submission_set_entity_device_permutation_id not in ( + select + snapshot_submission_set_entity_device_permutation_id + from + snapshot_submission_attribution_set_entity_device_permutation +); + +-- name: DeleteOrphanedEntitySet :exec +delete from snapshot_submission_set_entity_device_permutation where id not in ( + select + snapshot_submission_set_entity_device_permutation_id + from + snapshot_submission_set_content_entity_device_permutation +); + +-- name: DeleteOrphanedEntity :exec +delete from snapshot_submission_entity where id not in ( + select + snapshot_submission_entity_id + from + snapshot_submission_set_content_entity_device_permutation +); + +-- name: DeleteOrphanedSubmission :exec +delete from snapshot_submission where id not in ( + select + snapshot_submission_id + from + snapshot_submission_attribution_submission +); diff --git a/project/server/src/service/database/query/staging/snapshot-get.sql b/project/server/src/service/database/query/staging/snapshot-get.sql index 7d737505..ea2258aa 100644 --- a/project/server/src/service/database/query/staging/snapshot-get.sql +++ b/project/server/src/service/database/query/staging/snapshot-get.sql @@ -31,6 +31,18 @@ from where ssad.snapshot_submission_id = @submissionId; +-- name: GetDeviceByIntegration :many +select + id, + integration, + manufacturer, + model, + model_id "modelId" +from + snapshot_submission_device +where + integration = @integration; + -- name: GetDevicePermutationBySubmissionId :many select id, @@ -46,6 +58,19 @@ from where ssadp.snapshot_submission_id = @submissionId; +-- name: GetDevicePermutationByDeviceId :many +select + id, + snapshot_submission_device_id "deviceId", + entry_type "entryType", + has_configuration_url "hasConfigurationUrl", + version_sw "versionSw", + version_hw "versionHw" +from + snapshot_submission_device_permutation +where + snapshot_submission_device_id = @deviceId; + -- name: GetDevicePermutationLinkBySubmissionId :many select id, @@ -83,6 +108,20 @@ where ssasedp.snapshot_submission_id = @submissionId and sssedp.snapshot_submission_device_permutation_id = @devicePermutationId; +-- name: GetEntityByDomain :many +select + id, + domain, + assumed_state "assumedState", + has_name "hasName", + category, + original_device_class "originalDeviceClass", + unit_of_measurement "unitOfMeasurement" +from + snapshot_submission_entity +where + domain = @domain; + -- name: GetEntityCompositionByDevicePermutationId :many select count(distinct ssasedp.snapshot_submission_id) "count", diff --git a/project/server/src/service/database/schema/staging/01_snapshot.sql b/project/server/src/service/database/schema/staging/01_snapshot.sql index 3fca52f1..1c9ed359 100644 --- a/project/server/src/service/database/schema/staging/01_snapshot.sql +++ b/project/server/src/service/database/schema/staging/01_snapshot.sql @@ -40,13 +40,14 @@ create table snapshot_submission_attribution_device ( snapshot_submission_device_id text not null references snapshot_submission_device(id) on delete cascade, primary key(snapshot_submission_id, snapshot_submission_device_id) ) strict, without rowid; +create index snapshot_submission_attribution_device_snapshot_submission_device_id_idx on snapshot_submission_attribution_device(snapshot_submission_device_id); -- ← device -- → device permutation create table snapshot_submission_device_permutation ( -- synthetic identifier id text not null primary key, - snapshot_submission_device_id text not null references snapshot_submission_device(id), + snapshot_submission_device_id text not null references snapshot_submission_device(id) on delete cascade, entry_type text, -- boolean has_configuration_url integer, @@ -84,6 +85,7 @@ create table snapshot_submission_attribution_device_permutation_link ( snapshot_submission_device_permutation_link_id text not null references snapshot_submission_device_permutation_link(id) on delete cascade, primary key(snapshot_submission_id, snapshot_submission_device_permutation_link_id) ) strict, without rowid; +create index snapshot_submission_attribution_device_permutation_link_snapshot_submission_device_permutation_link_id_idx on snapshot_submission_attribution_device_permutation_link(snapshot_submission_device_permutation_link_id); -- ← device permutation -- → entity @@ -125,6 +127,7 @@ create table snapshot_submission_set_entity_device_permutation ( snapshot_submission_device_permutation_id text not null references snapshot_submission_device_permutation(id) on delete cascade, unique(hash, snapshot_submission_device_permutation_id) ) strict, without rowid; +create index snapshot_submission_set_entity_device_permutation_snapshot_submission_device_permutation_id_idx on snapshot_submission_set_entity_device_permutation(snapshot_submission_device_permutation_id); create table snapshot_submission_set_content_entity_device_permutation ( snapshot_submission_set_entity_device_permutation_id text not null references snapshot_submission_set_entity_device_permutation(id) on delete cascade, @@ -140,4 +143,5 @@ create table snapshot_submission_attribution_set_entity_device_permutation ( primary key(id, snapshot_submission_id) ) strict, without rowid; create index snapshot_submission_attribution_set_entity_device_permutation_snapshot_submission_id_idx on snapshot_submission_attribution_set_entity_device_permutation (snapshot_submission_id); +create index snapshot_submission_attribution_set_entity_device_permutation_snapshot_submission_set_entity_device_permutation_id_idx on snapshot_submission_attribution_set_entity_device_permutation (snapshot_submission_set_entity_device_permutation_id); -- ← entity diff --git a/project/server/src/service/snapshot/index.ts b/project/server/src/service/snapshot/index.ts index 47118d8c..68c392c4 100644 --- a/project/server/src/service/snapshot/index.ts +++ b/project/server/src/service/snapshot/index.ts @@ -12,17 +12,23 @@ import { Uuid, uuid } from "../../type/codec/uuid"; import { isNone, isSome, type Maybe } from "../../type/maybe"; import { cyclicNodes } from "../../utility/cyclic-dfs"; import { type DatabaseTransaction, IDatabaseStaging } from "../database"; -import { deleteSnapshot } from "../database/query/staging/snapshot-delete"; +import { + deleteAttributionSubmission, + deleteSnapshot, +} from "../database/query/staging/snapshot-delete"; import { getAttributionSubmission, getAttributionSubmissionByCreatedAtRange, getAttributionSubmissionBySubject, getAttributionSubmissionCountGroupedByHassVersion, + getDeviceByIntegration, getDeviceBySubmissionId, getDeviceManufacturerAndIntegrationCount, + getDevicePermutationByDeviceId, getDevicePermutationBySubmissionId, getDevicePermutationCount, getDevicePermutationLinkBySubmissionId, + getEntityByDomain, getEntityBySubmissionIdAndDevicePermutationId, getEntityCompositionByDevicePermutationId, getEntityDomainAndOriginalDeviceClassCount, @@ -279,12 +285,22 @@ type PolyAttributionSubmissionQuery = type PolyDeviceQueryBySubmissionId = { submissionId: Uuid; }; -type PolyDeviceQuery = PolyDeviceQueryBySubmissionId; +type PolyDeviceQueryByIntegration = { + integration: string; +}; +type PolyDeviceQuery = + | PolyDeviceQueryBySubmissionId + | PolyDeviceQueryByIntegration; type PolyDevicePermutationQueryBySubmissionId = { submissionId: Uuid; }; -type PolyDevicePermutationQuery = PolyDevicePermutationQueryBySubmissionId; +type PolyDevicePermutationQueryByDeviceId = { + deviceId: Uuid; +}; +type PolyDevicePermutationQuery = + | PolyDevicePermutationQueryBySubmissionId + | PolyDevicePermutationQueryByDeviceId; type PolyDevicePermutationLinkQueryBySubmissionId = { submissionId: Uuid; @@ -296,6 +312,12 @@ type PolyEntityQueryBySubmissionIdAndDevicePermutationId = { submissionId: Uuid; devicePermutationId: Uuid; }; +type PolyEntityQueryByDomain = { + domain: string; +}; +type PolyEntityQuery = + | PolyEntityQueryBySubmissionIdAndDevicePermutationId + | PolyEntityQueryByDomain; type PolyEntityCompositionQueryByDevicePermutationId = { devicePermutationId: Uuid; @@ -363,9 +385,7 @@ export interface ISnapshot { devicePermutationLinks( query: PolyDevicePermutationLinkQuery, ): AsyncIterable; - entities( - query: PolyEntityQueryBySubmissionIdAndDevicePermutationId, - ): AsyncIterable; + entities(query: PolyEntityQuery): AsyncIterable; entities( query: PolyEntityCompositionQueryByDevicePermutationId, ): AsyncIterable<[count: number, entities: SnapshotEntity[]]>; @@ -374,6 +394,7 @@ export interface ISnapshot { submissions( query: PolyAttributionSubmissionQuery, ): AsyncIterable; + delete(id: Uuid): Promise; }; }; } @@ -1334,9 +1355,16 @@ export class Snapshot implements ISnapshot { private async *stagingDevices( query: PolyDeviceQuery, ): AsyncIterable { - const bound = getDeviceBySubmissionId.bind.named({ - submissionId: query.submissionId, - }); + let bound; + if ("integration" in query) { + bound = getDeviceByIntegration.bind.named({ + integration: query.integration, + }); + } else { + bound = getDeviceBySubmissionId.bind.named({ + submissionId: query.submissionId, + }); + } const validatorId = Schema.is(Uuid); @@ -1358,9 +1386,16 @@ export class Snapshot implements ISnapshot { private async *stagingDevicePermutations( query: PolyDevicePermutationQuery, ): AsyncIterable { - const bound = getDevicePermutationBySubmissionId.bind.named({ - submissionId: query.submissionId, - }); + let bound; + if ("deviceId" in query) { + bound = getDevicePermutationByDeviceId.bind.named({ + deviceId: query.deviceId, + }); + } else { + bound = getDevicePermutationBySubmissionId.bind.named({ + submissionId: query.submissionId, + }); + } const validatorId = Schema.is(Uuid); const validatorDeviceId = Schema.is(Uuid); @@ -1418,15 +1453,13 @@ export class Snapshot implements ISnapshot { } private stagingEntities( - query: PolyEntityQueryBySubmissionIdAndDevicePermutationId, + query: PolyEntityQuery, ): AsyncIterable; private stagingEntities( query: PolyEntityCompositionQueryByDevicePermutationId, ): AsyncIterable<[count: number, entities: SnapshotEntity[]]>; private async *stagingEntities( - query: - | PolyEntityQueryBySubmissionIdAndDevicePermutationId - | PolyEntityCompositionQueryByDevicePermutationId, + query: PolyEntityQuery | PolyEntityCompositionQueryByDevicePermutationId, ): AsyncIterable< SnapshotEntity | [count: number, entities: SnapshotEntity[]] > { @@ -1438,6 +1471,18 @@ export class Snapshot implements ISnapshot { submissionId: query.submissionId, }); + for await (const row of this.database.run(bound)) { + if (!validator(row)) { + continue; + } + + yield exposeEntityPersisted(row); + } + } else if ("domain" in query) { + const validator = Schema.is(SnapshotEntityPersisted); + + const bound = getEntityByDomain.bind.named({ domain: query.domain }); + for await (const row of this.database.run(bound)) { if (!validator(row)) { continue; @@ -1513,6 +1558,10 @@ export class Snapshot implements ISnapshot { } } + private async stagingAttributionSubmissionDelete(id: Uuid): Promise { + await this.database.run(deleteAttributionSubmission.bind.anonymous([id])); + } + staging = { submissions: this.stagingSubmissions.bind(this), devices: this.stagingDevices.bind(this), @@ -1522,6 +1571,7 @@ export class Snapshot implements ISnapshot { attribution: { submissions: this.stagingAttributionSubmission.bind(this), + delete: this.stagingAttributionSubmissionDelete.bind(this), }, }; } diff --git a/project/server/src/utility/timed.ts b/project/server/src/utility/timed.ts new file mode 100644 index 00000000..ae790632 --- /dev/null +++ b/project/server/src/utility/timed.ts @@ -0,0 +1,9 @@ +import { hrtime } from "node:process"; + +export const timed = async ( + measuring: () => Promise, +): Promise => { + const start = hrtime.bigint(); + await measuring(); + return hrtime.bigint() - start; +};