@justair/justair-library 7.5.0-alpha.ed6e6e7 → 7.5.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.
@@ -5,4 +5,10 @@ export const DEVICE_TYPES: Readonly<{
5
5
  }>;
6
6
  export const DEVICE_TYPE_VALUES: readonly ("monitor" | "sample-site" | "linked-device")[];
7
7
  export function isAqiCapableDeviceType(deviceType: any): boolean;
8
+ /**
9
+ * The device-type vocabulary as a type, so consumers get autocomplete and a
10
+ * compile error on a mis-cased value like "sampleSite" — the exact bug the
11
+ * runtime comments in utils/subscribedDevices.js warn about at length.
12
+ */
13
+ export type DeviceType = (typeof DEVICE_TYPES)[keyof typeof DEVICE_TYPES];
8
14
  //# sourceMappingURL=deviceTypes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAStE,iEAEoC"}
1
+ {"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAiBtE,iEAEoC;;;;;;yBAZ9B,CAAA,OAAO,YAAY,EAAC,MAAM,OAAO,YAAY,CAAC"}
@@ -1,11 +1,12 @@
1
1
  export const SUBSCRIBED_DEVICES_ERRORS: Readonly<{
2
2
  MISSING_SITE_RESOLVER: "missing_site_resolver";
3
3
  RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array";
4
+ RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays";
4
5
  RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents";
5
6
  }>;
