0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-25 06:01:46 -05:00

♻️ Reimplement GC mechanism for penpot database objects.

This commit is contained in:
Andrey Antukh 2021-06-07 16:51:09 +02:00 committed by Andrés Moya
parent 71c4145ea2
commit 860e0227af
20 changed files with 437 additions and 104 deletions

View file

@ -172,18 +172,21 @@
{:cron #app/cron "0 0 * * * ?" ;; hourly
:task :file-xlog-gc}
{:cron #app/cron "0 0 1 * * ?" ;; daily (1 hour shift)
{:cron #app/cron "0 0 0 * * ?" ;; daily
:task :storage-deleted-gc}
{:cron #app/cron "0 0 2 * * ?" ;; daily (2 hour shift)
{:cron #app/cron "0 0 0 * * ?" ;; daily
:task :storage-touched-gc}
{:cron #app/cron "0 0 3 * * ?" ;; daily (3 hour shift)
{:cron #app/cron "0 0 0 * * ?" ;; daily
:task :session-gc}
{:cron #app/cron "0 0 * * * ?" ;; hourly
:task :storage-recheck}
{:cron #app/cron "0 0 0 * * ?" ;; daily
:task :objects-gc}
{:cron #app/cron "0 0 0 * * ?" ;; daily
:task :tasks-gc}
@ -203,6 +206,7 @@
{:metrics (ig/ref :app.metrics/metrics)
:tasks
{:sendmail (ig/ref :app.emails/sendmail-handler)
:objects-gc (ig/ref :app.tasks.objects-gc/handler)
:delete-object (ig/ref :app.tasks.delete-object/handler)
:delete-profile (ig/ref :app.tasks.delete-profile/handler)
:file-media-gc (ig/ref :app.tasks.file-media-gc/handler)
@ -236,6 +240,11 @@
{:pool (ig/ref :app.db/pool)
:storage (ig/ref :app.storage/storage)}
:app.tasks.objects-gc/handler
{:pool (ig/ref :app.db/pool)
:storage (ig/ref :app.storage/storage)
:max-age cf/deletion-delay}
:app.tasks.delete-profile/handler
{:pool (ig/ref :app.db/pool)}

View file

@ -175,6 +175,15 @@
{:name "0055-mod-file-media-object-table"
:fn (mg/resource "app/migrations/sql/0055-mod-file-media-object-table.sql")}
{:name "0056-add-missing-index-on-deleted-at"
:fn (mg/resource "app/migrations/sql/0056-add-missing-index-on-deleted-at.sql")}
{:name "0057-del-profile-on-delete-trigger"
:fn (mg/resource "app/migrations/sql/0057-del-profile-on-delete-trigger.sql")}
{:name "0058-del-team-on-delete-trigger"
:fn (mg/resource "app/migrations/sql/0058-del-team-on-delete-trigger.sql")}
])

View file

@ -0,0 +1,15 @@
CREATE INDEX profile_deleted_at_idx
ON profile(deleted_at, id)
WHERE deleted_at IS NOT NULL;
CREATE INDEX project_deleted_at_idx
ON project(deleted_at, id)
WHERE deleted_at IS NOT NULL;
CREATE INDEX team_deleted_at_idx
ON team(deleted_at, id)
WHERE deleted_at IS NOT NULL;
CREATE INDEX team_font_variant_deleted_at_idx
ON team_font_variant(deleted_at, id)
WHERE deleted_at IS NOT NULL;

View file

@ -0,0 +1,2 @@
DROP TRIGGER profile__on_delete__tgr ON profile CASCADE;
DROP FUNCTION on_delete_profile ();

View file

@ -0,0 +1,2 @@
DROP TRIGGER team__on_delete__tgr ON team CASCADE;
DROP FUNCTION on_delete_team ();

View file

@ -15,7 +15,7 @@
[app.rpc.mutations.profile :as profile]
[app.setup.initial-data :as sid]
[app.util.services :as sv]
[app.worker :as wrk]
[app.util.time :as dt]
[buddy.core.codecs :as bc]
[buddy.core.nonce :as bn]
[clojure.spec.alpha :as s]))
@ -35,6 +35,7 @@
:email email
:fullname fullname
:is-demo true
:deleted-at (dt/in-future cfg/deletion-delay)
:password password
:props {:onboarding-viewed true}}]
@ -48,12 +49,6 @@
(#'profile/create-profile-relations conn)
(sid/load-initial-project! conn))
;; Schedule deletion of the demo profile
(wrk/submit! {::wrk/task :delete-profile
::wrk/delay cfg/deletion-delay
::wrk/conn conn
:profile-id id})
(with-meta {:email email
:password password}
{::audit/profile-id id}))))

