mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Validate and decode a job result with its declared schema (#12350)
* 🐛 Decode job results with the schema of the job-def A job result is stored as plain JSON, so a uuid comes back as the text it is on disk and a uuid used as a map key comes back as a keyword. The import resolution is a map keyed by file id, and the wizard lost that identity: choosing a library in the link step created no link at all, with no error and with the modal closing as if it had worked. A job-def can now declare a result schema and its precompiled decoder, the pair the params already have, and the substrate applies them when the row is read. A def that declares neither answers with the JSON the row holds, as before; a row whose job-def is gone is read untouched instead of raising. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Type the import result so its resolution keeps uuid keys The resolution of a version 3 import is a map keyed by the new file id, and the result column is plain JSON: without a schema of its own every key came back as a keyword while the :id of the entry came back as text, so the dashboard wizard could not match the file it was linking and created no library link at all. Declare the shape of the resolution (files, done and pending entries and their candidates) and give the job-def its result schema and decoder. The check before the result is stored keeps validating the same schema. Closes #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Take the imported file id from the entry, not the map key The wizard looked a file up in the selection with the key of the resolution map while it had indexed the selection by the :id of the entry. The key is keywordized on its way through the job result, so the lookup missed and the confirm button created no library link at all, with no error and with the modal closing as if it had worked. Take the id from the entry, the source the rest of the wizard already uses, and move the decision to a pure function covered by tests of both shapes of the resolution. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Test that an import result keeps its ids as uuids The regression this branch fixes is only visible through the read path of the substrate: the resolution of an import is a map keyed by the new file id, and a test that works with the value in memory never sees the keys coming back as keywords from the plain JSON column. Import a package whose library needs a replacement chosen by the user, read the result with `jobs/get-job` as the client does, and check the keys, the file ids and the ids of the candidates. Without the result decoder of the import job-def the test answers with text ids. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📚 Document the result schema of a job-def The memories and the docstring of the substrate said a job result has no schema of its own and comes back as the JSON the row holds. A job-def can now declare its result schema and decoder, and the substrate applies them when the row is read. Keep the rule next to it: a result map keyed by uuid needs its schema declared, or every key comes back as a keyword, and the link-later step of the import wizard takes the file id from the entry and never from the key of the resolution map. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📚 Fix the documented contract of a job result The docstring of get-job said a result has no schema of its own and comes back as the JSON the row holds, which is what this branch changes: the result is decoded with the schema its job-def declares, and a result that does not conform never reaches the column because the write validates it. Say the same in the management command that stores the result of an external worker, the other writer of the column. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Check the result of a job against its schema before storing it The result of a job crosses a plain JSON column, so a uuid comes back as text and a uuid used as a map key as a keyword. The job-def now declares the schema of its result and the substrate compiles the decoder from it, which is what the read path uses to get the types back. The same schema is the contract of the write path: complete refuses a result that does not conform, with the malli explanation, so nothing invalid reaches the column, and the answer is the same for the runner and for the external worker that completes a job through the management RPC. A job that is no longer completable is skipped without checking, and the read path decodes only: a row written by another version stays readable. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Check the result of an import inside its transaction The check ran after the transaction of the import had committed, so a result the schema does not accept failed the job with the files already created in the destination project: a half imported project and an error. The core decides whether the whole import is kept or rolled back, so the check belongs inside its transaction, which is what turns a contract violation into a rollback. The substrate checks the result again when the job is completed, which is the only net for a job that does not validate its own result. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Cover the edges of an import result read by the client The shape of a result with no libraries to resolve, and the one of a version 1 import, is the most common in production and neither was covered by the tests that read a result the way the client does. Cover both through jobs/get-job, and the case of a registry that no longer knows the kind of the row: it answers with the JSON of the column, keywordized keys and uuid as text, instead of raising. Say in the two tests that depend on the decoder what implementation without it makes them fail. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📚 Fix the prose and the fixtures of the result contract The result schema of a job-def is what the read decodes with and what the write checks: say that in the memories, with the rule that any uuid of a result needs it, whether a value or a map key. The motive of the wizard helper was a fact this branch makes false: what a key of the resolution map holds depends on the result schema of the job-def that wrote it, so the rule is to read the id from the entry. Same correction in its memory and in the test that pins the shape. The candidates of a pending entry match by slugified name, not by name, and every test row now states the kind of its job, because the name is what resolves its job-def when the result is read or checked. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Keep the wizard link helper private and cover its shape The helper that decides which links the wizard creates belongs to its namespace, as its neighbours do: reach it from the test with @#', the pattern the frontend tests already use for a private helper. Cover the shapes it had no test for: a file with two libraries pending resolution, a file whose libraries were auto-linked, and a selection where only one of two entries was resolved. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Share one job-row helper across the job tests Three test namespaces had their own copy of the same insert, differing only in the kind of the job and in whether the row holds the resource it consumes. Move it to the test helpers as create-job*, with the kind of the job as its first argument: it is what resolves the job-def of the row when its result is read or checked, so every test states it. The bang of the name goes away with it: the design rules keep it for the mutating API of app.db, and this is a fixture of the tests. The three namespaces lose the requires that only their removed copy used. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
7383e08683
commit
57fa280209
@ -55,7 +55,7 @@ For deeper details on transaction semantics, advisory locks, Transit vs JSON hel
|
||||
|
||||
## Background tasks (unified jobs)
|
||||
|
||||
Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init). The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref).
|
||||
Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init), optionally with `::jobs/result-schema` when the result carries types plain JSON does not keep (a uuid, whether a value or a map key): the substrate compiles the decoder from that schema on registry init and checks a result against the schema before storing it. The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref).
|
||||
|
||||
- `::jobs/handler` is `(fn [context params] ...)`. It is NOT given the runner cfg: a job-def closes over its own dependencies, and handing it the runner cfg would let a handler reach the pool or any other component. A wrapper passes the context to its implementation only when the implementation takes it; most do not, and their implementation stays `[cfg params]`.
|
||||
- The context is built by `jobs/make-context` and has exactly five keys: `id`, `name`, `label`, `resource-id`, `profile-id`, behind a closed schema. `profile-id` is the owner of a user-facing job and nil for an internal one, and it is there so a handler can revalidate permissions and check who owns its resource without a second query. `queue` is absent on purpose (a routing detail of the dispatcher, not a property of the job) and so are `retry-num`/`max-retries`/`attempt` (retry policy belongs to the runner, and an attempt number cannot be durable because the `noop` strategy re-runs without incrementing the counter).
|
||||
@ -64,14 +64,14 @@ Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` i
|
||||
- A job-def may carry optional metadata keys read from the registry by its consumers, never by the substrate: `::jobs/family` (e.g. `:export`) groups the jobs of a family for quotes, `::jobs/resource-role` (`:input` or `:output`) says whether the job's `resource_id` is what it consumes or what it produces, and `::jobs/queue-name` names the queue the creation command routes the job to (`submit-job` falls back to `:binfile` when the def does not set it).
|
||||
- Job resources (`app.jobs.storage`) are the objects a job owns through `job.resource_id`: they live in the `job-resource` bucket, are written with the profile of the job as owner (only that profile can read them back from `/assets/by-id`) and are never deduplicated. They carry no expiry of their own, because `expired-at` is stored as `deleted_at` and an object with it is unreadable from birth: the retention of the artifact is the retention of its job row. The writer marks the object as touched right away, so an artifact that no row references (a crash between the upload and the completion) is reclaimed by `storage-gc-touched`, and a row that does reference it freezes it until the jobs GC deletes the row and touches the object.
|
||||
- The binfile jobs (`:export-binfile` and `:import-binfile`) run the core of `bf.v1`/`bf.v3` through `app.binfile.jobs`, which turns the `:progress` taps of the core into `progress` events of the job with `jobs/heartbeat` (the `modified_at` touch at most once per second, progress events at up to ~200ms). Each event is a self-contained milestone: a `:stage` keyword of one vocabulary shared by the two formats (the same work has a different name in v1 and v3) and an optional `:counters` map, with the counter of the activity named after its own stage and the outer scopes in course as context, which the tap carries itself (the reader puts `::outer-counters` in every tap inside them — today only `:files`, extendable to `:teams`/`:projects` for multi-scope exports — so the adapter remembers nothing between taps and a tap that happens outside any scope has no context); the ids and names the taps carry never reach the event. Every tap is reported, so a tap with no units to count still keeps the job alive, and a milestone the throttle drops loses nothing because the next one carries the same state. The adapter checks that the job is still active before the run, and the beat raises an `:interrupt` right there on the run thread when the job is no longer active, so the work stops at its next event point inside the transaction of its handler, which rolls back. A run whose last tap predates the interruption is discarded by the check once it returns. The adapter passes the job id from the handler context. Their job-def components carry pool, storage, metrics and msgbus, because a job with a `profile_id` cannot store an event without a msgbus on its cfg. Both readers answer with the same shape (`:file-ids` and, in version 3, the resolution of the libraries), because the version 1 reader returns a bare set of file ids.
|
||||
- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it, because the job-def has no result schema of its own.
|
||||
- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it (the import job-def declares that same schema as its result schema, which is what types the resolution when the row is read).
|
||||
- The domain cfg of a binfile request lives in `app.binfile.common` (`export-cfg`, `import-cfg`), so the legacy RPC and the jobs freeze the same ids, resolve the same team features and apply the same anti-zip-bomb limits. `resolve-export-type` is the translation the legacy RPC needs for its deprecated boolean flags; the creation command asks the caller for the type instead of guessing a default.
|
||||
- Two retention knobs, easy to confuse: `:jobs-retention` sweeps terminal internal rows (the ones without a profile), while `:jobs-user-ttl` is the default expiry a user-facing job gets when it is created; the jobs GC deletes the row once `expires_at` passes and touches its resource. The heavy work of a user job runs on its own `:binfile` queue (`:worker-binfile-parallelism`, default 1) so it does not block the default runner.
|
||||
- The quote of a family of jobs (`::export-jobs-per-profile`, `::import-jobs-per-profile`) counts the jobs of the profile that are still on their way (`new`/`scheduled`/`running`/`retry`) whose name the registry gives for that family, so a new job of the family is covered without touching the quote.
|
||||
- A user-facing job is created through `app.rpc.commands.jobs`. One command per job type: `create-export-binfile-job` and `create-import-binfile-job` name the job they create and their body carries only `:params`, whose schema the command imports from the job namespace (`::sm/params`), so the RPC validates the business params up front, before the command runs; the job-def is still the place that owns them and validates them again at run time. The commands check the permission and the quote of the family before the expensive part, submit with `max-retries 0`, an expiry from `:jobs-user-ttl` and the queue the job-def routes to through `::jobs/queue-name` (`:binfile` when the def says nothing; read by the command, never by the substrate), and answer with a summary of the row. The import command has its own creation schema (`import-binfile/schema:create-params`, version optional) because the request is not the stored params: the command fills the version (from the caller or the package header) and the manifest metadata kept for audit. It assembles the upload, reads the manifest of a version 3 package for the audit (never its content) and stores the package in `job-resource` as the resource of the job, releasing it when the job never comes to own it.
|
||||
- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is the plain JSON the row holds, so a uuid comes back as the text it is on disk (a result has no schema of its own like the params do). The event log still has no read surface: a client that follows a job takes its progress from the websocket.
|
||||
- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is then decoded with the decoder the substrate compiled from the result schema the job-def the row names declares (`decode-result`; a def without one, or a row whose job-def is gone, keeps the plain JSON the row holds: a uuid as the text it is on disk, a uuid used as a map key as a keyword). The event log still has no read surface: a client that follows a job takes its progress from the websocket.
|
||||
- `heartbeat` uses named options `[cfg & {:keys [job-id progress] :as options}]` and returns the number of durable writes, so 0 means there was nothing to write: no job context, or the throttle did not allow it. A write that finds the job no longer active (terminal or gone) raises an `:interrupt` (`code :job-interrupted`, carrying the job id and the status it found) through the shared `jobs/check-active` instead of counting zero, so a task in course stops at its next beat; the interrupt is internal and never reaches HTTP (the management progress report answers `:skip` for it). The job id falls back to `::jobs/job-id` on the cfg and then to the runner-bound `*job-id*`. The `modified_at` touch is throttled to ~1s and the progress event to ~200ms, so the check fires at most once per second with no extra query (the read of the row happens only when a write already found nothing). A progress report is a milestone (`schema:progress`): a required `:stage` keyword plus an optional `:counters` map of `scope -> {:current, :total?}`, and no other key. Counters are non-negative, a `total` is positive and never lower than its `current`, and stage and counter keys are names of at most 64 characters. A milestone without counters is valid (a point of the run with no units to count), the stage vocabulary belongs to the worker and not to the substrate, and a client must degrade for a stage it does not know. Read a stored payload back with `jobs/decode-progress`: the database holds the keywords as strings. The 1s beat (down from 60s, so a cancelled heavy job is seen as gone at its next beat) costs one `modified_at` touch per beating job per second: cheap while the only heavy beater runs alone on its queue, revisit if concurrent beating jobs ever grow.
|
||||
- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in.
|
||||
- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. It checks the result against the schema the job-def of the row declares before it writes (a violation raises `:invalid-result` and nothing is stored), and only for a transition that is going to write: a terminal job answers `:skip` without validating. `complete` is the single door of a result, so the check covers the runner and the external worker that completes a job through the management RPC (`::complete-job`), which stores a result the substrate would otherwise take blind.
|
||||
|
||||
For worker dispatch, cron, retry semantics (`ex/raise :type ::wrk/retry` with `:delay`/`:strategy`), deduplication, job events, and queue internals: `mem:backend/subtleties`.
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
- Whether a call is nested is decided by the CONNECTION, not by an ambient flag: `db/transact!` binds `*after-commit-conn*` around the body, and a transactable that is not identical to that connection is an independent unit of work that owns its own after-commit context. A `tx-run!` that passes a pool while a transaction is open therefore commits on its own and drains its own callbacks, instead of leaving them in a context that may roll back.
|
||||
- `after-commit` callbacks run on commit only. A transaction marked `::db/rollback` (used by `srepl` and `app.tasks/offload-file-data`) commits nothing, so it runs none of them.
|
||||
- `db/run!` opens/reuses one connection but does not create a transaction.
|
||||
- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`).
|
||||
- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`). Job results (`job.result`) are plain JSON too: they are read with the result decoder of the job-def (`app.jobs/decode-result`), and a result map keyed by uuid must declare its schema (`[:map-of ::sm/uuid ...]`, the import resolution does) or every key comes back as a keyword and every uuid as the text it is on disk.
|
||||
- Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash.
|
||||
|
||||
## Workers and cron
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
- Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id.
|
||||
- Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly.
|
||||
- Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications.
|
||||
- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The legacy SSE import stays in the backend for other clients.
|
||||
- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients.
|
||||
- File creation/duplication strips file `:data` before putting file summaries into dashboard state.
|
||||
|
||||
## Viewer
|
||||
|
||||
@ -9,11 +9,15 @@
|
||||
|
||||
Every job is defined as a job-def map returned by the `ig/init-key` of its
|
||||
module: {::name, ::schema, ::handler, ::decoder, ::validator} — decoder and
|
||||
validator are precompiled at init time. A job-def may also carry the
|
||||
optional metadata {::family, ::resource-role}, which the consumers of the
|
||||
job (quotes, storage helpers) read from the registry: the substrate never
|
||||
reads them. The registry of job-defs is plain integrant wiring (::jobs/defs);
|
||||
submit, dispatch and management consume it by reference.
|
||||
validator are precompiled at init time. A def whose result needs its types
|
||||
back declares the optional ::result-schema: the substrate compiles the
|
||||
decoder from that schema and checks a result against it before storing it,
|
||||
while a def without one answers with the JSON the row holds. A job-def may
|
||||
also carry the optional metadata {::family, ::resource-role}, which the
|
||||
consumers of the job (quotes, storage helpers) read from the registry: the
|
||||
substrate never reads them. The registry of job-defs is plain integrant
|
||||
wiring (::jobs/defs); submit, dispatch and management consume it by
|
||||
reference.
|
||||
|
||||
Two execution modes are provided:
|
||||
- `submit` (durable): validates + JSON-encodes params and inserts a row
|
||||
@ -24,6 +28,11 @@
|
||||
|
||||
Params payloads are stored as plain JSON (not transit) in the `params` jsonb
|
||||
column and decoded back to typed Clojure values using the job-def decoder.
|
||||
Results are plain JSON in the `result` jsonb column: they are decoded with the
|
||||
decoder the substrate compiled from the schema the def declares, and a result
|
||||
that does not conform to that schema is refused before it is stored. Without a
|
||||
schema a uuid comes back as the text it is on disk and a uuid used as a map
|
||||
key as a keyword, so a def that keys a result map by uuid must declare it.
|
||||
|
||||
Any job that can run longer than `:jobs-lease` must call `heartbeat`
|
||||
on its loop, otherwise the dispatcher marks it `aborted` (a system-side
|
||||
@ -151,7 +160,14 @@
|
||||
;; implementation stays [cfg params].
|
||||
[::handler ::sm/fn]
|
||||
[::decoder ::sm/fn]
|
||||
[::validator ::sm/fn]])
|
||||
[::validator ::sm/fn]
|
||||
;; A result crosses a plain JSON column on its way out of the job, so a
|
||||
;; uuid comes back as the text it is on disk and a uuid used as a map
|
||||
;; key comes back as a keyword. A def that declares the schema of its
|
||||
;; result gets its types back when the row is read (the substrate
|
||||
;; compiles the decoder from the schema), and its result is checked
|
||||
;; against that schema before it is stored.
|
||||
[::result-schema {:optional true} any?]])
|
||||
|
||||
(def ^:private schema:job-defs
|
||||
[:map-of :keyword schema:job-def])
|
||||
@ -189,11 +205,24 @@
|
||||
:job (d/name name)
|
||||
:expected (d/name (::name job-def))))))
|
||||
|
||||
(defn- compile-result-schema
|
||||
"Compile the decoder of a job-def from the result schema it declares, so the
|
||||
pair cannot drift from the schema and a def without one keeps working
|
||||
untouched. The write path checks the result against the schema itself."
|
||||
[job-def]
|
||||
(if-let [schema (::result-schema job-def)]
|
||||
(assoc job-def ::result-decoder (sm/decoder schema sm/json-transformer))
|
||||
job-def))
|
||||
|
||||
(defmethod ig/init-key ::defs
|
||||
[_ defs]
|
||||
(reset! defs-registry defs)
|
||||
(l/inf :hint "job definitions initialized" :jobs (count defs))
|
||||
defs)
|
||||
(let [defs (reduce-kv (fn [acc name job-def]
|
||||
(assoc acc name (compile-result-schema job-def)))
|
||||
{}
|
||||
defs)]
|
||||
(reset! defs-registry defs)
|
||||
(l/inf :hint "job definitions initialized" :jobs (count defs))
|
||||
defs))
|
||||
|
||||
(defmethod ig/halt-key! ::defs
|
||||
[_ defs]
|
||||
@ -222,6 +251,32 @@
|
||||
db/decode-json-pgobject)
|
||||
((::decoder job-def))))
|
||||
|
||||
(defn decode-result
|
||||
"Decode the raw JSON result (pgobject or decoded map) with the decoder the
|
||||
substrate compiled from the result schema of the job-def. A def without one
|
||||
answers with the JSON the row holds: every key keywordized and every uuid as
|
||||
the text it is on disk."
|
||||
[job-def result]
|
||||
(when (some? result)
|
||||
(let [value (cond-> result
|
||||
(db/pgobject? result)
|
||||
db/decode-json-pgobject)]
|
||||
(if-let [decoder (::result-decoder job-def)]
|
||||
(decoder value)
|
||||
value))))
|
||||
|
||||
(defn- check-result
|
||||
"Validate the result of a job against the schema its job-def declares, with
|
||||
the malli explanation on failure. A job-def without a result schema has
|
||||
nothing to check, and so has a job whose definition is gone."
|
||||
[job-def result]
|
||||
(when-let [schema (::result-schema job-def)]
|
||||
(sm/check schema result
|
||||
:hint "invalid result of a job"
|
||||
:type :validation
|
||||
:code :invalid-result))
|
||||
result)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SUBMIT API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -432,11 +487,21 @@
|
||||
(decode-json-col :result)
|
||||
(decode-json-col :error)))
|
||||
|
||||
(defn- decode-result-of
|
||||
"Decode the result of a row with the decoder of the job-def that owns it.
|
||||
A kind the registry no longer knows (a retired job-def) answers with the
|
||||
row untouched instead of raising."
|
||||
[cfg row]
|
||||
(if-let [job-def (get (get-defs cfg) (keyword (:name row)))]
|
||||
(update row :result #(decode-result job-def %))
|
||||
row))
|
||||
|
||||
(defn get-job
|
||||
"Retrieve the job row (with the raw JSON params decoded to a plain map)."
|
||||
"Retrieve the job row, with params and result decoded to plain maps (the
|
||||
result only when its job-def declares a result decoder)."
|
||||
[cfg job-id]
|
||||
(some-> (db/get* cfg :job {:id job-id})
|
||||
(decode-row)))
|
||||
(when-let [row (db/get* cfg :job {:id job-id})]
|
||||
(decode-result-of cfg (decode-row row))))
|
||||
|
||||
(defn get-user-status
|
||||
"Map the internal job status to the user-facing status."
|
||||
@ -1058,7 +1123,8 @@
|
||||
|
||||
- `:job-id` the job to complete.
|
||||
- `:result` the JSON-encodable result, nil when the job returns
|
||||
nothing.
|
||||
nothing; a result that does not conform to the schema
|
||||
its job-def declares is refused.
|
||||
- `:resource-id` optional storage object to associate with the job. A
|
||||
resource is only ever set, never replaced: passing one
|
||||
for a job that already has a resource is a validation
|
||||
@ -1073,11 +1139,13 @@
|
||||
aborted by the dispatcher — is never overwritten) and writes no event. Returns
|
||||
the number of affected rows.
|
||||
|
||||
The row is read locked inside that same transaction, so the two rules
|
||||
are applied in a fixed order against the row and not against the
|
||||
clock: a job that is no longer completable is skipped, and only a job
|
||||
that is still completable can be refused for its resource. A
|
||||
concurrent completion waits for the lock and then sees the outcome."
|
||||
The row is read locked inside that same transaction, so the rules are
|
||||
applied in a fixed order against the row and not against the clock: a
|
||||
job that is no longer completable is skipped, only a job that is still
|
||||
completable can be refused for its resource, and the result is checked
|
||||
against the schema its job-def declares before it is stored (a def
|
||||
without one has nothing to check). A concurrent completion waits for
|
||||
the lock and then sees the outcome."
|
||||
[cfg & {:keys [job-id result resource-id]}]
|
||||
(assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg")
|
||||
(check-resource-id resource-id)
|
||||
@ -1097,6 +1165,9 @@
|
||||
:hint "the job already has a resource, it cannot be replaced"
|
||||
:job-id job-id
|
||||
:resource-id (:resource-id job)))
|
||||
(check-result (get (get-defs tx-cfg)
|
||||
(keyword (:name job)))
|
||||
result)
|
||||
(if (db/exec-one! conn [sql:complete-job (ct/now) (ct/now)
|
||||
encoded resource-id job-id])
|
||||
(do
|
||||
|
||||
@ -330,11 +330,15 @@
|
||||
"Read a job the caller created: its state and, once it is over, what it
|
||||
produced or why it failed.
|
||||
|
||||
The result of a finished job is the plain JSON the row holds, because a
|
||||
result has no schema of its own like the params do: a uuid comes back as
|
||||
the text it is on disk. The progress and the history of the job are not
|
||||
part of the answer: a client that follows a job takes its progress from
|
||||
the websocket, and the event log still has no read surface."
|
||||
The result of a finished job is the JSON the row holds, decoded with the
|
||||
result schema its job-def declares: a job that keys its result by uuid
|
||||
(the import resolution) answers with the types it stored, and a job whose
|
||||
job-def declares no schema answers with the JSON as it is on disk, where a
|
||||
uuid is text and a uuid used as a map key is a keyword. A result that does
|
||||
not conform to the declared schema never reaches the column: the substrate
|
||||
validates it when the job is completed. The progress and the history of
|
||||
the job are not part of the answer: a client that follows a job takes its
|
||||
progress from the websocket, and the event log still has no read surface."
|
||||
{::doc/added "2.20"
|
||||
::sm/params schema:get-job
|
||||
::sm/result schema:job-detail}
|
||||
|
||||
@ -106,7 +106,10 @@
|
||||
[:job-id ::sm/uuid]
|
||||
;; Per-job result: any JSON-encodable value, nil when the job returns
|
||||
;; nothing. Nested maps and vectors are allowed; unserializable
|
||||
;; values are dropped to nil by jobs/encode-result with a warning.
|
||||
;; values are dropped to nil by jobs/encode-result with a warning. A
|
||||
;; job whose job-def declares a result schema has its result validated
|
||||
;; against it: one that does not conform is rejected and the job is
|
||||
;; not completed.
|
||||
[:result [:maybe :any]]
|
||||
;; Storage object the worker produced for this job. A resource is only
|
||||
;; set, never replaced: a job that already has one is rejected.
|
||||
@ -117,6 +120,13 @@
|
||||
[:action [:enum :run :skip]]])
|
||||
|
||||
(sv/defmethod ::complete-job
|
||||
"Complete a job on behalf of the worker that ran it, with the result it
|
||||
produced and, when it wrote one, the resource it stores.
|
||||
|
||||
The result of a job whose job-def declares a result schema is validated
|
||||
against it before it is stored: one that does not conform is rejected and
|
||||
the job is not completed. A job that is no longer completable is skipped,
|
||||
and nothing is validated for it."
|
||||
{::doc/added "2.20"
|
||||
::sm/params schema:complete-job-params
|
||||
::sm/result schema:complete-job-result
|
||||
|
||||
@ -44,12 +44,53 @@
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:version {:optional true} [:enum 1 3]]])
|
||||
|
||||
(def schema:candidate
|
||||
"A library of the instance whose slugified name matches the one the package
|
||||
used and is therefore offered to the user as a replacement."
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:project-id ::sm/uuid]
|
||||
[:project-name :string]])
|
||||
|
||||
(def schema:done-entry
|
||||
"A library of the package that was linked while it was read."
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:linked-to ::sm/uuid]])
|
||||
|
||||
(def schema:pending-entry
|
||||
"A library of the package that needs the user to choose a replacement:
|
||||
candidates are the libraries of the instance with the same slugified name
|
||||
(`bfc/find-shared-files-by-slug`)."
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:candidates [:vector schema:candidate]]])
|
||||
|
||||
(def schema:resolution-entry
|
||||
"The libraries one imported file uses, split in the ones already linked
|
||||
and the ones pending resolution."
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:done [:vector schema:done-entry]]
|
||||
[:pending [:vector schema:pending-entry]]])
|
||||
|
||||
(def schema:resolution
|
||||
"The resolution of every imported file, keyed by its new file id. The key
|
||||
is a uuid, so the map needs this schema to come back typed: the result
|
||||
column is plain JSON, and without a decoder every key of the map would be
|
||||
read back as a keyword."
|
||||
[:map-of ::sm/uuid schema:resolution-entry])
|
||||
|
||||
(def schema:result
|
||||
"What the job stores when the import succeeds: the files it created and
|
||||
how the libraries of the package were resolved."
|
||||
[:map {:title "import-binfile-result" :closed true}
|
||||
[:file-ids [:vector ::sm/uuid]]
|
||||
[:resolution {:optional true} :map]
|
||||
[:resolution {:optional true} schema:resolution]
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:version [:enum 1 3]]])
|
||||
@ -66,7 +107,11 @@
|
||||
The edition permission is checked again here, and not only when the job
|
||||
was created: it can be revoked while the job waits in its queue. The
|
||||
core runs inside a transaction, so a cancellation detected between units
|
||||
rolls the whole import back.
|
||||
rolls the whole import back, and the result is checked against its schema
|
||||
inside that same transaction: a result the contract does not accept rolls
|
||||
the import back instead of leaving half of a file imported. The substrate
|
||||
checks the result again when the job is completed, which is the only net
|
||||
for a job that does not validate its own result.
|
||||
|
||||
The package is the input of the job: it is released and its temporary
|
||||
copy deleted once the job is terminal, whatever the outcome. A crash is
|
||||
@ -92,17 +137,18 @@
|
||||
;; the core joins this transaction: it
|
||||
;; is the one that decides whether the
|
||||
;; whole import is kept or rolled back
|
||||
(bfj/import-files tx-cfg context
|
||||
{:profile-id profile-id
|
||||
:project-id project-id
|
||||
:team team
|
||||
:name (:name params)
|
||||
:input input
|
||||
:version (:version params)})))]
|
||||
(check-result
|
||||
(assoc (bfj/import-files tx-cfg context
|
||||
{:profile-id profile-id
|
||||
:project-id project-id
|
||||
:team team
|
||||
:name (:name params)
|
||||
:input input
|
||||
:version (:version params)})
|
||||
:name (:name params)
|
||||
:version (:version params)))))]
|
||||
|
||||
(check-result (assoc result
|
||||
:name (:name params)
|
||||
:version (:version params))))
|
||||
result)
|
||||
|
||||
(finally
|
||||
(fs/delete input)
|
||||
@ -123,4 +169,9 @@
|
||||
::jobs/handler (fn [context params]
|
||||
(execute-import cfg context params))
|
||||
::jobs/decoder (sm/decoder schema:params sm/json-transformer)
|
||||
::jobs/validator (sm/validator schema:params)})
|
||||
::jobs/validator (sm/validator schema:params)
|
||||
;; The resolution is keyed by file id: without the result schema the
|
||||
;; keys come back as keywords and the dashboard wizard can no longer
|
||||
;; match them with the file ids it holds. The substrate compiles the
|
||||
;; decoder from it and checks the result against it before storing it.
|
||||
::jobs/result-schema schema:result})
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
[app.binfile.v3 :as v3]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.jobs :as jobs]
|
||||
[app.jobs.storage :as js]
|
||||
@ -49,27 +48,6 @@
|
||||
(assoc ::bfc/input (fixture-path))
|
||||
(v3/import-files!)))
|
||||
|
||||
(defn- make-job!
|
||||
"A job row as the substrate writes it: running, owned by a profile and
|
||||
holding a resource when the test needs one."
|
||||
[profile-id resource-id]
|
||||
(let [id (uuid/next)]
|
||||
(th/db-insert! :job {:id id
|
||||
:name "export-binfile"
|
||||
:tenant (cf/get :tenant)
|
||||
:queue "binfile"
|
||||
:params (db/json {})
|
||||
:priority 100
|
||||
:max-retries 0
|
||||
:retry-num 0
|
||||
:status "running"
|
||||
:profile-id profile-id
|
||||
:resource-id resource-id
|
||||
:scheduled-at (ct/now)
|
||||
:created-at (ct/now)
|
||||
:modified-at (ct/now)})
|
||||
id))
|
||||
|
||||
(defn- job-cfg
|
||||
"The system cfg as the runner hands it to a handler. The job id is NOT
|
||||
bound here: the adapter takes it from the context, because the progress
|
||||
@ -90,6 +68,46 @@
|
||||
job-id])
|
||||
(mapv #(jobs/decode-progress (:payload %)))))
|
||||
|
||||
(defn- export-link-later-package
|
||||
"A package of one file that uses a shared library, exported so the
|
||||
library is resolved while importing. The library of the package is gone
|
||||
afterwards and two replacements share its name, so the import cannot
|
||||
link it by itself and leaves the file pending resolution."
|
||||
[profile]
|
||||
(let [library (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
file (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
output (tmp/tempfile :suffix ".zip")]
|
||||
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
(v3/export-files! (-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/export-type :link-later))
|
||||
(io/output-stream output))
|
||||
|
||||
(db/update! th/*system* :file
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id library)})
|
||||
|
||||
(th/create-file* 3 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
(th/create-file* 4 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
|
||||
{:library-id (:id library)
|
||||
:output output}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; ADAPTER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -97,7 +115,7 @@
|
||||
(t/deftest export-through-the-adapter-produces-the-artifact
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile) nil)
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
output (tmp/tempfile* :suffix ".penpot")]
|
||||
|
||||
(bfj/export-files (job-cfg) (job-context job-id)
|
||||
@ -129,7 +147,8 @@
|
||||
{:content (sto/content (fixture-path))
|
||||
:filename "package.penpot"
|
||||
:mtype "application/zip"})
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
context (job-context job-id)
|
||||
result (bfj/import-files (job-cfg) context
|
||||
{:profile-id (:id profile)
|
||||
@ -148,7 +167,7 @@
|
||||
(t/deftest the-adapter-composes-a-milestone-per-tap
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile) nil)
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
reports (atom [])]
|
||||
|
||||
;; the substrate throttles the durable events, so the whole stream of
|
||||
@ -183,7 +202,7 @@
|
||||
(t/deftest a-tap-carries-the-outer-counters-in-course
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile) nil)
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
taps (atom [])]
|
||||
|
||||
;; the taps themselves, before the adapter turns them into events
|
||||
@ -242,7 +261,8 @@
|
||||
{:content (sto/content (fixture-path))
|
||||
:filename "package.penpot"
|
||||
:mtype "application/zip"})
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
context (job-context job-id)
|
||||
reports (atom [])]
|
||||
|
||||
@ -278,7 +298,7 @@
|
||||
(t/deftest a-job-that-is-no-longer-active-stops-the-work
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile) nil)
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
context (job-context job-id)]
|
||||
|
||||
(t/testing "a running job is active"
|
||||
@ -369,7 +389,7 @@
|
||||
(t/deftest the-export-handler-runs-through-the-registry
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile) nil)
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
result (jobs/invoke (-> th/*system*
|
||||
(assoc ::jobs/name :export-binfile)
|
||||
(assoc ::jobs/params {:file-ids #{file-id}
|
||||
@ -389,7 +409,8 @@
|
||||
{:content (sto/content (fixture-path))
|
||||
:filename "package.penpot"
|
||||
:mtype "application/zip"})
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
result (jobs/invoke (-> th/*system*
|
||||
(assoc ::jobs/name :import-binfile)
|
||||
(assoc ::jobs/params {:project-id (:default-project-id profile)
|
||||
@ -401,3 +422,122 @@
|
||||
(t/is (pos? (count (:file-ids result)))))
|
||||
(t/testing "the handler reported progress without knowing the job id"
|
||||
(t/is (pos? (count (progress-events job-id)))))))
|
||||
|
||||
(t/deftest the-import-result-keeps-the-type-of-its-ids
|
||||
"The resolution of an import is a map keyed by the new file id, and it
|
||||
crosses a plain JSON column: the result schema the job-def declares is
|
||||
what keeps the keys and the values as uuids, which is what the dashboard
|
||||
wizard matches against the ids it holds. Without the decoder the substrate
|
||||
compiles from that schema, the key comes back as a keyword and the entry as
|
||||
text, and this test fails."
|
||||
(let [profile (th/create-profile* 1)
|
||||
{:keys [library-id output]} (export-link-later-package profile)
|
||||
|
||||
staged (js/put-resource th/*system* (:id profile)
|
||||
{:content (sto/content output)
|
||||
:filename "package.penpot"
|
||||
:mtype "application/zip"})
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
result (jobs/invoke (-> th/*system*
|
||||
(assoc ::jobs/name :import-binfile)
|
||||
(assoc ::jobs/params {:project-id (:default-project-id profile)
|
||||
:name "imported"
|
||||
:version 3})
|
||||
(assoc ::jobs/context (job-context job-id))))]
|
||||
|
||||
(t/testing "the result of the job is stored as the substrate does"
|
||||
(t/is (= 1 (jobs/complete th/*system* :job-id job-id :result result))))
|
||||
|
||||
(let [stored (:result (jobs/get-job th/*system* job-id))
|
||||
[file-id file-res] (first (:resolution stored))]
|
||||
|
||||
(t/testing "the resolution comes back keyed by the new file id"
|
||||
(t/is (= 1 (count (:resolution stored))))
|
||||
(t/is (uuid? file-id))
|
||||
(t/is (= file-id (:id file-res))))
|
||||
|
||||
(t/testing "the ids of the entries keep their type"
|
||||
(t/is (= [file-id] (:file-ids stored)))
|
||||
(t/is (= [] (:done file-res)))
|
||||
(t/is (= 1 (count (:pending file-res))))
|
||||
|
||||
(let [pending (first (:pending file-res))]
|
||||
(t/is (= library-id (:id pending)))
|
||||
(t/is (= 2 (count (:candidates pending))))
|
||||
(t/is (every? (comp uuid? :id) (:candidates pending)))
|
||||
(t/is (every? (comp uuid? :project-id) (:candidates pending))))))))
|
||||
|
||||
(t/deftest a-package-without-external-libraries-reads-with-an-empty-resolution
|
||||
(let [profile (th/create-profile* 1)
|
||||
staged (js/put-resource th/*system* (:id profile)
|
||||
{:content (sto/content (fixture-path))
|
||||
:filename "package.penpot"
|
||||
:mtype "application/zip"})
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
result (jobs/invoke (-> th/*system*
|
||||
(assoc ::jobs/name :import-binfile)
|
||||
(assoc ::jobs/params {:project-id (:default-project-id profile)
|
||||
:name "imported"
|
||||
:version 3})
|
||||
(assoc ::jobs/context (job-context job-id))))]
|
||||
|
||||
(t/is (= 1 (jobs/complete th/*system* :job-id job-id :result result)))
|
||||
|
||||
(let [stored (:result (jobs/get-job th/*system* job-id))]
|
||||
|
||||
(t/testing "a package without external libraries resolves nothing"
|
||||
(t/is (= {} (:resolution stored))))
|
||||
|
||||
(t/testing "and the file ids come back typed"
|
||||
(t/is (= 1 (count (:file-ids stored))))
|
||||
(t/is (every? uuid? (:file-ids stored)))))))
|
||||
|
||||
(t/deftest a-version-1-result-reads-without-a-resolution
|
||||
"A version 1 import answers with the file ids and no resolution: the read
|
||||
path decodes it with the schema of the job-def, which declares the
|
||||
resolution as optional. Without that schema the file ids come back as text."
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (uuid/next)
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)})]
|
||||
|
||||
(t/is (= 1 (jobs/complete th/*system* :job-id job-id
|
||||
:result {:file-ids [file-id]
|
||||
:name "imported-v1"
|
||||
:version 1})))
|
||||
|
||||
(let [stored (:result (jobs/get-job th/*system* job-id))]
|
||||
|
||||
(t/testing "there is no resolution to decode"
|
||||
(t/is (nil? (:resolution stored))))
|
||||
|
||||
(t/testing "and the file ids come back typed"
|
||||
(t/is (= [file-id] (:file-ids stored)))
|
||||
(t/is (= 1 (:version stored)))))))
|
||||
|
||||
(t/deftest a-result-of-a-retired-job-def-reads-as-plain-json
|
||||
"A registry that no longer knows the kind of the row answers with the JSON
|
||||
the column holds, keywordized keys and uuid as text, instead of raising."
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (uuid/next)
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)})
|
||||
key (keyword (str file-id))]
|
||||
|
||||
(t/is (= 1 (jobs/complete th/*system* :job-id job-id
|
||||
:result {:file-ids [file-id]
|
||||
:resolution {file-id {:id file-id
|
||||
:name "card.penpot"
|
||||
:done []
|
||||
:pending []}}
|
||||
:name "imported"
|
||||
:version 3})))
|
||||
|
||||
(let [stored (:result (jobs/get-job (assoc th/*system* ::jobs/defs {}) job-id))]
|
||||
|
||||
(t/testing "the resolution comes back as it is on disk"
|
||||
(t/is (= [key] (keys (:resolution stored))))
|
||||
(t/is (= (str file-id) (get-in stored [:resolution key :id]))))
|
||||
|
||||
(t/testing "and so do the file ids"
|
||||
(t/is (= [(str file-id)] (:file-ids stored)))))))
|
||||
|
||||
@ -252,6 +252,30 @@
|
||||
:features features}
|
||||
params)))))))
|
||||
|
||||
(defn create-job*
|
||||
"A job row in the state a worker finds it: running, in the `binfile` queue,
|
||||
owned by `profile-id` and holding `resource-id` when it consumes one.
|
||||
`job-name` is the kind of the job (a keyword like the `::jobs/name` a caller
|
||||
would pass, or the text the row holds) and it is what resolves the job-def
|
||||
of the row when its result is read or checked."
|
||||
[job-name {:keys [profile-id resource-id]}]
|
||||
(let [id (uuid/next)]
|
||||
(db/insert! *pool* :job {:id id
|
||||
:name (name job-name)
|
||||
:tenant (cf/get :tenant)
|
||||
:queue "binfile"
|
||||
:params (db/json {})
|
||||
:priority 100
|
||||
:max-retries 0
|
||||
:retry-num 0
|
||||
:status "running"
|
||||
:profile-id profile-id
|
||||
:resource-id resource-id
|
||||
:scheduled-at (ct/now)
|
||||
:created-at (ct/now)
|
||||
:modified-at (ct/now)})
|
||||
id))
|
||||
|
||||
(defn mark-file-deleted*
|
||||
([params]
|
||||
(mark-file-deleted* *system* params))
|
||||
|
||||
@ -65,10 +65,31 @@
|
||||
::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer)
|
||||
::jobs/validator (sm/validator schema:echo-params)})
|
||||
|
||||
(def schema:typed-result
|
||||
"A result with a uuid keyed map, the shape that needs a declared schema
|
||||
to survive the plain JSON round trip of the `job.result` column."
|
||||
[:map
|
||||
[:file-ids [:vector ::sm/uuid]]
|
||||
[:resolution [:map-of ::sm/uuid [:map [:id ::sm/uuid]]]]])
|
||||
|
||||
(defmethod ig/init-key ::typed-job
|
||||
[_ cfg]
|
||||
{::jobs/name :typed
|
||||
::jobs/schema schema:echo-params
|
||||
::jobs/handler (fn [_context params]
|
||||
(echo-handler cfg params))
|
||||
::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer)
|
||||
::jobs/validator (sm/validator schema:echo-params)
|
||||
;; the substrate compiles the decoder from this schema and checks the
|
||||
;; result against it before storing it
|
||||
::jobs/result-schema schema:typed-result})
|
||||
|
||||
(defn- get-job-defs
|
||||
[]
|
||||
(-> {::jobs/defs {:echo (ig/ref ::echo-job)}
|
||||
::echo-job {::db/pool th/*pool*}}
|
||||
(-> {::jobs/defs {:echo (ig/ref ::echo-job)
|
||||
:typed (ig/ref ::typed-job)}
|
||||
::echo-job {::db/pool th/*pool*}
|
||||
::typed-job {::db/pool th/*pool*}}
|
||||
(ig/expand)
|
||||
(ig/init)
|
||||
(get ::jobs/defs)))
|
||||
@ -1188,11 +1209,12 @@
|
||||
|
||||
(defn- mk-running
|
||||
"A job already claimed by a worker, ready for a terminal transition."
|
||||
[cfg]
|
||||
(let [job-id (jobs/submit cfg {::jobs/name :echo
|
||||
::jobs/params (make-params)})]
|
||||
(jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id)))
|
||||
job-id))
|
||||
([cfg] (mk-running cfg :echo))
|
||||
([cfg name]
|
||||
(let [job-id (jobs/submit cfg {::jobs/name name
|
||||
::jobs/params (make-params)})]
|
||||
(jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id)))
|
||||
job-id)))
|
||||
|
||||
(t/deftest lifecycle-writers-require-metrics
|
||||
;; Every one of the six writers must check the metrics instance before
|
||||
@ -1375,6 +1397,99 @@
|
||||
(t/is (= 0 (jobs/cancel cfg job-id)))
|
||||
(t/is (= ["cancelled"] (get-outcomes job-id))))))
|
||||
|
||||
(t/deftest get-job-decodes-the-result-with-the-schema-of-the-def
|
||||
"A result with a declared schema comes back typed. Without it, the plain
|
||||
JSON of the column keywordizes every key and leaves every uuid as text;
|
||||
without the substrate compiling the decoder from that schema, the key of
|
||||
the uuid map comes back as a keyword and this test fails."
|
||||
(let [cfg (make-cfg (get-job-defs))
|
||||
job-id (mk-running cfg :typed)
|
||||
file-id (uuid/next)
|
||||
lib-id (uuid/next)]
|
||||
|
||||
(t/is (= 1 (jobs/complete cfg :job-id job-id
|
||||
:result {:file-ids [lib-id]
|
||||
:resolution {file-id {:id file-id}}})))
|
||||
|
||||
(let [result (:result (jobs/get-job cfg job-id))]
|
||||
(t/testing "the uuid keyed map keeps the type of its key and values"
|
||||
(t/is (= [file-id] (keys (:resolution result))))
|
||||
(t/is (uuid? (:id (get (:resolution result) file-id)))))
|
||||
|
||||
(t/testing "the vector of uuids keeps the type of its elements"
|
||||
(t/is (= [lib-id] (:file-ids result)))))))
|
||||
|
||||
(t/deftest get-job-leaves-a-result-without-a-schema-untouched
|
||||
(let [cfg (make-cfg (get-job-defs))
|
||||
job-id (mk-running cfg)
|
||||
file-id (uuid/next)]
|
||||
|
||||
(t/is (= 1 (jobs/complete cfg :job-id job-id
|
||||
:result {:resolution {file-id {:id file-id}}})))
|
||||
|
||||
(t/testing "the raw JSON shape survives: keywordized keys, uuid as text"
|
||||
(let [result (:result (jobs/get-job cfg job-id))
|
||||
key (keyword (str file-id))]
|
||||
(t/is (= [key] (keys (:resolution result))))
|
||||
(t/is (= (str file-id) (get-in result [:resolution key :id])))))))
|
||||
|
||||
(t/deftest get-job-reads-a-row-whose-job-def-is-gone
|
||||
(let [job-id (mk-running (make-cfg (get-job-defs)))
|
||||
;; a registry that no longer knows the kind of the row (a retired
|
||||
;; job-def): reading its result must not raise
|
||||
row (jobs/get-job (make-cfg {}) job-id)]
|
||||
(t/is (some? row))
|
||||
(t/is (= "echo" (:name row)))))
|
||||
|
||||
(t/deftest complete-refuses-a-result-that-does-not-conform
|
||||
"The schema a job-def declares is checked before the result is stored: the
|
||||
row keeps no result and the job is not completed."
|
||||
(let [cfg (make-cfg (get-job-defs))
|
||||
job-id (mk-running cfg :typed)]
|
||||
|
||||
(t/is (thrown-with-msg? Exception #"invalid result of a job"
|
||||
(jobs/complete cfg :job-id job-id
|
||||
:result {:resolution "not-a-map"})))
|
||||
|
||||
(t/testing "the row was not completed and holds no result"
|
||||
(let [row (jobs/get-job cfg job-id)]
|
||||
(t/is (= "running" (:status row)))
|
||||
(t/is (nil? (:result row)))))
|
||||
|
||||
(t/testing "a job that is no longer completable is skipped without checking"
|
||||
(th/db-update! :job {:status "aborted"} {:id job-id})
|
||||
(t/is (zero? (jobs/complete cfg :job-id job-id
|
||||
:result {:resolution "not-a-map"}))))))
|
||||
|
||||
(t/deftest a-stored-result-that-does-not-conform-is-read-decode-only
|
||||
"A row written by another version may hold a result the schema of today no
|
||||
longer accepts: reading it decodes and never raises."
|
||||
(let [cfg (make-cfg (get-job-defs))
|
||||
job-id (mk-running cfg :typed)]
|
||||
|
||||
(th/db-update! :job
|
||||
{:status "completed"
|
||||
:result (db/json {:resolution "not-a-map"})}
|
||||
{:id job-id})
|
||||
|
||||
(let [row (jobs/get-job cfg job-id)]
|
||||
(t/is (= "completed" (:status row)))
|
||||
(t/is (= {:resolution "not-a-map"} (:result row))))))
|
||||
|
||||
(t/deftest decode-result-accepts-a-pgobject
|
||||
(let [job-def (jobs/get-job-def (get (make-cfg (get-job-defs)) ::jobs/defs) :typed)
|
||||
file-id (uuid/next)]
|
||||
|
||||
(t/testing "a raw column value is decoded with the schema of the def"
|
||||
(let [decoded (jobs/decode-result job-def
|
||||
(db/json {:file-ids [file-id]
|
||||
:resolution {file-id {:id file-id}}}))]
|
||||
(t/is (= [file-id] (:file-ids decoded)))
|
||||
(t/is (= [file-id] (keys (:resolution decoded))))))
|
||||
|
||||
(t/testing "no result answers nil"
|
||||
(t/is (nil? (jobs/decode-result job-def nil))))))
|
||||
|
||||
(t/deftest aborted-job-is-terminal-for-all-writers
|
||||
"An aborted job (system-side terminal set by the orphan sweep) cannot be
|
||||
moved by any lifecycle writer: first-terminal-wins, with no event and no
|
||||
|
||||
@ -11,10 +11,6 @@
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v3 :as v3]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.jobs :as jobs]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
@ -46,25 +42,6 @@
|
||||
(assoc ::bfc/input (-> fixture io/resource jio/file))
|
||||
(v3/import-files!)))
|
||||
|
||||
(defn- make-job!
|
||||
"A job row as the substrate writes it: running and owned by a profile."
|
||||
[profile-id]
|
||||
(let [id (uuid/next)]
|
||||
(th/db-insert! :job {:id id
|
||||
:name "export-binfile"
|
||||
:tenant (cf/get :tenant)
|
||||
:queue "binfile"
|
||||
:params (db/json {})
|
||||
:priority 100
|
||||
:max-retries 0
|
||||
:retry-num 0
|
||||
:status "running"
|
||||
:profile-id profile-id
|
||||
:scheduled-at (ct/now)
|
||||
:created-at (ct/now)
|
||||
:modified-at (ct/now)})
|
||||
id))
|
||||
|
||||
(defn- run-export
|
||||
"Run the export handler of a job, as the runner does."
|
||||
[job-id params]
|
||||
@ -99,7 +76,7 @@
|
||||
(t/deftest the-export-handler-stores-the-artifact-of-its-job
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile))
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
result (run-export job-id {:file-ids #{file-id}
|
||||
:export-type :detach-libraries})
|
||||
object (sto/get-object (get-storage) (:resource-id result))]
|
||||
@ -136,7 +113,7 @@
|
||||
(let [owner (th/create-profile* 1)
|
||||
other (th/create-profile* 2)
|
||||
file-id (first (:file-ids (import-fixture! owner)))
|
||||
job-id (make-job! (:id other))
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id other)})
|
||||
before (count-storage-objects)]
|
||||
|
||||
(t/testing "a job whose owner cannot read a file does not export it"
|
||||
@ -154,7 +131,7 @@
|
||||
(t/deftest the-export-handler-leaves-nothing-when-the-storage-fails
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile))
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
before (count-storage-objects)]
|
||||
|
||||
(with-redefs [sto/put-object! (fn [& _]
|
||||
@ -177,7 +154,7 @@
|
||||
(t/deftest the-export-handler-leaves-nothing-behind-when-the-job-is-cancelled
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
job-id (make-job! (:id profile))
|
||||
job-id (th/create-job* :export-binfile {:profile-id (:id profile)})
|
||||
before (count-storage-objects)]
|
||||
|
||||
(th/db-update! :job {:status "cancelled"} {:id job-id})
|
||||
|
||||
@ -9,11 +9,10 @@
|
||||
it leaves behind when it fails."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.jobs :as bfj]
|
||||
[app.binfile.v1 :as v1]
|
||||
[app.binfile.v3 :as v3]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.jobs :as jobs]
|
||||
[app.jobs.storage :as js]
|
||||
@ -65,27 +64,6 @@
|
||||
:filename "package.penpot"
|
||||
:mtype "application/zip"}))
|
||||
|
||||
(defn- make-job!
|
||||
"A job row as the substrate writes it: running, owned by a profile and
|
||||
holding the package it consumes."
|
||||
[profile-id resource-id]
|
||||
(let [id (uuid/next)]
|
||||
(th/db-insert! :job {:id id
|
||||
:name "import-binfile"
|
||||
:tenant (cf/get :tenant)
|
||||
:queue "binfile"
|
||||
:params (db/json {})
|
||||
:priority 100
|
||||
:max-retries 0
|
||||
:retry-num 0
|
||||
:status "running"
|
||||
:profile-id profile-id
|
||||
:resource-id resource-id
|
||||
:scheduled-at (ct/now)
|
||||
:created-at (ct/now)
|
||||
:modified-at (ct/now)})
|
||||
id))
|
||||
|
||||
(defn- run-import
|
||||
"Run the import handler of a job, as the runner does."
|
||||
[job-id params]
|
||||
@ -119,7 +97,8 @@
|
||||
(t/deftest the-import-handler-imports-the-package-of-its-job
|
||||
(let [profile (th/create-profile* 1)
|
||||
staged (stage-package! profile (fixture-path))
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
result (run-import job-id {:project-id (:default-project-id profile)
|
||||
:name "imported"
|
||||
:version 3})]
|
||||
@ -142,7 +121,8 @@
|
||||
(let [profile (th/create-profile* 1)
|
||||
project-id (:default-project-id profile)
|
||||
staged (stage-package! profile (fixture-path))
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
before (project-modified-at project-id)]
|
||||
|
||||
(run-import job-id {:project-id project-id
|
||||
@ -157,7 +137,8 @@
|
||||
(let [profile (th/create-profile* 1)
|
||||
file-id (first (:file-ids (import-fixture! profile)))
|
||||
staged (stage-package! profile (export-to-v1! file-id))
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
result (run-import job-id {:project-id (:default-project-id profile)
|
||||
:name "imported-v1"
|
||||
:version 1})]
|
||||
@ -171,7 +152,8 @@
|
||||
staged (stage-package! other (fixture-path))
|
||||
;; the package belongs to `other`, but the destination project
|
||||
;; belongs to `owner`: an import there is not allowed
|
||||
job-id (make-job! (:id other) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id other)
|
||||
:resource-id (:resource-id staged)})
|
||||
before (project-files (:default-project-id owner))]
|
||||
|
||||
(t/testing "a job that cannot edit the destination does not import it"
|
||||
@ -189,7 +171,8 @@
|
||||
(t/deftest a-job-that-was-already-cancelled-never-imports
|
||||
(let [profile (th/create-profile* 1)
|
||||
staged (stage-package! profile (fixture-path))
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
before (project-files (:default-project-id profile))]
|
||||
|
||||
(th/db-update! :job {:status "cancelled"} {:id job-id})
|
||||
@ -207,7 +190,8 @@
|
||||
(t/deftest an-import-cancelled-while-it-runs-leaves-nothing-behind
|
||||
(let [profile (th/create-profile* 1)
|
||||
staged (stage-package! profile (fixture-path))
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
before (project-files (:default-project-id profile))]
|
||||
|
||||
;; the user cancels while the import is running: the first progress
|
||||
@ -230,7 +214,8 @@
|
||||
(t/deftest a-package-that-is-not-a-penpot-file-fails-without-writing
|
||||
(let [profile (th/create-profile* 1)
|
||||
staged (stage-package! profile (fixture-path))
|
||||
job-id (make-job! (:id profile) (:resource-id staged))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
before (project-files (:default-project-id profile))]
|
||||
|
||||
;; the bytes of the resource are replaced by something that is not a
|
||||
@ -252,3 +237,32 @@
|
||||
|
||||
(t/testing "and the broken package is released too"
|
||||
(t/is (some? (:deleted-at (input-row (:resource-id broken)))))))))
|
||||
|
||||
(t/deftest a-result-that-does-not-conform-leaves-nothing-behind
|
||||
(let [profile (th/create-profile* 1)
|
||||
project-id (:default-project-id profile)
|
||||
staged (stage-package! profile (fixture-path))
|
||||
job-id (th/create-job* :import-binfile {:profile-id (:id profile)
|
||||
:resource-id (:resource-id staged)})
|
||||
before (project-files project-id)]
|
||||
|
||||
;; the core does its work inside the transaction and answers with a result
|
||||
;; the schema of the task does not accept: the check runs before the
|
||||
;; transaction commits, so the work is rolled back with it
|
||||
(with-redefs [bfj/import-files
|
||||
(fn [tx-cfg _context _params]
|
||||
(th/create-file* tx-cfg 7 {:profile-id (:id profile)
|
||||
:project-id project-id
|
||||
:is-shared false
|
||||
:name "half imported"})
|
||||
{:file-ids [(uuid/next)]
|
||||
:resolution "not-a-map"})]
|
||||
|
||||
(t/testing "the handler refuses the result"
|
||||
(t/is (thrown-with-msg? Exception #"invalid result of an import job"
|
||||
(run-import job-id {:project-id project-id
|
||||
:name "imported"
|
||||
:version 3}))))
|
||||
|
||||
(t/testing "and the import is rolled back: the destination keeps no files"
|
||||
(t/is (= before (project-files project-id)))))))
|
||||
|
||||
@ -156,6 +156,20 @@
|
||||
0
|
||||
resolution))
|
||||
|
||||
(defn- pending-library-links
|
||||
"The library links the wizard must create for the files of a resolution:
|
||||
one per pending entry the user chose a candidate for. The file id comes
|
||||
from the entry and never from the key of the map: what a key holds depends
|
||||
on the result schema of the job-def that wrote it, while the wizard indexes
|
||||
its selection by the id of the entry."
|
||||
[resolution selection]
|
||||
(->> (vals resolution)
|
||||
(mapcat (fn [{:keys [id pending]}]
|
||||
(keep (fn [entry]
|
||||
(when-let [library-id (get-in selection [id (:id entry)])]
|
||||
{:file-id id :library-id library-id}))
|
||||
pending)))))
|
||||
|
||||
(defn- analyze-entries
|
||||
[state entries]
|
||||
(->> (imp/analyze entries)
|
||||
@ -866,22 +880,17 @@
|
||||
(mf/deps resolution selection on-finish-import)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(let [slc selection]
|
||||
;; For each file with pending candidates, link it to the selected libraries
|
||||
(->> (rx/from (seq resolution))
|
||||
(rx/merge-map
|
||||
(fn [[file-id resolution-file]]
|
||||
(->> (rx/from (:pending resolution-file))
|
||||
(rx/merge-map
|
||||
(fn [{:keys [id]}]
|
||||
(when-let [selected-lib (get-in slc [file-id id])]
|
||||
(link-files-to-library! [file-id] selected-lib)))))))
|
||||
(rx/subs! (constantly nil)
|
||||
(constantly nil)
|
||||
(fn []
|
||||
(st/emit! (modal/hide))
|
||||
(when (fn? on-finish-import)
|
||||
(on-finish-import))))))))
|
||||
;; For each file with pending candidates, link it to the selected libraries
|
||||
(->> (rx/from (pending-library-links resolution selection))
|
||||
(rx/merge-map
|
||||
(fn [{:keys [file-id library-id]}]
|
||||
(link-files-to-library! [file-id] library-id)))
|
||||
(rx/subs! (constantly nil)
|
||||
(constantly nil)
|
||||
(fn []
|
||||
(st/emit! (modal/hide))
|
||||
(when (fn? on-finish-import)
|
||||
(on-finish-import)))))))
|
||||
|
||||
on-wizard-next
|
||||
(mf/use-fn
|
||||
|
||||
@ -115,6 +115,7 @@
|
||||
[frontend-tests.ui.comment-input-ime-test]
|
||||
[frontend-tests.ui.comments-clustering-test]
|
||||
[frontend-tests.ui.comments-position-modifier-test]
|
||||
[frontend-tests.ui.dashboard-import-linking-test]
|
||||
[frontend-tests.ui.drawarea-test]
|
||||
[frontend-tests.ui.ds-controls-numeric-input-test]
|
||||
[frontend-tests.ui.error-boundary-test]
|
||||
@ -271,6 +272,7 @@
|
||||
'frontend-tests.ui.comment-input-ime-test
|
||||
'frontend-tests.ui.comments-clustering-test
|
||||
'frontend-tests.ui.comments-position-modifier-test
|
||||
'frontend-tests.ui.dashboard-import-linking-test
|
||||
'frontend-tests.ui.drawarea-test
|
||||
'frontend-tests.ui.ds-controls-numeric-input-test
|
||||
'frontend-tests.ui.error-boundary-test
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
;; 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) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.ui.dashboard-import-linking-test
|
||||
"The links the import wizard creates for a file of the package whose
|
||||
library the auto-link could not resolve. The resolution map comes from the
|
||||
job result, so the type of its keys is not something the wizard can rely
|
||||
on: the file id of a link must come from the entry and never from the key
|
||||
of the map."
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.ui.dashboard.import :as import]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(def ^:private pending-library-links
|
||||
@#'import/pending-library-links)
|
||||
|
||||
(defn- pending-entry
|
||||
"One library of the package the user has to resolve, with a candidate."
|
||||
[library-id]
|
||||
{:id library-id
|
||||
:name "Tokens"
|
||||
:candidates [{:id (uuid/next)
|
||||
:name "Tokens"
|
||||
:project-id (uuid/next)
|
||||
:project-name "Design system"}]})
|
||||
|
||||
(defn- file-resolution
|
||||
"One file of a resolution, with a single library pending resolution."
|
||||
[file-id library-id]
|
||||
{:id file-id
|
||||
:name "card.penpot"
|
||||
:done []
|
||||
:pending [(pending-entry library-id)]})
|
||||
|
||||
(t/deftest the-file-id-of-a-link-comes-from-the-entry-and-not-from-the-key
|
||||
(let [file-id (uuid/next)
|
||||
old-lib (uuid/next)
|
||||
new-lib (uuid/next)
|
||||
;; the shape a result without a declared schema has: the key of the
|
||||
;; map is a keyword and the entry carries the same id as text
|
||||
resolution {(keyword (str file-id)) (file-resolution (str file-id) (str old-lib))}
|
||||
selection {(str file-id) {(str old-lib) (str new-lib)}}]
|
||||
|
||||
(t/is (= [{:file-id (str file-id) :library-id (str new-lib)}]
|
||||
(pending-library-links resolution selection)))))
|
||||
|
||||
(t/deftest the-links-are-the-same-with-a-typed-resolution
|
||||
(let [file-id (uuid/next)
|
||||
old-lib (uuid/next)
|
||||
new-lib (uuid/next)
|
||||
resolution {file-id (file-resolution file-id old-lib)}
|
||||
selection {file-id {old-lib new-lib}}]
|
||||
|
||||
(t/is (= [{:file-id file-id :library-id new-lib}]
|
||||
(pending-library-links resolution selection)))))
|
||||
|
||||
(t/deftest a-file-with-several-pending-libraries-creates-one-link-each
|
||||
(let [file-id (uuid/next)
|
||||
lib-a (uuid/next)
|
||||
lib-b (uuid/next)
|
||||
new-a (uuid/next)
|
||||
new-b (uuid/next)
|
||||
resolution {file-id {:id file-id
|
||||
:name "card.penpot"
|
||||
:done []
|
||||
:pending [(pending-entry lib-a)
|
||||
(pending-entry lib-b)]}}
|
||||
selection {file-id {lib-a new-a
|
||||
lib-b new-b}}]
|
||||
|
||||
(t/testing "both entries the user resolved become a link"
|
||||
(t/is (= #{{:file-id file-id :library-id new-a}
|
||||
{:file-id file-id :library-id new-b}}
|
||||
(set (pending-library-links resolution selection)))))))
|
||||
|
||||
(t/deftest a-partial-selection-creates-only-the-chosen-link
|
||||
(let [file-id (uuid/next)
|
||||
lib-a (uuid/next)
|
||||
lib-b (uuid/next)
|
||||
new-a (uuid/next)
|
||||
resolution {file-id {:id file-id
|
||||
:name "card.penpot"
|
||||
:done []
|
||||
:pending [(pending-entry lib-a)
|
||||
(pending-entry lib-b)]}}
|
||||
selection {file-id {lib-a new-a}}]
|
||||
|
||||
(t/testing "the entry with no candidate chosen emits nothing"
|
||||
(t/is (= [{:file-id file-id :library-id new-a}]
|
||||
(pending-library-links resolution selection))))))
|
||||
|
||||
(t/deftest a-done-entry-creates-no-link
|
||||
(let [file-id (uuid/next)
|
||||
library (uuid/next)]
|
||||
|
||||
(t/testing "a file whose libraries were auto-linked has nothing to emit"
|
||||
(t/is (= [] (pending-library-links
|
||||
{file-id {:id file-id
|
||||
:name "card.penpot"
|
||||
:done [{:id library
|
||||
:name "Tokens"
|
||||
:linked-to library}]
|
||||
:pending []}}
|
||||
{file-id {library (uuid/next)}}))))))
|
||||
|
||||
(t/deftest pending-entries-without-a-selection-create-no-link
|
||||
(let [file-id (uuid/next)
|
||||
old-lib (uuid/next)]
|
||||
|
||||
(t/testing "an entry the user skipped creates nothing"
|
||||
(t/is (= [] (pending-library-links
|
||||
{file-id (file-resolution file-id old-lib)}
|
||||
{file-id {}}))))
|
||||
|
||||
(t/testing "a file with nothing pending creates nothing"
|
||||
(t/is (= [] (pending-library-links
|
||||
{file-id {:id file-id
|
||||
:name "card.penpot"
|
||||
:done []
|
||||
:pending []}}
|
||||
{file-id {old-lib (uuid/next)}}))))
|
||||
|
||||
(t/testing "an empty resolution creates nothing"
|
||||
(t/is (= [] (pending-library-links {} {}))))))
|
||||
Loading…
x
Reference in New Issue
Block a user