6
7
  export function projectSubscribedMonitors(subscribedDevices?: Array<{
7
8
  deviceId: import("mongoose").Types.ObjectId;
8
- deviceType: string;
9
+ deviceType: import("../constants/deviceTypes.js").DeviceType;
9
10
  }>, { resolveSiteMonitorIds }?: {
10
11
  resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
11
12
  }): Promise<import("mongoose").Types.ObjectId[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AA4EA;;;;GAIG;AAwII,8DALI,KAAK,CAAC;IAAE,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,8BAC1E;IAAE,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAC7E,OAAO,CAAC,KAAK,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,CAAA;CAAE,GAC9D,OAAO,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CA0BxD"}
1
+ {"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AAqFA;;;;;GAKG;AA4II,8DANI,KAAK,CAAC;IAAE,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;IAC1D,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;CAAE,CAAC,8BACzD;IAAE,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAC7E,OAAO,CAAC,KAAK,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,CAAA;CAAE,GAC9D,OAAO,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CA0BxD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.5.0-alpha.ed6e6e7",
3
+ "version": "7.5.0",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -32,6 +32,14 @@ export const DEVICE_TYPES = Object.freeze({
32
32
 
33
33
  export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
34
34
 
35
+ /**
36
+ * The device-type vocabulary as a type, so consumers get autocomplete and a
37
+ * compile error on a mis-cased value like "sampleSite" — the exact bug the
38
+ * runtime comments in utils/subscribedDevices.js warn about at length.
39
+ *
40
+ * @typedef {typeof DEVICE_TYPES[keyof typeof DEVICE_TYPES]} DeviceType
41
+ */
42
+
35
43
  // Whether a device of this type can ever report AQI, and therefore whether a
36
44
  // subscription to it can contribute to AQI alert delivery.
37
45
  //
package/src/index.js CHANGED
@@ -276,7 +276,8 @@ export {
276
276
  // Every consumer that writes a subscription recomputes the AQI audience with
277
277
  // this instead of re-implementing the per-device-type rule.
278
278
  // SUBSCRIBED_DEVICES_ERRORS is the stable `err.code` vocabulary for the
279
- // throw above; consumers branch on it rather than on message wording.
279
+ // errors projectSubscribedMonitors throws; consumers branch on it rather
280
+ // than on message wording.
280
281
  projectSubscribedMonitors,
281
282
  SUBSCRIBED_DEVICES_ERRORS,
282
283
  // Utilities
@@ -42,11 +42,20 @@
42
42
  // { deviceId, deviceType: "monitor" } entry per existing id), then
43
43
  // overwrite freely. This is the target state.
44
44
  // 2. Until that backfill has run for the user, union the projection with the
45
- // existing subscribedMonitors instead of overwriting. THIS IS
46
- // SUBSCRIBE-ONLY. A union can only ever add ids, so using it for an
47
- // unsubscribe makes the removal a silent no-op. There is deliberately no
48
- // third option: an unsubscribe on a user who predates subscribedDevices
49
- // MUST run the per-user backfill in option 1 first, and only then
45
+ // existing subscribedMonitors instead of overwriting — AND write the
46
+ // per-user backfill in the same update, one
47
+ // { deviceId, deviceType: "monitor" } entry per id already in
48
+ // subscribedMonitors, alongside the device being subscribed. Both halves
49
+ // are required. Unioning without backfilling leaves a user who looks
50
+ // migrated (subscribedDevices is non-empty) but is not, so the obvious
51
+ // "has this user been backfilled?" check answers yes and the next
52
+ // unsubscribe overwrites subscribedMonitors with a projection missing
53
+ // every monitor they had before. A half-migrated user must never exist.
54
+ //
55
+ // This path is also SUBSCRIBE-ONLY. A union can only ever add ids, so
56
+ // using it for an unsubscribe makes the removal a silent no-op. There is
57
+ // deliberately no third option: an unsubscribe on a user who predates
58
+ // subscribedDevices MUST be preceded by the backfill, and only then
50
59
  // overwrite. Removing a subscription is not correct any other way.
51
60
  //
52
61
  // This module deliberately does NOT offer a `union: true` flag. A blanket
@@ -77,6 +86,7 @@ import { DEVICE_TYPES } from "../constants/deviceTypes.js";
77
86
  export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
78
87
  MISSING_SITE_RESOLVER: "missing_site_resolver",
79
88
  RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
89
+ RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
80
90
  RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
81
91
  });
82
92
 
@@ -125,38 +135,6 @@ const linkedSiteIds = (entries) =>
125
135
  .filter((entry) => entry.deviceType === DEVICE_TYPES.LINKED_DEVICE)
126
136
  .map((entry) => entry.deviceId);
127
137
 
128
- // Returns the monitor ids that should be stored in Users.subscribedMonitors for
129
- // the given subscribedDevices array. Order is deterministic — direct `monitor`
130
- // subscriptions in array order, then Site-derived ids in resolver order, first
131
- // occurrence wins — but carries no meaning; treat the result as a set.
132
- //
133
- // `subscribedDevices` tolerates undefined/null: a user row written before this
134
- // field existed has no key at all on a `.lean()` read (pinned in
135
- // models/tests/users.test.js), and a projection over "no subscriptions" is an
136
- // empty audience, not a crash.
137
- //
138
- // `resolveSiteMonitorIds(siteIds)` is called at most once, with every
139
- // linked-device id at a time, and must resolve to the flat union of those
140
- // Sites' monitorIds — the ids themselves, not the Site documents:
141
- //
142
- // const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
143
- // return sites.flatMap((site) => site.monitorIds ?? []);
144
- //
145
- // Returning the documents is the easy mistake, so it is rejected rather than
146
- // merely documented: the return is validated and a document-shaped entry
147
- // throws RESOLVER_RETURNED_DOCUMENTS, as a non-array return throws
148
- // RESOLVER_RETURNED_NON_ARRAY. Left unchecked it would fail silently — each
149
- // Site doc casts to its own _id on write and subscribedMonitors fills with
150
- // Site ids, exactly the polymorphic id this field split exists to keep out.
151
- //
152
- // It is not called at all when nothing is subscribed to a linked-device, so a
153
- // consumer that only handles monitors pays no query. A resolver may return
154
- // nullish entries — Sites.monitorIds is `default: undefined`, so a sample-only
155
- // Site has no array — and they are filtered out here rather than trusted to
156
- // the caller.
157
- //
158
- // Read the PRECONDITION and STALENESS notes at the top of this file before
159
- // writing the result to Users.subscribedMonitors.
160
138
  // The resolver is consumer-supplied, so its return is the one input this
161
139
  // module cannot check at the type boundary. Validate it rather than trust it:
162
140
  // both bad shapes below would otherwise corrupt subscribedMonitors silently,
@@ -176,26 +154,29 @@ const validateResolvedSiteMonitorIds = (resolved) => {
176
154
  throw error;
177
155
  }
178
156
 
179
- // Discriminating a document from an id without importing mongoose: both
180
- // expose `_id`, because mongoose defines `_id` on ObjectId.prototype as a
181
- // self-reference (`oid._id === oid`). A document's `_id` is a *different*
182
- // object, so the self-reference is the test — checking merely for the
183
- // presence of `_id` would reject every ordinary ObjectId.
184
- // `.map` instead of `.flatMap` yields [[m1, m2]]. It is not caught by the
185
- // document check (an array is not a plain object) and fails inconsistently
186
- // downstream: one monitor per Site casts back to the id and silently works,
187
- // two throws a CastError at write time, far from the cause.
157
+ // `.map` instead of `.flatMap` yields [[m1, m2]]. An array is not a plain
158
+ // object, so the document check below never sees it, and it fails
159
+ // inconsistently downstream: one monitor per Site casts back to the id and
160
+ // silently works, two throws a CastError at write time, far from the cause.
188
161
  if (resolved.some(Array.isArray)) {
189
162
  const error = new TypeError(
190
163
  "resolveSiteMonitorIds must resolve to a flat array of monitor ids"
191
164
  );
192
- error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY;
165
+ error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS;
193
166
  throw error;
194
167
  }
195
168
 
169
+ // Discriminating a document from an id without importing mongoose: test for
170
+ // what only an id has. `toHexString` is on ObjectId (mongoose-patched or raw
171
+ // bson) and on nothing a Sites query returns, hydrated or lean.
172
+ //
173
+ // Deliberately not keyed on `_id`, which is wrong in both directions: every
174
+ // ObjectId appears to carry one (mongoose defines `_id` on ObjectId.prototype
175
+ // as a self-reference), and a lean read that projects it away
176
+ // (`Sites.find(..., "monitorIds -_id").lean()`) carries none.
196
177
  if (
197
178
  resolved.some(
198
- (id) => isPlainObject(id) && id._id !== undefined && id._id !== id
179
+ (id) => isPlainObject(id) && typeof id.toHexString !== "function"
199
180
  )
200
181
  ) {
201
182
  const error = new TypeError(
@@ -208,8 +189,41 @@ const validateResolvedSiteMonitorIds = (resolved) => {
208
189
  return resolved;
209
190
  };
210
191
 
192
+ // Returns the monitor ids that should be stored in Users.subscribedMonitors for
193
+ // the given subscribedDevices array. Order is deterministic — direct `monitor`
194
+ // subscriptions in array order, then Site-derived ids in resolver order, first
195
+ // occurrence wins — but carries no meaning; treat the result as a set.
196
+ //
197
+ // `subscribedDevices` tolerates undefined/null: a user row written before this
198
+ // field existed has no key at all on a `.lean()` read (pinned in
199
+ // models/tests/users.test.js), and a projection over "no subscriptions" is an
200
+ // empty audience, not a crash.
201
+ //
202
+ // `resolveSiteMonitorIds(siteIds)` is called at most once, with every
203
+ // linked-device id at a time, and must resolve to the flat union of those
204
+ // Sites' monitorIds — the ids themselves, not the Site documents:
205
+ //
206
+ // const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
207
+ // return sites.flatMap((site) => site.monitorIds ?? []);
208
+ //
209
+ // Returning the documents is the easy mistake, so it is rejected rather than
210
+ // merely documented: the return is validated and a document-shaped entry
211
+ // throws RESOLVER_RETURNED_DOCUMENTS, as a non-array return throws
212
+ // RESOLVER_RETURNED_NON_ARRAY. Left unchecked it would fail silently — each
213
+ // Site doc casts to its own _id on write and subscribedMonitors fills with
214
+ // Site ids, exactly the polymorphic id this field split exists to keep out.
215
+ //
216
+ // It is not called at all when nothing is subscribed to a linked-device, so a
217
+ // consumer that only handles monitors pays no query. A resolver may return
218
+ // nullish entries — Sites.monitorIds is `default: undefined`, so a sample-only
219
+ // Site has no array — and they are filtered out here rather than trusted to
220
+ // the caller.
221
+ //
222
+ // Read the PRECONDITION and STALENESS notes at the top of this file before
223
+ // writing the result to Users.subscribedMonitors.
211
224
  /**
212
- * @param {Array<{ deviceId: import("mongoose").Types.ObjectId, deviceType: string }>} [subscribedDevices]
225
+ * @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
226
+ * deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
213
227
  * @param {{ resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) =>
214
228
  * Promise<Array<import("mongoose").Types.ObjectId | null | undefined>> }} [options]
215
229
  * @returns {Promise<import("mongoose").Types.ObjectId[]>}
@@ -1,4 +1,9 @@
1
1
  import mongoose from 'mongoose';
2
+ import { Users } from '../../models/users.js';
3
+ import {
4
+ DEVICE_TYPE_VALUES,
5
+ isAqiCapableDeviceType,
6
+ } from '../../constants/deviceTypes.js';
2
7
  import {
3
8
  projectSubscribedMonitors,
4
9
  SUBSCRIBED_DEVICES_ERRORS,
@@ -140,15 +145,46 @@ describe('projectSubscribedMonitors', () => {
140
145
  resolveSiteMonitorIds: async () => [[oid(), oid()]],
141
146
  }),
142
147
  ).rejects.toMatchObject({
143
- code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY,
148
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS,
149
+ });
150
+ });
151
+
152
+ test('rejects a lean Site read that projected _id away', async () => {
153
+ // The `_id`-based discriminator missed this: `Sites.find(..., "monitorIds
154
+ // -_id").lean()` yields objects carrying no _id at all, which would
155
+ // otherwise key as "[object Object]" in dedupeById and land in
156
+ // subscribedMonitors as junk.
157
+ await expect(
158
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
159
+ resolveSiteMonitorIds: async () => [{ monitorIds: [oid()] }],
160
+ }),
161
+ ).rejects.toMatchObject({
162
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
163
+ });
164
+ });
165
+
166
+ test('rejects a hydrated Site document (the forgot-.lean() case)', async () => {
167
+ // The likeliest real-world shape of this mistake. A throwaway model keeps
168
+ // this file off the real Sites schema; hydration needs no connection.
169
+ const HydratedSite = mongoose.model(
170
+ 'SubscribedDevicesProbeSite',
171
+ new mongoose.Schema({ monitorIds: [mongoose.Schema.Types.ObjectId] }),
172
+ );
173
+
174
+ await expect(
175
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
176
+ resolveSiteMonitorIds: async () => [new HydratedSite({ monitorIds: [oid()] })],
177
+ }),
178
+ ).rejects.toMatchObject({
179
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
144
180
  });
145
181
  });
146
182
 
147
183
  test('does not mistake ordinary ObjectIds for documents', async () => {
148
- // Regression guard for the discriminator: mongoose defines `_id` on
149
- // ObjectId.prototype as a self-reference, so a naive "has _id" check
150
- // rejects every legitimate resolver result. Only a differing `_id` marks
151
- // an actual document.
184
+ // Regression guard for the discriminator: an id has `toHexString`, a Site
185
+ // document does not. Keying on `_id` instead gets it wrong both ways —
186
+ // mongoose defines `_id` on ObjectId.prototype as a self-reference, so a
187
+ // naive "has _id" check rejects every legitimate resolver result.
152
188
  const siteId = oid();
153
189
  const monitorId = oid();
154
190
 
@@ -331,3 +367,49 @@ describe('projectSubscribedMonitors', () => {
331
367
  expect(after).toEqual([shared]);
332
368
  });
333
369
  });
370
+
371
+ // Two encodings of "which device types can produce AQI" exist: the predicate
372
+ // isAqiCapableDeviceType, and the selectors inside projectSubscribedMonitors.
373
+ // Nothing structural keeps them in step, so a fourth type added to one and not
374
+ // the other would report as AQI-capable while contributing no audience — the
375
+ // silent failure this module exists to prevent. This pins them together.
376
+ // Every other case here passes plain object literals, but the real call site
377
+ // hands in user.subscribedDevices — a mongoose DocumentArray of subdocuments,
378
+ // which has to survive the isPlainObject filter. If it ever stopped doing so
379
+ // every entry would be dropped and the projection would return an empty
380
+ // audience with no error. Hydration needs no connection, so this stays DB-free.
381
+ describe('accepts real mongoose subdocuments, not just object literals', () => {
382
+ test('projects a hydrated user.subscribedDevices the same as plain objects', async () => {
383
+ const monitorId = oid();
384
+ const siteId = oid();
385
+ const siteMonitorId = oid();
386
+
387
+ const user = new Users({
388
+ subscribedDevices: [
389
+ { deviceId: monitorId, deviceType: 'monitor' },
390
+ { deviceId: siteId, deviceType: 'linked-device' },
391
+ { deviceId: oid(), deviceType: 'sample-site' },
392
+ ],
393
+ });
394
+
395
+ await expect(
396
+ projectSubscribedMonitors(user.subscribedDevices, {
397
+ resolveSiteMonitorIds: async () => [siteMonitorId],
398
+ }),
399
+ ).resolves.toEqual([monitorId, siteMonitorId]);
400
+ });
401
+ });
402
+
403
+ describe('isAqiCapableDeviceType agrees with the projection', () => {
404
+ test.each(DEVICE_TYPE_VALUES)('%s', async (deviceType) => {
405
+ const deviceId = oid();
406
+ const projected = await projectSubscribedMonitors(
407
+ [{ deviceId, deviceType }],
408
+ // Resolves as though the id were a Site linking exactly one monitor, so
409
+ // a linked-device yields an audience and a sample-site still yields none.
410
+ { resolveSiteMonitorIds: async () => [oid()] },
411
+ );
412
+
413
+ expect(projected.length > 0).toBe(isAqiCapableDeviceType(deviceType));
414
+ });
415
+ });