View file

@ -11,7 +11,6 @@
[app.common.pages.migrations :as pmg]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.rpc.permissions :as perms]
[app.rpc.queries.files :as files]
@ -19,7 +18,6 @@
[app.util.blob :as blob]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]))
;; --- Helpers & Specs
@ -121,14 +119,6 @@
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
(db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id id)
;; Schedule object deletion
(wrk/submit! {::wrk/task :delete-object
::wrk/delay cfg/deletion-delay
::wrk/conn conn
:id id
:type :file})
(mark-file-deleted conn params)))
(defn mark-file-deleted

View file

@ -104,21 +104,10 @@
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(let [items (db/query conn :team-font-variant
{:font-id id :team-id team-id}
{:for-update true})]
(doseq [item items]
;; Schedule object deletion
(wrk/submit! {::wrk/task :delete-object
::wrk/delay cf/deletion-delay
::wrk/conn conn
:id (:id item)
:type :team-font-variant}))
(db/update! conn :team-font-variant
{:deleted-at (dt/now)}
{:font-id id :team-id team-id})
nil)))
(db/update! conn :team-font-variant
{:deleted-at (dt/now)}
{:font-id id :team-id team-id})
nil))
;; --- DELETE FONT VARIANT

View file

@ -22,7 +22,6 @@
[app.storage :as sto]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[buddy.hashers :as hashers]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
@ -179,9 +178,9 @@
:valid false})))
(defn create-profile
"Create the profile entry on the database with limited input
filling all the other fields with defaults."
[conn {:keys [id fullname email password is-active is-muted is-demo opts]
"Create the profile entry on the database with limited input filling
all the other fields with defaults."
[conn {:keys [id fullname email password is-active is-muted is-demo opts deleted-at]
:or {is-active false is-muted false is-demo false}
:as params}]
(let [id (or id (uuid/next))
@ -193,6 +192,7 @@
:email (str/lower email)
:auth-backend "penpot"
:password password
:deleted-at deleted-at
:props props
:is-active is-active
:is-muted is-muted
@ -264,7 +264,8 @@
(let [profile (->> (profile/retrieve-profile-data-by-email conn email)
(validate-profile)
(profile/strip-private-attrs)
(profile/populate-additional-data conn))]
(profile/populate-additional-data conn))
profile (update profile :props db/decode-transit-pgobject)]
(if-let [token (:invitation-token params)]
;; If the request comes with an invitation token, this means
;; that user wants to accept it with different user. A very
@ -619,12 +620,6 @@
(db/with-atomic [conn pool]
(check-can-delete-profile! conn profile-id)
;; Schedule a complete deletion of profile
(wrk/submit! {::wrk/task :delete-profile
::wrk/delay cfg/deletion-delay
::wrk/conn conn
:profile-id profile-id})
(db/update! conn :profile
{:deleted-at (dt/now)}
{:id profile-id})

View file

@ -8,14 +8,12 @@
(:require
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.rpc.permissions :as perms]
[app.rpc.queries.projects :as proj]
[app.rpc.queries.teams :as teams]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]))
;; --- Helpers & Specs
@ -123,14 +121,6 @@
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
(db/with-atomic [conn pool]
(proj/check-edition-permissions! conn profile-id id)
;; Schedule object deletion
(wrk/submit! {::wrk/task :delete-object
::wrk/delay cfg/deletion-delay
::wrk/conn conn
:id id
:type :project})
(db/update! conn :project
{:deleted-at (dt/now)}
{:id id})

View file

@ -10,7 +10,6 @@
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.emails :as eml]
[app.media :as media]
@ -21,7 +20,6 @@
[app.storage :as sto]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[datoteka.core :as fs]))
@ -135,13 +133,6 @@
(ex/raise :type :validation
:code :only-owner-can-delete-team))
;; Schedule object deletion
(wrk/submit! {::wrk/task :delete-object
::wrk/delay cfg/deletion-delay
::wrk/conn conn
:id id
:type :team})
(db/update! conn :team
{:deleted-at (dt/now)}
{:id id})

