Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ interface Rule {

type Category = [string[], Rule];

interface BaseQueryParams {
export interface BaseQueryParams {
include_audible?: boolean;
categories: Category[];
filter_categories: string[][];
Expand All @@ -63,14 +63,14 @@ interface BaseQueryParams {
return_variable_suffix?: string;
}

interface DesktopQueryParams extends BaseQueryParams {
export interface DesktopQueryParams extends BaseQueryParams {
bid_window: string;
bid_afk: string;
filter_afk: boolean;
always_active_pattern?: string;
}

interface AndroidQueryParams extends BaseQueryParams {
export interface AndroidQueryParams extends BaseQueryParams {
bid_android: string;
/** True when the bucket is an aw-import-screentime (iOS) bucket.
* ScreenTime events carry a "title" key; aw-watcher-android events do not.
Expand All @@ -79,12 +79,14 @@ interface AndroidQueryParams extends BaseQueryParams {
isIos?: boolean;
}

interface MultiQueryParams extends BaseQueryParams {
export interface MultiQueryParams extends BaseQueryParams {
hosts: string[];
filter_afk: boolean;
always_active_pattern: string;
// This can be used to override params on a per-host basis
host_params: { [host: string]: DesktopQueryParams | AndroidQueryParams };
// This can be used to override params on a per-host basis. Only the
// keys present (and non-empty) are applied, so partial objects such as
// {bid_window, bid_afk} are valid overrides.
host_params: { [host: string]: Partial<DesktopQueryParams> | Partial<AndroidQueryParams> };
}

function get_params(
Expand All @@ -107,9 +109,12 @@ function get_params(
console.error(`Invalid host_params for host ${host}: ${JSON.stringify(host_params)}`);
}
// Only override the params if they are defined and set to a truthy value
Object.keys(host_params).forEach(key => {
if (host_params[key] && host_params[key].length > 0) {
new_params[key] = host_params[key];
const overrides = host_params as Record<string, unknown>;
const target = new_params as unknown as Record<string, unknown>;
Object.keys(overrides).forEach(key => {
const value = overrides[key];
if ((typeof value === 'string' || Array.isArray(value)) && value.length > 0) {
target[key] = value;
}
});
}
Expand Down
24 changes: 19 additions & 5 deletions src/stores/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useBucketsStore } from '~/stores/buckets';
import { useCategoryStore } from '~/stores/categories';

import { getClient } from '~/util/awclient';
import { buildMultideviceHostParams } from '~/util/multidevice';
import {
FullDesktopQueryResult,
mergeFullDesktopResults,
Expand Down Expand Up @@ -297,12 +298,13 @@ export const useActivityStore = defineStore('activity', {
);
if (settingsStore.useMultidevice) {
const hostnames = bucketsStore.hosts.filter(
// require that the host has window buckets,
// and that the host is not a fakedata host,
// unless we're explicitly querying fakedata
// require that the host has both window and afk buckets
// (canonicalEvents needs the pair), and that the host is not
// a fakedata host, unless we're explicitly querying fakedata
host =>
host &&
bucketsStore.bucketsWindow(host).length > 0 &&
bucketsStore.bucketsAFK(host).length > 0 &&
(!host.startsWith('fakedata') || query_options.host.startsWith('fakedata'))
);
console.info('Including hosts in multiquery: ', hostnames);
Expand Down Expand Up @@ -418,13 +420,25 @@ export const useActivityStore = defineStore('activity', {
) {
const periods = periodsForFullDesktopQuery(timeperiod);
const categories = useCategoryStore().classes_for_query;
const bucketsStore = useBucketsStore();

const q = queries.multideviceQuery({
// Pass each host's actual bucket IDs (see buildMultideviceHostParams),
// so that buckets synced from another host — whose IDs carry an
// "-synced-from-<host>" suffix — are queried instead of the
// reconstructed "aw-watcher-window_<host>" IDs which don't exist in
// the local datastore.
const { host_params, hosts_with_buckets } = buildMultideviceHostParams(
hosts,
host => bucketsStore.bucketsWindow(host),
host => bucketsStore.bucketsAFK(host)
);

const q = queries.multideviceQuery({
hosts: hosts_with_buckets,
filter_afk,
categories,
filter_categories,
host_params: {},
host_params,
always_active_pattern,
});
const merged = await queryDesktopPeriods(periods, q, 'multidevice');
Expand Down
42 changes: 42 additions & 0 deletions src/util/multidevice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Pure helpers for building the per-host bucket-ID overrides used by the
// multidevice query. Kept free of store imports so they can be unit-tested
// in isolation.

export interface MultideviceHostSelection {
/** Per-host bucket-ID overrides, keyed by hostname. */
host_params: { [host: string]: { bid_window: string; bid_afk: string } };
/** The subset of `hosts` that had both a window and an afk bucket. */
hosts_with_buckets: string[];
}

/**
* Build per-host bucket-ID overrides for the multidevice query from the
* actual buckets available for each host.
*
* This is needed because buckets synced from another host (via aw-sync)
* keep their original hostname but carry an "-synced-from-<host>" suffix in
* their bucket ID, so the reconstructed "aw-watcher-window_<hostname>" IDs
* do not exist in the local datastore.
*
* Hosts lacking either a window or an afk bucket are skipped (with a
* warning), since canonicalEvents requires the pair.
*/
export function buildMultideviceHostParams(
hosts: string[],
bucketsWindow: (host: string) => string[],
bucketsAFK: (host: string) => string[]
): MultideviceHostSelection {
const host_params: MultideviceHostSelection['host_params'] = {};
const hosts_with_buckets: string[] = [];
hosts.forEach(host => {
const bid_window = bucketsWindow(host)[0];
const bid_afk = bucketsAFK(host)[0];
if (bid_window && bid_afk) {
host_params[host] = { bid_window, bid_afk };
hosts_with_buckets.push(host);
} else {
console.warn(`Skipping host ${host} in multidevice query: missing window/afk bucket`);
}
});
return { host_params, hosts_with_buckets };
}
90 changes: 90 additions & 0 deletions test/multidevice.test.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { buildMultideviceHostParams } from '~/util/multidevice';
import queries from '~/queries';

// Simulated bucket inventories, mirroring how aw-sync stores pulled data:
// bucket IDs carry an "-synced-from-<host>" suffix while the hostname
// (which the store groups by) stays the original.
const windowBuckets: { [host: string]: string[] } = {
myhost: ['aw-watcher-window_myhost'],
otherhost: ['aw-watcher-window_otherhost-synced-from-otherhost'],
noafkhost: ['aw-watcher-window_noafkhost'],
nowindowhost: [],
};
const afkBuckets: { [host: string]: string[] } = {
myhost: ['aw-watcher-afk_myhost'],
otherhost: ['aw-watcher-afk_otherhost-synced-from-otherhost'],
noafkhost: [],
nowindowhost: ['aw-watcher-afk_nowindowhost'],
};

describe('buildMultideviceHostParams', () => {
it('uses the actual bucket IDs for each host', () => {
const { host_params, hosts_with_buckets } = buildMultideviceHostParams(
['myhost', 'otherhost'],
host => windowBuckets[host] || [],
host => afkBuckets[host] || []
);
expect(hosts_with_buckets).toEqual(['myhost', 'otherhost']);
expect(host_params['myhost']).toEqual({
bid_window: 'aw-watcher-window_myhost',
bid_afk: 'aw-watcher-afk_myhost',
});
expect(host_params['otherhost']).toEqual({
bid_window: 'aw-watcher-window_otherhost-synced-from-otherhost',
bid_afk: 'aw-watcher-afk_otherhost-synced-from-otherhost',
});
});

it('skips hosts that lack either a window or an afk bucket', () => {
const { host_params, hosts_with_buckets } = buildMultideviceHostParams(
['noafkhost', 'nowindowhost'],
host => windowBuckets[host] || [],
host => afkBuckets[host] || []
);
expect(hosts_with_buckets).toEqual([]);
expect(host_params).toEqual({});
});
});

describe('multideviceQuery with host_params overrides', () => {
const baseParams = {
filter_afk: true,
categories: [],
filter_categories: [],
always_active_pattern: '',
};

it('queries hosts by their actual (synced) bucket IDs', () => {
const q = queries
.multideviceQuery({
...baseParams,
hosts: ['otherhost'],
host_params: {
otherhost: {
bid_window: 'aw-watcher-window_otherhost-synced-from-otherhost',
bid_afk: 'aw-watcher-afk_otherhost-synced-from-otherhost',
},
},
})
.join('\n');
expect(q).toContain('query_bucket("aw-watcher-window_otherhost-synced-from-otherhost")');
expect(q).toContain('query_bucket("aw-watcher-afk_otherhost-synced-from-otherhost")');
// The reconstructed ID (without the -synced-from- suffix) must not be
// queried — it does not exist in the local datastore and would fail
// with BucketNotFound.
expect(q).not.toContain('query_bucket("aw-watcher-window_otherhost")');
expect(q).not.toContain('query_bucket("aw-watcher-afk_otherhost")');
});

it('falls back to reconstructed IDs when no override is given', () => {
const q = queries
.multideviceQuery({
...baseParams,
hosts: ['myhost'],
host_params: {},
})
.join('\n');
expect(q).toContain('query_bucket("aw-watcher-window_myhost")');
expect(q).toContain('query_bucket("aw-watcher-afk_myhost")');
});
});