@absolutejs/sync-pack-counters 0.1.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/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @absolutejs/sync-pack-counters
2
+
3
+ Read-set-tracked live counters for
4
+ [`@absolutejs/sync`](https://github.com/absolutejs/sync). Define a compute
5
+ function that reads through `db`; the engine re-runs and pushes the new
6
+ value whenever any table the compute read changes. No manual invalidation,
7
+ no polling, no operator graph — just a function.
8
+
9
+ ```bash
10
+ bun add @absolutejs/sync-pack-counters
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createSyncEngine } from '@absolutejs/sync/engine';
17
+ import { createCountersPack } from '@absolutejs/sync-pack-counters';
18
+
19
+ const engine = createSyncEngine();
20
+ engine.registerReader('tasks', { all: () => db.tasks.list() });
21
+ engine.registerReader('notifications', { all: () => db.notifications.list() });
22
+
23
+ engine.registerPack(
24
+ createCountersPack({
25
+ counters: {
26
+ // Bare function form.
27
+ openTasks: async ({ db }) =>
28
+ (await db.all<Task>('tasks')).filter((t) => !t.done).length,
29
+
30
+ // Per-actor counter using ctx.
31
+ unreadNotifications: async ({ db, ctx }) =>
32
+ (await db.all<Notification>('notifications'))
33
+ .filter((n) => n.actorId === ctx.userId && n.readAt === null)
34
+ .length,
35
+
36
+ // Object form when you need a per-counter authorize override —
37
+ // e.g. a public site-wide stat anyone can subscribe to.
38
+ totalUsers: {
39
+ authorize: () => true,
40
+ compute: async ({ db }) => (await db.all('users')).length,
41
+ },
42
+ },
43
+ }),
44
+ );
45
+ ```
46
+
47
+ Each counter becomes its own reactive query collection named
48
+ `counter:<key>` returning a single row. Subscribe from the client:
49
+
50
+ ```ts
51
+ useSyncCollection<CounterRow>({ collection: 'counter:openTasks' });
52
+ // Emits { id: 'openTasks', key: 'openTasks', value: 3, computedAt: ... }
53
+ ```
54
+
55
+ ## Why `defineReactiveQuery`?
56
+
57
+ The pack is one big use case for sync's read-set tracking: a counter is
58
+ literally "compute a number from one or more tables, and re-emit it when
59
+ those tables change." The engine instruments every `db.all` / `db.get` /
60
+ `db.where` your compute makes, records the resulting dependency set, and
61
+ parks the query on it. Any subsequent change to a touched table triggers
62
+ a re-run; rows that didn't change don't.
63
+
64
+ Prefer `db.where(table, predicate)` over `db.all(table).filter(...)`
65
+ when possible — `where` records a **range** dependency that re-runs only
66
+ when a change matches the predicate now or matched it before, instead of
67
+ on every change to the table.
68
+
69
+ ## Permissions
70
+
71
+ The default `authorize` requires the caller's ctx to expose an actor id
72
+ (via `getActorId`, defaulting to `(ctx) => ctx.userId`). Per-counter
73
+ `authorize` overrides this — return `() => true` for a public counter,
74
+ or implement role-based gating.
75
+
76
+ ## What the pack ships
77
+
78
+ | Surface | Name | What it does |
79
+ | -------------------- | --------------------- | ------------------------------------------ |
80
+ | Reactive query (×N) | `counter:<key>` | One per counter; emits a single CounterRow |
81
+
82
+ The pack owns no tables and reads no tables of its own — every counter's
83
+ read-set comes from the host's registered readers. `engine.inspect().packs[0]`
84
+ reports empty `ownsTables` and `readsTables`.
85
+
86
+ ## Multiple instances
87
+
88
+ Pass `prefix` to coexist with another counters pack instance:
89
+
90
+ ```ts
91
+ engine.registerPack(createCountersPack({ prefix: 'team_', counters: { ... } }));
92
+ engine.registerPack(createCountersPack({ prefix: 'org_', counters: { ... } }));
93
+ // Collections: team_counter:<key> and org_counter:<key>
94
+ ```
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `@absolutejs/sync-pack-counters` — read-set-tracked live counts.
3
+ *
4
+ * Each counter is a `defineReactiveQuery` whose compute function reads
5
+ * through `ctx.db` to derive a numeric value from host tables. The engine
6
+ * re-runs the query (and pushes the new value to subscribers) whenever any
7
+ * table it read changes — no manual invalidation, no operator graph, no
8
+ * polling. Counters are derived views; the pack owns no tables.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { createSyncEngine } from '@absolutejs/sync/engine';
13
+ * import { createCountersPack } from '@absolutejs/sync-pack-counters';
14
+ *
15
+ * const engine = createSyncEngine();
16
+ * engine.registerReader('tasks', { all: () => db.tasks.list() });
17
+ * engine.registerReader('notifications', { all: () => db.notifications.list() });
18
+ *
19
+ * engine.registerPack(createCountersPack({
20
+ * counters: {
21
+ * openTasks: async ({ db }) =>
22
+ * (await db.all<Task>('tasks')).filter((task) => !task.done).length,
23
+ * unreadNotifications: async ({ db, ctx }) =>
24
+ * (await db.all<Notification>('notifications'))
25
+ * .filter((n) => n.actorId === ctx.userId && n.readAt === null)
26
+ * .length,
27
+ * },
28
+ * }));
29
+ *
30
+ * // Subscribe to a single counter — emits one row { id, key, value }.
31
+ * useSyncCollection<CounterRow>({ collection: 'counter:openTasks' });
32
+ * ```
33
+ */
34
+ import { type CollectionContext, type ReadHandle, type SyncPack } from '@absolutejs/sync/engine';
35
+ /** One reactive count emission. */
36
+ export type CounterRow = {
37
+ /** Row key — matches the counter's name. */
38
+ id: string;
39
+ key: string;
40
+ value: number;
41
+ /** Epoch ms the compute completed. Useful for "as of" labelling. */
42
+ computedAt: number;
43
+ };
44
+ /** What a counter's `compute` sees. Same shape as a reactive query's run-context. */
45
+ export type CounterContext<Ctx> = {
46
+ db: ReadHandle;
47
+ ctx: Ctx;
48
+ };
49
+ /** Function form — convenience when no per-counter authorize override is needed. */
50
+ export type CounterCompute<Ctx> = (context: CounterContext<Ctx>) => number | Promise<number>;
51
+ /** Full form — `compute` + per-counter `authorize` override. */
52
+ export type CounterDefinition<Ctx = CollectionContext> = {
53
+ /**
54
+ * Compute the counter value. Reads through `db` are tracked — the
55
+ * query re-runs whenever any table this compute read changes. Use
56
+ * `db.where(...)` when possible for range-level dependencies that
57
+ * are cheaper than full-table.
58
+ */
59
+ compute: CounterCompute<Ctx>;
60
+ /**
61
+ * Optional access control. Return false (or throw) to deny the
62
+ * subscription. Default: any ctx with a defined `getActorId` value.
63
+ * For a "global" counter (e.g. site-wide stats visible to everyone),
64
+ * return `() => true`.
65
+ */
66
+ authorize?: (ctx: Ctx) => boolean | Promise<boolean>;
67
+ };
68
+ /** A counter is either a bare compute function or a full definition object. */
69
+ export type CounterEntry<Ctx = CollectionContext> = CounterCompute<Ctx> | CounterDefinition<Ctx>;
70
+ export type CountersPackConfig<Ctx = CollectionContext> = {
71
+ prefix?: string;
72
+ /**
73
+ * Used by the default `authorize` to gate counter subscriptions to
74
+ * authenticated callers. Override `authorize` per-counter for global
75
+ * counters. Default `(ctx) => ctx.userId`.
76
+ */
77
+ getActorId?: (ctx: Ctx) => string | undefined;
78
+ /**
79
+ * Counter definitions keyed by name. Each value can be a bare compute
80
+ * function (the common case) or a full {@link CounterDefinition} when
81
+ * you need to override the default authorize.
82
+ */
83
+ counters: Record<string, CounterEntry<Ctx>>;
84
+ };
85
+ /**
86
+ * Build a {@link SyncPack} that exposes read-set-tracked counters. Each
87
+ * entry in `config.counters` becomes a separate reactive query named
88
+ * `${prefix}counter:${key}` returning a single {@link CounterRow}.
89
+ */
90
+ export declare const createCountersPack: <Ctx = CollectionContext>(config: CountersPackConfig<Ctx>) => SyncPack;
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import {
4
+ defineReactiveQuery,
5
+ defineSyncPack
6
+ } from "@absolutejs/sync/engine";
7
+ var asCounterDefinition = (entry) => typeof entry === "function" ? { compute: entry } : entry;
8
+ var now = () => Date.now();
9
+ var createCountersPack = (config) => {
10
+ const prefix = config.prefix ?? "";
11
+ const getActorId = config.getActorId ?? ((ctx) => ctx.userId);
12
+ const reactiveQueries = Object.entries(config.counters).map(([key, entry]) => {
13
+ const counter = asCounterDefinition(entry);
14
+ const collectionName = `${prefix}counter:${key}`;
15
+ return defineReactiveQuery({
16
+ name: collectionName,
17
+ key: (row) => row.id,
18
+ run: async ({ db, ctx }) => {
19
+ const value = await counter.compute({
20
+ ctx,
21
+ db
22
+ });
23
+ return [{ computedAt: now(), id: key, key, value }];
24
+ },
25
+ authorize: (_params, ctx) => {
26
+ if (counter.authorize !== undefined) {
27
+ return counter.authorize(ctx);
28
+ }
29
+ return getActorId(ctx) !== undefined;
30
+ }
31
+ });
32
+ });
33
+ return defineSyncPack({
34
+ name: "@absolutejs/sync-pack-counters",
35
+ ownsTables: [],
36
+ readsTables: [],
37
+ version: "0.1.0",
38
+ reactiveQueries
39
+ });
40
+ };
41
+ export {
42
+ createCountersPack
43
+ };
44
+
45
+ //# debugId=E612E90E978ACBE464756E2164756E21
46
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * `@absolutejs/sync-pack-counters` — read-set-tracked live counts.\n *\n * Each counter is a `defineReactiveQuery` whose compute function reads\n * through `ctx.db` to derive a numeric value from host tables. The engine\n * re-runs the query (and pushes the new value to subscribers) whenever any\n * table it read changes — no manual invalidation, no operator graph, no\n * polling. Counters are derived views; the pack owns no tables.\n *\n * @example\n * ```ts\n * import { createSyncEngine } from '@absolutejs/sync/engine';\n * import { createCountersPack } from '@absolutejs/sync-pack-counters';\n *\n * const engine = createSyncEngine();\n * engine.registerReader('tasks', { all: () => db.tasks.list() });\n * engine.registerReader('notifications', { all: () => db.notifications.list() });\n *\n * engine.registerPack(createCountersPack({\n * counters: {\n * openTasks: async ({ db }) =>\n * (await db.all<Task>('tasks')).filter((task) => !task.done).length,\n * unreadNotifications: async ({ db, ctx }) =>\n * (await db.all<Notification>('notifications'))\n * .filter((n) => n.actorId === ctx.userId && n.readAt === null)\n * .length,\n * },\n * }));\n *\n * // Subscribe to a single counter — emits one row { id, key, value }.\n * useSyncCollection<CounterRow>({ collection: 'counter:openTasks' });\n * ```\n */\n\nimport {\n\tdefineReactiveQuery,\n\tdefineSyncPack,\n\ttype CollectionContext,\n\ttype ReadHandle,\n\ttype SyncPack\n} from '@absolutejs/sync/engine';\n\n/** One reactive count emission. */\nexport type CounterRow = {\n\t/** Row key — matches the counter's name. */\n\tid: string;\n\tkey: string;\n\tvalue: number;\n\t/** Epoch ms the compute completed. Useful for \"as of\" labelling. */\n\tcomputedAt: number;\n};\n\n/** What a counter's `compute` sees. Same shape as a reactive query's run-context. */\nexport type CounterContext<Ctx> = {\n\tdb: ReadHandle;\n\tctx: Ctx;\n};\n\n/** Function form — convenience when no per-counter authorize override is needed. */\nexport type CounterCompute<Ctx> = (\n\tcontext: CounterContext<Ctx>\n) => number | Promise<number>;\n\n/** Full form — `compute` + per-counter `authorize` override. */\nexport type CounterDefinition<Ctx = CollectionContext> = {\n\t/**\n\t * Compute the counter value. Reads through `db` are tracked — the\n\t * query re-runs whenever any table this compute read changes. Use\n\t * `db.where(...)` when possible for range-level dependencies that\n\t * are cheaper than full-table.\n\t */\n\tcompute: CounterCompute<Ctx>;\n\t/**\n\t * Optional access control. Return false (or throw) to deny the\n\t * subscription. Default: any ctx with a defined `getActorId` value.\n\t * For a \"global\" counter (e.g. site-wide stats visible to everyone),\n\t * return `() => true`.\n\t */\n\tauthorize?: (ctx: Ctx) => boolean | Promise<boolean>;\n};\n\n/** A counter is either a bare compute function or a full definition object. */\nexport type CounterEntry<Ctx = CollectionContext> =\n\t| CounterCompute<Ctx>\n\t| CounterDefinition<Ctx>;\n\nconst asCounterDefinition = <Ctx>(\n\tentry: CounterEntry<Ctx>\n): CounterDefinition<Ctx> =>\n\ttypeof entry === 'function' ? { compute: entry } : entry;\n\nexport type CountersPackConfig<Ctx = CollectionContext> = {\n\tprefix?: string;\n\t/**\n\t * Used by the default `authorize` to gate counter subscriptions to\n\t * authenticated callers. Override `authorize` per-counter for global\n\t * counters. Default `(ctx) => ctx.userId`.\n\t */\n\tgetActorId?: (ctx: Ctx) => string | undefined;\n\t/**\n\t * Counter definitions keyed by name. Each value can be a bare compute\n\t * function (the common case) or a full {@link CounterDefinition} when\n\t * you need to override the default authorize.\n\t */\n\tcounters: Record<string, CounterEntry<Ctx>>;\n};\n\nconst now = () => Date.now();\n\n/**\n * Build a {@link SyncPack} that exposes read-set-tracked counters. Each\n * entry in `config.counters` becomes a separate reactive query named\n * `${prefix}counter:${key}` returning a single {@link CounterRow}.\n */\nexport const createCountersPack = <Ctx = CollectionContext>(\n\tconfig: CountersPackConfig<Ctx>\n): SyncPack => {\n\tconst prefix = config.prefix ?? '';\n\tconst getActorId = (config.getActorId ??\n\t\t((ctx: CollectionContext) =>\n\t\t\t(ctx as { userId?: string }).userId)) as (\n\t\tctx: CollectionContext\n\t) => string | undefined;\n\n\tconst reactiveQueries = Object.entries(config.counters).map(\n\t\t([key, entry]) => {\n\t\t\tconst counter = asCounterDefinition<Ctx>(entry);\n\t\t\tconst collectionName = `${prefix}counter:${key}`;\n\t\t\treturn defineReactiveQuery<CounterRow, void, CollectionContext>({\n\t\t\t\tname: collectionName,\n\t\t\t\tkey: (row) => row.id,\n\t\t\t\trun: async ({ db, ctx }) => {\n\t\t\t\t\tconst value = await counter.compute({\n\t\t\t\t\t\tctx: ctx as Ctx,\n\t\t\t\t\t\tdb\n\t\t\t\t\t});\n\t\t\t\t\treturn [{ computedAt: now(), id: key, key, value }];\n\t\t\t\t},\n\t\t\t\tauthorize: (_params, ctx) => {\n\t\t\t\t\tif (counter.authorize !== undefined) {\n\t\t\t\t\t\treturn counter.authorize(ctx as Ctx);\n\t\t\t\t\t}\n\t\t\t\t\treturn getActorId(ctx) !== undefined;\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t);\n\n\treturn defineSyncPack({\n\t\tname: '@absolutejs/sync-pack-counters',\n\t\townsTables: [],\n\t\treadsTables: [],\n\t\tversion: '0.1.0',\n\t\treactiveQueries\n\t});\n};\n"
6
+ ],
7
+ "mappings": ";;AAkCA;AAAA;AAAA;AAAA;AAoDA,IAAM,sBAAsB,CAC3B,UAEA,OAAO,UAAU,aAAa,EAAE,SAAS,MAAM,IAAI;AAkBpD,IAAM,MAAM,MAAM,KAAK,IAAI;AAOpB,IAAM,qBAAqB,CACjC,WACc;AAAA,EACd,MAAM,SAAS,OAAO,UAAU;AAAA,EAChC,MAAM,aAAc,OAAO,eACzB,CAAC,QACA,IAA4B;AAAA,EAI/B,MAAM,kBAAkB,OAAO,QAAQ,OAAO,QAAQ,EAAE,IACvD,EAAE,KAAK,WAAW;AAAA,IACjB,MAAM,UAAU,oBAAyB,KAAK;AAAA,IAC9C,MAAM,iBAAiB,GAAG,iBAAiB;AAAA,IAC3C,OAAO,oBAAyD;AAAA,MAC/D,MAAM;AAAA,MACN,KAAK,CAAC,QAAQ,IAAI;AAAA,MAClB,KAAK,SAAS,IAAI,UAAU;AAAA,QAC3B,MAAM,QAAQ,MAAM,QAAQ,QAAQ;AAAA,UACnC;AAAA,UACA;AAAA,QACD,CAAC;AAAA,QACD,OAAO,CAAC,EAAE,YAAY,IAAI,GAAG,IAAI,KAAK,KAAK,MAAM,CAAC;AAAA;AAAA,MAEnD,WAAW,CAAC,SAAS,QAAQ;AAAA,QAC5B,IAAI,QAAQ,cAAc,WAAW;AAAA,UACpC,OAAO,QAAQ,UAAU,GAAU;AAAA,QACpC;AAAA,QACA,OAAO,WAAW,GAAG,MAAM;AAAA;AAAA,IAE7B,CAAC;AAAA,GAEH;AAAA,EAEA,OAAO,eAAe;AAAA,IACrB,MAAM;AAAA,IACN,YAAY,CAAC;AAAA,IACb,aAAa,CAAC;AAAA,IACd,SAAS;AAAA,IACT;AAAA,EACD,CAAC;AAAA;",
8
+ "debugId": "E612E90E978ACBE464756E2164756E21",
9
+ "names": []
10
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@absolutejs/sync-pack-counters",
3
+ "version": "0.1.0",
4
+ "description": "Read-set-tracked live counters for @absolutejs/sync — define a compute function, get a reactive count derived from host tables",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/absolutejs/sync-packs.git",
8
+ "directory": "counters"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "type": "module",
14
+ "license": "CC BY-NC 4.0",
15
+ "author": "Alex Kahn",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "absolutejs",
21
+ "sync",
22
+ "sync-pack",
23
+ "counters",
24
+ "stats",
25
+ "reactive"
26
+ ],
27
+ "scripts": {
28
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=bun --external @absolutejs/sync && tsc --project tsconfig.build.json",
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit",
31
+ "format": "prettier --write \"./**/*.{ts,json,md}\"",
32
+ "release": "bun run format && bun run typecheck && bun run test && bun run build && bun publish"
33
+ },
34
+ "peerDependencies": {
35
+ "@absolutejs/sync": ">= 1.9.2"
36
+ },
37
+ "devDependencies": {
38
+ "@absolutejs/sync": "^1.9.2",
39
+ "@types/bun": "1.3.14",
40
+ "prettier": "3.5.3",
41
+ "typescript": "5.8.3"
42
+ },
43
+ "exports": {
44
+ ".": {
45
+ "types": "./dist/index.d.ts",
46
+ "import": "./dist/index.js",
47
+ "default": "./dist/index.js"
48
+ }
49
+ },
50
+ "files": ["dist", "README.md"]
51
+ }