View file

@ -11,7 +11,8 @@
[app.common.uuid :as uuid]
[app.db :as db]
[app.util.services :as sv]
[clojure.spec.alpha :as s]))
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
;; --- Helpers & Specs
@ -90,16 +91,11 @@
profile))
(def sql:retrieve-profile-by-email
"select p.* from profile as p
where p.email = lower(?)
and p.deleted_at is null")
(defn retrieve-profile-data-by-email
[conn email]
(let [sql [sql:retrieve-profile-by-email email]]
(some-> (db/exec-one! conn sql)
(decode-profile-row))))
(try
(db/get-by-params conn :profile {:email (str/lower email)})
(catch Exception _e)))
;; --- Attrs Helpers

View file

@ -4,6 +4,9 @@
;;
;; Copyright (c) UXBOX Labs SL
;; TODO: DEPRECATED
;; Should be removed in the 1.8.x
(ns app.tasks.delete-object
"Generic task for permanent deletion of objects."
(:require

View file

@ -14,6 +14,9 @@
[clojure.spec.alpha :as s]
[integrant.core :as ig]))
;; TODO: DEPRECATED
;; Should be removed in the 1.8.x
(declare delete-profile-data)
;; --- INIT

View file

@ -100,6 +100,7 @@
:id (:id mobj)
:media-id (:media-id mobj)
:thumbnail-id (:thumbnail-id mobj))
;; NOTE: deleting the file-media-object in the database
;; automatically marks as toched the referenced storage
;; objects. The touch mechanism is needed because many files can

View file

@ -0,0 +1,152 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.tasks.objects-gc
"A maintenance task that performs a general purpose garbage collection
of deleted objects."
(:require
[app.db :as db]
[app.storage :as sto]
[app.util.logging :as l]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]))
(def target-tables
["profile"
"team"
"file"
"project"
"team_font_variant"])
(defmulti delete-objects :table)
(def sql:delete-objects
"with deleted as (
select id from %(table)s
where deleted_at is not null
and deleted_at < now() - ?::interval
order by deleted_at
limit %(limit)s
)
delete from %(table)s
where id in (select id from deleted)
returning *")
;; --- IMPL: generic object deletion
(defmethod delete-objects :default
[{:keys [conn max-age table] :as cfg}]
(let [sql (str/fmt sql:delete-objects
{:table table :limit 50})
result (db/exec! conn [sql max-age])]
(doseq [{:keys [id] :as item} result]
(l/trace :action "delete object" :table table :id id))
(count result)))
;; --- IMPL: team-font-variant deletion
(defmethod delete-objects "team_font_variant"
[{:keys [conn max-age storage table] :as cfg}]
(let [sql (str/fmt sql:delete-objects
{:table table :limit 50})
fonts (db/exec! conn [sql max-age])
storage (assoc storage :conn conn)]
(doseq [{:keys [id] :as font} fonts]
(l/trace :action "delete object" :table table :id id)
(some->> (:woff1-file-id font) (sto/del-object storage))
(some->> (:woff2-file-id font) (sto/del-object storage))
(some->> (:otf-file-id font) (sto/del-object storage))
(some->> (:ttf-file-id font) (sto/del-object storage)))
(count fonts)))
;; --- IMPL: team deletion
(defmethod delete-objects "team"
[{:keys [conn max-age storage table] :as cfg}]
(let [sql (str/fmt sql:delete-objects
{:table table :limit 50})
teams (db/exec! conn [sql max-age])
storage (assoc storage :conn conn)]
(doseq [{:keys [id] :as team} teams]
(l/trace :action "delete object" :table table :id id)
(some->> (:photo-id team) (sto/del-object storage)))
(count teams)))
;; --- IMPL: profile deletion
(def sql:retrieve-deleted-profiles
"select id, photo_id from profile
where deleted_at is not null
and deleted_at < now() - ?::interval
order by deleted_at
limit %(limit)s
for update")
(def sql:mark-owned-teams-deleted
"with owned as (
select tpr.team_id as id
from team_profile_rel as tpr
where tpr.is_owner is true
and tpr.profile_id = ?
)
update team set deleted_at = now() - ?::interval
where id in (select id from owned)")
(defmethod delete-objects "profile"
[{:keys [conn max-age storage table] :as cfg}]
(let [sql (str/fmt sql:retrieve-deleted-profiles {:limit 50})
profiles (db/exec! conn [sql max-age])
storage (assoc storage :conn conn)]
(doseq [{:keys [id] :as profile} profiles]
(l/trace :action "delete object" :table table :id id)
;; Mark the owned teams as deleted; this enables them to be procesed
;; in the same transaction in the "team" table step.
(db/exec-one! conn [sql:mark-owned-teams-deleted id max-age])
;; Mark as deleted the storage object related with the photo-id
;; field.
(some->> (:photo-id profile) (sto/del-object storage))
;; And finally, permanently delete the profile.
(db/delete! conn :profile {:id id}))
(count profiles)))
;; --- INIT
(defn- process-table
[{:keys [table] :as cfg}]
(loop [n 0]
(let [res (delete-objects cfg)]
(if (pos? res)
(recur (+ n res))
(l/debug :hint "table gc summary" :table table :deleted n)))))
(s/def ::max-age ::dt/duration)
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::db/pool ::sto/storage ::max-age]))
(defmethod ig/init-key ::handler
[_ {:keys [pool max-age] :as cfg}]
(fn [task]
;; Checking first on task argument allows properly testing it.
(let [max-age (get task :max-age max-age)]
(db/with-atomic [conn pool]
(let [max-age (db/interval max-age)
cfg (-> cfg
(assoc :max-age max-age)
(assoc :conn conn))]
(doseq [table target-tables]
(process-table (assoc cfg :table table))))))))

