Konubinix' opinionated web of thoughts

The Data Layer Behind My Photo Apps

Fleeting

Why this note

A handful of little apps read the same archive of photos and videos. At the time of writing there are three: the frise (a chronological timeline, Preact + HTM + urql), memories (a triage/slideshow wall, Solid), and doublons (a duplicate-photo batch resolver, Hyperapp) — and there may be more, or renamed ones, later. The render technology is deliberately different in each: that’s the point of the family, several honest takes on the same no-build, locality-of-behaviour, ESM-from-esm.sh recipe. But underneath, they all sit on one data layer — the same Postgres, the same PostGraphile, the same LAN auth gate, the same /ipfs/ gateway, the same lighttpd proxy that makes all of it same-origin.

This note is that shared layer. Each app note covers only what is particular to it (the frise’s ruler, memories’ lightbox, doublons’ heuristic) and links here for the common ground. Concretely it owns:

  • The docs database and the photovideo shape every app reads.
  • The GraphQL contract — the PostGraphile fields the apps query, and the opaque-result-JSON trick the duplicates view leans on.
  • The LAN gate — why /graphql is gated, and how (proxy + a single shared cookie).
  • The IPFS gateway and the proxy — why /graphql and /ipfs/ are same-origin in dev, test and prod, and the Traefik/consul routing that publishes each app.
  • The schema — the duplicates_photovideo view (canonical here); labels + FTS and the photovideos* functions still live in the frise (linked below).
  • The search DSL — the query language in the box, factored out as one shared ESM module (../shared/dsl.js) so it has a single source of truth across the apps rather than a copy each.

The docs database

