@drift-beacon/plugin 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/bin/dbplugin.js +11 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +38 -0
  6. package/dist/dev.d.ts +38 -0
  7. package/dist/dev.js +8 -0
  8. package/dist/errors.d.ts +7 -0
  9. package/dist/errors.js +9 -0
  10. package/dist/index.d.ts +11 -0
  11. package/dist/index.js +8 -0
  12. package/dist/internal/actions.d.ts +35 -0
  13. package/dist/internal/actions.js +1 -0
  14. package/dist/internal/collection.d.ts +15 -0
  15. package/dist/internal/collection.js +52 -0
  16. package/dist/internal/compatibility.d.ts +26 -0
  17. package/dist/internal/compatibility.js +52 -0
  18. package/dist/internal/json-equal.d.ts +3 -0
  19. package/dist/internal/json-equal.js +20 -0
  20. package/dist/internal/manifest.d.ts +51 -0
  21. package/dist/internal/manifest.js +73 -0
  22. package/dist/internal/models.d.ts +62 -0
  23. package/dist/internal/models.js +387 -0
  24. package/dist/internal/package-layout.d.ts +21 -0
  25. package/dist/internal/package-layout.js +19 -0
  26. package/dist/internal/protocol.d.ts +96 -0
  27. package/dist/internal/protocol.js +8 -0
  28. package/dist/internal/rows.d.ts +41 -0
  29. package/dist/internal/rows.js +1 -0
  30. package/dist/internal.d.ts +17 -0
  31. package/dist/internal.js +13 -0
  32. package/dist/main.d.ts +92 -0
  33. package/dist/main.js +14 -0
  34. package/dist/pack.d.ts +24 -0
  35. package/dist/pack.js +131 -0
  36. package/dist/prepare.d.ts +28 -0
  37. package/dist/prepare.js +121 -0
  38. package/dist/types.d.ts +193 -0
  39. package/dist/types.js +10 -0
  40. package/dist/ui.d.ts +25 -0
  41. package/dist/ui.js +265 -0
  42. package/dist/version.d.ts +6 -0
  43. package/dist/version.js +5 -0
  44. package/dist/vite.d.ts +8 -0
  45. package/dist/vite.js +370 -0
  46. package/package.json +73 -0
