@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/agent-meta/manifest.json +16 -0
  4. package/agent-meta/skills/client-job/SKILL.md +101 -0
  5. package/build/consts.d.ts +15 -0
  6. package/build/consts.d.ts.map +1 -0
  7. package/build/consts.js +14 -0
  8. package/build/consts.js.map +1 -0
  9. package/build/feed.d.ts +15 -0
  10. package/build/feed.d.ts.map +1 -0
  11. package/build/feed.js +65 -0
  12. package/build/feed.js.map +1 -0
  13. package/build/helper.d.ts +17 -0
  14. package/build/helper.d.ts.map +1 -0
  15. package/build/helper.js +22 -0
  16. package/build/helper.js.map +1 -0
  17. package/build/hooks.d.ts +18 -0
  18. package/build/hooks.d.ts.map +1 -0
  19. package/build/hooks.js +21 -0
  20. package/build/hooks.js.map +1 -0
  21. package/build/index.d.ts +7 -0
  22. package/build/index.d.ts.map +1 -0
  23. package/build/index.js +6 -0
  24. package/build/index.js.map +1 -0
  25. package/build/types.d.ts +42 -0
  26. package/build/types.d.ts.map +1 -0
  27. package/build/types.js +2 -0
  28. package/build/types.js.map +1 -0
  29. package/build/utils/event.d.ts +15 -0
  30. package/build/utils/event.d.ts.map +1 -0
  31. package/build/utils/event.js +33 -0
  32. package/build/utils/event.js.map +1 -0
  33. package/build/utils/index.d.ts +2 -0
  34. package/build/utils/index.d.ts.map +1 -0
  35. package/build/utils/index.js +2 -0
  36. package/build/utils/index.js.map +1 -0
  37. package/package.json +46 -0
  38. package/src/consts.ts +18 -0
  39. package/src/feed.ts +74 -0
  40. package/src/helper.ts +30 -0
  41. package/src/hooks.ts +29 -0
  42. package/src/index.ts +7 -0
  43. package/src/types.ts +46 -0
  44. package/src/utils/event.ts +39 -0
  45. package/src/utils/index.ts +1 -0
  46. 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"}
@@ -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"}
@@ -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"}
@@ -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"}
@@ -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"}
@@ -0,0 +1,7 @@
1
+ export type * from './types.js';
2
+ export * from './consts.js';
3
+ export * from './helper.js';
4
+ export * from './hooks.js';
5
+ export * from './feed.js';
6
+ export * from './utils/index.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -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,6 @@
1
+ export * from './consts.js';
2
+ export * from './helper.js';
3
+ export * from './hooks.js';
4
+ export * from './feed.js';
5
+ export * from './utils/index.js';
6
+ //# sourceMappingURL=index.js.map
@@ -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"}
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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,2 @@
1
+ export * from './event.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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,2 @@
1
+ export * from './event.js';
2
+ //# sourceMappingURL=index.js.map
@@ -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
@@ -0,0 +1,7 @@
1
+ export type * from './types.js'
2
+
3
+ export * from './consts.js'
4
+ export * from './helper.js'
5
+ export * from './hooks.js'
6
+ export * from './feed.js'
7
+ export * from './utils/index.js'
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
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.react.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
11
+ }