The archive is a single Postgres database, docs, on the LAN host (192.168.2.14). Everything photo-related hangs off one table-ish entity, photovideo — every imported still or clip is a row, identified by its IPFS cid. The columns the apps actually read:

  • cid — the content address (also the IPFS path of the original); the primary key.
  • thumbnail_cid, web_cid — derived renditions: a small thumbnail for walls, a web-sized copy for the lightbox/enlarged tiles. Both are /ipfs/ paths.
  • date — the capture timestamp (or, for date-only imports, midnight T00:00:00).
  • mimetypeimage/* or video/*; how a photo is told from a clip.
  • owner — whose photo it is (konubinix, ayla, aylapomme, …); the archive holds more than one person’s photos.
  • labels — free-text, ;-separated tags: the metadata these apps read and search (through Postgres FTS). The DB also carries tag=/=tagmap join tables that these apps don’t read.
  • state — the triage lifecycle: todo / wip / done / bad / delete … . A soft delete is just state‘delete’=; the apps’ views exclude it, so a deleted row simply drops out of sight while staying recoverable.
  • perceptualhash — a perceptual fingerprint; equal hashes are near-identical images. This is what doublons groups on.
  • size — the original’s byte size; doublons uses it as a resolution proxy (there is no width/height column).
  • camera_type — the capturing app/device when known (e.g. whatsapp for a re-share); doublons’ heuristic prefers a real camera over a WhatsApp recompression.

Prod is only ever read, never written: each test harness seeds a throwaway copy of docs with pg_dump and runs against that.

The GraphQL contract

The apps never touch Postgres directly. PostGraphile (the konubinix/postgraphile image) reflects the public schema into a GraphQL API at /graphql, and that single endpoint is the whole contract. There is no app-side auth code, no ORM, no REST — every app just POST=s =\{query, variables\} to its own /graphql and reads JSON back.

The fields the apps rely on:

  • photovideosSample / photovideosCount / photovideosSearch — the frise’s server-side search functions: window by since=/=until, filter by states, kinds, owners, an onthisday anniversary window (aday=/=awin), and a free-text search over the FTS index, sampled down to a cap. Both the frise and memories drive their walls through these (memories’ query language compiles to exactly their variables). They’re defined in the frise’s schema chapter.
  • duplicatesPhotovideos — the connection doublons reads. One node per perceptual-hash cluster, each carrying an opaque result JSON; see the schema chapter below.
  • updatePhotovideo(input:\{cid, patch\}) — the mutation every app uses to change a row, almost always to set state (label edits, marking for deletion, doublons’ soft-delete of the losers). labelCompletions(prefix:) feeds the label autocomplete.

The opaque-result-JSON trick. The duplicates_photovideo view emits a single jsonb column, result. To PostGraphile that’s one scalar of type JSON — it can’t see inside it. So you can add keys to the JSON (a new per-duplicate field) without changing the GraphQL schema at all, which means no PostGraphile restart when the view changes shape. (Contrast a real new column, which would change the reflected type.) That’s why doublons can evolve its cluster payload by editing only the view’s SQL.

The search DSL — one shared module

The search box is small enough to double as a query language. A query is a ;-separated list of segments — the semicolon, not whitespace, because the free text carries its own spaces and operators (below) and splitting on those would shatter it. Each segment is either free text or a typed token: a since:=/=until: date bound, a type:=/=sort:=/=owner: filter, or onthisday. Anything that isn’t a well-formed token is free text, so a half-typed since:201 simply binds nothing yet.

Left alone, a query means “the whole archive”: the parser’s bounds default to the floor of the dated collection — 2007, the same floor the frise uses — up to today.

export const MIN_DATE = '2007-01-01';
export const todayISO = () => new Date(Date.now()).toISOString().slice(0, 10);

The typed filters map a free word onto a closed vocabulary, so a typo binds nothing rather than mis-filtering. type: takes a few friendly aliases (photo, movie, …) but resolves to a mimetype prefix, image or video. sort: is just the wall’s order — date by default, or random, the archive’s legacy myrandom draw (an ordering, nothing the frame knows about). owner: must name one of the archive’s owners. And a date is recognised only in YYYY, YYYY-MM or YYYY-MM-DD shape.

const DATE_RE = /^\d{4}(-\d{1,2}){0,2}$/;
const KIND_ALIAS = { image: 'image', images: 'image', photo: 'image', photos: 'image',
                     video: 'video', videos: 'video', movie: 'video' };
const SORT_ALIAS = { date: 'date', random: 'random', shuffle: 'random' };
const KINDS = ['image', 'video'];                    // the closed mimetype vocabulary
const OWNERS = ['konubinix', 'ayla', 'aylapomme'];
// a leading - negates a closed-vocab token: -type:video is "every kind but video". Since
// both vocabularies are small and closed, exclusion is just the complement, computed here —
// no server-side "not in" needed. include wins where given; otherwise start from the whole
// vocabulary and drop the excluded members (so -owner:a -owner:b removes both).
const resolveSet = (inc, exc, all) => {
    if(!exc.length) return inc.length ? inc : null;
    const out = (inc.length ? inc : all).filter(x => !exc.includes(x));
    return out.length ? out : null;
};

A since:=/=until: names a period, not an instant, so we snap it to the period’s edge: since to the first moment, until to the last. That is why until:2015 keeps all of 2015, and a bare until:2020-02 reaches the 29th. A partial date fills its missing parts from that same edge.

const startOf = v => { const [y, m, d] = v.split('-');
    return `${y}-${(m || '01').padStart(2, '0')}-${(d || '01').padStart(2, '0')}`; };
const endOf = v => { const [y, m, d] = v.split('-');
    if(d) return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}T23:59:59`;
    const M = m ? +m : 12, last = new Date(Date.UTC(+y, M, 0)).getUTCDate();
    return `${y}-${String(M).padStart(2, '0')}-${last}T23:59:59`; };

A whole calendar year is named directly too: year:2015 is exactly since:2015 until:2015, both bounds snapped to that year’s edges — year:current for this one.

A bound can also be a relative word, so you rarely type a date at all: today, yesterday, lastweek=/=lastmonth=/=lastyear, or N days/weeks/months/years ago. Each resolves against today into a plain YYYY-MM-DD; an unrecognised word resolves to nothing, leaving the bound at its default.

const RELATIVES = ['today', 'yesterday', 'lastweek', 'lastmonth', 'lastyear'];
function relativeDate(word){
    const n = new Date(Date.now());
    const d = new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate()));
    const ago = /^(\d+)\s*(day|week|month|year)s?\s+ago$/.exec(word);
    if(ago){
        const k = +ago[1];
        if(ago[2] === 'day') d.setUTCDate(d.getUTCDate() - k);
        else if(ago[2] === 'week') d.setUTCDate(d.getUTCDate() - 7 * k);
        else if(ago[2] === 'month') d.setUTCMonth(d.getUTCMonth() - k);
        else d.setUTCFullYear(d.getUTCFullYear() - k);
    }
    else if(word === 'yesterday') d.setUTCDate(d.getUTCDate() - 1);
    else if(word === 'lastweek') d.setUTCDate(d.getUTCDate() - 7);
    else if(word === 'lastmonth') d.setUTCMonth(d.getUTCMonth() - 1);
    else if(word === 'lastyear') d.setUTCFullYear(d.getUTCFullYear() - 1);
    else if(word !== 'today') return null;
    return d.toISOString().slice(0, 10);
}

onthisday matches one calendar day across every year — today’s, ±1. That idea generalises into a pair of recurring-component filters that pin a row by its month or its day, ignoring the year it fell in. month: takes a number, a month name, or current (this month); day: takes an MM-DD or current (today). Either accepts a trailing (N) that widens the match to a ±N window — months for month:, days for day: — wrapping at the year boundary, so month:1(1) reaches December. Said this way, day:current(1) is onthisday:1. A value resolves to a target (a month 1–12, or a date whose year the SQL ignores) and a window; current reads the clock, a name or number maps to its month, an MM-DD projects onto a leap year so 02-29 stays valid.

const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june',
                'july', 'august', 'september', 'october', 'november', 'december'];
const winSplit = v => { const m = /^(.+?)\s*\((\d+)\)$/.exec(v); return m ? [m[1].trim(), +m[2]] : [v, null]; };
function monthNum(v){
    if(v === 'current') return +todayISO().slice(5, 7);
    if(/^\d{1,2}$/.test(v)){ const n = +v; return n >= 1 && n <= 12 ? n : null; }
    return (MONTHS.indexOf(v) + 1) || null;          // indexOf -1 → 0 → null
}
function dayDate(v){
    if(v === 'current') return todayISO();
    const m = /^(\d{1,2})-(\d{1,2})$/.exec(v); if(!m) return null;
    const mo = +m[1], d = +m[2]; if(mo < 1 || mo > 12 || d < 1 || d > 31) return null;
    return `2000-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;   // any (leap) year; SQL drops it
}

With the vocabularies and the date helpers in hand, the parser walks the segments and folds each into the filter variables the contract’s photovideos* functions take. A key:value segment routes on its key: month:=/=day: set the recurring target and its window, onthisday[:N] the same anniversary as day:current(:N); everything else is free text, joined and handed to Postgres websearch_to_tsquery untouched — so a label search reads as boolean: terms AND, or unions, a leading - excludes, "…" is a phrase.

That leading - isn’t only for labels. On a closed-vocabulary filter it negates too: -type:video keeps every kind but video, -owner:konubinix every owner but him. Because those vocabularies are small and closed, exclusion needs no new server arg — it is just the complement, taken here (start from the whole set, drop the excluded members, so stacking -owner:a -owner:b removes both) and handed to the same kinds=/=owners the include form fills. The open-ended filters (since:=/=until:=/=month:=/=day:) don’t take a -; a date bound has no complement, and a recurring window would need a server-side not.

export function parseQuery(text){
    let since = MIN_DATE, until = todayISO(), sort = 'date', aday = null, awin = 1, month = null, mwin = 0;
    let sinceSet = false, untilSet = false;   // did a token set the window edge (vs the default)?
    const labels = [], kinds = [], kindsX = [], owners = [], ownersX = [];
    for(const raw of (text || '').split(';')){
        const t = raw.trim(); if(!t) continue;
        const od = /^onthisday(?::(\d+))?$/i.exec(t);
        if(od){ aday = todayISO(); if(od[1]) awin = +od[1]; continue; }
        // a -type:/-owner: token excludes; route its value to the exclusion set
        const nm = /^-(type|owner):(.+)$/i.exec(t);
        if(nm){ const v = nm[2].trim().toLowerCase();
            if(nm[1].toLowerCase() === 'type'){ const k = KIND_ALIAS[v]; if(k) kindsX.push(k); }
            else if(OWNERS.includes(v)) ownersX.push(v);
            continue; }
        const m = /^(since|until|type|sort|owner|month|day|year):(.+)$/i.exec(t);
        if(!m){ labels.push(t); continue; }
        const key = m[1].toLowerCase(), v = m[2].trim();
        if(key === 'type'){ const k = KIND_ALIAS[v.toLowerCase()]; if(k) kinds.push(k); continue; }
        if(key === 'sort'){ const sv = SORT_ALIAS[v.toLowerCase()]; if(sv) sort = sv; continue; }
        if(key === 'owner'){ const o = v.toLowerCase(); if(OWNERS.includes(o)) owners.push(o); continue; }
        if(key === 'month'){ const [base, w] = winSplit(v.toLowerCase()); const mo = monthNum(base);
            if(mo){ month = mo; mwin = w ?? 0; } continue; }
        if(key === 'day'){ const [base, w] = winSplit(v.toLowerCase()); const dd = dayDate(base);
            if(dd){ aday = dd; awin = w ?? 0; } continue; }
        if(key === 'year'){ const y = v.toLowerCase() === 'current' ? todayISO().slice(0, 4) : v;
            if(/^\d{4}$/.test(y)){ since = startOf(y); until = endOf(y); sinceSet = untilSet = true; } continue; }
        const rel = relativeDate(v.toLowerCase());
        if(rel) key === 'since' ? (since = rel, sinceSet = true) : (until = rel + 'T23:59:59', untilSet = true);
        else if(DATE_RE.test(v)) key === 'since' ? (since = startOf(v), sinceSet = true) : (until = endOf(v), untilSet = true);
    }
    return { search: labels.join(' '),
             since, until, kinds: resolveSet(kinds, kindsX, KINDS),
             owners: resolveSet(owners, ownersX, OWNERS), sort, aday, awin, month, mwin,
             dateBounds: (sinceSet || untilSet)
                 ? { from: sinceSet ? since : null, to: untilSet ? until : null } : null };
}

An app whose date range lives somewhere other than the box — the frise, whose range is its timeline window — needs two things the box knows and shouldn’t re-derive: which tokens are window commands (since:=/=until:=/=year:, not the recurring month:=/=day:) and the bounds they resolve to. parseQuery reports the latter as dateBounds (null when the query names no window; each edge filled only when its token is present), and stripWindowTokens removes just those tokens from the text, leaving the other filters. So such an app routes a typed date to its window while re-learning no syntax — and a new window token added here reaches it untouched.

export const stripWindowTokens = text => (text || '').split(';')
    .map(s => s.trim()).filter(s => s && !/^(since|until|year):/i.test(s)).join('; ');

Completion reuses that same ;-split. Whichever box you’re in — the search box following a live caret, or a label box sitting at end-of-text — the unit to complete is the segment the caret falls in; picking a value rewrites only that segment, keeping its leading “; " spacing intact.

export const segSpan = (text, caret) => {
    let s = caret, e = caret;
    while(s > 0 && text[s - 1] !== ';') s--;
    while(e < text.length && text[e] !== ';') e++;
    return [s, e];
};
export const segAt = (text, caret) => text.slice(...segSpan(text, caret)).trim();
export const replaceSegAt = (text, caret, picked) => { const [s, e] = segSpan(text, caret);
    const lead = (text.slice(s, e).match(/^\s*/) || [''])[0];
    return text.slice(0, s) + lead + picked + text.slice(e); };
