@justair/justair-library 7.5.0-alpha.c87c244 → 7.5.0-alpha.d0806df
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/dist/constants/deviceTypes.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/deviceTypes.js +7 -7
- package/src/index.js +2 -1
- package/src/utils/subscribedDevices.js +27 -15
- package/src/utils/tests/subscribedDevices.test.js +63 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAiBtE,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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"
|
|
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
|
@@ -32,13 +32,6 @@ export const DEVICE_TYPES = Object.freeze({
|
|
|
32
32
|
|
|
33
33
|
export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
|
|
34
34
|
|
|
35
|
-
// Whether a device of this type can ever report AQI, and therefore whether a
|
|
36
|
-
// subscription to it can contribute to AQI alert delivery.
|
|
37
|
-
//
|
|
38
|
-
// Note the asymmetry: `sample-site` is unconditionally false, but a true result
|
|
39
|
-
// for `linked-device` is necessary and NOT sufficient — a Site is only actually
|
|
40
|
-
// AQI-capable when it links at least one monitor, which the type alone cannot
|
|
41
|
-
// tell you. Callers must still check the Site's monitorIds.
|
|
42
35
|
/**
|
|
43
36
|
* The device-type vocabulary as a type, so consumers get autocomplete and a
|
|
44
37
|
* compile error on a mis-cased value like "sampleSite" — the exact bug the
|
|
@@ -47,6 +40,13 @@ export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
|
|
|
47
40
|
* @typedef {typeof DEVICE_TYPES[keyof typeof DEVICE_TYPES]} DeviceType
|
|
48
41
|
*/
|
|
49
42
|
|
|
43
|
+
// Whether a device of this type can ever report AQI, and therefore whether a
|
|
44
|
+
// subscription to it can contribute to AQI alert delivery.
|
|
45
|
+
//
|
|
46
|
+
// Note the asymmetry: `sample-site` is unconditionally false, but a true result
|
|
47
|
+
// for `linked-device` is necessary and NOT sufficient — a Site is only actually
|
|
48
|
+
// AQI-capable when it links at least one monitor, which the type alone cannot
|
|
49
|
+
// tell you. Callers must still check the Site's monitorIds.
|
|
50
50
|
export const isAqiCapableDeviceType = (deviceType) =>
|
|
51
51
|
deviceType === DEVICE_TYPES.MONITOR ||
|
|
52
52
|
deviceType === DEVICE_TYPES.LINKED_DEVICE;
|
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
|
-
//
|
|
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
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
@@ -145,15 +154,10 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
145
154
|
throw error;
|
|
146
155
|
}
|
|
147
156
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// presence of `_id` would reject every ordinary ObjectId.
|
|
153
|
-
// `.map` instead of `.flatMap` yields [[m1, m2]]. It is not caught by the
|
|
154
|
-
// document check (an array is not a plain object) and fails inconsistently
|
|
155
|
-
// downstream: one monitor per Site casts back to the id and silently works,
|
|
156
|
-
// 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.
|
|
157
161
|
if (resolved.some(Array.isArray)) {
|
|
158
162
|
const error = new TypeError(
|
|
159
163
|
"resolveSiteMonitorIds must resolve to a flat array of monitor ids"
|
|
@@ -162,9 +166,17 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
162
166
|
throw error;
|
|
163
167
|
}
|
|
164
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.
|
|
165
177
|
if (
|
|
166
178
|
resolved.some(
|
|
167
|
-
(id) => isPlainObject(id) &&
|
|
179
|
+
(id) => isPlainObject(id) && typeof id.toHexString !== "function"
|
|
168
180
|
)
|
|
169
181
|
) {
|
|
170
182
|
const error = new TypeError(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
|
+
import { Users } from '../../models/users.js';
|
|
2
3
|
import {
|
|
3
4
|
DEVICE_TYPE_VALUES,
|
|
4
5
|
isAqiCapableDeviceType,
|
|
@@ -148,11 +149,42 @@ describe('projectSubscribedMonitors', () => {
|
|
|
148
149
|
});
|
|
149
150
|
});
|
|
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,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
151
183
|
test('does not mistake ordinary ObjectIds for documents', async () => {
|
|
152
|
-
// Regression guard for the discriminator:
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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.
|
|
156
188
|
const siteId = oid();
|
|
157
189
|
const monitorId = oid();
|
|
158
190
|
|
|
@@ -341,6 +373,33 @@ describe('projectSubscribedMonitors', () => {
|
|
|
341
373
|
// Nothing structural keeps them in step, so a fourth type added to one and not
|
|
342
374
|
// the other would report as AQI-capable while contributing no audience — the
|
|
343
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
|
+
|
|
344
403
|
describe('isAqiCapableDeviceType agrees with the projection', () => {
|
|
345
404
|
test.each(DEVICE_TYPE_VALUES)('%s', async (deviceType) => {
|
|
346
405
|
const deviceId = oid();
|