locusdb-client 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,91 @@
1
+ # locusdb-client
2
+
3
+ The Node.js client for [Locus](https://github.com/intenttext/locus). Any Redis
4
+ driver works against Locus for standard commands — this package adds the two
5
+ things a plain driver can't give you ergonomically:
6
+
7
+ 1. **Typed helpers** for the differentiator verbs (geo, sketches, CAS, secondary
8
+ index, cross-shard changefeed) so you get types and autocomplete instead of
9
+ stringly-typed `redis.call(...)`.
10
+ 2. **The reactive API** — a live **changefeed** and **geofence** delivered as
11
+ events. Locus streams these as a custom push protocol that `ioredis` doesn't
12
+ model, so the client owns a dedicated connection and parses the frames for you.
13
+
14
+ It wraps `ioredis` (exposed as `.redis`) — it does not reimplement RESP.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install locusdb-client ioredis
20
+ ```
21
+
22
+ ## Use
23
+
24
+ ```ts
25
+ import { LocusClient } from "locusdb-client";
26
+
27
+ const locus = new LocusClient({ host: "127.0.0.1", port: 6379 });
28
+
29
+ // Standard Redis: the raw ioredis connection is right there.
30
+ await locus.redis.set("hello", "world");
31
+
32
+ // Differentiator verbs, typed:
33
+ await locus.geoSet("driver:7", 13.36, 38.11, { status: "free" });
34
+ const hits = await locus.geoSearch({
35
+ fromLonLat: [13.4, 38.1],
36
+ byRadius: [50, "km"],
37
+ withDist: true,
38
+ where: { status: "free" }, // attribute filter
39
+ });
40
+ // hits -> [{ key: "driver:7", dist: 3.67 }]
41
+
42
+ await locus.bfAdd("seen", "msg-42"); // 1 new, 0 probably-duplicate
43
+ await locus.cas("flag", "old", "new"); // atomic check-and-set
44
+ await locus.idxCreate("by_status", "status");
45
+ await locus.idxGet("by_status", "paid"); // -> ["order:1"]
46
+ ```
47
+
48
+ ### Live changefeed (push)
49
+
50
+ ```ts
51
+ const feed = locus.changefeed("user:"); // optional key prefix
52
+ feed.on("ready", ({ count, offset }) => console.log("snapshot done", count));
53
+ feed.on("change", (c) => console.log(c.op, c.key, c.value)); // write | del | expire
54
+ // ...later
55
+ feed.close();
56
+ ```
57
+
58
+ ### Live geofencing
59
+
60
+ ```ts
61
+ const fence = locus.geofence(13.4, 38.1, 5, "km");
62
+ fence.on("enter", (m) => console.log("entered", m.key, m.value)); // "lon,lat"
63
+ fence.on("move", (m) => console.log("moved", m.key, m.value));
64
+ fence.on("leave", (m) => console.log("left", m.key));
65
+ ```
66
+
67
+ Both run on their own connection; the snapshot (`"snapshot"` events, then
68
+ `"ready"`) precedes live updates, so there's no gap or duplicate.
69
+
70
+ ### Cross-shard changefeed (clustered)
71
+
72
+ ```ts
73
+ let since = 0;
74
+ for (;;) {
75
+ const batch = await locus.clusterCdcMerge(since, 100); // global HLC order
76
+ for (const c of batch) { handle(c); since = c.hlc; }
77
+ if (!batch.length) await new Promise((r) => setTimeout(r, 250));
78
+ }
79
+ ```
80
+
81
+ ## Notes
82
+
83
+ - Pull-style changefeed (`cdcRead`) and consumer groups work over the normal
84
+ connection and need server retention (`LOCUS_CDC_MAXLEN`). The push API above
85
+ does not.
86
+ - For TLS, pass ioredis's `tls` option; the subscription connection honors it too.
87
+ - See the [changefeed](../../docs/CHANGEFEED.md) and [geo](../../docs/GEO.md) docs
88
+ for semantics, and [COMMANDS.md](../../docs/COMMANDS.md) for the full surface.
89
+
90
+ Run the example against a local server: `node examples/quickstart.cjs` (after
91
+ `npm run build`).
@@ -0,0 +1,62 @@
1
+ import { EventEmitter } from "node:events";
2
+ import net from "node:net";
3
+ /** Connection details for a dedicated subscription socket. */
4
+ export interface SubscribeOptions {
5
+ host: string;
6
+ port: number;
7
+ password?: string;
8
+ tls?: boolean;
9
+ }
10
+ /** One keyspace change delivered live. `value` is the new string for writes, null otherwise. */
11
+ export interface Change {
12
+ offset: number;
13
+ op: "write" | "del" | "expire" | string;
14
+ key: string;
15
+ value: string | null;
16
+ }
17
+ /** A geofence membership event. */
18
+ export interface GeoEvent {
19
+ key: string;
20
+ value: string | null;
21
+ }
22
+ /**
23
+ * A live changefeed subscription on its own connection (Locus pushes the
24
+ * snapshot then live frames; a plain Redis driver can't model this custom push
25
+ * command, so we own the socket and parse the frames ourselves).
26
+ *
27
+ * Events:
28
+ * - `"snapshot"` ({@link GeoEvent}) one per key in the initial atomic snapshot
29
+ * - `"ready"` ({ count, offset }) snapshot complete; live changes follow
30
+ * - `"change"` ({@link Change}) a live keyspace change
31
+ * - `"error"` (Error) socket error
32
+ * - `"close"` () connection closed
33
+ */
34
+ export declare class Changefeed extends EventEmitter {
35
+ private readonly opts;
36
+ private readonly command;
37
+ protected socket?: net.Socket;
38
+ private reader;
39
+ private closed;
40
+ constructor(opts: SubscribeOptions, command: string[]);
41
+ private connect;
42
+ private write;
43
+ private onConnect;
44
+ private dispatch;
45
+ /** Close the subscription connection and drop all listeners. Destroys the
46
+ * socket so it releases the event loop (a graceful end() can hang waiting on
47
+ * the server). */
48
+ close(): void;
49
+ }
50
+ /**
51
+ * A live geofence: subscribe to a circular region and get membership
52
+ * transitions. Tracks members so it can distinguish first entry from movement.
53
+ *
54
+ * Events (in addition to the base {@link Changefeed} events):
55
+ * - `"enter"` ({@link GeoEvent}) a key entered the region
56
+ * - `"move"` ({@link GeoEvent}) a key already inside moved
57
+ * - `"leave"` ({@link GeoEvent}) a key left the region
58
+ */
59
+ export declare class Geofence extends Changefeed {
60
+ private readonly members;
61
+ constructor(opts: SubscribeOptions, lon: number, lat: number, radius: number, unit?: string);
62
+ }
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Geofence = exports.Changefeed = void 0;
7
+ const node_events_1 = require("node:events");
8
+ const node_net_1 = __importDefault(require("node:net"));
9
+ const node_tls_1 = __importDefault(require("node:tls"));
10
+ const resp_1 = require("./resp");
11
+ /**
12
+ * A live changefeed subscription on its own connection (Locus pushes the
13
+ * snapshot then live frames; a plain Redis driver can't model this custom push
14
+ * command, so we own the socket and parse the frames ourselves).
15
+ *
16
+ * Events:
17
+ * - `"snapshot"` ({@link GeoEvent}) one per key in the initial atomic snapshot
18
+ * - `"ready"` ({ count, offset }) snapshot complete; live changes follow
19
+ * - `"change"` ({@link Change}) a live keyspace change
20
+ * - `"error"` (Error) socket error
21
+ * - `"close"` () connection closed
22
+ */
23
+ class Changefeed extends node_events_1.EventEmitter {
24
+ constructor(opts, command) {
25
+ super();
26
+ this.opts = opts;
27
+ this.command = command;
28
+ this.reader = new resp_1.RespReader();
29
+ this.closed = false;
30
+ this.connect();
31
+ }
32
+ connect() {
33
+ const { host, port } = this.opts;
34
+ const sock = this.opts.tls
35
+ ? node_tls_1.default.connect({ host, port, rejectUnauthorized: false })
36
+ : node_net_1.default.connect({ host, port });
37
+ this.socket = sock;
38
+ const ready = () => this.onConnect();
39
+ sock.once(this.opts.tls ? "secureConnect" : "connect", ready);
40
+ sock.on("data", (d) => {
41
+ for (const frame of this.reader.push(d))
42
+ this.dispatch(frame);
43
+ });
44
+ sock.on("error", (e) => this.emit("error", e));
45
+ sock.on("close", () => {
46
+ if (!this.closed)
47
+ this.emit("close");
48
+ });
49
+ }
50
+ write(args) {
51
+ let out = `*${args.length}\r\n`;
52
+ for (const a of args)
53
+ out += `$${Buffer.byteLength(a)}\r\n${a}\r\n`;
54
+ this.socket?.write(out);
55
+ }
56
+ onConnect() {
57
+ if (this.opts.password)
58
+ this.write(["AUTH", this.opts.password]);
59
+ this.write(this.command);
60
+ }
61
+ dispatch(frame) {
62
+ if (!Array.isArray(frame) || typeof frame[0] !== "string")
63
+ return; // +OK etc.
64
+ switch (frame[0]) {
65
+ case "cdc-snapshot":
66
+ this.emit("snapshot", {
67
+ key: String(frame[1]),
68
+ value: (frame[2] ?? null),
69
+ });
70
+ break;
71
+ case "cdc-snapshot-done":
72
+ this.emit("ready", { count: Number(frame[1]), offset: Number(frame[2]) });
73
+ break;
74
+ case "cdc-change":
75
+ this.emit("change", {
76
+ offset: Number(frame[1]),
77
+ op: String(frame[2]),
78
+ key: String(frame[3]),
79
+ value: (frame[4] ?? null),
80
+ });
81
+ break;
82
+ }
83
+ }
84
+ /** Close the subscription connection and drop all listeners. Destroys the
85
+ * socket so it releases the event loop (a graceful end() can hang waiting on
86
+ * the server). */
87
+ close() {
88
+ this.closed = true;
89
+ this.socket?.destroy();
90
+ this.removeAllListeners();
91
+ }
92
+ }
93
+ exports.Changefeed = Changefeed;
94
+ /**
95
+ * A live geofence: subscribe to a circular region and get membership
96
+ * transitions. Tracks members so it can distinguish first entry from movement.
97
+ *
98
+ * Events (in addition to the base {@link Changefeed} events):
99
+ * - `"enter"` ({@link GeoEvent}) a key entered the region
100
+ * - `"move"` ({@link GeoEvent}) a key already inside moved
101
+ * - `"leave"` ({@link GeoEvent}) a key left the region
102
+ */
103
+ class Geofence extends Changefeed {
104
+ constructor(opts, lon, lat, radius, unit = "km") {
105
+ super(opts, [
106
+ "CDCSUBSCRIBE",
107
+ "REGION",
108
+ String(lon),
109
+ String(lat),
110
+ String(radius),
111
+ unit,
112
+ ]);
113
+ this.members = new Set();
114
+ this.on("snapshot", (s) => this.members.add(s.key));
115
+ this.on("change", (c) => {
116
+ if (c.op === "del") {
117
+ this.members.delete(c.key);
118
+ this.emit("leave", { key: c.key, value: null });
119
+ }
120
+ else {
121
+ const inside = this.members.has(c.key);
122
+ this.members.add(c.key);
123
+ this.emit(inside ? "move" : "enter", { key: c.key, value: c.value });
124
+ }
125
+ });
126
+ }
127
+ }
128
+ exports.Geofence = Geofence;
@@ -0,0 +1,81 @@
1
+ import Redis, { RedisOptions } from "ioredis";
2
+ import { Change, Changefeed, Geofence } from "./changefeed";
3
+ export type LocusOptions = RedisOptions;
4
+ /** A `GEOSEARCH` query. Provide exactly one origin and exactly one shape. */
5
+ export interface GeoSearchQuery {
6
+ fromLonLat?: [number, number];
7
+ fromMember?: string;
8
+ byRadius?: [number, GeoUnit];
9
+ byBox?: [number, number, GeoUnit];
10
+ order?: "ASC" | "DESC";
11
+ count?: number;
12
+ withCoord?: boolean;
13
+ withDist?: boolean;
14
+ /** Inline attribute equality filters (AND), e.g. `{ status: "free" }`. */
15
+ where?: Record<string, string>;
16
+ }
17
+ export type GeoUnit = "m" | "km" | "mi" | "ft";
18
+ /** One `GEOSEARCH` hit; `dist`/`coord` are present only if requested. */
19
+ export interface GeoHit {
20
+ key: string;
21
+ dist?: number;
22
+ coord?: [number, number];
23
+ }
24
+ /**
25
+ * A Locus client. Wraps `ioredis` for every request/reply command (standard
26
+ * Redis ops via `.redis`, the differentiator verbs via the typed helpers here)
27
+ * and adds the reactive APIs — {@link changefeed} and {@link geofence} — that a
28
+ * plain Redis driver can't surface.
29
+ */
30
+ export declare class LocusClient {
31
+ /** The underlying ioredis connection — use it for any standard Redis command. */
32
+ readonly redis: Redis;
33
+ private readonly sub;
34
+ constructor(options?: LocusOptions);
35
+ /** Store a geo point, with optional inline attributes for `where` filtering. */
36
+ geoSet(key: string, lon: number, lat: number, attrs?: Record<string, string>): Promise<unknown>;
37
+ /** `[lon, lat]` for a key, or null if absent. */
38
+ geoPos(key: string): Promise<[number, number] | null>;
39
+ /** Distance between two geo keys in `unit` (default meters). */
40
+ geoDist(a: string, b: string, unit?: GeoUnit): Promise<number | null>;
41
+ /** Spatial search; returns parsed hits (cluster-aware — the server merges shards). */
42
+ geoSearch(q: GeoSearchQuery): Promise<GeoHit[]>;
43
+ /** The cell hashtag for a point — name geo keys `{cell}id` for bounded cluster search. */
44
+ cell(lon: number, lat: number): Promise<string>;
45
+ /** Bloom add — resolves 1 if newly added, 0 if probably already present. */
46
+ bfAdd(key: string, item: string): Promise<number>;
47
+ bfExists(key: string, item: string): Promise<number>;
48
+ cmsIncrBy(key: string, item: string, by: number): Promise<unknown>;
49
+ cmsQuery(key: string, item: string): Promise<number>;
50
+ topkAdd(key: string, ...items: string[]): Promise<unknown>;
51
+ topkList(key: string): Promise<string[]>;
52
+ tdAdd(key: string, ...values: number[]): Promise<unknown>;
53
+ tdQuantile(key: string, ...quantiles: number[]): Promise<number[]>;
54
+ /** Set `key` to `next` only if it currently equals `expected`. 1 on swap. */
55
+ cas(key: string, expected: string, next: string): Promise<number>;
56
+ caDel(key: string, expected: string): Promise<number>;
57
+ setMax(key: string, value: number): Promise<unknown>;
58
+ incrCap(key: string, by: number, cap: number): Promise<unknown>;
59
+ idxCreate(name: string, field: string): Promise<unknown>;
60
+ idxDrop(name: string): Promise<unknown>;
61
+ idxGet(name: string, value: string): Promise<string[]>;
62
+ idxRange(name: string, min: string, max: string, count?: number): Promise<string[]>;
63
+ /** Read changes after `offset` (needs `LOCUS_CDC_MAXLEN` retention on the server). */
64
+ cdcRead(offset: number, opts?: {
65
+ count?: number;
66
+ prefix?: string;
67
+ }): Promise<Change[]>;
68
+ /** Global, HLC-ordered changefeed merged across all shards. Advance `sinceHlc`. */
69
+ clusterCdcMerge(sinceHlc?: number, count?: number): Promise<Array<{
70
+ hlc: number;
71
+ op: string;
72
+ key: string;
73
+ value: string | null;
74
+ }>>;
75
+ /** Live changefeed over a dedicated connection. Pass a key prefix to filter. */
76
+ changefeed(prefix?: string): Changefeed;
77
+ /** Live geofence over a dedicated connection — emits enter/move/leave. */
78
+ geofence(lon: number, lat: number, radius: number, unit?: GeoUnit): Geofence;
79
+ /** Close the underlying ioredis connection. */
80
+ quit(): Promise<"OK">;
81
+ }
package/dist/client.js ADDED
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LocusClient = void 0;
7
+ const ioredis_1 = __importDefault(require("ioredis"));
8
+ const changefeed_1 = require("./changefeed");
9
+ /**
10
+ * A Locus client. Wraps `ioredis` for every request/reply command (standard
11
+ * Redis ops via `.redis`, the differentiator verbs via the typed helpers here)
12
+ * and adds the reactive APIs — {@link changefeed} and {@link geofence} — that a
13
+ * plain Redis driver can't surface.
14
+ */
15
+ class LocusClient {
16
+ constructor(options = {}) {
17
+ this.redis = new ioredis_1.default(options);
18
+ this.sub = {
19
+ host: options.host ?? "127.0.0.1",
20
+ port: options.port ?? 6379,
21
+ password: typeof options.password === "string" ? options.password : undefined,
22
+ tls: !!options.tls,
23
+ };
24
+ }
25
+ // ---- geo ----------------------------------------------------------------
26
+ /** Store a geo point, with optional inline attributes for `where` filtering. */
27
+ geoSet(key, lon, lat, attrs) {
28
+ const extra = attrs ? Object.entries(attrs).flat() : [];
29
+ return this.redis.call("GEOSET", key, String(lon), String(lat), ...extra);
30
+ }
31
+ /** `[lon, lat]` for a key, or null if absent. */
32
+ async geoPos(key) {
33
+ const r = (await this.redis.call("GEOPOS", key));
34
+ return r && r.length >= 2 ? [Number(r[0]), Number(r[1])] : null;
35
+ }
36
+ /** Distance between two geo keys in `unit` (default meters). */
37
+ async geoDist(a, b, unit = "m") {
38
+ const r = (await this.redis.call("GEODIST", a, b, unit));
39
+ return r == null ? null : Number(r);
40
+ }
41
+ /** Spatial search; returns parsed hits (cluster-aware — the server merges shards). */
42
+ async geoSearch(q) {
43
+ const args = [];
44
+ if (q.fromLonLat)
45
+ args.push("FROMLONLAT", String(q.fromLonLat[0]), String(q.fromLonLat[1]));
46
+ if (q.fromMember)
47
+ args.push("FROMMEMBER", q.fromMember);
48
+ if (q.byRadius)
49
+ args.push("BYRADIUS", String(q.byRadius[0]), q.byRadius[1]);
50
+ if (q.byBox)
51
+ args.push("BYBOX", String(q.byBox[0]), String(q.byBox[1]), q.byBox[2]);
52
+ if (q.order)
53
+ args.push(q.order);
54
+ if (q.count != null)
55
+ args.push("COUNT", String(q.count));
56
+ if (q.withCoord)
57
+ args.push("WITHCOORD");
58
+ if (q.withDist)
59
+ args.push("WITHDIST");
60
+ if (q.where)
61
+ for (const [f, v] of Object.entries(q.where))
62
+ args.push("WHERE", f, v);
63
+ const raw = (await this.redis.call("GEOSEARCH", ...args));
64
+ if (!q.withCoord && !q.withDist) {
65
+ return raw.map((key) => ({ key }));
66
+ }
67
+ return raw.map((row) => {
68
+ const hit = { key: String(row[0]) };
69
+ let i = 1;
70
+ if (q.withDist)
71
+ hit.dist = Number(row[i++]);
72
+ if (q.withCoord) {
73
+ const c = row[i++];
74
+ hit.coord = [Number(c[0]), Number(c[1])];
75
+ }
76
+ return hit;
77
+ });
78
+ }
79
+ /** The cell hashtag for a point — name geo keys `{cell}id` for bounded cluster search. */
80
+ async cell(lon, lat) {
81
+ return String(await this.redis.call("CLUSTER", "CELL", String(lon), String(lat)));
82
+ }
83
+ // ---- sketches -----------------------------------------------------------
84
+ /** Bloom add — resolves 1 if newly added, 0 if probably already present. */
85
+ bfAdd(key, item) {
86
+ return this.redis.call("BFADD", key, item);
87
+ }
88
+ bfExists(key, item) {
89
+ return this.redis.call("BFEXISTS", key, item);
90
+ }
91
+ cmsIncrBy(key, item, by) {
92
+ return this.redis.call("CMSINCRBY", key, item, String(by));
93
+ }
94
+ async cmsQuery(key, item) {
95
+ return Number(await this.redis.call("CMSQUERY", key, item));
96
+ }
97
+ topkAdd(key, ...items) {
98
+ return this.redis.call("TOPKADD", key, ...items);
99
+ }
100
+ topkList(key) {
101
+ return this.redis.call("TOPKLIST", key);
102
+ }
103
+ tdAdd(key, ...values) {
104
+ return this.redis.call("TDADD", key, ...values.map(String));
105
+ }
106
+ async tdQuantile(key, ...quantiles) {
107
+ const r = (await this.redis.call("TDQUANTILE", key, ...quantiles.map(String)));
108
+ return r.map(Number);
109
+ }
110
+ // ---- conditional writes (CAS) ------------------------------------------
111
+ /** Set `key` to `next` only if it currently equals `expected`. 1 on swap. */
112
+ cas(key, expected, next) {
113
+ return this.redis.call("CAS", key, expected, next);
114
+ }
115
+ caDel(key, expected) {
116
+ return this.redis.call("CADEL", key, expected);
117
+ }
118
+ setMax(key, value) {
119
+ return this.redis.call("SETMAX", key, String(value));
120
+ }
121
+ incrCap(key, by, cap) {
122
+ return this.redis.call("INCRCAP", key, String(by), String(cap));
123
+ }
124
+ // ---- secondary index ----------------------------------------------------
125
+ idxCreate(name, field) {
126
+ return this.redis.call("IDXCREATE", name, field);
127
+ }
128
+ idxDrop(name) {
129
+ return this.redis.call("IDXDROP", name);
130
+ }
131
+ idxGet(name, value) {
132
+ return this.redis.call("IDXGET", name, value);
133
+ }
134
+ idxRange(name, min, max, count) {
135
+ const extra = count != null ? ["COUNT", String(count)] : [];
136
+ return this.redis.call("IDXRANGE", name, min, max, ...extra);
137
+ }
138
+ // ---- changefeed: pull ---------------------------------------------------
139
+ /** Read changes after `offset` (needs `LOCUS_CDC_MAXLEN` retention on the server). */
140
+ async cdcRead(offset, opts = {}) {
141
+ const args = [String(offset)];
142
+ if (opts.count != null)
143
+ args.push("COUNT", String(opts.count));
144
+ if (opts.prefix != null)
145
+ args.push("PREFIX", opts.prefix);
146
+ const raw = (await this.redis.call("CDCREAD", ...args));
147
+ return raw.map((r) => ({
148
+ offset: Number(r[0]),
149
+ op: String(r[1]),
150
+ key: String(r[2]),
151
+ value: (r[3] ?? null),
152
+ }));
153
+ }
154
+ // ---- cluster: cross-shard changefeed -----------------------------------
155
+ /** Global, HLC-ordered changefeed merged across all shards. Advance `sinceHlc`. */
156
+ async clusterCdcMerge(sinceHlc = 0, count) {
157
+ const extra = count != null ? ["COUNT", String(count)] : [];
158
+ const raw = (await this.redis.call("CLUSTER", "CDCMERGE", String(sinceHlc), ...extra));
159
+ return raw.map((r) => ({
160
+ hlc: Number(r[0]),
161
+ op: String(r[1]),
162
+ key: String(r[2]),
163
+ value: (r[3] ?? null),
164
+ }));
165
+ }
166
+ // ---- reactive (the part a plain driver can't do) ------------------------
167
+ /** Live changefeed over a dedicated connection. Pass a key prefix to filter. */
168
+ changefeed(prefix) {
169
+ return new changefeed_1.Changefeed(this.sub, prefix ? ["CDCSUBSCRIBE", prefix] : ["CDCSUBSCRIBE"]);
170
+ }
171
+ /** Live geofence over a dedicated connection — emits enter/move/leave. */
172
+ geofence(lon, lat, radius, unit = "km") {
173
+ return new changefeed_1.Geofence(this.sub, lon, lat, radius, unit);
174
+ }
175
+ /** Close the underlying ioredis connection. */
176
+ quit() {
177
+ return this.redis.quit();
178
+ }
179
+ }
180
+ exports.LocusClient = LocusClient;
@@ -0,0 +1,6 @@
1
+ export { LocusClient } from "./client";
2
+ export type { LocusOptions, GeoSearchQuery, GeoUnit, GeoHit, } from "./client";
3
+ export { Changefeed, Geofence } from "./changefeed";
4
+ export type { SubscribeOptions, Change, GeoEvent } from "./changefeed";
5
+ export { RespReader } from "./resp";
6
+ export type { RespValue } from "./resp";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RespReader = exports.Geofence = exports.Changefeed = exports.LocusClient = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "LocusClient", { enumerable: true, get: function () { return client_1.LocusClient; } });
6
+ var changefeed_1 = require("./changefeed");
7
+ Object.defineProperty(exports, "Changefeed", { enumerable: true, get: function () { return changefeed_1.Changefeed; } });
8
+ Object.defineProperty(exports, "Geofence", { enumerable: true, get: function () { return changefeed_1.Geofence; } });
9
+ var resp_1 = require("./resp");
10
+ Object.defineProperty(exports, "RespReader", { enumerable: true, get: function () { return resp_1.RespReader; } });
package/dist/resp.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type RespValue = string | number | null | RespValue[];
2
+ export declare class RespReader {
3
+ private buf;
4
+ /** Feed received bytes; return every complete top-level value now available. */
5
+ push(chunk: Buffer): RespValue[];
6
+ private parse;
7
+ private readLine;
8
+ }
package/dist/resp.js ADDED
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ // A minimal, incremental RESP reader — just enough to consume the changefeed
3
+ // push stream on a dedicated connection. The full request/reply path uses
4
+ // ioredis; this exists only because ioredis can't surface Locus's custom push
5
+ // frames (CDCSUBSCRIBE), which arrive as arrays of bulk strings out-of-band.
6
+ //
7
+ // Handles the frame types Locus can send on a subscription: simple string (+),
8
+ // error (-), integer (:), bulk string ($), array (*), RESP3 push (>), and null.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.RespReader = void 0;
11
+ const CR = 0x0d;
12
+ class RespReader {
13
+ constructor() {
14
+ this.buf = Buffer.alloc(0);
15
+ }
16
+ /** Feed received bytes; return every complete top-level value now available. */
17
+ push(chunk) {
18
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
19
+ const out = [];
20
+ for (;;) {
21
+ const r = this.parse(0);
22
+ if (!r)
23
+ break; // incomplete — wait for more bytes
24
+ out.push(r.value);
25
+ this.buf = this.buf.subarray(r.next);
26
+ }
27
+ return out;
28
+ }
29
+ // Parse one value starting at absolute offset `i`. Returns the value and the
30
+ // offset just past it, or null if the buffer doesn't hold a complete value yet.
31
+ parse(i) {
32
+ if (i >= this.buf.length)
33
+ return null;
34
+ const type = this.buf[i];
35
+ const line = this.readLine(i + 1);
36
+ if (!line)
37
+ return null;
38
+ const { text, next } = line;
39
+ switch (type) {
40
+ case 0x2b: // '+' simple string
41
+ case 0x2d: // '-' error (surfaced as a string; callers ignore handshake noise)
42
+ return { value: text, next };
43
+ case 0x3a: // ':' integer
44
+ return { value: Number(text), next };
45
+ case 0x24: {
46
+ // '$' bulk string
47
+ const len = Number(text);
48
+ if (len < 0)
49
+ return { value: null, next };
50
+ const end = next + len;
51
+ if (end + 2 > this.buf.length)
52
+ return null; // payload + CRLF not all here
53
+ return { value: this.buf.toString('utf8', next, end), next: end + 2 };
54
+ }
55
+ case 0x2a: // '*' array
56
+ case 0x3e: {
57
+ // '>' RESP3 push (same framing as an array)
58
+ const len = Number(text);
59
+ if (len < 0)
60
+ return { value: null, next };
61
+ const arr = [];
62
+ let pos = next;
63
+ for (let k = 0; k < len; k++) {
64
+ const el = this.parse(pos);
65
+ if (!el)
66
+ return null; // a nested element is incomplete
67
+ arr.push(el.value);
68
+ pos = el.next;
69
+ }
70
+ return { value: arr, next: pos };
71
+ }
72
+ case 0x5f: // '_' RESP3 null
73
+ return { value: null, next };
74
+ default:
75
+ // Inline/unknown line — return as text so we never stall the stream.
76
+ return { value: text, next };
77
+ }
78
+ }
79
+ readLine(i) {
80
+ const cr = this.buf.indexOf(CR, i);
81
+ if (cr < 0 || cr + 1 >= this.buf.length)
82
+ return null; // need the '\n' too
83
+ return { text: this.buf.toString('utf8', i, cr), next: cr + 2 };
84
+ }
85
+ }
86
+ exports.RespReader = RespReader;
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "locusdb-client",
3
+ "version": "0.1.0",
4
+ "description": "Node.js client for Locus — typed access to the differentiator verbs (geo, sketches, CAS, secondary index) plus the reactive changefeed and live geofencing that a plain Redis driver can't surface.",
5
+ "keywords": ["locus", "locusdb", "redis", "changefeed", "cdc", "geo", "geofence"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/intenttext/locus",
10
+ "directory": "clients/node"
11
+ },
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "files": ["dist", "README.md"],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {
23
+ "ioredis": "^5.4.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.0.0",
27
+ "typescript": "^5.4.0"
28
+ }
29
+ }