export const replaceSeg = (text, picked) => replaceSegAt(text, (text || '').length, picked);

Inside an open since:=/=until: the completion turns into a progressive date picker, so you tap a date rather than spell it — drilling from the relative words and years down through months to days. A non-numeric fragment narrows the relative words by prefix, so last shortlists lastweek/lastmonth/lastyear.

const ARCHIVE_START = +MIN_DATE.slice(0, 4);
export function dateSuggestions(key, val){
    if(!val){
        const out = RELATIVES.map(r => key + r), thisYear = +todayISO().slice(0, 4);
        for(let y = thisYear; y >= ARCHIVE_START; y--) out.push(key + y);
        return out;
    }
    if(!/^\d/.test(val))
        return RELATIVES.filter(r => r.startsWith(val.toLowerCase())).map(r => key + r);
    const m = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/.exec(val);
    if(!m) return [];
    const [, y, mo, d] = m;
    if(mo === undefined){
        const out = [key + y];
        for(let i = 1; i <= 12; i++) out.push(`${key}${y}-${String(i).padStart(2, '0')}`);
        return out;
    }
    const mm = mo.padStart(2, '0');
    if(d === undefined){
        const last = new Date(Date.UTC(+y, +mo, 0)).getUTCDate(), out = [`${key}${y}-${mm}`];
        for(let i = 1; i <= last; i++) out.push(`${key}${y}-${mm}-${String(i).padStart(2, '0')}`);
        return out;
    }
    return [`${key}${y}-${mm}-${d.padStart(2, '0')}`];
}

