@dszp/netsapiens-lib 0.5.0 → 0.6.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 +10 -2
- package/dist/attribution.d.ts +13 -1
- package/dist/attribution.d.ts.map +1 -1
- package/dist/attribution.js +20 -12
- package/dist/attribution.js.map +1 -1
- package/package.json +1 -1
- package/src/attribution.selftest.ts +24 -3
- package/src/attribution.ts +31 -11
package/README.md
CHANGED
|
@@ -172,9 +172,17 @@ against one of them needs to know which inventory items are actually its own. `a
|
|
|
172
172
|
answers that: it's pure (no account knowledge, just the snapshot) and labels every extension, number,
|
|
173
173
|
address and SMS number either `own-site` (the item's own site matches), `via-user:<ext>` /
|
|
174
174
|
`via-users:<exts>` (it's reachable only through another item that has a site), or an
|
|
175
|
-
`unattributed:<reason>` — `no-site`, `routed-to:<x>`, `
|
|
175
|
+
`unattributed:<reason>` — `no-site`, `routed-to:<x>`, `unreferenced`, or
|
|
176
176
|
`sms-user-unknown` when a domain-level SMS number can't be matched to a user because the snapshot was
|
|
177
|
-
never fetched with `includeUserSmsNumbers`.
|
|
177
|
+
never fetched with `includeUserSmsNumbers`.
|
|
178
|
+
|
|
179
|
+
Each verdict carries **`sites: string[]`** as well as `site`. They agree wherever there is one site,
|
|
180
|
+
and only an **E911 address** can carry more than one: an address is a fact about a place, and users on
|
|
181
|
+
four sites can all reference it, so `sites` names every one of them while `site` stays `null`. A
|
|
182
|
+
consumer splitting a domain between billing accounts should read `sites` — placing such an address on
|
|
183
|
+
exactly one account leaves every other referencing account's E911 line short.
|
|
184
|
+
|
|
185
|
+
Filter `listDomainInventory(snapshot)`'s items by that
|
|
178
186
|
attribution to scope a domain down to one site, then run `countInventoryDetail(detail)` over what's left
|
|
179
187
|
to get counts that agree with what you kept, rather than recomputing `countDomainInventory` against the
|
|
180
188
|
whole domain and hoping the numbers happen to match.
|
package/dist/attribution.d.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import type { Snapshot } from './model.js';
|
|
2
2
|
export interface ItemAttribution {
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* The single site this item belongs to; `null` when there is not exactly one — nothing placed it, or
|
|
5
|
+
* (addresses only) several did. Read {@link sites} to tell those two apart.
|
|
6
|
+
*/
|
|
4
7
|
site: string | null;
|
|
8
|
+
/**
|
|
9
|
+
* EVERY site this item belongs to, unique and sorted. One entry wherever {@link site} is set, none
|
|
10
|
+
* where nothing placed it, and — for an address referenced from several sites — one per site.
|
|
11
|
+
*
|
|
12
|
+
* The wider field, and the one a consumer splitting a domain between accounts should read: only an
|
|
13
|
+
* ADDRESS can carry more than one, because only an address is a fact about a place rather than about
|
|
14
|
+
* a user, a number or a route.
|
|
15
|
+
*/
|
|
16
|
+
sites: string[];
|
|
5
17
|
/** `own-site` | `via-user:<ext>` | `via-users:<ext,ext>` | `unattributed:<reason>`. */
|
|
6
18
|
how: string;
|
|
7
19
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAO,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,iBAAiB;IAAG,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;CAAE;AAI7E,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,GAAG,iBAAiB,CA8D9E"}
|
package/dist/attribution.js
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
* a queue at the North site is a North number. Only a site-less system user falls back to
|
|
19
19
|
* `routed-to:<that user's service-code>`; when the destination names nobody, `routed-to:<application>`;
|
|
20
20
|
* a site-less real user is `no-site`.
|
|
21
|
-
* - An address:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* - An address: EVERY site a REAL extension referencing it sits on (`via-users:<exts>`), in `sites`.
|
|
22
|
+
* An address is a fact about a PLACE, and users on four sites can legitimately reference one — so
|
|
23
|
+
* the multi-site case is not a failure to attribute, it is the answer. `site` is the single site
|
|
24
|
+
* when there is exactly one and `null` otherwise, so a consumer that can only hold one still reads
|
|
25
|
+
* the unambiguous case correctly. `unreferenced` when no real extension names it; `no-site` when the
|
|
26
|
+
* ones that do have no site.
|
|
24
27
|
* - An SMS number: the site of the user whose per-user list carries it (`via-user:<ext>`), or
|
|
25
28
|
* `sms-user-unknown` when no per-user list does — including when the per-user read was never
|
|
26
29
|
* made. Never guessed from the domain-level list, which does not say.
|
|
@@ -32,7 +35,7 @@
|
|
|
32
35
|
* invariant is this library's own, and this is the one place allowed to lean on it.
|
|
33
36
|
*/
|
|
34
37
|
import { isSystemUser, listDomainInventory, str, usersByExt } from './inventory.js';
|
|
35
|
-
const none = (reason) => ({ site: null, how: `unattributed:${reason}` });
|
|
38
|
+
const none = (reason) => ({ site: null, sites: [], how: `unattributed:${reason}` });
|
|
36
39
|
export function attributeDomainInventory(snapshot) {
|
|
37
40
|
const users = Array.isArray(snapshot.users) ? snapshot.users : [];
|
|
38
41
|
const phonenumbers = Array.isArray(snapshot.phonenumbers) ? snapshot.phonenumbers : [];
|
|
@@ -42,9 +45,11 @@ export function attributeDomainInventory(snapshot) {
|
|
|
42
45
|
const d = listDomainInventory(snapshot);
|
|
43
46
|
const items = {};
|
|
44
47
|
const userByExt = usersByExt(users);
|
|
48
|
+
/** The single-site verdict, with `sites` kept in step so the two fields can never disagree. */
|
|
49
|
+
const one = (site, how) => ({ site, sites: [site], how });
|
|
45
50
|
// Extensions: the detail already carries `site`, so no record is needed here.
|
|
46
51
|
for (const x of d.extensions)
|
|
47
|
-
items[x.key] = x.site ?
|
|
52
|
+
items[x.key] = x.site ? one(x.site, 'own-site') : none('no-site');
|
|
48
53
|
// Numbers, by index against `phonenumbers`.
|
|
49
54
|
for (let i = 0; i < d.dids.length; i++) {
|
|
50
55
|
const key = d.dids[i].key, p = phonenumbers[i] ?? {};
|
|
@@ -60,7 +65,7 @@ export function attributeDomainInventory(snapshot) {
|
|
|
60
65
|
items[key] = isSystemUser(u) ? none(`routed-to:${str(u['service-code'])}`) : none('no-site');
|
|
61
66
|
continue;
|
|
62
67
|
}
|
|
63
|
-
items[key] =
|
|
68
|
+
items[key] = one(site, `via-user:${dest}`);
|
|
64
69
|
}
|
|
65
70
|
// Addresses, by index against `addresses`; referenced by REAL extensions only.
|
|
66
71
|
const refs = new Map();
|
|
@@ -82,12 +87,15 @@ export function attributeDomainInventory(snapshot) {
|
|
|
82
87
|
continue;
|
|
83
88
|
}
|
|
84
89
|
const sites = [...new Set(who.map((u) => str(u.site)).filter(Boolean))].sort();
|
|
85
|
-
if (sites.length === 0)
|
|
90
|
+
if (sites.length === 0) {
|
|
86
91
|
items[key] = none('no-site');
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// SEVERAL SITES IS AN ANSWER, not a failure. The referencing users name every place this address
|
|
95
|
+
// is used, and a consumer billing per site needs all of them; `site` still answers only the
|
|
96
|
+
// unambiguous case, which is what keeps a one-site consumer correct without reading `sites`.
|
|
97
|
+
const how = `via-users:${who.map((u) => str(u.user)).filter(Boolean).sort().join(',')}`;
|
|
98
|
+
items[key] = sites.length === 1 ? one(sites[0], how) : { site: null, sites, how };
|
|
91
99
|
}
|
|
92
100
|
// SMS numbers, by index against `smsnumbers`, joined to a user through the per-user lists.
|
|
93
101
|
const extBySms = new Map();
|
|
@@ -107,7 +115,7 @@ export function attributeDomainInventory(snapshot) {
|
|
|
107
115
|
continue;
|
|
108
116
|
}
|
|
109
117
|
const site = str(u.site);
|
|
110
|
-
items[key] = site ?
|
|
118
|
+
items[key] = site ? one(site, `via-user:${ext}`) : none('no-site');
|
|
111
119
|
}
|
|
112
120
|
return { items };
|
|
113
121
|
}
|
package/dist/attribution.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attribution.js","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"attribution.js","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dszp/netsapiens-lib",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Portable, Node-free NetSapiens toolkit: split read/write API clients, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,9 +49,30 @@ ok(a['did:13175550102']!.site === null && a['did:13175550102']!.how === 'unattri
|
|
|
49
49
|
ok(a['did:13175550103']!.site === null && a['did:13175550103']!.how === 'unattributed:no-site', 'a number routed to a site-less user is no-site, not routed-to');
|
|
50
50
|
ok(a['did:13175550104']!.site === null && a['did:13175550104']!.how === 'unattributed:routed-to:to-user', 'a number whose destination user does not exist falls back to the application name');
|
|
51
51
|
|
|
52
|
-
ok(a['addr:a-1']!.site === null && a['addr:a-1']!.
|
|
53
|
-
|
|
54
|
-
ok(a['addr:a-
|
|
52
|
+
ok(a['addr:a-1']!.site === null && JSON.stringify(a['addr:a-1']!.sites) === JSON.stringify(['North', 'South']) && a['addr:a-1']!.how === 'via-users:100,101',
|
|
53
|
+
'an address referenced from two sites carries BOTH, sorted, with no single site and no shared-across reason');
|
|
54
|
+
ok(a['addr:a-2']!.site === 'South' && JSON.stringify(a['addr:a-2']!.sites) === JSON.stringify(['South']) && a['addr:a-2']!.how === 'via-users:102', 'an address referenced from one site follows it and names the users');
|
|
55
|
+
ok(a['addr:a-3']!.site === null && a['addr:a-3']!.sites.length === 0 && a['addr:a-3']!.how === 'unattributed:unreferenced', 'an address nobody references is unreferenced');
|
|
56
|
+
|
|
57
|
+
// `sites` is the list form of `site` for every kind that has exactly one, so a consumer can read the
|
|
58
|
+
// one field and never branch on the kind.
|
|
59
|
+
ok(JSON.stringify(a['ext:100']!.sites) === JSON.stringify(['North']), 'an extension carries its own site as a one-entry list');
|
|
60
|
+
ok(a['ext:103']!.sites.length === 0, 'and a site-less one carries an empty list');
|
|
61
|
+
ok(JSON.stringify(a['did:13175550100']!.sites) === JSON.stringify(['North']), 'a number carries the site it inherited');
|
|
62
|
+
ok(a['did:13175550102']!.sites.length === 0, 'and an unattributed number carries none');
|
|
63
|
+
ok(JSON.stringify(a['sms:13175550100']!.sites) === JSON.stringify(['North']), 'an SMS number carries the site of the user it is enabled on');
|
|
64
|
+
ok(a['sms:13175550199']!.sites.length === 0, 'and an unknown one carries none');
|
|
65
|
+
ok(Object.values(a).every((v) => JSON.stringify(v.sites) === JSON.stringify([...new Set(v.sites)].sort())), 'every sites list is unique and sorted');
|
|
66
|
+
ok(Object.values(a).every((v) => (v.site === null ? true : v.sites.length === 1 && v.sites[0] === v.site)), 'a single site is exactly the one-entry list, and never disagrees with it');
|
|
67
|
+
|
|
68
|
+
// An address whose referencing users have no site at all: still unattributed, and the reason says why.
|
|
69
|
+
const noSiteAddr = attributeDomainInventory({
|
|
70
|
+
...snap,
|
|
71
|
+
users: [{ user: '104', site: '', 'service-code': '', 'emergency-address-id': 'a-4' }],
|
|
72
|
+
addresses: [{ 'emergency-address-id': 'a-4' }],
|
|
73
|
+
}).items;
|
|
74
|
+
ok(noSiteAddr['addr:a-4']!.how === 'unattributed:no-site' && noSiteAddr['addr:a-4']!.sites.length === 0,
|
|
75
|
+
'an address whose referencing users have no site is no-site, with no sites to name');
|
|
55
76
|
|
|
56
77
|
ok(a['sms:13175550100']!.site === 'North' && a['sms:13175550100']!.how === 'via-user:100', 'an SMS number follows the user it is enabled on');
|
|
57
78
|
ok(a['sms:13175550102']!.site === null && a['sms:13175550102']!.how === 'unattributed:no-site', 'an SMS number on a site-less user is no-site');
|
package/src/attribution.ts
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
* a queue at the North site is a North number. Only a site-less system user falls back to
|
|
19
19
|
* `routed-to:<that user's service-code>`; when the destination names nobody, `routed-to:<application>`;
|
|
20
20
|
* a site-less real user is `no-site`.
|
|
21
|
-
* - An address:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* - An address: EVERY site a REAL extension referencing it sits on (`via-users:<exts>`), in `sites`.
|
|
22
|
+
* An address is a fact about a PLACE, and users on four sites can legitimately reference one — so
|
|
23
|
+
* the multi-site case is not a failure to attribute, it is the answer. `site` is the single site
|
|
24
|
+
* when there is exactly one and `null` otherwise, so a consumer that can only hold one still reads
|
|
25
|
+
* the unambiguous case correctly. `unreferenced` when no real extension names it; `no-site` when the
|
|
26
|
+
* ones that do have no site.
|
|
24
27
|
* - An SMS number: the site of the user whose per-user list carries it (`via-user:<ext>`), or
|
|
25
28
|
* `sms-user-unknown` when no per-user list does — including when the per-user read was never
|
|
26
29
|
* made. Never guessed from the domain-level list, which does not say.
|
|
@@ -35,14 +38,26 @@ import { isSystemUser, listDomainInventory, str, usersByExt } from './inventory.
|
|
|
35
38
|
import type { Rec, Snapshot } from './model.js';
|
|
36
39
|
|
|
37
40
|
export interface ItemAttribution {
|
|
38
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The single site this item belongs to; `null` when there is not exactly one — nothing placed it, or
|
|
43
|
+
* (addresses only) several did. Read {@link sites} to tell those two apart.
|
|
44
|
+
*/
|
|
39
45
|
site: string | null;
|
|
46
|
+
/**
|
|
47
|
+
* EVERY site this item belongs to, unique and sorted. One entry wherever {@link site} is set, none
|
|
48
|
+
* where nothing placed it, and — for an address referenced from several sites — one per site.
|
|
49
|
+
*
|
|
50
|
+
* The wider field, and the one a consumer splitting a domain between accounts should read: only an
|
|
51
|
+
* ADDRESS can carry more than one, because only an address is a fact about a place rather than about
|
|
52
|
+
* a user, a number or a route.
|
|
53
|
+
*/
|
|
54
|
+
sites: string[];
|
|
40
55
|
/** `own-site` | `via-user:<ext>` | `via-users:<ext,ext>` | `unattributed:<reason>`. */
|
|
41
56
|
how: string;
|
|
42
57
|
}
|
|
43
58
|
export interface DomainAttribution { items: Record<string, ItemAttribution> }
|
|
44
59
|
|
|
45
|
-
const none = (reason: string): ItemAttribution => ({ site: null, how: `unattributed:${reason}` });
|
|
60
|
+
const none = (reason: string): ItemAttribution => ({ site: null, sites: [], how: `unattributed:${reason}` });
|
|
46
61
|
|
|
47
62
|
export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution {
|
|
48
63
|
const users: Rec[] = Array.isArray(snapshot.users) ? snapshot.users : [];
|
|
@@ -54,9 +69,11 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
|
|
|
54
69
|
const items: Record<string, ItemAttribution> = {};
|
|
55
70
|
|
|
56
71
|
const userByExt = usersByExt(users);
|
|
72
|
+
/** The single-site verdict, with `sites` kept in step so the two fields can never disagree. */
|
|
73
|
+
const one = (site: string, how: string): ItemAttribution => ({ site, sites: [site], how });
|
|
57
74
|
|
|
58
75
|
// Extensions: the detail already carries `site`, so no record is needed here.
|
|
59
|
-
for (const x of d.extensions) items[x.key] = x.site ?
|
|
76
|
+
for (const x of d.extensions) items[x.key] = x.site ? one(x.site, 'own-site') : none('no-site');
|
|
60
77
|
|
|
61
78
|
// Numbers, by index against `phonenumbers`.
|
|
62
79
|
for (let i = 0; i < d.dids.length; i++) {
|
|
@@ -67,7 +84,7 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
|
|
|
67
84
|
if (!u) { items[key] = none(`routed-to:${app}`); continue; }
|
|
68
85
|
const site = str(u.site);
|
|
69
86
|
if (!site) { items[key] = isSystemUser(u) ? none(`routed-to:${str(u['service-code'])}`) : none('no-site'); continue; }
|
|
70
|
-
items[key] =
|
|
87
|
+
items[key] = one(site, `via-user:${dest}`);
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
// Addresses, by index against `addresses`; referenced by REAL extensions only.
|
|
@@ -83,9 +100,12 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
|
|
|
83
100
|
const who = id ? refs.get(id) ?? [] : [];
|
|
84
101
|
if (!who.length) { items[key] = none('unreferenced'); continue; }
|
|
85
102
|
const sites = [...new Set(who.map((u) => str(u.site)).filter(Boolean))].sort();
|
|
86
|
-
if (sites.length === 0) items[key] = none('no-site');
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
if (sites.length === 0) { items[key] = none('no-site'); continue; }
|
|
104
|
+
// SEVERAL SITES IS AN ANSWER, not a failure. The referencing users name every place this address
|
|
105
|
+
// is used, and a consumer billing per site needs all of them; `site` still answers only the
|
|
106
|
+
// unambiguous case, which is what keeps a one-site consumer correct without reading `sites`.
|
|
107
|
+
const how = `via-users:${who.map((u) => str(u.user)).filter(Boolean).sort().join(',')}`;
|
|
108
|
+
items[key] = sites.length === 1 ? one(sites[0]!, how) : { site: null, sites, how };
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
// SMS numbers, by index against `smsnumbers`, joined to a user through the per-user lists.
|
|
@@ -97,7 +117,7 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
|
|
|
97
117
|
const u = ext ? userByExt.get(ext) : undefined;
|
|
98
118
|
if (!u) { items[key] = none('sms-user-unknown'); continue; }
|
|
99
119
|
const site = str(u.site);
|
|
100
|
-
items[key] = site ?
|
|
120
|
+
items[key] = site ? one(site, `via-user:${ext}`) : none('no-site');
|
|
101
121
|
}
|
|
102
122
|
|
|
103
123
|
return { items };
|