@luckystack/sync 0.9.2 → 0.10.1

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.
@@ -20,7 +20,7 @@ Every sync error — whether produced client-side before sending or server-side
20
20
  }
21
21
  ```
22
22
 
23
- `message` is **always** the localized string for `errorCode` (with `errorParams` interpolated). The default i18n catalog ships built-in keys for every framework code; project-defined codes use whatever the project i18n catalog defines.
23
+ `message` is **always** the localized string for `errorCode` (with `errorParams` interpolated). The framework itself ships no message catalog: the `sync.*` keys live in the scaffold's `src/_locales/*.json`, and `translate()` returns the raw key when a key is missing — so a code that is not in your locale files surfaces as `sync.someCode` in the UI rather than as a sentence. The canonical list of every `sync.*` code the framework can emit (both transports plus the server's HTTP sync route, e.g. `sync.disabled`, `sync.methodNotAllowed`, `sync.requestTimeout`) is exported as `SYNC_ERROR_CODES` (with the type `SyncErrorCode`) from both `@luckystack/sync` and `@luckystack/sync/client`. Write a parity test of your locale files against that array instead of retyping the tables below — the framework's own test keeps the scaffold locales in parity the same way. Project-defined codes use whatever the project catalog defines.
24
24
 
25
25
  ---
26
26
 
@@ -56,12 +56,14 @@ Emitted by `handleSyncRequest` / `handleHttpSyncRequest` to the originator's ack
56
56
  | `sync.notFound` | Neither `_server_v{N}` nor `_client_v{N}` exists for this route | `404` | — |
57
57
  | `auth.required` | `auth.login === true` on `_server` and no session resolved | `401` | — |
58
58
  | `auth.forbidden` (or route-specific code from `validateRequest`) | `validateRequest(auth.additional)` rejected | `403` (default; some predicates override) | predicate-defined |
59
+ | `sync.receiverNotAllowed` | `receiver: 'all'` requested while `sync.allowClientReceiverAll` is `false` | `403` | — |
60
+ | `sync.notRoomMember` | `sync.requireRoomMembership` is on and the requester is not a member of `receiver` — socket: physical membership of the originating socket; HTTP: logical membership from the session's `roomCodes` (+ its own token-room); an anonymous HTTP caller fails closed. See `/docs/ARCHITECTURE_SYNC.md` → "Room membership is transport-specific" | `403` | — |
59
61
  | `sync.rateLimitExceeded` | Per-route or per-IP rate-limit bucket rejected | `429` | `[{ key: 'seconds', value: <retry in> }]` |
60
62
  | `sync.invalidInputType` | `validateInputByType` rejected (Zod schema mismatch) | `400` | `[{ key: 'message', value: <readable reason> }]` |
61
63
  | `sync.serverExecutionFailed` | `_server`'s `main(...)` threw (caught by `tryCatch`) | `500` | — |
62
64
  | `<route-supplied>` (e.g. `board.cardNotFound`) | `_server` returned `{ status: 'error', errorCode }` | `defaultHttpStatusForResponse` or as-provided | as-provided |
63
65
  | `sync.invalidServerResponse` | `_server` returned a value with `status` other than `'success'` / `'error'` | `500` | — |
64
- | `sync.noReceiversFound` | Resolved sockets is `undefined` or empty | `404` | — |
66
+ | `sync.noReceiversFound` | The cross-instance recipient list for `receiver` is empty. Both transports since 0.10.0 (the HTTP path used to answer `success` with zero recipients). Recipients are resolved BEFORE `_server` runs, so nothing has been persisted — a retry is safe | `404` | — |
65
67
  | `<hook-supplied>` | `preSyncAuthorize` / `preSyncFanout` stop signal | from hook | from hook |
66
68
 
67
69
  The `preSyncAuthorize` and `preSyncFanout` hooks can stop with any `errorCode` they choose; the framework normalizes the message via `normalizeErrorResponse` using the originator's locale.
@@ -88,7 +90,7 @@ Recipients see these on their own `socketEventNames.sync` channel as if they wer
88
90
  `httpStatus` defaults via `defaultHttpStatusForResponse(errorCode)` in `@luckystack/core`. Default rules (paraphrased — see core for exact source):
89
91
 
90
92
  - `auth.required` -> `401`
91
- - `auth.forbidden` and similar role/policy codes -> `403`
93
+ - `auth.forbidden` and similar role/policy codes, plus the receiver-policy codes `sync.receiverNotAllowed` / `sync.notRoomMember` -> `403`
92
94
  - Validation failures (`*.invalidRequest`, `*.invalidInputType`, `*.invalidName`, `routing.invalidServiceRouteName`) -> `400`
93
95
  - Not-found (`*.notFound`, `sync.noReceiversFound`) -> `404`
94
96
  - Rate limit (`*.rateLimitExceeded`) -> `429`
@@ -237,7 +239,9 @@ These are dev hints, not production behavior. Toggle both to `false` for product
237
239
  | Server-side schema fail | `sync.invalidInputType` | `_server`'s `SyncParams.clientInput` interface + generated Zod schema |
238
240
  | Handler threw | `sync.serverExecutionFailed` / `sync.clientExecutionFailed` | Registered error-tracker capture |
239
241
  | Domain logic rejected | `<route-supplied errorCode>` | The `_server` or `_client` file's return path |
240
- | Empty room | `sync.noReceiversFound` | `receiver` argument; check room membership |
242
+ | Receiver rejected before `_server` ran (403) | `sync.receiverNotAllowed`, `sync.notRoomMember` | `projectConfig.sync.allowClientReceiverAll` / `requireRoomMembership`; on `routed-http` membership is the session's `roomCodes`, not the socket — `/docs/ARCHITECTURE_SYNC.md` |
243
+ | Empty room (both transports; before `_server` ran, nothing persisted) | `sync.noReceiversFound` | `receiver` argument; whether any socket actually (re)joined the room — [`./room-fanout.md`](./room-fanout.md) §8 |
244
+ | Recipient lookup failed (both transports; before `_server` ran, nothing persisted) | `sync.serverExecutionFailed` | The Redis adapter's `fetchSockets()` timed out: an instance on the same adapter key + Redis did not answer — `/docs/ARCHITECTURE_MULTI_INSTANCE.md` pitfalls (shared Redis across environments) |
241
245
  | Offline + caller didn't await | `offline.queueFull` | Bump `offlineQueue.maxSize` or switch `dropPolicy` |
242
246
 
243
247
  ---
@@ -249,5 +253,6 @@ These are dev hints, not production behavior. Toggle both to `false` for product
249
253
  - Rate limiting hook + buckets: [`./room-fanout.md`](./room-fanout.md) §5
250
254
  - Hook payload shapes: `@luckystack/core` `HookPayloads`
251
255
  - Status-code mapping source: `@luckystack/core` `defaultHttpStatusForResponse`
256
+ - Canonical code list for locale parity tests: `SYNC_ERROR_CODES` / `SyncErrorCode` (`@luckystack/sync` and `@luckystack/sync/client`)
252
257
  - Locale extraction: `@luckystack/core` `extractLanguageFromHeader`, `normalizeErrorResponse`
253
258
  - Project config: `projectConfig.rateLimiting`, `projectConfig.offlineQueue`, `projectConfig.logging`
@@ -4,20 +4,22 @@
4
4
 
5
5
  For the originator's `receiver` argument see [`./sync-request.md`](./sync-request.md). For per-recipient handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
6
6
 
7
+ > **Applies from `@luckystack/sync` 0.10.0.** Earlier versions resolved recipients from this process's own adapter maps, and only the socket transport rejected an empty room. Everything below describes the cross-instance model that replaced that.
8
+
7
9
  ---
8
10
 
9
11
  ## 1. How `receiver` resolves to sockets
10
12
 
11
- `handleSyncRequest` branches on `receiver`:
13
+ Both transports — `handleSyncRequest` (socket) and `handleHttpSyncRequest` (HTTP/SSE) — resolve recipients the same way, in the same order:
12
14
 
13
- ```ts
14
- const sockets = receiver === 'all'
15
- ? ioInstance.sockets.sockets // Map<socketId, Socket>
16
- : ioInstance.sockets.adapter.rooms.get(receiver); // Set<socketId> | undefined
17
- ```
15
+ 1. **Format the room.** The `receiver` string goes through the room-name formatter from `@luckystack/core` under the canonical `'broadcast'` purpose, so a non-identity `registerRoomNameFormatter` targets the same physical room the sockets joined (join formats the name too). `'all'` is a sentinel and is never formatted.
16
+ 2. **Enumerate across instances — before `_server` runs.** The framework asks Socket.io for the members of that physical room on **every** instance sharing the Redis adapter (`io.in(room).fetchSockets()`), or for every socket everywhere when the receiver is `'all'`. The result is a list of `RemoteSocket`s — never a lookup in this process's local room map. This happens after auth, rate-limit and the authorize hooks but **before** input validation and the `_server` handler, so nothing has been persisted yet when it fails.
17
+ 3. **Empty → `sync.noReceiversFound`.** An empty list ends the request with that error on **both** transports, before `_server` runs, before `preSyncFanout` fires and before any recipient is touched. See §8.
18
+ 4. `_server` (validate input, execute, persist), then `preSyncFanout`, then the per-recipient loop (§3).
18
19
 
19
- - `receiver === 'all'` -> every connected socket on **this instance** (production with Redis adapter: every socket on every instance, see §6).
20
- - Any other string -> the Socket.io room with that name. If the room does not exist or is empty, `sockets` is `undefined` and the fanout fails with `sync.noReceiversFound`.
20
+ Three consequences worth internalising: a recipient on another instance is a first-class recipient (its `_client` runs, see §7); "does the room exist locally" is never the question — the only question is whether the cross-instance query returned members; and an error from this step is a **pre-commit** rejection — the cross-instance query can fail (an adapter request timeout when an instance on the channel does not answer) and when that happened after `_server` had persisted its mutation the caller was told `error` about a change that had been saved, so a retry applied it twice. The cost of resolving first is a recipient snapshot taken one handler-execution earlier: a socket that joins the room while `_server` runs misses that one fan-out.
21
+
22
+ Whether the sender is *allowed* to target the room at all is a separate, earlier check (`sync.requireRoomMembership`) whose semantics deliberately differ per transport — see [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md) → "Room membership is transport-specific".
21
23
 
22
24
  There is no "broadcast to all but yourself" sentinel — pass `ignoreSelf: true` to skip the originator's own sockets. See [`./ignore-self.md`](./ignore-self.md).
23
25
 
@@ -52,6 +54,8 @@ preSyncFanout (stop signal aborts before any recipient is touched)
52
54
  for each socket in <resolved sockets>:
53
55
  yield every N (configurable)
54
56
  skip if ignoreSelf && token === recipientToken
57
+ preSyncRecipient (stop without overrideOutput skips this recipient, uncounted;
58
+ stop WITH overrideOutput replaces serverOutput for this recipient)
55
59
  recipientCount++
56
60
  if _client exists:
57
61
  run clientHandler
@@ -89,7 +93,7 @@ Tuning:
89
93
  - **Lower `fanoutYieldEvery` + higher `fanoutYieldMs`** = smoother for concurrent traffic but slower fanout.
90
94
  - For typical room sizes (<100 recipients), the yield never triggers — defaults are tuned for `receiver: 'all'` worst cases.
91
95
 
92
- This loop only exists on the socket path (`handleSyncRequest`). The HTTP path (`handleHttpSyncRequest`) does not yield — HTTP requests are inherently isolated per Node.js handler invocation, and the per-instance fanout sits inside a single async block anyway.
96
+ Both transports yield on the same schedule — the HTTP handler's loop reads the same two config keys. An HTTP-invoked fanout still runs on the event loop of the instance that received the request, so a giant `'all'` fanout would starve it just the same.
93
97
 
94
98
  ---
95
99
 
@@ -113,6 +117,18 @@ Fires **after** `_server` runs successfully and the recipient set is resolved, *
113
117
  - "Throttle fanout-heavy routes during peak load."
114
118
  - "Inject a cross-room replication hop before the room receives the payload."
115
119
 
120
+ ### `preSyncRecipient`
121
+
122
+ Fires once per resolved recipient, after the `ignoreSelf` skip and before that one socket receives anything. It carries the route name, the receiver, the recipient's socket id, the recipient's session token (`recipientToken`, `null` for an anonymous socket) and the `serverOutput` about to be sent. Exact shape: `HookPayloads` in `@luckystack/core`.
123
+
124
+ Semantics that differ from the other fanout hooks:
125
+
126
+ - A stop signal **without** `overrideOutput` skips just this recipient — the loop continues, and the skipped socket is not counted in `recipientCount`.
127
+ - A stop signal **with** `overrideOutput` still delivers to this recipient, but with the override in place of `serverOutput` — per-recipient redaction without a `_client` file.
128
+ - `recipientUserId` is **always `null`**. There is no resolver option: resolving a user per recipient would cost a session read per socket on the hot path. A handler that needs the user reads the session itself by `recipientToken` (the token is already known in the loop, so it costs nothing to expose).
129
+
130
+ Fires on both transports and for remote recipients too (§7).
131
+
116
132
  ### `postSyncFanout`
117
133
 
118
134
  ```ts
@@ -163,41 +179,49 @@ Fires before fanout begins, when either bucket rejects. Used to surface abusive
163
179
 
164
180
  ## 6. `recipientCount` vs raw room size
165
181
 
166
- `recipientCount` differs from `sockets.size` in three cases:
182
+ `recipientCount` differs from the size of the resolved recipient list in two cases, identically on both transports:
167
183
 
168
184
  1. **`ignoreSelf: true`** — every socket whose extracted token matches the sender's is skipped. If a user has 3 sockets in the room and triggered the sync themselves, `recipientCount` is `size - 3`.
169
- 2. **Sockets disappearing mid-fanout** — `ioInstance.sockets.sockets.get(socketId)` can return `undefined` between resolving the room set and emitting (the socket disconnected). Those iterations `continue` without bumping `recipientCount`.
170
- 3. **HTTP transport** — the HTTP path's `_client` execution `continue`s without bumping `recipientCount` only for per-recipient failures it could not recover from; the socket path always bumps for handled (success or error) recipients and only skips disappeared sockets.
185
+ 2. **`preSyncRecipient` stop without `overrideOutput`** — the recipient is skipped and not counted (§5).
171
186
 
172
- This is why the hook payload exposes `recipientCount` instead of a raw room size — observers want the count that actually saw the payload.
187
+ What does **not** reduce it:
188
+
189
+ - **Sockets disappearing mid-fanout.** The recipient list is a snapshot of `RemoteSocket`s taken once, before the loop. A socket that disconnects between the snapshot and its turn is still iterated: the loop emits to the remote handle, the adapter drops the emit because the target is gone, and the recipient is still counted. There is no per-iteration "does this socket still exist" re-check against a local map — that check would only be answerable for local sockets anyway.
190
+ - **A failing `_client`.** The recipient is counted before `_client` runs, so a recipient that received an error frame counts the same as one that received a success frame.
191
+
192
+ So `recipientCount` is "how many recipients the loop emitted to", not "how many received the payload" — the second number does not exist on the server side of an adapter. Observers wanting delivery confirmation need a client-side ack of their own.
173
193
 
174
194
  ---
175
195
 
176
196
  ## 7. Cross-instance fanout via Redis adapter
177
197
 
178
- Single-instance fanout is built into Socket.io. **Cross-instance fanout requires the Redis adapter.** Without it, a `broadcastStream` on instance A reaches sockets on instance A only; sockets on instance B (same room name, different Node process) get nothing.
179
-
180
- `@luckystack/server` wires the Redis adapter when `REDIS_URL` is set and the Socket.io Redis adapter peer is installed. With it:
198
+ Single-instance fanout is built into Socket.io. **Cross-instance fanout requires the Redis adapter**, which `@luckystack/server` attaches on every backend (see [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md)). Two mechanisms ride on it:
181
199
 
182
- - `io.to(roomCode).emit(...)` reaches every socket in the room **across every instance**.
183
- - `ioInstance.sockets.adapter.rooms.get(roomCode)` still only sees the local instance's members — the Redis adapter handles the cross-instance fanout transparently at the emit layer.
200
+ - `io.to(room).emit(...)` — used by the streaming emitters — publishes to Redis; every instance delivers to its own members of that room.
201
+ - `io.in(room).fetchSockets()` + `RemoteSocket.emit()` — used by the regular sync fanout — enumerates the room across all instances and delivers to each member individually.
184
202
 
185
- This means `handleSyncRequest`'s **per-recipient `_client` execution only runs against local sockets**. If you need a `_client` handler to execute on every recipient regardless of which instance they're connected to, either:
203
+ **The per-recipient `_client` handler runs for remote recipients too.** The fanout loop iterates the cross-instance list, runs `_client` on the instance that is handling the request (it has the route code, the `serverOutput`, and the recipient's handshake headers and token), and delivers the result through the recipient's `RemoteSocket`. No sticky sessions are needed for correctness; spreading one room's members across instances is fine. The cost model (one Redis round-trip per fanout plus one emit per remote recipient) is in [`/docs/ARCHITECTURE_MULTI_INSTANCE.md`](../../../docs/ARCHITECTURE_MULTI_INSTANCE.md).
186
204
 
187
- 1. Pin sticky sessions so a given room's sockets all land on the same instance (simplest), OR
188
- 2. Wire your own cross-instance per-recipient runner (rare, advanced).
205
+ > **Warning — `io.sockets.adapter.rooms`, `io.sockets.adapter.sids` and enumerating `io.sockets.sockets` are per-instance maps.** They answer "who is connected to *this process*", which under the Redis adapter is a partial view that looks complete: no error, just missing members. A helper that builds a room snapshot, a presence list or a broadcast target from them works on one instance and silently drops the rest of the cluster on two. Use instead:
206
+ >
207
+ > - `getRoomSockets(room, { userId? })` from `@luckystack/core` — routes the room through the formatter under `'broadcast'` and returns the cross-instance `RemoteSocket[]` (`'all'` = every socket everywhere). It throws when no Socket.io server is registered, because a silent empty list is exactly the failure this exists to prevent.
208
+ > - `io.in(room).fetchSockets()` / `io.to(room).emit()` directly when you already hold the physical room name.
209
+ >
210
+ > Three guards catch the mistake before production does: the ESLint rule `luckystack/no-local-socket-enumeration` in `@luckystack/core/eslint` flags the patterns statically; outside production `getIoInstance()` returns a guarded view that **throws** on those accessors (`sockets.sockets.get(id)` stays allowed); and `getIoInstance({ raw: true })` is the explicit opt-in for deliberate per-instance work — a sweep cleaning up its own connections, or sampling this process's backpressure. In production the raw instance is always returned, so the guard costs nothing on the hot path.
189
211
 
190
- For the common case (broadcast a single `serverOutput` to everyone in the room), the Redis adapter is sufficient. See [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md) for the adapter setup.
212
+ `recipientCount` in `postSyncFanout` counts recipients on every instance, not just local ones.
191
213
 
192
214
  ---
193
215
 
194
216
  ## 8. `sync.noReceiversFound`
195
217
 
196
- Triggered when the resolved `sockets` is `undefined` or falsy:
218
+ Triggered on **both transports** when the cross-instance recipient list (§1) comes back empty. Before 0.10.0 only the socket transport rejected this; the HTTP/SSE path returned `success` with zero recipients, so an HTTP caller could not tell "delivered" from "nobody there". Typical causes:
197
219
 
198
220
  - The room name was misspelled.
199
221
  - Every member already disconnected before the request reached the fanout step.
200
222
  - A bug had the client `joinRoom`-ing under a different name than the sender's `receiver` argument.
223
+ - A room-name formatter that folds the caller's `userId` into the physical name — join and fanout then land in different physical rooms (see the formatter contract in `packages/core/docs/socket-bootstrap.md`).
224
+ - On `routed-http`: the caller's session still lists the room in `roomCodes` (so the membership check passed) but no socket has re-joined it — see the membership section in [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md).
201
225
  - `receiver: 'all'` while no sockets are connected at all (development edge case).
202
226
 
203
227
  Surfaced to the originator as:
@@ -208,7 +232,7 @@ Surfaced to the originator as:
208
232
 
209
233
  Default `httpStatus` mapping for this code comes from `defaultHttpStatusForResponse` in `@luckystack/core`.
210
234
 
211
- This is not necessarily a bug — sending to an empty room is legal if the sender doesn't yet know the room is empty. UI typically treats `sync.noReceiversFound` as "your mutation succeeded server-side (the `_server` already ran and persisted state); just nobody happened to be listening". `_server`'s mutations are NOT rolled back when there are zero recipients.
235
+ This is not necessarily a bug — sending to an empty room is legal if the sender doesn't yet know the room is empty. Recipients are resolved **before** `_server` runs (§1), so on this error nothing has been persisted: the mutation did NOT happen, and a retry once someone is listening is safe. (Earlier versions ran the check after `_server`, so the UI had to read this error as "saved, but nobody was listening"; that reading is now wrong.)
212
236
 
213
237
  ---
214
238
 
@@ -156,16 +156,17 @@ incoming msg (Socket.io 'sync' event)
156
156
  validateRequest(auth.additional) -> auth.forbidden
157
157
  8. dispatchHook('preSyncAuthorize') -> stop signal becomes error envelope
158
158
  9. applySyncRateLimits() -> sync.rateLimitExceeded
159
- 10. If _server exists:
159
+ 10. Resolve recipients (cross-instance, both transports, BEFORE anything is persisted — see ./room-fanout.md §1):
160
+ receiver === 'all' -> every socket on every instance (fetchSockets())
161
+ otherwise -> formatRoomName(receiver, 'broadcast') -> io.in(room).fetchSockets()
162
+ fetchSockets throws -> sync.serverExecutionFailed (adapter request timeout; nothing persisted)
163
+ empty list -> sync.noReceiversFound (nothing persisted)
164
+ 11. If _server exists:
160
165
  validateInputByType(clientInput) -> sync.invalidInputType
161
166
  tryCatch(serverMain(...)) -> sync.serverExecutionFailed
162
167
  status !== 'success' | 'error' -> sync.invalidServerResponse
163
168
  status === 'error' -> server's errorCode (normalized)
164
169
  status === 'success' -> serverOutput = result (minus status)
165
- 11. Resolve recipients:
166
- receiver === 'all' -> io.sockets.sockets (Map of every connected socket)
167
- otherwise -> io.sockets.adapter.rooms.get(receiver) (Set of IDs)
168
- no sockets found -> sync.noReceiversFound
169
170
  12. dispatchHook('preSyncFanout') -> stop signal becomes error envelope
170
171
  13. Per-recipient loop (with periodic event-loop yield):
171
172
  if (ignoreSelf && token === recipientToken) continue
@@ -201,7 +202,8 @@ Per-recipient failures inside step 13 do **not** abort the fanout — every othe
201
202
  | Server execution | Thrown | `sync.serverExecutionFailed` | originator ack |
202
203
  | Server return | `status: 'error'` from `_server` | route-supplied `errorCode` | originator ack |
203
204
  | Server return | Anything other than `success`/`error` | `sync.invalidServerResponse` | originator ack |
204
- | Fanout | No sockets in room | `sync.noReceiversFound` | originator ack |
205
+ | Recipients (before `_server`) | No sockets in room | `sync.noReceiversFound` | originator ack |
206
+ | Recipients (before `_server`) | `fetchSockets()` threw (adapter request timeout) | `sync.serverExecutionFailed` | originator ack |
205
207
  | Fanout | `preSyncFanout` stop | hook's `errorCode` | originator ack |
206
208
  | Per-recipient | `_client` thrown | `sync.clientExecutionFailed` | that recipient only |
207
209
  | Per-recipient | `_client` returned `status: 'error'` | route code or `sync.clientRejected` | that recipient only |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/sync",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -60,8 +60,8 @@
60
60
  "test": "vitest run"
61
61
  },
62
62
  "dependencies": {
63
- "@luckystack/core": "^0.9.2",
64
- "@luckystack/error-tracking": "^0.9.2"
63
+ "@luckystack/core": "^0.10.1",
64
+ "@luckystack/error-tracking": "^0.10.1"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@prisma/client": "^6.19.0",