Before a value is even started, completion offers the token keys themselves, and for the closed filters their members — the same vocabulary the parser accepts.

export const DSL_KEYS = ['since:', 'until:', 'type:', 'sort:', 'owner:', 'month:', 'day:', 'year:', 'onthisday'];
export const ENUMS = { 'type:': ['image', 'video'], 'sort:': ['date', 'random'],
                       'owner:': ['konubinix', 'ayla', 'aylapomme'],
                       'month:': ['current', ...MONTHS], 'day:': ['current'] };

Completing the tokens is shared too. Given the segment under the caret, dslSuggestions offers the keys a prefix matches, a closed filter’s members, the run of years inside an open year:, or — inside an open since:=/=until: — the date picker; it returns nothing for free text, so each app falls back to its own label vocabulary there.

export function dslSuggestions(seg){
    if(seg[0] === '-'){                              // negation completes only the closed-vocab keys
        const s = seg.slice(1), nem = /^(type|owner):(.*)$/i.exec(s);
        if(nem){ const key = nem[1].toLowerCase() + ':';
            return ENUMS[key].filter(v => v.startsWith(nem[2].toLowerCase())).map(v => '-' + key + v); }
        const ns = s.toLowerCase();
        return s.length >= 2 ? ['type:', 'owner:'].filter(k => k.startsWith(ns)).map(k => '-' + k) : [];
    }
    const m = /^(since|until):(.*)$/i.exec(seg);
    if(m) return dateSuggestions(m[1].toLowerCase() + ':', m[2]);
    const ym = /^year:(.*)$/i.exec(seg);
    if(ym){ const v = ym[1].toLowerCase(), ty = +todayISO().slice(0, 4), out = ['year:current'];
        for(let y = ty; y >= ARCHIVE_START; y--) out.push('year:' + y);
        return out.filter(s => s.toLowerCase().startsWith('year:' + v)); }
    const em = /^(type|sort|owner|month|day):(.*)$/i.exec(seg);
    if(em){ const key = em[1].toLowerCase() + ':';
        return ENUMS[key].filter(v => v.startsWith(em[2].toLowerCase())).map(v => key + v); }
    const lower = seg.toLowerCase();
    return seg.length >= 2 ? DSL_KEYS.filter(k => k.startsWith(lower)) : [];
}