@@ -0,0 +1,52 @@
1
+ import { API_VERSION } from "../version.js";
2
+ /**
3
+ * API versions this build of Drift Beacon runs: the newest minor of each supported major.
4
+ * Before 1.0 only the current version is listed, because every 0.x minor may break plugins.
5
+ * From 1.0, the previous major stays listed for at least six months after its successor ships.
6
+ */
7
+ export const SUPPORTED_API_VERSIONS = [API_VERSION];
8
+ const API_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
9
+ /** Parse a `major.minor` API version; null when malformed. */
10
+ export function parseApiVersion(value) {
11
+ if (typeof value !== "string")
12
+ return null;
13
+ const match = API_VERSION_PATTERN.exec(value);
14
+ if (!match)
15
+ return null;
16
+ return { major: Number(match[1]), minor: Number(match[2]) };
17
+ }
18
+ /**
19
+ * Whether a plugin built for `pluginVersion` runs on a build supporting `supported`.
20
+ * Before 1.0 the minor must match exactly; from 1.0 the major must match and the plugin's
21
+ * minor must not be newer than the host's.
22
+ */
23
+ export function checkApiCompatibility(pluginVersion, supported = SUPPORTED_API_VERSIONS) {
24
+ const plugin = parseApiVersion(pluginVersion);
25
+ if (!plugin) {
26
+ return {
27
+ compatible: false,
28
+ reason: "invalid",
29
+ message: `The plugin's apiVersion must be "major.minor", for example "${API_VERSION}".`,
30
+ };
31
+ }
32
+ const hosts = supported.map(parseApiVersion).filter((version) => version !== null);
33
+ const runs = hosts.some((host) => host.major === 0
34
+ ? plugin.major === 0 && plugin.minor === host.minor
35
+ : plugin.major === host.major && plugin.minor <= host.minor);
36
+ if (runs)
37
+ return { compatible: true };
38
+ const newest = hosts.reduce((best, host) => !best || host.major > best.major || (host.major === best.major && host.minor > best.minor) ? host : best, null);
39
+ const tooNew = !newest || plugin.major > newest.major || (plugin.major === newest.major && plugin.minor > newest.minor);
40
+ const list = supported.join(", ");
41
+ return tooNew
42
+ ? {
43
+ compatible: false,
44
+ reason: "needs-newer-app",
45
+ message: `Built for plugin API ${pluginVersion}. Update Drift Beacon to run it (this version supports ${list}).`,
46
+ }
47
+ : {
48
+ compatible: false,
49
+ reason: "needs-plugin-update",
50
+ message: `Built for plugin API ${pluginVersion}, which this version of Drift Beacon no longer runs (it supports ${list}). The plugin needs an update.`,
51
+ };
52
+ }
@@ -0,0 +1,3 @@
1
+ /** Structural equality for JSON-shaped values such as data rows and storage items. */
2
+ export declare function jsonEqual(a: unknown, b: unknown): boolean;
3
+ //# sourceMappingURL=json-equal.d.ts.map
@@ -0,0 +1,20 @@
1
+ /** Structural equality for JSON-shaped values such as data rows and storage items. */
2
+ export function jsonEqual(a, b) {
3
+ if (a === b)
4
+ return true;
5
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
6
+ return false;
7
+ if (Array.isArray(a)) {
8
+ if (!Array.isArray(b) || a.length !== b.length)
9
+ return false;
10
+ return a.every((item, index) => jsonEqual(item, b[index]));
11
+ }
12
+ if (Array.isArray(b))
13
+ return false;
14
+ const left = a;
15
+ const right = b;
16
+ const keys = Object.keys(left);
17
+ if (keys.length !== Object.keys(right).length)
18
+ return false;
19
+ return keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]));
20
+ }
@@ -0,0 +1,51 @@
1
+ interface ConfigurationItemBase {
2
+ name: string;
3
+ title: string;
4
+ description?: string;
5
+ required?: boolean;
6
+ }
7
+ export interface StringConfigurationItem extends ConfigurationItemBase {
8
+ type: "string";
9
+ default?: string;
10
+ placeholder?: string;
11
+ }
12
+ export interface NumberConfigurationItem extends ConfigurationItemBase {
13
+ type: "number";
14
+ default?: number;
15
+ minimum?: number;
16
+ maximum?: number;
17
+ }
18
+ export interface BooleanConfigurationItem extends ConfigurationItemBase {
19
+ type: "boolean";
20
+ default?: boolean;
21
+ }
22
+ export interface DropdownConfigurationItem extends ConfigurationItemBase {
23
+ type: "dropdown";
24
+ default?: string;
25
+ data: ReadonlyArray<{
26
+ readonly title: string;
27
+ readonly value: string;
28
+ }>;
29
+ }
30
+ /** One setting declared in `manifest.json`; drives the settings form and config validation. */
31
+ export type ConfigurationItem = StringConfigurationItem | NumberConfigurationItem | BooleanConfigurationItem | DropdownConfigurationItem;
32
+ export interface PluginManifest {
33
+ id: string;
34
+ name: string;
35
+ version: string;
36
+ /** Plugin API version (`major.minor`) the plugin was built for. */
37
+ apiVersion: string;
38
+ description: string;
39
+ author: {
40
+ name: string;
41
+ email?: string;
42
+ };
43
+ category: "plugin" | "utility" | "ui";
44
+ icon: string;
45
+ configuration: readonly ConfigurationItem[];
46
+ }
47
+ export declare const isStableVersion: (value: unknown) => value is string;
48
+ /** Throws a readable error when `manifest` is not a valid plugin manifest. Shared by the server and the pack tool. */
49
+ export declare function validateManifest(manifest: unknown): asserts manifest is PluginManifest;
50
+ export {};
51
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1,73 @@
1
+ import { parseApiVersion } from "./compatibility.js";
2
+ const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
3
+ export const isStableVersion = (value) => typeof value === "string" &&
4
+ STABLE_VERSION.test(value) &&
5
+ (value.split("+")[0] ?? "").split(".").every((part) => Number.isSafeInteger(Number(part)));
6
+ /** Throws a readable error when `manifest` is not a valid plugin manifest. Shared by the server and the pack tool. */
7
+ export function validateManifest(manifest) {
8
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
9
+ throw new Error("Invalid plugin manifest");
10
+ const value = manifest;
11
+ for (const field of ["id", "name", "version", "apiVersion", "description", "category"]) {
12
+ const text = value[field];
13
+ if (typeof text !== "string" || !text.trim())
14
+ throw new Error(`Plugin manifest missing required field: ${field}`);
15
+ }
16
+ const { id, version, apiVersion, author, category, icon, configuration } = value;
17
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
18
+ throw new Error("Plugin id must contain only lowercase letters, numbers and hyphens (maximum 64 characters)");
19
+ }
20
+ if (!isStableVersion(version)) {
21
+ throw new Error("Plugin version must be a stable semantic version (for example 1.2.3)");
22
+ }
23
+ if (!parseApiVersion(apiVersion))
24
+ throw new Error('Plugin apiVersion must be "major.minor", for example "0.1"');
25
+ if (!author || typeof author.name !== "string" || !author.name.trim()) {
26
+ throw new Error("Plugin manifest author must have a name");
27
+ }
28
+ if (!["plugin", "utility", "ui"].includes(category))
29
+ throw new Error("Invalid plugin category");
30
+ if (typeof icon !== "string" || /[/\\]|^\./.test(icon)) {
31
+ throw new Error("Plugin icon must be a file name in ui/public/, or empty");
32
+ }
33
+ if (!Array.isArray(configuration))
34
+ throw new Error("Plugin configuration must be an array");
35
+ const names = new Set();
36
+ for (const item of configuration)
37
+ validateConfigurationItem(item, names);
38
+ }
39
+ function validateConfigurationItem(value, names) {
40
+ const item = value;
41
+ if (!item ||
42
+ typeof item !== "object" ||
43
+ typeof item.name !== "string" ||
44
+ !item.name.trim() ||
45
+ typeof item.title !== "string" ||
46
+ !["string", "number", "boolean", "dropdown"].includes(item.type) ||
47
+ (item.required !== undefined && typeof item.required !== "boolean") ||
48
+ (item.description !== undefined && typeof item.description !== "string"))
49
+ throw new Error("Invalid plugin configuration item");
50
+ if (names.has(item.name))
51
+ throw new Error(`Duplicate plugin configuration name: ${item.name}`);
52
+ names.add(item.name);
53
+ const defaultType = item.type === "dropdown" ? "string" : item.type;
54
+ if (item.default !== undefined && typeof item.default !== defaultType)
55
+ throw new Error(`Invalid configuration default: ${item.name}`);
56
+ if (item.type === "string" && item.placeholder !== undefined && typeof item.placeholder !== "string") {
57
+ throw new Error(`Invalid configuration placeholder: ${item.name}`);
58
+ }
59
+ if (item.type === "number") {
60
+ for (const number of [item.default, item.minimum, item.maximum]) {
61
+ if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number)))
62
+ throw new Error(`Invalid numeric configuration: ${item.name}`);
63
+ }
64
+ if (item.minimum !== undefined && item.maximum !== undefined && item.minimum > item.maximum) {
65
+ throw new Error(`Configuration minimum exceeds maximum: ${item.name}`);
66
+ }
67
+ }
68
+ if (item.type === "dropdown" &&
69
+ (!Array.isArray(item.data) ||
70
+ item.data.some((option) => !option || typeof option.title !== "string" || typeof option.value !== "string"))) {
71
+ throw new Error(`Invalid dropdown configuration: ${item.name}`);
72
+ }
73
+ }
@@ -0,0 +1,62 @@
1
+ import type { ActivitiesApi, Activity, ActivityFilter, CategoriesApi, Category, Change, Session, SessionData, SessionFilter, SessionsApi } from "../types.ts";
2
+ import type { Collection } from "./collection.ts";
3
+ import type { ActivityRow, CategoryRow, SessionRow, WorkspaceRowChanges } from "./rows.ts";
4
+ export interface WorkspaceCollections {
5
+ readonly activities: Collection<ActivityRow>;
6
+ readonly categories: Collection<CategoryRow>;
7
+ readonly sessions: Collection<SessionRow>;
8
+ }
9
+ /** Session operations. Each resolves with the session's row once the snapshot containing the result has arrived. */
10
+ export interface SessionActions {
11
+ start(activityId: string): Promise<unknown>;
12
+ end(sessionId: string): Promise<unknown>;
13
+ mark(activityId: string): Promise<unknown>;
14
+ discard(sessionId: string): Promise<unknown>;
15
+ }
16
+ export interface ModelChanges {
17
+ readonly activities: readonly Change<Activity>[];
18
+ readonly categories: readonly Change<Category>[];
19
+ readonly sessions: readonly Change<Session>[];
20
+ }
21
+ /** The reads and actions of `ctx.activities`, `ctx.categories` and `ctx.sessions`. Contexts add the events. */
22
+ export interface ModelApis {
23
+ readonly activities: Omit<ActivitiesApi, "onChange">;
24
+ readonly categories: Omit<CategoriesApi, "onChange">;
25
+ readonly sessions: Omit<SessionsApi, "onChange">;
26
+ }
27
+ export declare class WorkspaceModels {
28
+ #private;
29
+ readonly userId: string;
30
+ readonly rows: WorkspaceCollections;
31
+ constructor(options: {
32
+ readonly userId: string;
33
+ readonly rows: WorkspaceCollections;
34
+ readonly actions: SessionActions;
35
+ });
36
+ /** The reads and actions for `ctx`. */
37
+ apis(): ModelApis;
38
+ activity(id: string): Activity | undefined;
39
+ category(id: string): Category | undefined;
40
+ session(id: string): Session | undefined;
41
+ /** In the app's order: by category (uncategorized last), then `sortOrder`, then name. */
42
+ activities(filter?: ActivityFilter): readonly Activity[];
43
+ /** By `sortOrder`, then name. */
44
+ categories(): readonly Category[];
45
+ /** Newest first. */
46
+ sessions(filter?: SessionFilter): readonly Session[];
47
+ start(activityId: string): Promise<Session>;
48
+ end(sessionId: string): Promise<Session>;
49
+ mark(activityId: string): Promise<Session>;
50
+ discard(sessionId: string): Promise<void>;
51
+ /** Turn the row changes of a snapshot into model changes. Call it after the collections were replaced. */
52
+ update(changes: WorkspaceRowChanges): ModelChanges;
53
+ /**
54
+ * The model for a session the app sent outside a snapshot (a live event or an action result):
55
+ * the snapshot's model, or, when the row isn't in the snapshot, a removed model holding these values.
56
+ */
57
+ sessionFrom(row: SessionRow): Session;
58
+ activityRow(id: string): ActivityRow;
59
+ categoryRow(id: string): CategoryRow;
60
+ sessionData(id: string): SessionData;
61
+ }
62
+ //# sourceMappingURL=models.d.ts.map
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The models plugins see: `Activity`, `Category` and `Session` objects over the workspace rows.
3
+ *
4
+ * One `WorkspaceModels` per `ctx`. The plugin host builds it for main code; the UI client builds it
5
+ * inside every UI bundle, so this file is part of the UI wire contract: change it only additively
6
+ * within an API version.
7
+ *
8
+ * Every read goes to the latest snapshot in the collections, synchronously. Nothing is applied ahead
9
+ * of a snapshot. Lists are computed once per snapshot, so they keep their identity until the data
10
+ * changes.
11
+ */
12
+ import { PluginError } from "../errors.js";
13
+ const ACTIVITY_FIELDS = [
14
+ "id",
15
+ "name",
16
+ "description",
17
+ "trackingType",
18
+ "icon",
19
+ "iconPath",
20
+ "color",
21
+ "categoryId",
22
+ "archived",
23
+ "sortOrder",
24
+ "unit",
25
+ ];
26
+ const CATEGORY_FIELDS = [
27
+ "id",
28
+ "name",
29
+ "description",
30
+ "color",
31
+ "icon",
32
+ "iconPath",
33
+ "sortOrder",
34
+ ];
35
+ const SESSION_FIELDS = [
36
+ "id",
37
+ "activityId",
38
+ "type",
39
+ "status",
40
+ "memberIds",
41
+ "startedAt",
42
+ "endedAt",
43
+ ];
44
+ const gone = (what) => Promise.reject(new PluginError("not-found", `${what} no longer exists`));
45
+ const compareText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
46
+ /**
47
+ * Give a model its data fields as enumerable getters, so spreading, `JSON.stringify` and
48
+ * `postMessage` copy the current values, then freeze it.
49
+ */
50
+ function seal(model, fields, data) {
51
+ for (const field of fields) {
52
+ Object.defineProperty(model, field, { enumerable: true, get: () => data()[field] });
53
+ }
54
+ Object.freeze(model);
55
+ }
56
+ class ActivityModel {
57
+ #models;
58
+ #id;
59
+ constructor(models, id) {
60
+ this.#models = models;
61
+ this.#id = id;
62
+ seal(this, ACTIVITY_FIELDS, () => this.data);
63
+ }
64
+ get exists() {
65
+ return this.#models.rows.activities.get(this.#id) !== undefined;
66
+ }
67
+ get data() {
68
+ return this.#models.activityRow(this.#id);
69
+ }
70
+ get isSpan() {
71
+ return this.data.trackingType === "span";
72
+ }
73
+ get isPoint() {
74
+ return this.data.trackingType === "point";
75
+ }
76
+ get isLive() {
77
+ return this.live({ mine: true }).length > 0;
78
+ }
79
+ get category() {
80
+ const categoryId = this.data.categoryId;
81
+ return categoryId === null ? undefined : this.#models.category(categoryId);
82
+ }
83
+ sessions(filter) {
84
+ return this.#models.sessions({ ...filter, activityId: this.#id });
85
+ }
86
+ live(options) {
87
+ return this.#models.sessions({ status: "live", mine: options?.mine, activityId: this.#id });
88
+ }
89
+ start() {
90
+ return this.exists ? this.#models.start(this.#id) : gone(`Activity ${this.#id}`);
91
+ }
92
+ mark() {
93
+ return this.exists ? this.#models.mark(this.#id) : gone(`Activity ${this.#id}`);
94
+ }
95
+ track() {
96
+ return this.isSpan ? this.start() : this.mark();
97
+ }
98
+ end() {
99
+ const live = this.live({ mine: true })[0];
100
+ if (live)
101
+ return live.end();
102
+ return Promise.reject(new PluginError("not-found", `No live session of "${this.data.name}" for this user`));
103
+ }
104
+ }
105
+ class CategoryModel {
106
+ #models;
107
+ #id;
108
+ constructor(models, id) {
109
+ this.#models = models;
110
+ this.#id = id;
111
+ seal(this, CATEGORY_FIELDS, () => this.data);
112
+ }
113
+ get exists() {
114
+ return this.#models.rows.categories.get(this.#id) !== undefined;
115
+ }
116
+ get data() {
117
+ return this.#models.categoryRow(this.#id);
118
+ }
119
+ activities(filter) {
120
+ return this.#models.activities({ ...filter, categoryId: this.#id });
121
+ }
122
+ }
123
+ class SessionModel {
124
+ #models;
125
+ #id;
126
+ constructor(models, id) {
127
+ this.#models = models;
128
+ this.#id = id;
129
+ seal(this, SESSION_FIELDS, () => this.data);
130
+ }
131
+ get exists() {
132
+ return this.#models.rows.sessions.get(this.#id) !== undefined;
133
+ }
134
+ get data() {
135
+ return this.#models.sessionData(this.#id);
136
+ }
137
+ get isSpan() {
138
+ return this.data.type === "span";
139
+ }
140
+ get isPoint() {
141
+ return this.data.type === "point";
142
+ }
143
+ get isLive() {
144
+ return this.data.status === "live";
145
+ }
146
+ get isMine() {
147
+ return this.data.memberIds.includes(this.#models.userId);
148
+ }
149
+ get activity() {
150
+ return this.#models.activity(this.data.activityId);
151
+ }
152
+ duration(at = new Date()) {
153
+ const { type, startedAt, endedAt } = this.data;
154
+ if (type === "point")
155
+ return 0;
156
+ return Math.max(0, (endedAt ?? at).getTime() - startedAt.getTime());
157
+ }
158
+ end() {
159
+ return this.exists ? this.#models.end(this.#id) : gone(`Session ${this.#id}`);
160
+ }
161
+ discard() {
162
+ return this.exists ? this.#models.discard(this.#id) : gone(`Session ${this.#id}`);
163
+ }
164
+ }
165
+ const isSessionRow = (value) => typeof value === "object" && value !== null && typeof value.id === "string";
166
+ export class WorkspaceModels {
167
+ userId;
168
+ rows;
169
+ #actions;
170
+ #activities = new Map();
171
+ #categories = new Map();
172
+ #sessions = new Map();
173
+ /** The last row seen for each id, so a model whose row was removed keeps its values. */
174
+ #lastActivity = new Map();
175
+ #lastCategory = new Map();
176
+ #lastSession = new Map();
177
+ /** Session rows with `Date` fields, one per row version. Built per `ctx`: a `Date` can't be frozen. */
178
+ #sessionData = new WeakMap();
179
+ #lists = new Map();
180
+ #listsSnapshot = [];
181
+ constructor(options) {
182
+ this.userId = options.userId;
183
+ this.rows = options.rows;
184
+ this.#actions = options.actions;
185
+ }
186
+ /** The reads and actions for `ctx`. */
187
+ apis() {
188
+ return {
189
+ activities: {
190
+ list: (filter) => this.activities(filter),
191
+ get: (id) => this.activity(id),
192
+ },
193
+ categories: {
194
+ list: () => this.categories(),
195
+ get: (id) => this.category(id),
196
+ },
197
+ sessions: {
198
+ list: (filter) => this.sessions(filter),
199
+ get: (id) => this.session(id),
200
+ live: (options) => this.sessions({ status: "live", mine: options?.mine }),
201
+ start: (activityId) => this.start(activityId),
202
+ end: (sessionId) => this.end(sessionId),
203
+ mark: (activityId) => this.mark(activityId),
204
+ discard: (sessionId) => this.discard(sessionId),
205
+ },
206
+ };
207
+ }
208
+ // --------------------------------------------------------------------------
209
+ // Reads
210
+ // --------------------------------------------------------------------------
211
+ activity(id) {
212
+ return this.rows.activities.get(id) ? this.#activityModel(id) : undefined;
213
+ }
214
+ category(id) {
215
+ return this.rows.categories.get(id) ? this.#categoryModel(id) : undefined;
216
+ }
217
+ session(id) {
218
+ return this.rows.sessions.get(id) ? this.#sessionModel(id) : undefined;
219
+ }
220
+ /** In the app's order: by category (uncategorized last), then `sortOrder`, then name. */
221
+ activities(filter = {}) {
222
+ const { categoryId, trackingType, includeArchived = false } = filter;
223
+ const key = {
224
+ list: "activities",
225
+ categoryId: categoryId === undefined ? {} : categoryId,
226
+ trackingType,
227
+ includeArchived,
228
+ };
229
+ return this.#list(key, () => {
230
+ const rank = new Map(this.categories().map((category, index) => [category.id, index]));
231
+ const rankOf = (row) => row.categoryId === null ? rank.size : (rank.get(row.categoryId) ?? rank.size);
232
+ return this.rows.activities
233
+ .list()
234
+ .filter((row) => (includeArchived || !row.archived) &&
235
+ (categoryId === undefined || row.categoryId === categoryId) &&
236
+ (trackingType === undefined || row.trackingType === trackingType))
237
+ .sort((a, b) => rankOf(a) - rankOf(b) ||
238
+ a.sortOrder - b.sortOrder ||
239
+ compareText(a.name, b.name) ||
240
+ compareText(a.id, b.id))
241
+ .map((row) => this.#activityModel(row.id));
242
+ });
243
+ }
244
+ /** By `sortOrder`, then name. */
245
+ categories() {
246
+ return this.#list({ list: "categories" }, () => [...this.rows.categories.list()]
247
+ .sort((a, b) => a.sortOrder - b.sortOrder || compareText(a.name, b.name) || compareText(a.id, b.id))
248
+ .map((row) => this.#categoryModel(row.id)));
249
+ }
250
+ /** Newest first. */
251
+ sessions(filter = {}) {
252
+ const { activityId, status, mine = false } = filter;
253
+ return this.#list({ list: "sessions", activityId, status, mine }, () => this.rows.sessions
254
+ .list()
255
+ .filter((row) => (activityId === undefined || row.activityId === activityId) &&
256
+ (status === undefined || row.status === status) &&
257
+ (!mine || row.memberIds.includes(this.userId)))
258
+ .map((row) => ({ row, at: Date.parse(row.startedAt) }))
259
+ .sort((a, b) => b.at - a.at || compareText(a.row.id, b.row.id))
260
+ .map(({ row }) => this.#sessionModel(row.id)));
261
+ }
262
+ // --------------------------------------------------------------------------
263
+ // Actions
264
+ // --------------------------------------------------------------------------
265
+ async start(activityId) {
266
+ return this.#result(await this.#actions.start(activityId));
267
+ }
268
+ async end(sessionId) {
269
+ return this.#result(await this.#actions.end(sessionId));
270
+ }
271
+ async mark(activityId) {
272
+ return this.#result(await this.#actions.mark(activityId));
273
+ }
274
+ async discard(sessionId) {
275
+ await this.#actions.discard(sessionId);
276
+ }
277
+ // --------------------------------------------------------------------------
278
+ // Deliveries
279
+ // --------------------------------------------------------------------------
280
+ /** Turn the row changes of a snapshot into model changes. Call it after the collections were replaced. */
281
+ update(changes) {
282
+ return {
283
+ activities: changes.activities.map((change) => this.#change(change, this.#lastActivity, (id) => this.#activityModel(id), (row) => row)),
284
+ categories: changes.categories.map((change) => this.#change(change, this.#lastCategory, (id) => this.#categoryModel(id), (row) => row)),
285
+ sessions: changes.sessions.map((change) => this.#change(change, this.#lastSession, (id) => this.#sessionModel(id), (row) => this.#toSessionData(row))),
286
+ };
287
+ }
288
+ /**
289
+ * The model for a session the app sent outside a snapshot (a live event or an action result):
290
+ * the snapshot's model, or, when the row isn't in the snapshot, a removed model holding these values.
291
+ */
292
+ sessionFrom(row) {
293
+ if (!this.rows.sessions.get(row.id))
294
+ this.#lastSession.set(row.id, row);
295
+ return this.#sessionModel(row.id);
296
+ }
297
+ // --------------------------------------------------------------------------
298
+ // Rows, for the models
299
+ // --------------------------------------------------------------------------
300
+ activityRow(id) {
301
+ return latest(this.rows.activities.get(id), this.#lastActivity, id);
302
+ }
303
+ categoryRow(id) {
304
+ return latest(this.rows.categories.get(id), this.#lastCategory, id);
305
+ }
306
+ sessionData(id) {
307
+ return this.#toSessionData(latest(this.rows.sessions.get(id), this.#lastSession, id));
308
+ }
309
+ // --------------------------------------------------------------------------
310
+ // Internals
311
+ // --------------------------------------------------------------------------
312
+ #activityModel(id) {
313
+ let model = this.#activities.get(id);
314
+ if (!model) {
315
+ model = new ActivityModel(this, id);
316
+ this.#activities.set(id, model);
317
+ }
318
+ return model;
319
+ }
320
+ #categoryModel(id) {
321
+ let model = this.#categories.get(id);
322
+ if (!model) {
323
+ model = new CategoryModel(this, id);
324
+ this.#categories.set(id, model);
325
+ }
326
+ return model;
327
+ }
328
+ #sessionModel(id) {
329
+ let model = this.#sessions.get(id);
330
+ if (!model) {
331
+ model = new SessionModel(this, id);
332
+ this.#sessions.set(id, model);
333
+ }
334
+ return model;
335
+ }
336
+ #toSessionData(row) {
337
+ let data = this.#sessionData.get(row);
338
+ if (!data) {
339
+ data = Object.freeze({
340
+ ...row,
341
+ startedAt: new Date(row.startedAt),
342
+ endedAt: row.endedAt === null ? null : new Date(row.endedAt),
343
+ });
344
+ this.#sessionData.set(row, data);
345
+ }
346
+ return data;
347
+ }
348
+ #change(change, last, model, data) {
349
+ last.set(change.item.id, change.item);
350
+ const item = model(change.item.id);
351
+ if (change.type === "updated")
352
+ return { type: "updated", item, previous: data(change.previous) };
353
+ return { type: change.type, item };
354
+ }
355
+ #result(value) {
356
+ if (!isSessionRow(value))
357
+ throw new PluginError("failed", "Drift Beacon did not return the session");
358
+ return this.sessionFrom(value);
359
+ }
360
+ /** A list computed once per snapshot: the same frozen array until any collection changes. */
361
+ #list(key, compute) {
362
+ const snapshot = [this.rows.activities.list(), this.rows.categories.list(), this.rows.sessions.list()];
363
+ if (snapshot.some((list, index) => list !== this.#listsSnapshot[index])) {
364
+ this.#lists = new Map();
365
+ this.#listsSnapshot = snapshot;
366
+ }
367
+ const cacheKey = JSON.stringify(key);
368
+ let list = this.#lists.get(cacheKey);
369
+ if (!list) {
370
+ list = Object.freeze(compute());
371
+ this.#lists.set(cacheKey, list);
372
+ }
373
+ return list;
374
+ }
375
+ }
376
+ /** The row in the snapshot, remembered as the last one seen; otherwise the last one seen. */
377
+ function latest(row, last, id) {
378
+ if (row !== undefined) {
379
+ if (last.get(id) !== row)
380
+ last.set(id, row);
381
+ return row;
382
+ }
383
+ const remembered = last.get(id);
384
+ if (remembered === undefined)
385
+ throw new PluginError("not-found", `No data for ${id}`);
386
+ return remembered;
387
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The plugin package: `manifest.json`, `package.json`, `main/index.js` (plus chunks) and `ui/index.html`
3
+ * (plus assets and the manifest icon). Shared by the pack tool and the server. Pure: the web app bundles
4
+ * `/internal`, so no `node:` imports.
5
+ */
6
+ export declare const PACKAGE_LIMITS: {
7
+ readonly entries: 2000;
8
+ readonly fileBytes: number;
9
+ readonly totalBytes: number;
10
+ readonly archiveBytes: number;
11
+ readonly manifestBytes: number;
12
+ };
13
+ /** The `package.json` every package gets: main is ESM. */
14
+ export declare const PACKAGE_JSON: {
15
+ readonly type: "module";
16
+ readonly private: true;
17
+ };
18
+ export declare const REQUIRED_FILES: readonly ["main/index.js", "ui/index.html"];
19
+ /** manifest.json, package.json, main/** and ui/** (directories: main, ui and below). `name` has no trailing slash. */
20
+ export declare const isPackagePath: (name: string, directory: boolean) => boolean;
21
+ //# sourceMappingURL=package-layout.d.ts.map