@owlmeans/client-job 0.1.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/client-job/SKILL.md +101 -0
- package/build/consts.d.ts +15 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +14 -0
- package/build/consts.js.map +1 -0
- package/build/feed.d.ts +15 -0
- package/build/feed.d.ts.map +1 -0
- package/build/feed.js +65 -0
- package/build/feed.js.map +1 -0
- package/build/helper.d.ts +17 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +22 -0
- package/build/helper.js.map +1 -0
- package/build/hooks.d.ts +18 -0
- package/build/hooks.d.ts.map +1 -0
- package/build/hooks.js +21 -0
- package/build/hooks.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +42 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/event.d.ts +15 -0
- package/build/utils/event.d.ts.map +1 -0
- package/build/utils/event.js +33 -0
- package/build/utils/event.js.map +1 -0
- package/build/utils/index.d.ts +2 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +2 -0
- package/build/utils/index.js.map +1 -0
- package/package.json +46 -0
- package/src/consts.ts +18 -0
- package/src/feed.ts +74 -0
- package/src/helper.ts +30 -0
- package/src/hooks.ts +29 -0
- package/src/index.ts +7 -0
- package/src/types.ts +46 -0
- package/src/utils/event.ts +39 -0
- package/src/utils/index.ts +1 -0
- package/tsconfig.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OwlMeans Common — Fullstack typescript framework
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @owlmeans/client-job
|
|
2
|
+
|
|
3
|
+
The browser half of a job feed: a state store holding `JobRecord`s, hooks to read it, and one
|
|
4
|
+
socket subscription that keeps it current. It addresses the entrypoints
|
|
5
|
+
[`@owlmeans/server-job`](../server-job) declares.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
- `appendJobs(context, alias?)` — register the job store on the client context
|
|
10
|
+
- `useJob(id)` / `useJobs(filter)` — live reads over that store
|
|
11
|
+
- `useJobFeed(opts?)` — one socket plus one seeding list call; mount it ONCE per screen
|
|
12
|
+
- `applyJobEvent(store, event)` — the fold, for a feed of your own
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add @owlmeans/client-job@^0.1.18-rc.0
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { appendJobs } from '@owlmeans/client-job'
|
|
24
|
+
|
|
25
|
+
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
26
|
+
const context = makeBasicContext<C, T>(cfg)
|
|
27
|
+
appendJobs<C, T>(context)
|
|
28
|
+
return context
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { useJobFeed, useJobs } from '@owlmeans/client-job'
|
|
34
|
+
import { JobProgress, JobStatus, useJobToasts } from '@owlmeans/web-panel/jobs'
|
|
35
|
+
import { JobState } from '@owlmeans/queue'
|
|
36
|
+
|
|
37
|
+
export const Jobs: FC = () => {
|
|
38
|
+
const { seeded } = useJobFeed({ root: REPORTS })
|
|
39
|
+
const jobs = useJobs({ state: [JobState.Waiting, JobState.Active] })
|
|
40
|
+
useJobToasts(jobs.map(job => job.record))
|
|
41
|
+
|
|
42
|
+
return seeded
|
|
43
|
+
? <ul>{jobs.map(job => <li key={job.id}>
|
|
44
|
+
<JobStatus job={job.record} /><JobProgress job={job.record} />
|
|
45
|
+
</li>)}</ul>
|
|
46
|
+
: <Spinner/>
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## The one gotcha
|
|
51
|
+
|
|
52
|
+
`useJobFeed` opens a socket per call. Mount it once, high enough that everything showing jobs sits
|
|
53
|
+
under it, and read the store everywhere else — a second feed makes the server push every event
|
|
54
|
+
twice and doubles what the screen reacts to.
|
|
55
|
+
|
|
56
|
+
## Depends On
|
|
57
|
+
|
|
58
|
+
- [`@owlmeans/state`](../state) — the store; [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList`
|
|
59
|
+
- [`@owlmeans/client-auth`](../client-auth) — `useWs`, which puts the token on the socket query
|
|
60
|
+
- [`@owlmeans/queue`](../queue) — `JobRecord`, `JobEvent`, `JobState`
|
|
61
|
+
|
|
62
|
+
## Related
|
|
63
|
+
|
|
64
|
+
- [`@owlmeans/server-job`](../server-job) — the entrypoints this addresses
|
|
65
|
+
- [`@owlmeans/web-panel`](../web-panel) `./jobs` — the components to render what it collects
|
|
66
|
+
|
|
67
|
+
<!-- owlmeans:agent-guidance:start -->
|
|
68
|
+
## Agent guidance
|
|
69
|
+
|
|
70
|
+
This package ships embedded agent skills under `agent-meta/`. After installing your
|
|
71
|
+
`@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
|
|
72
|
+
your project's skill store (`.agents/skills/`):
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.12
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The embedded files are version-matched to this package release. Do not edit them
|
|
79
|
+
directly — they are regenerated on each publish. To contribute guidance edits,
|
|
80
|
+
open a PR against the source monorepo.
|
|
81
|
+
<!-- owlmeans:agent-guidance:end -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"package": "@owlmeans/client-job",
|
|
4
|
+
"version": "0.1.18-rc.0",
|
|
5
|
+
"generatedAt": "2026-09-04T22:43:25.447Z",
|
|
6
|
+
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
|
+
"entries": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "skill",
|
|
10
|
+
"name": "client-job",
|
|
11
|
+
"category": "package-specific",
|
|
12
|
+
"file": "skills/client-job/SKILL.md",
|
|
13
|
+
"canonicalPath": ".agents/skills/client-job/SKILL.md"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: client-job
|
|
3
|
+
description: How to use @owlmeans/client-job — appendJobs() to register the job store, useJob/useJobs to read it, and useJobFeed() to mount the socket subscription and seed it from the list entrypoint. Auto-invoked when showing queue job progress in a browser client or importing job hooks.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
7
|
+
|
|
8
|
+
# @owlmeans/client-job
|
|
9
|
+
|
|
10
|
+
**Layer:** Client
|
|
11
|
+
**Install:** `"@owlmeans/client-job": "^0.1.18-rc.0"` in `dependencies`
|
|
12
|
+
|
|
13
|
+
The browser half of a job feed. It holds `JobRecord`s in a `@owlmeans/state` store, keeps that
|
|
14
|
+
store current from the entrypoints `@owlmeans/server-job` declares, and hands screens the ordinary
|
|
15
|
+
state hooks. Rendering is `@owlmeans/web-panel/jobs`.
|
|
16
|
+
|
|
17
|
+
## Key Exports
|
|
18
|
+
|
|
19
|
+
| Export | Description |
|
|
20
|
+
|--------|-------------|
|
|
21
|
+
| `appendJobs(context, alias?)` | Register the job store on the client context |
|
|
22
|
+
| `useJob(id?, resource?)` | One job, live — an unknown or absent id yields an `empty` model, never a throw |
|
|
23
|
+
| `useJobs(filter?, opts?)` | A live query over the store, newest first unless `opts.sort` says otherwise |
|
|
24
|
+
| `useJobFeed(opts?)` | Mount the socket and seed the store. **Once per screen** |
|
|
25
|
+
| `applyJobEvent(store, event)` | The fold one `JobEvent` performs, for a feed of your own |
|
|
26
|
+
| `jobEntrypointAliases(root?)` | `{ base, list, get, cancel, watch }` — the same shape the server declares |
|
|
27
|
+
| `JobFeed` / `JobFeedOptions` / `UseJobsOptions` / `JobFilter` | What the feed reports, and the option shapes |
|
|
28
|
+
| `JOBS` | The store's alias (`job-state`), typed as `StateAlias<JobRecord>` |
|
|
29
|
+
| Constants | `DEFAULT_JOB_ROOT` (`jobs`), `JOB_EVENT` (`job-event`) |
|
|
30
|
+
|
|
31
|
+
## Wiring
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { appendJobs } from '@owlmeans/client-job'
|
|
35
|
+
|
|
36
|
+
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
37
|
+
const context = makeBasicContext<C, T>(cfg)
|
|
38
|
+
appendJobs<C, T>(context)
|
|
39
|
+
return context
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Jobs get a store of their own rather than the context's default one: a job id is the broker's, and
|
|
44
|
+
sharing an id space with the app's records is how a completed job overwrites an unrelated row.
|
|
45
|
+
`appendJobs` is idempotent like every `append*`.
|
|
46
|
+
|
|
47
|
+
## Reading
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
const { seeded, connected, error } = useJobFeed({ root: REPORTS, query: { size: 50 } })
|
|
51
|
+
|
|
52
|
+
const running = useJobs({ state: [JobState.Waiting, JobState.Active] })
|
|
53
|
+
const mine = useJobs({ 'data.target': projectId })
|
|
54
|
+
const one = useJob(id)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The criteria language is the resource one, so a filter written for the list entrypoint means the
|
|
58
|
+
same thing applied locally, and a dotted key reaches into the payload. `seeded` is what tells "no
|
|
59
|
+
jobs" apart from "not loaded yet" — `useJob` answers an `empty` model for an id the store has not
|
|
60
|
+
seen, which is a loading state and not an error. `connected` says the socket is open and `error`
|
|
61
|
+
carries whatever the seeding call threw, so a screen can distinguish a failed load from an empty
|
|
62
|
+
one. `JobFeedOptions` also takes `resource` for a store other than `JOBS`, and a `query` narrowing
|
|
63
|
+
the seeding call — compared by CONTENT, so a fresh object literal per render does not re-fetch
|
|
64
|
+
while a changed filter does re-seed.
|
|
65
|
+
|
|
66
|
+
## What `useJobFeed` does
|
|
67
|
+
|
|
68
|
+
1. Opens the watch socket through `@owlmeans/client-auth`'s `useWs`, which puts the current token
|
|
69
|
+
on the connection query — the watch entrypoint is guarded, and the server derives ownership
|
|
70
|
+
from that token.
|
|
71
|
+
2. Calls the list entrypoint once and writes the answer with **`store.replace`**, not a run of
|
|
72
|
+
saves: it is the server saying what exists, so a job cancelled from another tab has to LEAVE the
|
|
73
|
+
store, and one write wakes the subscribers once instead of once per record.
|
|
74
|
+
3. Folds each `JobEvent` frame in with `applyJobEvent` — merged over the record the store holds,
|
|
75
|
+
because an event carries only what changed while `save` replaces. An event for an id the store
|
|
76
|
+
never saw still writes a row, so a job enqueued in another tab reports its progress here and the
|
|
77
|
+
next seeding call fills in the rest.
|
|
78
|
+
|
|
79
|
+
Two details the fold gets right and a hand-written one usually does not: a progress frame never
|
|
80
|
+
moves a settled job back to `Active` (the completion and the last progress ping race), and an empty
|
|
81
|
+
`JobEvent.name` never overwrites a known one (the contract sends empty when the job is already
|
|
82
|
+
gone).
|
|
83
|
+
|
|
84
|
+
## The one gotcha
|
|
85
|
+
|
|
86
|
+
**`useJobFeed` opens a socket per call.** Mount it once, high enough that everything showing jobs
|
|
87
|
+
sits under it, and read the store everywhere else. Two feeds make the server push every event twice
|
|
88
|
+
and re-seed over each other.
|
|
89
|
+
|
|
90
|
+
## Depends On
|
|
91
|
+
|
|
92
|
+
- `@owlmeans/state` — the store, `replace` and the `StateModel` commit rules
|
|
93
|
+
- `@owlmeans/client` — `useStoreModel`, `useStoreList`, `useContext`
|
|
94
|
+
- `@owlmeans/client-auth` — `useWs` (the token-carrying socket)
|
|
95
|
+
- `@owlmeans/queue` — `JobRecord`, `JobEvent`, `JobState`, `isSettled`
|
|
96
|
+
|
|
97
|
+
## Related
|
|
98
|
+
|
|
99
|
+
- `server-job` — the entrypoints this addresses, and the ownership rule that decides what arrives
|
|
100
|
+
- `state` — the store contract; `client-socket` — how the connection is addressed
|
|
101
|
+
- `web-panel` — `./jobs` (`JobProgress`, `JobStatus`, `useJobToasts`)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JobRecord } from '@owlmeans/queue';
|
|
2
|
+
import type { StateAlias } from '@owlmeans/state';
|
|
3
|
+
/** The store this package registers and every hook here reads. */
|
|
4
|
+
export declare const JOBS: StateAlias<JobRecord>;
|
|
5
|
+
/**
|
|
6
|
+
* The alias root a group answers under when a caller names none — the same default
|
|
7
|
+
* `@owlmeans/server-job` declares with.
|
|
8
|
+
*
|
|
9
|
+
* That package's `<root>` / `<root>:<verb>` alias shape and its `job-event` frame name are the
|
|
10
|
+
* whole contract between the two halves. They are restated here rather than imported because the
|
|
11
|
+
* server half pulls fastify in, and a browser bundle must not.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_JOB_ROOT = "jobs";
|
|
14
|
+
export declare const JOB_EVENT = "job-event";
|
|
15
|
+
//# sourceMappingURL=consts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAEjD,kEAAkE;AAClE,eAAO,MAAM,IAAI,EAAE,UAAU,CAAC,SAAS,CAAsC,CAAA;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAEtC,eAAO,MAAM,SAAS,cAAc,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { stateAlias } from '@owlmeans/state';
|
|
2
|
+
/** The store this package registers and every hook here reads. */
|
|
3
|
+
export const JOBS = stateAlias('job-state');
|
|
4
|
+
/**
|
|
5
|
+
* The alias root a group answers under when a caller names none — the same default
|
|
6
|
+
* `@owlmeans/server-job` declares with.
|
|
7
|
+
*
|
|
8
|
+
* That package's `<root>` / `<root>:<verb>` alias shape and its `job-event` frame name are the
|
|
9
|
+
* whole contract between the two halves. They are restated here rather than imported because the
|
|
10
|
+
* server half pulls fastify in, and a browser bundle must not.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_JOB_ROOT = 'jobs';
|
|
13
|
+
export const JOB_EVENT = 'job-event';
|
|
14
|
+
//# sourceMappingURL=consts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAG5C,kEAAkE;AAClE,MAAM,CAAC,MAAM,IAAI,GAA0B,UAAU,CAAY,WAAW,CAAC,CAAA;AAE7E;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,CAAA;AAEtC,MAAM,CAAC,MAAM,SAAS,GAAG,WAAW,CAAA"}
|
package/build/feed.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JobFeed, JobFeedOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mount the job feed: one socket, seeded once from the list entrypoint.
|
|
4
|
+
*
|
|
5
|
+
* Mount it ONCE per screen that shows jobs — every call opens its own socket, and two feeds mean
|
|
6
|
+
* the server fans the same events out twice. Everything else reads the store through
|
|
7
|
+
* {@link useJob} / {@link useJobs}, so nothing else in the tree needs the connection.
|
|
8
|
+
*
|
|
9
|
+
* The seed is `replace`, not a run of saves: it is the server saying what exists, so a job
|
|
10
|
+
* cancelled from another tab has to LEAVE the store, and one write wakes the subscribers once.
|
|
11
|
+
* The socket is opened by `@owlmeans/client-auth`'s `useWs`, which puts the current token on the
|
|
12
|
+
* connection query — the watch entrypoint is guarded, and ownership is derived from that token.
|
|
13
|
+
*/
|
|
14
|
+
export declare const useJobFeed: (opts?: JobFeedOptions) => JobFeed;
|
|
15
|
+
//# sourceMappingURL=feed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../src/feed.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAmB,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAG1E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,UAAW,cAAc,KAAG,OAkDlD,CAAA"}
|
package/build/feed.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useContext } from '@owlmeans/client';
|
|
3
|
+
import { useWs } from '@owlmeans/client-auth';
|
|
4
|
+
import { DEFAULT_JOB_ROOT, JOBS, JOB_EVENT } from './consts.js';
|
|
5
|
+
import { jobEntrypointAliases } from './helper.js';
|
|
6
|
+
import { applyJobEvent } from './utils/index.js';
|
|
7
|
+
/**
|
|
8
|
+
* Mount the job feed: one socket, seeded once from the list entrypoint.
|
|
9
|
+
*
|
|
10
|
+
* Mount it ONCE per screen that shows jobs — every call opens its own socket, and two feeds mean
|
|
11
|
+
* the server fans the same events out twice. Everything else reads the store through
|
|
12
|
+
* {@link useJob} / {@link useJobs}, so nothing else in the tree needs the connection.
|
|
13
|
+
*
|
|
14
|
+
* The seed is `replace`, not a run of saves: it is the server saying what exists, so a job
|
|
15
|
+
* cancelled from another tab has to LEAVE the store, and one write wakes the subscribers once.
|
|
16
|
+
* The socket is opened by `@owlmeans/client-auth`'s `useWs`, which puts the current token on the
|
|
17
|
+
* connection query — the watch entrypoint is guarded, and ownership is derived from that token.
|
|
18
|
+
*/
|
|
19
|
+
export const useJobFeed = (opts) => {
|
|
20
|
+
const context = useContext();
|
|
21
|
+
const alias = opts?.resource ?? JOBS;
|
|
22
|
+
const aliases = useMemo(() => jobEntrypointAliases(opts?.root ?? DEFAULT_JOB_ROOT), [opts?.root]);
|
|
23
|
+
const connection = useWs(aliases.watch);
|
|
24
|
+
const [seed, setSeed] = useState({ seeded: false, error: null });
|
|
25
|
+
// The seeding query is compared by CONTENT: a screen that narrows its filter re-seeds, while a
|
|
26
|
+
// caller passing a fresh object literal every render does not re-fetch on every render.
|
|
27
|
+
const query = JSON.stringify(opts?.query ?? null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let live = true;
|
|
30
|
+
void (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const result = await context.entrypoint(aliases.list)
|
|
33
|
+
.call({ query: opts?.query });
|
|
34
|
+
if (!live) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await context.getStateResource(alias).replace(result.items);
|
|
38
|
+
setSeed({ seeded: true, error: null });
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (live) {
|
|
42
|
+
setSeed({ seeded: false, error: e });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
return () => { live = false; };
|
|
47
|
+
}, [context, aliases.list, alias, query]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (connection == null) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const store = context.getStateResource(alias);
|
|
53
|
+
const unsubscribe = connection.observe(JOB_EVENT, async (message) => {
|
|
54
|
+
try {
|
|
55
|
+
await applyJobEvent(store, message.payload);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
console.error('Job feed apply error:', e);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return () => unsubscribe();
|
|
62
|
+
}, [connection, context, alias]);
|
|
63
|
+
return { connected: connection != null, ...seed };
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=feed.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feed.js","sourceRoot":"","sources":["../src/feed.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StateAlias } from '@owlmeans/state';
|
|
2
|
+
import type { JobRecord } from '@owlmeans/queue';
|
|
3
|
+
import type { Config, Context, JobEntrypointAliases } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Register the store the job hooks read.
|
|
6
|
+
*
|
|
7
|
+
* Jobs get a store of their own rather than the context's default one because a job id is the
|
|
8
|
+
* broker's, and sharing an id space with the app's own records is how a completed job overwrites
|
|
9
|
+
* an unrelated row. Idempotent, like every `append*` — an app that calls it twice keeps whatever
|
|
10
|
+
* the store already collected.
|
|
11
|
+
*/
|
|
12
|
+
export declare const appendJobs: <C extends Config, T extends Context<C>>(context: T, alias?: StateAlias<JobRecord>) => T;
|
|
13
|
+
/**
|
|
14
|
+
* The aliases one job group answers under — the same shape `@owlmeans/server-job` declares.
|
|
15
|
+
*/
|
|
16
|
+
export declare const jobEntrypointAliases: (root?: string) => JobEntrypointAliases;
|
|
17
|
+
//# sourceMappingURL=helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,WACtD,CAAC,UAAS,UAAU,CAAC,SAAS,CAAC,KACvC,CAA8D,CAAA;AAEjE;;GAEG;AACH,eAAO,MAAM,oBAAoB,UACzB,MAAM,KACX,oBAMD,CAAA"}
|
package/build/helper.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { appendStateResource } from '@owlmeans/state';
|
|
2
|
+
import { DEFAULT_JOB_ROOT, JOBS } from './consts.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register the store the job hooks read.
|
|
5
|
+
*
|
|
6
|
+
* Jobs get a store of their own rather than the context's default one because a job id is the
|
|
7
|
+
* broker's, and sharing an id space with the app's own records is how a completed job overwrites
|
|
8
|
+
* an unrelated row. Idempotent, like every `append*` — an app that calls it twice keeps whatever
|
|
9
|
+
* the store already collected.
|
|
10
|
+
*/
|
|
11
|
+
export const appendJobs = (context, alias = JOBS) => appendStateResource(context, alias);
|
|
12
|
+
/**
|
|
13
|
+
* The aliases one job group answers under — the same shape `@owlmeans/server-job` declares.
|
|
14
|
+
*/
|
|
15
|
+
export const jobEntrypointAliases = (root = DEFAULT_JOB_ROOT) => ({
|
|
16
|
+
base: root,
|
|
17
|
+
list: `${root}:list`,
|
|
18
|
+
get: `${root}:get`,
|
|
19
|
+
cancel: `${root}:cancel`,
|
|
20
|
+
watch: `${root}:watch`,
|
|
21
|
+
});
|
|
22
|
+
//# sourceMappingURL=helper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helper.js","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAGrD,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAGpD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CACxB,OAAU,EAAE,KAAK,GAA0B,IAAI,EAC5C,EAAE,CAAC,mBAAmB,CAAkB,OAAO,EAAE,KAAK,CAAM,CAAA;AAEjE;;GAEG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAClC,IAAI,GAAW,gBAAgB,EACT,EAAE,CAAC,CAAC;IAC1B,IAAI,EAAE,IAAI;IACV,IAAI,EAAE,GAAG,IAAI,OAAO;IACpB,GAAG,EAAE,GAAG,IAAI,MAAM;IAClB,MAAM,EAAE,GAAG,IAAI,SAAS;IACxB,KAAK,EAAE,GAAG,IAAI,QAAQ;CACvB,CAAC,CAAA"}
|
package/build/hooks.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StateModel } from '@owlmeans/state';
|
|
2
|
+
import type { JobRecord } from '@owlmeans/queue';
|
|
3
|
+
import type { JobFilter, UseJobsOptions } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* One job, live.
|
|
6
|
+
*
|
|
7
|
+
* Never throws for an id the store has not seen — the model comes back `empty`, which is what a
|
|
8
|
+
* screen renders as "still loading" while {@link useJobFeed} seeds.
|
|
9
|
+
*/
|
|
10
|
+
export declare const useJob: <D = unknown, R = unknown>(id?: string, resource?: string) => StateModel<JobRecord<D, R>>;
|
|
11
|
+
/**
|
|
12
|
+
* A live query over the job store, newest first unless told otherwise.
|
|
13
|
+
*
|
|
14
|
+
* The criteria is the resource language, so a filter written for the list entrypoint means the
|
|
15
|
+
* same thing here: `{ state: JobState.Active }`, `{ name: 'report:build' }`, `{ 'data.target': id }`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const useJobs: <D = unknown, R = unknown>(filter?: JobFilter<D, R>, opts?: UseJobsOptions<D, R>) => StateModel<JobRecord<D, R>>[];
|
|
18
|
+
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAE3D;;;;;GAKG;AACH,eAAO,MAAM,MAAM,GAAI,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO,OACxC,MAAM,aAAY,MAAM,KAC5B,UAAU,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAiD,CAAA;AAE9E;;;;;GAKG;AACH,eAAO,MAAM,OAAO,GAAI,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO,WACrC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,SAAS,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,KACpD,UAAU,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAI5B,CAAA"}
|
package/build/hooks.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useStoreList, useStoreModel } from '@owlmeans/client';
|
|
2
|
+
import { JOBS } from './consts.js';
|
|
3
|
+
/**
|
|
4
|
+
* One job, live.
|
|
5
|
+
*
|
|
6
|
+
* Never throws for an id the store has not seen — the model comes back `empty`, which is what a
|
|
7
|
+
* screen renders as "still loading" while {@link useJobFeed} seeds.
|
|
8
|
+
*/
|
|
9
|
+
export const useJob = (id, resource = JOBS) => useStoreModel(id, resource);
|
|
10
|
+
/**
|
|
11
|
+
* A live query over the job store, newest first unless told otherwise.
|
|
12
|
+
*
|
|
13
|
+
* The criteria is the resource language, so a filter written for the list entrypoint means the
|
|
14
|
+
* same thing here: `{ state: JobState.Active }`, `{ name: 'report:build' }`, `{ 'data.target': id }`.
|
|
15
|
+
*/
|
|
16
|
+
export const useJobs = (filter, opts) => useStoreList({
|
|
17
|
+
query: filter,
|
|
18
|
+
sort: opts?.sort ?? [{ field: 'createdAt', order: 'desc' }],
|
|
19
|
+
resource: opts?.resource ?? JOBS,
|
|
20
|
+
});
|
|
21
|
+
//# sourceMappingURL=hooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hooks.js","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAG9D,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAGlC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CACpB,EAAW,EAAE,QAAQ,GAAW,IAAI,EACP,EAAE,CAAC,aAAa,CAAkB,EAAE,EAAE,QAAQ,CAAC,CAAA;AAE9E;;;;;GAKG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,CACrB,MAAwB,EAAE,IAA2B,EACtB,EAAE,CAAC,YAAY,CAAkB;IAChE,KAAK,EAAE,MAAM;IACb,IAAI,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAC3D,QAAQ,EAAE,IAAI,EAAE,QAAQ,IAAI,IAAI;CACjC,CAAC,CAAA"}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,kBAAkB,CAAA"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,kBAAkB,CAAA"}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ClientConfig } from '@owlmeans/client-context';
|
|
2
|
+
import type { ClientContext } from '@owlmeans/client';
|
|
3
|
+
import type { JobRecord } from '@owlmeans/queue';
|
|
4
|
+
import type { Criteria, Sort } from '@owlmeans/resource';
|
|
5
|
+
import type { StateAlias } from '@owlmeans/state';
|
|
6
|
+
export interface Config extends ClientConfig {
|
|
7
|
+
}
|
|
8
|
+
export interface Context<C extends Config = Config> extends ClientContext<C> {
|
|
9
|
+
}
|
|
10
|
+
/** The alias every entrypoint of one job group answers under. */
|
|
11
|
+
export interface JobEntrypointAliases {
|
|
12
|
+
base: string;
|
|
13
|
+
list: string;
|
|
14
|
+
get: string;
|
|
15
|
+
cancel: string;
|
|
16
|
+
watch: string;
|
|
17
|
+
}
|
|
18
|
+
export interface UseJobsOptions<D = unknown, R = unknown> {
|
|
19
|
+
sort?: Sort<JobRecord<D, R>>[];
|
|
20
|
+
/** Which store to read; the package's own when omitted. */
|
|
21
|
+
resource?: StateAlias<JobRecord>;
|
|
22
|
+
}
|
|
23
|
+
export interface JobFeedOptions {
|
|
24
|
+
/** The alias root the group was declared under. */
|
|
25
|
+
root?: string;
|
|
26
|
+
resource?: StateAlias<JobRecord>;
|
|
27
|
+
/** What the seeding list call asks for — `state`, `name`, `page`, `size`. */
|
|
28
|
+
query?: Record<string, string | number | undefined>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether the feed is carrying anything yet, so a screen can tell "no jobs" from "not loaded".
|
|
32
|
+
*/
|
|
33
|
+
export interface JobFeed {
|
|
34
|
+
/** The socket is open. */
|
|
35
|
+
connected: boolean;
|
|
36
|
+
/** The authoritative list has been written into the store at least once. */
|
|
37
|
+
seeded: boolean;
|
|
38
|
+
/** What the seeding call threw, if it threw. */
|
|
39
|
+
error: Error | null;
|
|
40
|
+
}
|
|
41
|
+
export type JobFilter<D = unknown, R = unknown> = Criteria<JobRecord<D, R>>;
|
|
42
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAEjD,MAAM,WAAW,MAAO,SAAQ,YAAY;CAAI;AAEhD,MAAM,WAAW,OAAO,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,aAAa,CAAC,CAAC,CAAC;CAAI;AAEhF,iEAAiE;AACjE,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO;IACtD,IAAI,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;IAC9B,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAA;IAChC,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,CAAA;CACpD;AAED;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,0BAA0B;IAC1B,SAAS,EAAE,OAAO,CAAA;IAClB,4EAA4E;IAC5E,MAAM,EAAE,OAAO,CAAA;IACf,gDAAgD;IAChD,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AAED,MAAM,MAAM,SAAS,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO,IAAI,QAAQ,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA"}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JobEvent, JobRecord } from '@owlmeans/queue';
|
|
2
|
+
import type { StateResource } from '@owlmeans/state';
|
|
3
|
+
/**
|
|
4
|
+
* Fold one lifecycle frame into the store.
|
|
5
|
+
*
|
|
6
|
+
* The frame is merged over whatever the store holds rather than saved as itself: an event carries
|
|
7
|
+
* only what changed, and `save` replaces the record. An event for an id the store never saw still
|
|
8
|
+
* writes a row — a job enqueued in another tab reports its progress here too, and the next seeding
|
|
9
|
+
* call fills in the rest.
|
|
10
|
+
*
|
|
11
|
+
* A progress frame does NOT move a settled job back to active: the broker's completion and its
|
|
12
|
+
* last progress ping race, and the losing order would leave a finished job spinning forever.
|
|
13
|
+
*/
|
|
14
|
+
export declare const applyJobEvent: (store: StateResource<JobRecord>, event: JobEvent) => Promise<void>;
|
|
15
|
+
//# sourceMappingURL=event.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event.d.ts","sourceRoot":"","sources":["../../src/utils/event.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAE1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAEpD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,UACjB,aAAa,CAAC,SAAS,CAAC,SAAS,QAAQ,KAC/C,OAAO,CAAC,IAAI,CAqBd,CAAA"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isSettled, JobEventType, JobState } from '@owlmeans/queue';
|
|
2
|
+
/**
|
|
3
|
+
* Fold one lifecycle frame into the store.
|
|
4
|
+
*
|
|
5
|
+
* The frame is merged over whatever the store holds rather than saved as itself: an event carries
|
|
6
|
+
* only what changed, and `save` replaces the record. An event for an id the store never saw still
|
|
7
|
+
* writes a row — a job enqueued in another tab reports its progress here too, and the next seeding
|
|
8
|
+
* call fills in the rest.
|
|
9
|
+
*
|
|
10
|
+
* A progress frame does NOT move a settled job back to active: the broker's completion and its
|
|
11
|
+
* last progress ping race, and the losing order would leave a finished job spinning forever.
|
|
12
|
+
*/
|
|
13
|
+
export const applyJobEvent = async (store, event) => {
|
|
14
|
+
const known = await store.load(event.id);
|
|
15
|
+
const change = event.type === JobEventType.Completed
|
|
16
|
+
? { state: JobState.Completed, result: event.result }
|
|
17
|
+
: event.type === JobEventType.Failed
|
|
18
|
+
? { state: JobState.Failed, error: event.error }
|
|
19
|
+
: {
|
|
20
|
+
progress: event.progress,
|
|
21
|
+
...(isSettled(known?.state) ? {} : { state: JobState.Active }),
|
|
22
|
+
};
|
|
23
|
+
await store.save({
|
|
24
|
+
...(known ?? { data: undefined }),
|
|
25
|
+
id: event.id,
|
|
26
|
+
queue: event.queue,
|
|
27
|
+
// A queue that removed the job has no name left to report — never overwrite a known one
|
|
28
|
+
// with the empty string the contract sends in its place.
|
|
29
|
+
...(event.name !== '' ? { name: event.name } : {}),
|
|
30
|
+
...change,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=event.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event.js","sourceRoot":"","sources":["../../src/utils/event.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAGnE;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAChC,KAA+B,EAAE,KAAe,EACjC,EAAE;IACjB,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;IAExC,MAAM,MAAM,GAAuB,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC,SAAS;QACtE,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE;QACrD,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC,MAAM;YAClC,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE;YAChD,CAAC,CAAC;gBACA,QAAQ,EAAE,KAAK,CAAC,QAAQ;gBACxB,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;aAC/D,CAAA;IAEL,MAAM,KAAK,CAAC,IAAI,CAAC;QACf,GAAG,CAAC,KAAK,IAAI,EAAE,IAAI,EAAE,SAAoB,EAAE,CAAC;QAC5C,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,wFAAwF;QACxF,yDAAyD;QACzD,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAClD,GAAG,MAAM;KACV,CAAC,CAAA;AACJ,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/client-job",
|
|
3
|
+
"version": "0.1.18-rc.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -b",
|
|
8
|
+
"dev": "sleep 126 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty"
|
|
10
|
+
},
|
|
11
|
+
"main": "build/index.js",
|
|
12
|
+
"module": "build/index.js",
|
|
13
|
+
"types": "build/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": "./build/index.js",
|
|
17
|
+
"require": "./build/index.js",
|
|
18
|
+
"default": "./build/index.js",
|
|
19
|
+
"module": "./build/index.js",
|
|
20
|
+
"types": "./build/index.d.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": "*"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
28
|
+
"@types/react": "^19.2.17",
|
|
29
|
+
"nodemon": "^3.1.14",
|
|
30
|
+
"typescript": "^7.0.2"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@owlmeans/client": "^0.1.18-rc.16",
|
|
34
|
+
"@owlmeans/client-auth": "^0.1.18-rc.20",
|
|
35
|
+
"@owlmeans/client-context": "^0.1.18-rc.13",
|
|
36
|
+
"@owlmeans/client-entrypoint": "^0.1.18-rc.13",
|
|
37
|
+
"@owlmeans/context": "^0.1.18-rc.8",
|
|
38
|
+
"@owlmeans/queue": "^0.1.18-rc.9",
|
|
39
|
+
"@owlmeans/resource": "^0.1.18-rc.9",
|
|
40
|
+
"@owlmeans/socket": "^0.1.18-rc.9",
|
|
41
|
+
"@owlmeans/state": "^0.1.18-rc.10"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JobRecord } from '@owlmeans/queue'
|
|
2
|
+
import { stateAlias } from '@owlmeans/state'
|
|
3
|
+
import type { StateAlias } from '@owlmeans/state'
|
|
4
|
+
|
|
5
|
+
/** The store this package registers and every hook here reads. */
|
|
6
|
+
export const JOBS: StateAlias<JobRecord> = stateAlias<JobRecord>('job-state')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The alias root a group answers under when a caller names none — the same default
|
|
10
|
+
* `@owlmeans/server-job` declares with.
|
|
11
|
+
*
|
|
12
|
+
* That package's `<root>` / `<root>:<verb>` alias shape and its `job-event` frame name are the
|
|
13
|
+
* whole contract between the two halves. They are restated here rather than imported because the
|
|
14
|
+
* server half pulls fastify in, and a browser bundle must not.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_JOB_ROOT = 'jobs'
|
|
17
|
+
|
|
18
|
+
export const JOB_EVENT = 'job-event'
|
package/src/feed.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { useContext } from '@owlmeans/client'
|
|
3
|
+
import { useWs } from '@owlmeans/client-auth'
|
|
4
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
5
|
+
import type { JobEvent, JobRecord } from '@owlmeans/queue'
|
|
6
|
+
import type { ListResult } from '@owlmeans/resource'
|
|
7
|
+
import { DEFAULT_JOB_ROOT, JOBS, JOB_EVENT } from './consts.js'
|
|
8
|
+
import { jobEntrypointAliases } from './helper.js'
|
|
9
|
+
import type { Config, Context, JobFeed, JobFeedOptions } from './types.js'
|
|
10
|
+
import { applyJobEvent } from './utils/index.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mount the job feed: one socket, seeded once from the list entrypoint.
|
|
14
|
+
*
|
|
15
|
+
* Mount it ONCE per screen that shows jobs — every call opens its own socket, and two feeds mean
|
|
16
|
+
* the server fans the same events out twice. Everything else reads the store through
|
|
17
|
+
* {@link useJob} / {@link useJobs}, so nothing else in the tree needs the connection.
|
|
18
|
+
*
|
|
19
|
+
* The seed is `replace`, not a run of saves: it is the server saying what exists, so a job
|
|
20
|
+
* cancelled from another tab has to LEAVE the store, and one write wakes the subscribers once.
|
|
21
|
+
* The socket is opened by `@owlmeans/client-auth`'s `useWs`, which puts the current token on the
|
|
22
|
+
* connection query — the watch entrypoint is guarded, and ownership is derived from that token.
|
|
23
|
+
*/
|
|
24
|
+
export const useJobFeed = (opts?: JobFeedOptions): JobFeed => {
|
|
25
|
+
const context = useContext<Config, Context>()
|
|
26
|
+
const alias = opts?.resource ?? JOBS
|
|
27
|
+
const aliases = useMemo(
|
|
28
|
+
() => jobEntrypointAliases(opts?.root ?? DEFAULT_JOB_ROOT), [opts?.root]
|
|
29
|
+
)
|
|
30
|
+
const connection = useWs(aliases.watch)
|
|
31
|
+
const [seed, setSeed] = useState<Pick<JobFeed, 'seeded' | 'error'>>({ seeded: false, error: null })
|
|
32
|
+
// The seeding query is compared by CONTENT: a screen that narrows its filter re-seeds, while a
|
|
33
|
+
// caller passing a fresh object literal every render does not re-fetch on every render.
|
|
34
|
+
const query = JSON.stringify(opts?.query ?? null)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let live = true
|
|
38
|
+
void (async () => {
|
|
39
|
+
try {
|
|
40
|
+
const result = await context.entrypoint<ClientEntrypoint<ListResult<JobRecord>>>(aliases.list)
|
|
41
|
+
.call<ListResult<JobRecord>>({ query: opts?.query })
|
|
42
|
+
if (!live) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
await context.getStateResource(alias).replace(result.items)
|
|
46
|
+
setSeed({ seeded: true, error: null })
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (live) {
|
|
49
|
+
setSeed({ seeded: false, error: e as Error })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
|
|
54
|
+
return () => { live = false }
|
|
55
|
+
}, [context, aliases.list, alias, query])
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (connection == null) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const store = context.getStateResource(alias)
|
|
62
|
+
const unsubscribe = connection.observe<JobEvent>(JOB_EVENT, async message => {
|
|
63
|
+
try {
|
|
64
|
+
await applyJobEvent(store, message.payload)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error('Job feed apply error:', e)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return () => unsubscribe()
|
|
71
|
+
}, [connection, context, alias])
|
|
72
|
+
|
|
73
|
+
return { connected: connection != null, ...seed }
|
|
74
|
+
}
|
package/src/helper.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendStateResource } from '@owlmeans/state'
|
|
2
|
+
import type { StateAlias } from '@owlmeans/state'
|
|
3
|
+
import type { JobRecord } from '@owlmeans/queue'
|
|
4
|
+
import { DEFAULT_JOB_ROOT, JOBS } from './consts.js'
|
|
5
|
+
import type { Config, Context, JobEntrypointAliases } from './types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the store the job hooks read.
|
|
9
|
+
*
|
|
10
|
+
* Jobs get a store of their own rather than the context's default one because a job id is the
|
|
11
|
+
* broker's, and sharing an id space with the app's own records is how a completed job overwrites
|
|
12
|
+
* an unrelated row. Idempotent, like every `append*` — an app that calls it twice keeps whatever
|
|
13
|
+
* the store already collected.
|
|
14
|
+
*/
|
|
15
|
+
export const appendJobs = <C extends Config, T extends Context<C>>(
|
|
16
|
+
context: T, alias: StateAlias<JobRecord> = JOBS
|
|
17
|
+
): T => appendStateResource<C, T, JobRecord>(context, alias) as T
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The aliases one job group answers under — the same shape `@owlmeans/server-job` declares.
|
|
21
|
+
*/
|
|
22
|
+
export const jobEntrypointAliases = (
|
|
23
|
+
root: string = DEFAULT_JOB_ROOT
|
|
24
|
+
): JobEntrypointAliases => ({
|
|
25
|
+
base: root,
|
|
26
|
+
list: `${root}:list`,
|
|
27
|
+
get: `${root}:get`,
|
|
28
|
+
cancel: `${root}:cancel`,
|
|
29
|
+
watch: `${root}:watch`,
|
|
30
|
+
})
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useStoreList, useStoreModel } from '@owlmeans/client'
|
|
2
|
+
import type { StateModel } from '@owlmeans/state'
|
|
3
|
+
import type { JobRecord } from '@owlmeans/queue'
|
|
4
|
+
import { JOBS } from './consts.js'
|
|
5
|
+
import type { JobFilter, UseJobsOptions } from './types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One job, live.
|
|
9
|
+
*
|
|
10
|
+
* Never throws for an id the store has not seen — the model comes back `empty`, which is what a
|
|
11
|
+
* screen renders as "still loading" while {@link useJobFeed} seeds.
|
|
12
|
+
*/
|
|
13
|
+
export const useJob = <D = unknown, R = unknown>(
|
|
14
|
+
id?: string, resource: string = JOBS
|
|
15
|
+
): StateModel<JobRecord<D, R>> => useStoreModel<JobRecord<D, R>>(id, resource)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A live query over the job store, newest first unless told otherwise.
|
|
19
|
+
*
|
|
20
|
+
* The criteria is the resource language, so a filter written for the list entrypoint means the
|
|
21
|
+
* same thing here: `{ state: JobState.Active }`, `{ name: 'report:build' }`, `{ 'data.target': id }`.
|
|
22
|
+
*/
|
|
23
|
+
export const useJobs = <D = unknown, R = unknown>(
|
|
24
|
+
filter?: JobFilter<D, R>, opts?: UseJobsOptions<D, R>
|
|
25
|
+
): StateModel<JobRecord<D, R>>[] => useStoreList<JobRecord<D, R>>({
|
|
26
|
+
query: filter,
|
|
27
|
+
sort: opts?.sort ?? [{ field: 'createdAt', order: 'desc' }],
|
|
28
|
+
resource: opts?.resource ?? JOBS,
|
|
29
|
+
})
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ClientConfig } from '@owlmeans/client-context'
|
|
2
|
+
import type { ClientContext } from '@owlmeans/client'
|
|
3
|
+
import type { JobRecord } from '@owlmeans/queue'
|
|
4
|
+
import type { Criteria, Sort } from '@owlmeans/resource'
|
|
5
|
+
import type { StateAlias } from '@owlmeans/state'
|
|
6
|
+
|
|
7
|
+
export interface Config extends ClientConfig { }
|
|
8
|
+
|
|
9
|
+
export interface Context<C extends Config = Config> extends ClientContext<C> { }
|
|
10
|
+
|
|
11
|
+
/** The alias every entrypoint of one job group answers under. */
|
|
12
|
+
export interface JobEntrypointAliases {
|
|
13
|
+
base: string
|
|
14
|
+
list: string
|
|
15
|
+
get: string
|
|
16
|
+
cancel: string
|
|
17
|
+
watch: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UseJobsOptions<D = unknown, R = unknown> {
|
|
21
|
+
sort?: Sort<JobRecord<D, R>>[]
|
|
22
|
+
/** Which store to read; the package's own when omitted. */
|
|
23
|
+
resource?: StateAlias<JobRecord>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JobFeedOptions {
|
|
27
|
+
/** The alias root the group was declared under. */
|
|
28
|
+
root?: string
|
|
29
|
+
resource?: StateAlias<JobRecord>
|
|
30
|
+
/** What the seeding list call asks for — `state`, `name`, `page`, `size`. */
|
|
31
|
+
query?: Record<string, string | number | undefined>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the feed is carrying anything yet, so a screen can tell "no jobs" from "not loaded".
|
|
36
|
+
*/
|
|
37
|
+
export interface JobFeed {
|
|
38
|
+
/** The socket is open. */
|
|
39
|
+
connected: boolean
|
|
40
|
+
/** The authoritative list has been written into the store at least once. */
|
|
41
|
+
seeded: boolean
|
|
42
|
+
/** What the seeding call threw, if it threw. */
|
|
43
|
+
error: Error | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type JobFilter<D = unknown, R = unknown> = Criteria<JobRecord<D, R>>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { JobEvent, JobRecord } from '@owlmeans/queue'
|
|
2
|
+
import { isSettled, JobEventType, JobState } from '@owlmeans/queue'
|
|
3
|
+
import type { StateResource } from '@owlmeans/state'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fold one lifecycle frame into the store.
|
|
7
|
+
*
|
|
8
|
+
* The frame is merged over whatever the store holds rather than saved as itself: an event carries
|
|
9
|
+
* only what changed, and `save` replaces the record. An event for an id the store never saw still
|
|
10
|
+
* writes a row — a job enqueued in another tab reports its progress here too, and the next seeding
|
|
11
|
+
* call fills in the rest.
|
|
12
|
+
*
|
|
13
|
+
* A progress frame does NOT move a settled job back to active: the broker's completion and its
|
|
14
|
+
* last progress ping race, and the losing order would leave a finished job spinning forever.
|
|
15
|
+
*/
|
|
16
|
+
export const applyJobEvent = async (
|
|
17
|
+
store: StateResource<JobRecord>, event: JobEvent
|
|
18
|
+
): Promise<void> => {
|
|
19
|
+
const known = await store.load(event.id)
|
|
20
|
+
|
|
21
|
+
const change: Partial<JobRecord> = event.type === JobEventType.Completed
|
|
22
|
+
? { state: JobState.Completed, result: event.result }
|
|
23
|
+
: event.type === JobEventType.Failed
|
|
24
|
+
? { state: JobState.Failed, error: event.error }
|
|
25
|
+
: {
|
|
26
|
+
progress: event.progress,
|
|
27
|
+
...(isSettled(known?.state) ? {} : { state: JobState.Active }),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await store.save({
|
|
31
|
+
...(known ?? { data: undefined as unknown }),
|
|
32
|
+
id: event.id,
|
|
33
|
+
queue: event.queue,
|
|
34
|
+
// A queue that removed the job has no name left to report — never overwrite a known one
|
|
35
|
+
// with the empty string the contract sends in its place.
|
|
36
|
+
...(event.name !== '' ? { name: event.name } : {}),
|
|
37
|
+
...change,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './event.js'
|
package/tsconfig.json
ADDED