@butinapp/shapes 0.1.0 → 0.1.2
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/export-bundle.d.ts +1 -0
- package/dist/export-bundle.js +54 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/people.d.ts +41 -0
- package/dist/people.js +110 -0
- package/package.json +2 -2
package/dist/export-bundle.d.ts
CHANGED
package/dist/export-bundle.js
CHANGED
|
@@ -52,6 +52,50 @@ export const ExportBundleSchema = z.object({
|
|
|
52
52
|
// renderer ("the cloud is dumb on purpose").
|
|
53
53
|
overview: z.array(OverviewPluginSchema)
|
|
54
54
|
});
|
|
55
|
+
// The bundle envelope with its two service arrays left unvalidated (validated per-entry so one bad entry
|
|
56
|
+
// doesn't reject the whole blob). Scalar top-level fields still fail hard.
|
|
57
|
+
const ExportBundleEnvelopeSchema = ExportBundleSchema.extend({
|
|
58
|
+
plugins: z.array(z.unknown()),
|
|
59
|
+
overview: z.array(z.unknown())
|
|
60
|
+
});
|
|
61
|
+
// Walk a value along a zod issue path, returning what the issue points at (undefined if the path runs off a
|
|
62
|
+
// null/absent branch) — lets us tell an absent field apart from a present-but-wrong one.
|
|
63
|
+
const valueAtPath = (value, path) => path.reduce((acc, key) => (acc == null ? undefined : acc[key]), value);
|
|
64
|
+
const serviceOf = (kind, item) => {
|
|
65
|
+
if (kind === 'overview') {
|
|
66
|
+
const o = item;
|
|
67
|
+
return o?.pluginId ?? o?.pluginName;
|
|
68
|
+
}
|
|
69
|
+
return item?.meta?.id;
|
|
70
|
+
};
|
|
71
|
+
// Validate one array's entries independently: keep the ones that parse, and turn each failure into a warning
|
|
72
|
+
// that names the service and flags absent fields (which usually mean the entry predates a schema addition).
|
|
73
|
+
const salvageEntries = (schema, items, kind) => {
|
|
74
|
+
const kept = [];
|
|
75
|
+
const warnings = [];
|
|
76
|
+
items.forEach((item, index) => {
|
|
77
|
+
const parsed = schema.safeParse(item);
|
|
78
|
+
if (parsed.success) {
|
|
79
|
+
kept.push(parsed.data);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const service = serviceOf(kind, item);
|
|
83
|
+
const label = `${kind}[${index}]${service ? ` (service '${service}')` : ''}`;
|
|
84
|
+
let anyAbsent = false;
|
|
85
|
+
const detail = parsed.error.issues
|
|
86
|
+
.map((i) => {
|
|
87
|
+
const absent = valueAtPath(item, i.path) === undefined;
|
|
88
|
+
anyAbsent ||= absent;
|
|
89
|
+
return `${i.path.join('.') || '(value)'}: ${i.message}`;
|
|
90
|
+
})
|
|
91
|
+
.join('; ');
|
|
92
|
+
const hint = anyAbsent
|
|
93
|
+
? " — a field is absent (this service's cached data predates it); refresh it and re-export"
|
|
94
|
+
: '';
|
|
95
|
+
warnings.push(`dropped ${label}: ${detail}${hint}`);
|
|
96
|
+
});
|
|
97
|
+
return { kept, warnings };
|
|
98
|
+
};
|
|
55
99
|
export const parseExportBundle = (raw) => {
|
|
56
100
|
const version = raw?.formatVersion;
|
|
57
101
|
if (typeof version === 'number' && version > EXPORT_BUNDLE_FORMAT_VERSION) {
|
|
@@ -60,9 +104,15 @@ export const parseExportBundle = (raw) => {
|
|
|
60
104
|
errors: [`bundle formatVersion ${version} is newer than this viewer supports (${EXPORT_BUNDLE_FORMAT_VERSION})`]
|
|
61
105
|
};
|
|
62
106
|
}
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
65
|
-
return { ok: false, errors:
|
|
107
|
+
const envelope = ExportBundleEnvelopeSchema.safeParse(raw);
|
|
108
|
+
if (!envelope.success) {
|
|
109
|
+
return { ok: false, errors: envelope.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) };
|
|
66
110
|
}
|
|
67
|
-
|
|
111
|
+
const plugins = salvageEntries(ExportPluginSchema, envelope.data.plugins, 'plugins');
|
|
112
|
+
const overview = salvageEntries(OverviewPluginSchema, envelope.data.overview, 'overview');
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
bundle: { ...envelope.data, plugins: plugins.kept, overview: overview.kept },
|
|
116
|
+
warnings: [...plugins.warnings, ...overview.warnings]
|
|
117
|
+
};
|
|
68
118
|
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/people.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type CapabilityResult, type TableDataset } from '@butinapp/sdk/data';
|
|
2
|
+
export type ServiceMembers = {
|
|
3
|
+
pluginId: string;
|
|
4
|
+
serviceName: string;
|
|
5
|
+
asOf?: string;
|
|
6
|
+
rows: Record<string, unknown>[];
|
|
7
|
+
};
|
|
8
|
+
export type PersonRow = {
|
|
9
|
+
personId: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
email: string | null;
|
|
12
|
+
services: number;
|
|
13
|
+
};
|
|
14
|
+
export type AccessRow = {
|
|
15
|
+
personId: string;
|
|
16
|
+
service: string;
|
|
17
|
+
role: string | null;
|
|
18
|
+
asOf: string | null;
|
|
19
|
+
};
|
|
20
|
+
export type MergedPeople = {
|
|
21
|
+
people: PersonRow[];
|
|
22
|
+
access: AccessRow[];
|
|
23
|
+
};
|
|
24
|
+
export declare const mergePeople: (inputs: ServiceMembers[]) => MergedPeople;
|
|
25
|
+
export declare const membersDataset: (result: CapabilityResult | null) => TableDataset | null;
|
|
26
|
+
export type ServiceCapResult = {
|
|
27
|
+
id: string;
|
|
28
|
+
result: CapabilityResult | null;
|
|
29
|
+
asOf?: string;
|
|
30
|
+
};
|
|
31
|
+
export declare const serviceMembersFrom: (source: {
|
|
32
|
+
pluginId: string;
|
|
33
|
+
serviceName: string;
|
|
34
|
+
caps: ServiceCapResult[];
|
|
35
|
+
}) => ServiceMembers | null;
|
|
36
|
+
export declare const buildPeopleResult: (merged: MergedPeople) => CapabilityResult;
|
|
37
|
+
export declare const buildPeopleData: (inputs: ServiceMembers[]) => {
|
|
38
|
+
result: CapabilityResult;
|
|
39
|
+
people: number;
|
|
40
|
+
services: number;
|
|
41
|
+
} | null;
|
package/dist/people.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { capabilityResult, table } from '@butinapp/sdk/data';
|
|
2
|
+
const str = (v) => (typeof v === 'string' && v.length > 0 ? v : null);
|
|
3
|
+
// The most common non-null name a person appears under; first-seen wins a tie so the pick is stable.
|
|
4
|
+
const majorityName = (names) => {
|
|
5
|
+
let best = null;
|
|
6
|
+
let bestCount = 0;
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
for (const name of names) {
|
|
9
|
+
const count = (counts.get(name) ?? 0) + 1;
|
|
10
|
+
counts.set(name, count);
|
|
11
|
+
if (count > bestCount) {
|
|
12
|
+
best = name;
|
|
13
|
+
bestCount = count;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return best;
|
|
17
|
+
};
|
|
18
|
+
// Merge every service's member roster into people. Rows sharing a case-insensitive email are one person;
|
|
19
|
+
// a row without an email stays its own entry (keyed by service + row id) so name-only rows never falsely
|
|
20
|
+
// merge. Access rows carry the raw per-service role plus the report's fetch time.
|
|
21
|
+
export const mergePeople = (inputs) => {
|
|
22
|
+
const byId = new Map();
|
|
23
|
+
for (const svc of inputs) {
|
|
24
|
+
svc.rows.forEach((row, index) => {
|
|
25
|
+
const email = str(row.email);
|
|
26
|
+
const name = str(row.name);
|
|
27
|
+
// A row with neither a name nor an email identifies nobody (a service/placeholder account some rosters
|
|
28
|
+
// carry) — drop it rather than showing a blank "— / —" person in the audit.
|
|
29
|
+
if (!email && !name) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const rowId = str(row.id) ?? String(index);
|
|
33
|
+
const personId = email ? email.trim().toLowerCase() : `${svc.pluginId}:${rowId}`;
|
|
34
|
+
let acc = byId.get(personId);
|
|
35
|
+
if (!acc) {
|
|
36
|
+
acc = { personId, email, names: [], pluginIds: new Set(), access: [] };
|
|
37
|
+
byId.set(personId, acc);
|
|
38
|
+
}
|
|
39
|
+
if (name) {
|
|
40
|
+
acc.names.push(name);
|
|
41
|
+
}
|
|
42
|
+
acc.pluginIds.add(svc.pluginId);
|
|
43
|
+
acc.access.push({ personId, service: svc.serviceName, role: str(row.role), asOf: svc.asOf ?? null });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const accs = [...byId.values()];
|
|
47
|
+
const people = accs.map((a) => ({
|
|
48
|
+
personId: a.personId,
|
|
49
|
+
name: majorityName(a.names),
|
|
50
|
+
email: a.email,
|
|
51
|
+
services: a.pluginIds.size
|
|
52
|
+
}));
|
|
53
|
+
const label = (p) => p.name ?? p.email ?? p.personId;
|
|
54
|
+
people.sort((x, y) => y.services - x.services || label(x).localeCompare(label(y)));
|
|
55
|
+
const access = accs.flatMap((a) => a.access);
|
|
56
|
+
access.sort((x, y) => x.service.localeCompare(y.service));
|
|
57
|
+
return { people, access };
|
|
58
|
+
};
|
|
59
|
+
// The standard roster the members.result preset emits: a table with id 'members' that declares an email
|
|
60
|
+
// column. The email-column requirement excludes members-shaped usage breakdowns that reuse the id with
|
|
61
|
+
// different fields (per-member spend tables carry no email).
|
|
62
|
+
export const membersDataset = (result) => result?.datasets.find((d) => d.shape === 'table' && d.id === 'members' && d.columns.some((c) => c.key === 'email')) ?? null;
|
|
63
|
+
// Pick a service's roster from its capability results: prefer the `members` capability, else the first result
|
|
64
|
+
// carrying a members dataset. Returns null when the service reports no roster. The winning capability's fetch
|
|
65
|
+
// time becomes the contribution's `asOf`.
|
|
66
|
+
export const serviceMembersFrom = (source) => {
|
|
67
|
+
const ordered = [...source.caps].sort((a, b) => Number(b.id === 'members') - Number(a.id === 'members'));
|
|
68
|
+
for (const cap of ordered) {
|
|
69
|
+
const ds = membersDataset(cap.result);
|
|
70
|
+
if (ds) {
|
|
71
|
+
return { pluginId: source.pluginId, serviceName: source.serviceName, asOf: cap.asOf, rows: ds.rows };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
// Shape the merged rollup into the one renderable result the People page draws: a people table whose rows
|
|
77
|
+
// expand into that person's per-service access, joined on the hidden personId.
|
|
78
|
+
export const buildPeopleResult = (merged) => {
|
|
79
|
+
const people = table({
|
|
80
|
+
id: 'people',
|
|
81
|
+
columns: [
|
|
82
|
+
{ key: 'name', role: 'label', label: 'Name' },
|
|
83
|
+
{ key: 'email', role: 'identifier', label: 'Email' },
|
|
84
|
+
{ key: 'services', role: 'count', label: 'Services' },
|
|
85
|
+
{ key: 'personId', role: 'identifier', hidden: true }
|
|
86
|
+
],
|
|
87
|
+
rows: merged.people,
|
|
88
|
+
key: 'personId'
|
|
89
|
+
});
|
|
90
|
+
const access = table({
|
|
91
|
+
id: 'access',
|
|
92
|
+
columns: [
|
|
93
|
+
{ key: 'service', role: 'label', label: 'Service' },
|
|
94
|
+
{ key: 'role', role: 'category', label: 'Role' },
|
|
95
|
+
{ key: 'asOf', role: 'timestamp', label: 'As of' },
|
|
96
|
+
{ key: 'personId', role: 'identifier', hidden: true }
|
|
97
|
+
],
|
|
98
|
+
rows: merged.access
|
|
99
|
+
});
|
|
100
|
+
return capabilityResult({ sections: [people.table({ title: 'People', detail: { rows: access, on: 'personId' } })] });
|
|
101
|
+
};
|
|
102
|
+
// Assemble the People payload from every service's roster contributions: merge, shape, and count. Returns null
|
|
103
|
+
// when no service reports a roster.
|
|
104
|
+
export const buildPeopleData = (inputs) => {
|
|
105
|
+
if (inputs.length === 0) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const merged = mergePeople(inputs);
|
|
109
|
+
return { result: buildPeopleResult(merged), people: merged.people.length, services: inputs.length };
|
|
110
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@butinapp/shapes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Butin's host-layer data shapes built on the @butinapp/sdk data-view contract: the cross-service Overview input, the persisted ledger, the presentation manifest, and the portable export-bundle wire format.",
|
|
5
5
|
"homepage": "https://butin.app",
|
|
6
6
|
"bugs": "https://github.com/butinapp/butin/issues",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"lodash-es": "^4.18.1",
|
|
38
38
|
"luxon": "^3.7.2",
|
|
39
39
|
"zod": "^4.3.6",
|
|
40
|
-
"@butinapp/sdk": "0.1.
|
|
40
|
+
"@butinapp/sdk": "0.1.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"vitest": "^4.1.7"
|