View file

@ -11,6 +11,7 @@
[app.http :as http]
[app.storage :as sto]
[app.test-helpers :as th]
[app.util.time :as dt]
[clojure.test :as t]
[datoteka.core :as fs]))
@ -337,3 +338,69 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/deftest deletion-test
(let [task (:app.tasks.objects-gc/handler th/*system*)
profile1 (th/create-profile* 1)
file (th/create-file* 1 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)})]
;; file is not deleted because it does not meet all
;; conditions to be deleted.
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; query the list of files
(let [data {::th/type :project-files
:project-id (:default-project-id profile1)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count result)))))
;; Request file to be deleted
(let [params {::th/type :delete-file
:id (:id file)
:profile-id (:id profile1)}
out (th/mutation! params)]
(t/is (nil? (:error out))))
;; query the list of files after soft deletion
(let [data {::th/type :project-files
:project-id (:default-project-id profile1)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 0 (count result)))))
;; run permanent deletion (should be noop)
(let [result (task {:max-age (dt/duration {:minutes 1})})]
(t/is (nil? result)))
;; query the list of file libraries of a after hard deletion
(let [data {::th/type :file-libraries
:file-id (:id file)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 0 (count result)))))
;; run permanent deletion
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; query the list of file libraries of a after hard deletion
(let [data {::th/type :file-libraries
:file-id (:id file)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))
))

View file

@ -9,6 +9,7 @@
[app.db :as db]
[app.rpc.mutations.profile :as profile]
[app.test-helpers :as th]
[app.util.time :as dt]
[clojure.java.io :as io]
[clojure.test :as t]
[cuerdas.core :as str]
@ -117,7 +118,7 @@
))
(t/deftest profile-deletion-simple
(let [task (:app.tasks.delete-profile/handler th/*system*)
(let [task (:app.tasks.objects-gc/handler th/*system*)
prof (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
@ -125,23 +126,14 @@
;; profile is not deleted because it does not meet all
;; conditions to be deleted.
(let [result (task {:props {:profile-id (:id prof)}})]
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; Request profile to be deleted
(with-mocks [mock {:target 'app.worker/submit! :return nil}]
(let [params {::th/type :delete-profile
:profile-id (:id prof)}
out (th/mutation! params)]
(t/is (nil? (:error out)))
;; check the mock
(let [mock (deref mock)
mock-params (first (:call-args mock))]
(t/is (:called? mock))
(t/is (= 1 (:call-count mock)))
(t/is (= :delete-profile (:app.worker/task mock-params)))
(t/is (= (:id prof) (:profile-id mock-params))))))
(let [params {::th/type :delete-profile
:profile-id (:id prof)}
out (th/mutation! params)]
(t/is (nil? (:error out))))
;; query files after profile soft deletion
(let [params {::th/type :files
@ -153,8 +145,8 @@
(t/is (= 1 (count (:result out)))))
;; execute permanent deletion task
(let [result (task {:props {:profile-id (:id prof)}})]
(t/is (true? result)))
(let [result (task {:max-age (dt/duration "-1m")})]
(t/is (nil? result)))
;; query profile after delete
(let [params {::th/type :profile
@ -165,17 +157,6 @@
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))
;; query files after profile soft deletion
(let [params {::th/type :files
:project-id (:default-project-id prof)
:profile-id (:id prof)}
out (th/query! params)]
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))
))
(t/deftest registration-domain-whitelist

View file

@ -10,8 +10,8 @@
[app.db :as db]
[app.http :as http]
[app.test-helpers :as th]
[clojure.test :as t]
[promesa.core :as p]))
[app.util.time :as dt]
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@ -170,3 +170,71 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/deftest test-deletion
(let [task (:app.tasks.objects-gc/handler th/*system*)
profile1 (th/create-profile* 1)
project (th/create-project* 1 {:team-id (:default-team-id profile1)
:profile-id (:id profile1)})]
;; project is not deleted because it does not meet all
;; conditions to be deleted.
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; query the list of projects
(let [data {::th/type :projects
:team-id (:default-team-id profile1)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 2 (count result)))))
;; Request project to be deleted
(let [params {::th/type :delete-project
:id (:id project)
:profile-id (:id profile1)}
out (th/mutation! params)]
(t/is (nil? (:error out))))
;; query the list of projects after soft deletion
(let [data {::th/type :projects
:team-id (:default-team-id profile1)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count result)))))
;; run permanent deletion (should be noop)
(let [result (task {:max-age (dt/duration {:minutes 1})})]
(t/is (nil? result)))
;; query the list of files of a after soft deletion
(let [data {::th/type :project-files
:project-id (:id project)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 0 (count result)))))
;; run permanent deletion
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; query the list of files of a after hard deletion
(let [data {::th/type :project-files
:project-id (:id project)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))
))

View file

@ -11,6 +11,7 @@
[app.http :as http]
[app.storage :as sto]
[app.test-helpers :as th]
[app.util.time :as dt]
[clojure.test :as t]
[datoteka.core :as fs]
[mockery.core :refer [with-mocks]]))
@ -80,6 +81,80 @@
)))
(t/deftest test-deletion
(let [task (:app.tasks.objects-gc/handler th/*system*)
profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
pool (:app.db/pool th/*system*)
data {::th/type :delete-team
:team-id (:id team)
:profile-id (:id profile1)}]
;; team is not deleted because it does not meet all
;; conditions to be deleted.
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; query the list of teams
(let [data {::th/type :teams
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 2 (count result)))
(t/is (= (:id team) (get-in result [1 :id])))
(t/is (= (:default-team-id profile1) (get-in result [0 :id])))))
;; Request team to be deleted
(let [params {::th/type :delete-team
:id (:id team)
:profile-id (:id profile1)}
out (th/mutation! params)]
(t/is (nil? (:error out))))
;; query the list of teams after soft deletion
(let [data {::th/type :teams
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:default-team-id profile1) (get-in result [0 :id])))))
;; run permanent deletion (should be noop)
(let [result (task {:max-age (dt/duration {:minutes 1})})]
(t/is (nil? result)))
;; query the list of projects of a after hard deletion
(let [data {::th/type :projects
:team-id (:id team)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 0 (count result)))))
;; run permanent deletion
(let [result (task {:max-age (dt/duration 0)})]
(t/is (nil? result)))
;; query the list of projects of a after hard deletion
(let [data {::th/type :projects
:team-id (:id team)
:profile-id (:id profile1)}
out (th/query! data)]
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))
))