Why a module, not a copy each. All of the above is data-layer code — how you address the shared GraphQL contract — and it is pure: no DOM, no framework. So it has no business being duplicated in several render stacks. It tangles to one physical file, /run/user/1000/shared/dsl.js, which each app imports with the relative specifier ../shared/dsl.js. What stays in each app is the vocabulary completion — the app-side call to labelCompletions for label words — and the rendering of the suggestion list. Memories is the first consumer; the frise’s narrower label search and doublons’ cluster wall can adopt it as they grow a query box.

Tested through its consumers. The DSL carries no test harness of its own — it’s exercised end-to-end through the memories and frise Playwright suites: the since:=/=until: range, the year→month→day completion, the relative-date shortcuts, the owner: filter, and the month:=/=day: recurring filters (token completion and the wall narrowing across years). That is the honest seam — a user types into the box and the wall narrows — so changing the module and re-running those suites proves it.

Assembling the query — shared/data.js

The DSL turns the box’s text into a parsed query; turning that parse into the variables the server wants is a second concern, and a second shared module, shared/data.js. The apps differ in how they bind reactivity — memories’ Solid createResource, the frise’s urql hooks — and how they render, but what they ask is one shape, so it lives here rather than copied into each.

A window shows every photo it holds up to a cap — high enough that any window you can actually read shows every one; only a denser one samples down to it. One number, shared:

export const SAMPLE_CAP = 2000;

A parsed query becomes server variables the same way for everyone — the free text, the closed-vocabulary filters, the recurring month/day window. Only the date range differs: memories takes it from the parsed since=/=until, the frise from its timeline window — so that pair is an argument and the rest is read off the parse.

export function photoVars(parsed, { since, until } = {}){
    return { search: (parsed.search || '').trim(),
             since: since ?? parsed.since, until: until ?? parsed.until,
             kinds: parsed.kinds, owners: parsed.owners,
             aday: parsed.aday, awin: parsed.awin,
             amonth: parsed.month, mwin: parsed.mwin };
}

Those same nine fields recur in every photo query in two more forms: the variable declarations a query opens with, and the arguments that pass them to the server function — identical wherever they appear, since only each query’s own extras (a state filter, a sampling cap, a density window) differ. So the filter lives here in all three forms — the values photoVars builds, and the two strings a query splices into its declaration and its call — leaving each query to show only what makes it distinct.

export const PHOTO_FILTER_DECL =
  '$search:String!,$since:Datetime!,$until:Datetime!,$kinds:[String!],$owners:[OwnerType!],$aday:Date,$awin:Int,$amonth:Int,$mwin:Int';
export const PHOTO_FILTER_ARGS =
  'search:$search,since:$since,until:$until,kinds:$kinds,owners:$owners,aday:$aday,awin:$awin,amonth:$amonth,mwin:$mwin';

Both node-returning queries — memories’ sample and the frise’s search — ask for the same core of each photo: its content ids (cid, thumbnailCid, webCid), its file facts (date, mimetype, filename), and its labels. Only a field or two beyond that is app-specific, so the core is one fragment each query spreads and extends.

export const PHOTO_CORE = `fragment PhotoCore on Photovideo {
  cid date thumbnailCid webCid mimetype filename labels }`;

Reading is one shape; the one write the apps share is another — a label or state change on a single photo, through the auto-generated updatePhotovideo with a free-form PhotovideoPatch. It asks for the changed row back rather than a bare acknowledgement: a result that carries a Photovideo marks every cached view holding that row stale, so each app’s next read of it comes back fresh.

export const UPDATE_PHOTO = `mutation($cid:String!,$patch:PhotovideoPatch!){
  updatePhotovideo(input:{cid:$cid,patch:$patch}){ photovideo { cid labels state } } }`;

One more query both apps share: the label-completion lookup. As you type a label, each offers the words already in use — a prefix in, the matching words out. memories caps the list to a handful, the frise takes them all, so the cap rides as a parameter.

export const LABEL_COMPLETIONS = `query($prefix:String!, $first:Int){ labelCompletions(prefix:$prefix, first:$first){ nodes } }`;

The shared GraphQL client — shared/gql.js

Sending the query is one more shared concern. It goes to /graphql on the app’s own origin, behind the LAN auth gate — a 401/403 there means this device has no grant. The client that sends it, caches it, and reads that gate is shared/gql.js: an urql client whose document cache dedups a window’s repeated queries and serves a revisited one without a round-trip, wrapping a fetch that flips a tiny framework-agnostic store when the gate refuses. A consumer binds its own reactivity to that store and queries through the client.

import { createClient, cacheExchange, fetchExchange } from '@urql/core';
let _authNeeded = false;
const _authListeners = new Set();
const setAuthNeeded = v => { if(v !== _authNeeded){ _authNeeded = v; _authListeners.forEach(fn => fn(v)); } };
export const getAuthNeeded = () => _authNeeded;
export function subscribeAuth(fn){ _authListeners.add(fn); fn(_authNeeded); return () => _authListeners.delete(fn); }
export const client = createClient({
    url: '/graphql',
    fetch: async (input, init) => {
        const r = await fetch(input, init);
        if(r.status === 401 || r.status === 403) setAuthNeeded(true);
        else if(r.ok) setAuthNeeded(false);
        return r;
    },
    exchanges: [cacheExchange, fetchExchange],
});

TODO

  1. deal with - generically, like -owner:… or -type:video — a leading - on a closed-vocab token (type:=/=owner:) now negates it, resolved as the set complement at parse time (no server arg). Open-ended filters (since:=/=until:=/=month:=/=day:) still don’t take a -; a recurring window would need a server-side not (a later step).

Notes linking here