mnfst 0.5.247 → 0.5.249

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  (function () {
4
4
 
5
- const MANIFEST_BUILD_VERSION = '0.5.247';
5
+ const MANIFEST_BUILD_VERSION = '0.5.249';
6
6
 
7
7
  /* Manifest Data Sources - Appwrite Integration */
8
8
 
@@ -91,6 +91,20 @@ async function initializeAppwriteClient() {
91
91
  return appwriteClientPromise;
92
92
  }
93
93
 
94
+ // Shared Realtime: ONE instance (one socket) per tab, keyed to the client it
95
+ // wraps. Auth identity events drop it (below) so the next use rebuilds against
96
+ // the current session; manifest.data.realtime.js re-binds live channels.
97
+ let realtimeCache = null;
98
+ let realtimeClient = null;
99
+ function getSharedRealtime(client) {
100
+ if (!client || !window.Appwrite?.Realtime) return null;
101
+ if (!realtimeCache || realtimeClient !== client) {
102
+ realtimeCache = new window.Appwrite.Realtime(client);
103
+ realtimeClient = client;
104
+ }
105
+ return realtimeCache;
106
+ }
107
+
94
108
  // Get Appwrite data services (TablesDB, Storage, Realtime)
95
109
  // Works with or without auth plugin
96
110
  async function getAppwriteDataServices() {
@@ -102,7 +116,7 @@ async function getAppwriteDataServices() {
102
116
  return {
103
117
  tablesDB: window.Appwrite.TablesDB ? new window.Appwrite.TablesDB(client) : null,
104
118
  storage: window.Appwrite.Storage ? new window.Appwrite.Storage(client) : null,
105
- realtime: window.Appwrite.Realtime ? new window.Appwrite.Realtime(client) : null
119
+ realtime: getSharedRealtime(client)
106
120
  };
107
121
  }
108
122
 
@@ -110,7 +124,10 @@ async function getAppwriteDataServices() {
110
124
  let authCheck = null;
111
125
  let authVerified = false;
112
126
  ['manifest:auth:login', 'manifest:auth:logout', 'manifest:auth:anonymous', 'manifest:auth:session-cleared', 'manifest:auth:initialized']
113
- .forEach(type => window.addEventListener(type, () => { authCheck = null; authVerified = false; }));
127
+ .forEach(type => window.addEventListener(type, () => {
128
+ authCheck = null; authVerified = false;
129
+ realtimeCache = null; realtimeClient = null; // socket re-authenticates as the new identity
130
+ }));
114
131
 
115
132
  async function verifyAuthenticated() {
116
133
  if (!window.ManifestAppwriteAuthConfig || authVerified) return;
@@ -665,159 +682,278 @@ window.ManifestDataAppwrite = {
665
682
  };
666
683
 
667
684
 
668
- /* Manifest Data Sources - Real-time Subscriptions */
685
+ /* Manifest Data Sources - Real-time Subscriptions
686
+ One shared Realtime client per tab; subscriptions ref-counted by
687
+ (channel + canonical server-filter set) across sources.
688
+
689
+ Appwrite Realtime queries (server-side event filtering), verified against
690
+ the Web SDK and server source 2026-10:
691
+ - Realtime#subscribe(channels, callback, queries?) — queries are per
692
+ subscription (Query strings, ANDed; OR across subscriptions), supported
693
+ ops: equal/notEqual/lessThan(Equal)/greaterThan(Equal)/isNull/isNotNull/
694
+ and/or. SDK >=25.0.0 sends {type:'subscribe', data:[{subscriptionId,
695
+ channels, queries}]} socket messages and dispatches by the matched
696
+ subscription ids the server echoes; 22.4-24 carried per-slot queries in
697
+ the socket URL. subscribe() resolves to {unsubscribe, update, close}.
698
+ - Feature detection: a query-capable instance has pendingSubscribes (>=25)
699
+ or slotToSubscriptionId (22.4-24). Without either marker: channel-only
700
+ subscribe, exactly the 0.5.248 path.
701
+ - The server evaluates queries against the event payload only, so an
702
+ update that makes a held row STOP matching is not delivered. Therefore
703
+ serverFilter defaults to 'scope': only scope-column filters (permission-
704
+ mirrored, effectively immutable) ride the subscription; user queries stay
705
+ client-side so stop-matching rows still drop live. 'all' pushes every
706
+ expressible filter server-side (such rows then correct on the next load);
707
+ false sends none. The client matcher stays on in every mode. */
708
+
709
+ // Map<subKey, { channel, queries, refs, unsubscribe, handlers: Map<sourceName, handler> }>
710
+ const entries = new Map();
711
+ // Map<sourceName, { subKey, channel, token, handler, getFilters }> — token guards a stale unsubscribe closure
712
+ const sources = new Map();
713
+ // Map<sourceName, fn(eventType, row)> — pre-filters, run per event per source
714
+ const filters = new Map();
715
+ // Back-compat/debug surface: Map<sourceName, unsubscribeFunction>
716
+ const subscriptions = new Map();
717
+ // Map<sourceName, n> — explicit teardowns; aborts a subscribe in flight
718
+ const tombstones = new Map();
719
+ // Instance the current subscriptions live on
720
+ let activeRealtime = null;
721
+
722
+ function classify(event) {
723
+ if (typeof event !== 'string') return null;
724
+ if (event.includes('create')) return 'create';
725
+ if (event.includes('update')) return 'update';
726
+ if (event.includes('delete')) return 'delete';
727
+ return null;
728
+ }
669
729
 
670
- // Track active subscriptions
671
- const subscriptions = new Map(); // Map<dataSourceName, unsubscribeFunction>
730
+ // Query-capable Realtime service (see header): detect per instance, never assume
731
+ function supportsQueries(realtime) {
732
+ return !!(realtime && (realtime.pendingSubscribes || realtime.slotToSubscriptionId));
733
+ }
672
734
 
673
- // Subscribe to storage bucket file changes
674
- // isCurrent: false once the requesting load is superseded (logout) — skip, never touch a newer entry
675
- async function subscribeToStorageBucket(dataSourceName, bucketId, scope, onEvent, isCurrent) {
676
- // Unsubscribe from existing subscription if any
677
- if (subscriptions.has(dataSourceName)) {
678
- const unsubscribe = subscriptions.get(dataSourceName);
679
- if (unsubscribe && typeof unsubscribe === 'function') {
680
- unsubscribe();
735
+ // (channel, resolved filter set) → stable subscription key; resolved.key is
736
+ // already canonical (sorted) so equivalent filter sets share one subscription
737
+ function subKeyFor(channel, resolved) {
738
+ if (!resolved || !resolved.key) return channel;
739
+ return `${channel}\u001f${resolved.key}`;
740
+ }
741
+
742
+ // Fan one SDK message out: each registered source handler gets each event once,
743
+ // after that source's pre-filter (setFilter) — before any merge/dedup work
744
+ function dispatch(subKey, response) {
745
+ const entry = entries.get(subKey);
746
+ if (!entry || !response || !response.events) return;
747
+ const events = Array.isArray(response.events) ? response.events : [response.events];
748
+ let payload = response.payload;
749
+ if (!payload || (!payload.$id && !payload.row)) payload = response;
750
+ for (const event of events) {
751
+ const type = classify(event);
752
+ if (!type) continue;
753
+ for (const [name, handler] of entry.handlers) {
754
+ const filter = filters.get(name);
755
+ if (filter && filter(type, payload) === false) continue;
756
+ handler(type, payload);
681
757
  }
682
- subscriptions.delete(dataSourceName);
683
758
  }
759
+ }
684
760
 
685
- const services = await window.ManifestDataAppwrite.getAppwriteDataServices();
686
- if (isCurrent && !isCurrent()) return null;
687
- if (!services?.realtime) {
688
- console.warn('[Manifest Data] Realtime service not available for', dataSourceName);
689
- return null;
690
- }
761
+ // subscribe() returns an unsubscribe function (legacy client) or a
762
+ // Promise<RealtimeSubscription> (Realtime service): normalize teardown
763
+ function dropSub(sub) {
764
+ if (typeof sub === 'function') sub();
765
+ else if (sub && typeof sub.unsubscribe === 'function') sub.unsubscribe(); // per-subscription; never risks the shared socket
766
+ else if (sub && typeof sub.close === 'function') sub.close();
767
+ }
691
768
 
692
- // Channel format: buckets.[BUCKET_ID].files
693
- const channel = `buckets.${bucketId}.files`;
769
+ function makeUnsubscriber(result) {
770
+ if (!result || typeof result.then !== 'function') {
771
+ return () => { try { dropSub(result); } catch (e) { /* socket already gone */ } };
772
+ }
773
+ const settled = result.catch(() => null);
774
+ return () => { settled.then(dropSub).catch(() => { /* socket already gone */ }); };
775
+ }
694
776
 
777
+ function sdkSubscribe(realtime, subKey, channel, queries) {
778
+ const cb = response => dispatch(subKey, response);
779
+ const result = (queries && queries.length)
780
+ ? realtime.subscribe(channel, cb, queries)
781
+ : realtime.subscribe(channel, cb);
782
+ return makeUnsubscriber(result);
783
+ }
695
784
 
785
+ // Get or create the shared entry for (channel, filters); registered in the map
786
+ // before the SDK call so an event arriving mid-subscribe already dispatches
787
+ function ensureEntry(realtime, subKey, channel, queries) {
788
+ let entry = entries.get(subKey);
789
+ if (entry) return entry;
790
+ entry = { channel, queries: queries || [], refs: 0, unsubscribe: null, handlers: new Map() };
791
+ entries.set(subKey, entry);
696
792
  try {
697
- const unsubscribe = services.realtime.subscribe(channel, (response) => {
698
- if (!response || !response.events) {
699
- return;
700
- }
701
-
702
- // Handle both array and single event formats
703
- const events = Array.isArray(response.events) ? response.events : [response.events];
704
-
705
- events.forEach(event => {
706
- if (typeof event !== 'string') return;
707
-
708
- // Payload might be in response.payload or response directly
709
- const payload = response.payload || response;
710
-
711
- // Handle different event types
712
- if (event.includes('create') || event.includes('storage.files.create')) {
713
- // New file created
714
- onEvent('create', payload);
715
- } else if (event.includes('update') || event.includes('storage.files.update')) {
716
- // File updated
717
- onEvent('update', payload);
718
- } else if (event.includes('delete') || event.includes('storage.files.delete')) {
719
- // File deleted
720
- onEvent('delete', payload);
721
- }
722
- });
723
- });
724
-
725
- subscriptions.set(dataSourceName, unsubscribe);
726
-
727
- return unsubscribe;
793
+ entry.unsubscribe = sdkSubscribe(realtime, subKey, channel, queries);
728
794
  } catch (error) {
729
- console.error('[Manifest Data] Failed to subscribe to storage bucket:', error);
730
- return null;
795
+ entries.delete(subKey);
796
+ throw error;
731
797
  }
798
+ return entry;
732
799
  }
733
800
 
734
- // Unsubscribe from a data source
735
- function unsubscribeFromDataSource(dataSourceName) {
736
- if (subscriptions.has(dataSourceName)) {
737
- const unsubscribe = subscriptions.get(dataSourceName);
738
- if (unsubscribe && typeof unsubscribe === 'function') {
739
- unsubscribe();
801
+ function removeFromEntry(name, subKey) {
802
+ const entry = entries.get(subKey);
803
+ if (!entry) return;
804
+ entry.handlers.delete(name);
805
+ entry.refs = entry.handlers.size;
806
+ if (entry.refs === 0) {
807
+ entries.delete(subKey);
808
+ if (typeof entry.unsubscribe === 'function') {
809
+ try { entry.unsubscribe(); } catch (e) { /* socket already gone */ }
740
810
  }
741
- subscriptions.delete(dataSourceName);
742
811
  }
743
812
  }
744
813
 
745
- // Unsubscribe from all data sources
746
- function unsubscribeAll() {
747
- subscriptions.forEach((unsubscribe, dataSourceName) => {
748
- if (unsubscribe && typeof unsubscribe === 'function') {
749
- unsubscribe();
750
- }
751
- });
752
- subscriptions.clear();
814
+ // Drop a source's registration; tear the subscription down when the last source leaves
815
+ function detach(name, token) {
816
+ const cur = sources.get(name);
817
+ if (!cur || (token && cur.token !== token)) return;
818
+ sources.delete(name);
819
+ removeFromEntry(name, cur.subKey);
753
820
  }
754
821
 
755
- // Subscribe to database table row changes
756
- async function subscribeToTable(dataSourceName, databaseId, tableId, scope, onEvent, isCurrent) {
757
- // Unsubscribe from existing subscription if any
758
- if (subscriptions.has(dataSourceName)) {
759
- const unsubscribe = subscriptions.get(dataSourceName);
760
- if (unsubscribe && typeof unsubscribe === 'function') {
761
- unsubscribe();
822
+ // Instance changed (client rebuilt on identity change): re-resolve each source's
823
+ // filters ($auth. values are stale) and regroup subscriptions on the new instance.
824
+ // Chained so overlapping rebuilds run in order; a superseded rebuild stops.
825
+ let syncChain = Promise.resolve();
826
+ function syncInstance(realtime) {
827
+ if (activeRealtime === realtime) return syncChain;
828
+ activeRealtime = realtime;
829
+ syncChain = syncChain
830
+ .then(() => rebindAll(realtime))
831
+ .catch(error => console.error('[Manifest Data] Realtime rebind failed:', error));
832
+ return syncChain;
833
+ }
834
+
835
+ async function rebindAll(realtime) {
836
+ for (const [, entry] of entries) {
837
+ if (typeof entry.unsubscribe === 'function') {
838
+ try { entry.unsubscribe(); } catch (e) { /* old instance */ }
839
+ }
840
+ }
841
+ entries.clear();
842
+ const canQuery = supportsQueries(realtime);
843
+ for (const [name, src] of sources) {
844
+ if (activeRealtime !== realtime) return; // superseded by a newer instance
845
+ const resolved = (canQuery && src.getFilters) ? await src.getFilters() : null;
846
+ const subKey = subKeyFor(src.channel, resolved);
847
+ try {
848
+ const entry = ensureEntry(realtime, subKey, src.channel, resolved?.queries);
849
+ entry.handlers.set(name, src.handler);
850
+ entry.refs = entry.handlers.size;
851
+ src.subKey = subKey;
852
+ } catch (error) {
853
+ console.error('[Manifest Data] Failed to re-subscribe channel:', src.channel, error);
762
854
  }
763
- subscriptions.delete(dataSourceName);
764
855
  }
856
+ }
765
857
 
858
+ // One SDK subscribe per distinct (channel, filter set); sources sharing both share it.
859
+ // isCurrent: false once the requesting load is superseded (logout) — skip, never touch a newer entry.
860
+ // getFilters: async () => ({ queries, key }) | null, re-run per (re)subscribe so
861
+ // $auth. values resolve at subscribe time; null → channel-only (0.5.248 path).
862
+ // A replaced registration detaches only after the new subscription is live (no
863
+ // event gap); overlap double-delivery is absorbed by the per-source event dedupe.
864
+ async function subscribeChannel(dataSourceName, channel, onEvent, isCurrent, getFilters) {
865
+ const epoch = tombstones.get(dataSourceName) || 0;
766
866
  const services = await window.ManifestDataAppwrite.getAppwriteDataServices();
767
867
  if (isCurrent && !isCurrent()) return null;
768
868
  if (!services?.realtime) {
769
869
  console.warn('[Manifest Data] Realtime service not available for', dataSourceName);
870
+ detach(dataSourceName);
871
+ subscriptions.delete(dataSourceName);
770
872
  return null;
771
873
  }
874
+ const realtime = services.realtime;
875
+ await syncInstance(realtime);
876
+ if (isCurrent && !isCurrent()) return null;
772
877
 
773
- // Channel format: databases.[DATABASE_ID].tables.[TABLE_ID].rows
774
- const channel = `databases.${databaseId}.tables.${tableId}.rows`;
775
-
878
+ const resolved = (supportsQueries(realtime) && getFilters) ? await getFilters() : null;
879
+ if (isCurrent && !isCurrent()) return null;
880
+ // Torn down while resolving: land nothing
881
+ if ((tombstones.get(dataSourceName) || 0) !== epoch) return null;
882
+ // Instance replaced while resolving (identity rebuild): re-run against the live one
883
+ if (activeRealtime !== realtime) return subscribeChannel(dataSourceName, channel, onEvent, isCurrent, getFilters);
884
+ const subKey = subKeyFor(channel, resolved);
885
+
886
+ const prev = sources.get(dataSourceName);
887
+ let entry;
776
888
  try {
777
- const unsubscribe = services.realtime.subscribe(channel, (response) => {
778
- if (!response || !response.events) {
779
- return;
780
- }
781
-
782
- // Handle both array and single event formats
783
- const events = Array.isArray(response.events) ? response.events : [response.events];
889
+ entry = ensureEntry(realtime, subKey, channel, resolved?.queries);
890
+ } catch (error) {
891
+ console.error('[Manifest Data] Failed to subscribe:', channel, error);
892
+ detach(dataSourceName);
893
+ subscriptions.delete(dataSourceName);
894
+ return null;
895
+ }
896
+ const token = Symbol(dataSourceName);
897
+ entry.handlers.set(dataSourceName, onEvent);
898
+ entry.refs = entry.handlers.size;
899
+ sources.set(dataSourceName, { subKey, channel, token, handler: onEvent, getFilters });
900
+ if (prev && prev.subKey !== subKey) removeFromEntry(dataSourceName, prev.subKey);
784
901
 
785
- events.forEach(event => {
786
- if (typeof event !== 'string') {
787
- return;
788
- }
902
+ const unsubscribe = () => detach(dataSourceName, token);
903
+ subscriptions.set(dataSourceName, unsubscribe);
904
+ return unsubscribe;
905
+ }
789
906
 
790
- // Payload might be in response.payload or response directly
791
- // Appwrite realtime structure: { events: [...], payload: {...} }
792
- let payload = response.payload;
907
+ // Resolver re-runs per (re)subscribe so $auth. values are current at that moment
908
+ function filterResolver(filterSpec) {
909
+ if (!filterSpec) return null;
910
+ const build = window.ManifestDataQueries?.buildRealtimeQueries;
911
+ if (typeof build !== 'function') return null;
912
+ return () => build(filterSpec.queriesConfig, filterSpec.scope, filterSpec.scopeColumns, filterSpec.mode);
913
+ }
793
914
 
794
- // If payload doesn't have $id, try response directly
795
- if (!payload || (!payload.$id && !payload.row)) {
796
- payload = response;
797
- }
915
+ // Subscribe to storage bucket file changes
916
+ function subscribeToStorageBucket(dataSourceName, bucketId, scope, onEvent, isCurrent, filterSpec) {
917
+ return subscribeChannel(dataSourceName, `buckets.${bucketId}.files`, onEvent, isCurrent, filterResolver(filterSpec));
918
+ }
798
919
 
799
- // Handle different event types
800
- if (event.includes('create') || event.includes('rows.create') || event.includes('documents.create')) {
801
- // New row created
802
- onEvent('create', payload);
803
- } else if (event.includes('update') || event.includes('rows.update') || event.includes('documents.update')) {
804
- // Row updated
805
- onEvent('update', payload);
806
- } else if (event.includes('delete') || event.includes('rows.delete') || event.includes('documents.delete')) {
807
- // Row deleted
808
- onEvent('delete', payload);
809
- } else {
810
- }
811
- });
812
- });
920
+ // Subscribe to database table row changes
921
+ function subscribeToTable(dataSourceName, databaseId, tableId, scope, onEvent, isCurrent, filterSpec) {
922
+ return subscribeChannel(dataSourceName, `databases.${databaseId}.tables.${tableId}.rows`, onEvent, isCurrent, filterResolver(filterSpec));
923
+ }
813
924
 
814
- subscriptions.set(dataSourceName, unsubscribe);
925
+ // Unsubscribe from a data source
926
+ function unsubscribeFromDataSource(dataSourceName) {
927
+ tombstones.set(dataSourceName, (tombstones.get(dataSourceName) || 0) + 1);
928
+ detach(dataSourceName);
929
+ subscriptions.delete(dataSourceName);
930
+ }
815
931
 
816
- return unsubscribe;
817
- } catch (error) {
818
- console.error('[Manifest Data] Failed to subscribe to table:', error);
819
- return null;
932
+ // Unsubscribe from all data sources
933
+ function unsubscribeAll() {
934
+ for (const name of [...sources.keys()]) {
935
+ tombstones.set(name, (tombstones.get(name) || 0) + 1);
936
+ detach(name);
820
937
  }
938
+ subscriptions.clear();
939
+ }
940
+
941
+ // Programmatic pre-filter: fn(eventType, row) runs per event per source BEFORE
942
+ // any merge/dedup work; returning false drops the event. null clears it.
943
+ function setFilter(sourceName, fn) {
944
+ if (fn == null) filters.delete(sourceName);
945
+ else if (typeof fn === 'function') filters.set(sourceName, fn);
946
+ }
947
+
948
+ // Identity events rebuild the shared Realtime (manifest.data.appwrite.js drops
949
+ // its memo on the same events); re-bind live subscriptions to the new instance
950
+ if (typeof window !== 'undefined') {
951
+ ['manifest:auth:login', 'manifest:auth:logout', 'manifest:auth:anonymous', 'manifest:auth:session-cleared', 'manifest:auth:initialized']
952
+ .forEach(type => window.addEventListener(type, async () => {
953
+ if (!sources.size) return;
954
+ const services = await window.ManifestDataAppwrite?.getAppwriteDataServices?.();
955
+ if (services?.realtime) syncInstance(services.realtime);
956
+ }));
821
957
  }
822
958
 
823
959
  // Export functions
@@ -826,7 +962,9 @@ window.ManifestDataRealtime = {
826
962
  subscribeToTable,
827
963
  unsubscribeFromDataSource,
828
964
  unsubscribeAll,
829
- subscriptions // Expose for debugging
965
+ setFilter,
966
+ subscriptions, // Expose for debugging
967
+ _channels: entries // Expose for debugging (keyed by channel + filter set)
830
968
  };
831
969
 
832
970
 
@@ -1186,6 +1324,86 @@ async function buildQueries(queriesConfig, scope, scopeColumns) {
1186
1324
  return [...scopeQueries, ...userQueries];
1187
1325
  }
1188
1326
 
1327
+ // Query methods buildAppwriteQueries serializes (anything else is dropped at
1328
+ // load and must therefore never drop rows in the realtime matcher below)
1329
+ const QUERY_MAP = {
1330
+ 'equal': 'equal',
1331
+ 'notEqual': 'notEqual',
1332
+ 'lessThan': 'lessThan',
1333
+ 'lessThanEqual': 'lessThanEqual',
1334
+ 'greaterThan': 'greaterThan',
1335
+ 'greaterThanEqual': 'greaterThanEqual',
1336
+ 'contains': 'contains',
1337
+ 'search': 'search',
1338
+ 'or': 'or', // Support for multi-team queries (Query.or([Query.equal(...), ...]))
1339
+ 'orderAsc': 'orderAsc',
1340
+ 'orderDesc': 'orderDesc',
1341
+ 'limit': 'limit',
1342
+ 'offset': 'offset',
1343
+ 'cursorAfter': 'cursorAfter',
1344
+ 'cursorBefore': 'cursorBefore'
1345
+ };
1346
+
1347
+ // Client-side row matcher for realtime events: evaluates a source's configured
1348
+ // filter queries against one row. Only operators the load actually serializes
1349
+ // (QUERY_MAP) are evaluable. Liveness over precision: anything unevaluable —
1350
+ // search, an operator the load drops, or a $auth. arg that can't resolve
1351
+ // synchronously — counts as a MATCH so live events are never silently dropped.
1352
+ const NON_FILTER_METHODS = new Set(['limit', 'offset', 'orderAsc', 'orderDesc', 'select', 'cursorAfter', 'cursorBefore']);
1353
+
1354
+ function rowMatchesQueries(row, queriesConfig) {
1355
+ if (!row || !Array.isArray(queriesConfig) || queriesConfig.length === 0) return true;
1356
+ return queriesConfig.every(query => matchQuery(row, query));
1357
+ }
1358
+
1359
+ function matchQuery(row, query) {
1360
+ if (!Array.isArray(query) || query.length === 0) return true;
1361
+ let interpolated;
1362
+ try {
1363
+ interpolated = interpolateQuery(query);
1364
+ } catch (e) {
1365
+ if (e === NOT_READY) return true; // unresolved $auth. arg
1366
+ throw e;
1367
+ }
1368
+ const [method, ...args] = interpolated;
1369
+ if (NON_FILTER_METHODS.has(method)) return true;
1370
+ if (!(method in QUERY_MAP)) return true; // load drops it: unevaluable
1371
+ if (method === 'and') return Array.isArray(args[0]) ? args[0].every(sub => matchQuery(row, sub)) : true;
1372
+ if (method === 'or') return Array.isArray(args[0]) ? args[0].some(sub => matchQuery(row, sub)) : true;
1373
+
1374
+ const attr = args[0];
1375
+ const val = row ? row[attr] : undefined;
1376
+ const values = Array.isArray(args[1]) ? args[1] : [args[1]];
1377
+ // Strings compare case-insensitively (Appwrite's ci collation); other types strictly
1378
+ const eq = (a, b) => (typeof a === 'string' && typeof b === 'string')
1379
+ ? a.toLowerCase() === b.toLowerCase()
1380
+ : a === b;
1381
+ const anyEqual = () => Array.isArray(val)
1382
+ ? values.some(v => val.some(el => eq(el, v)))
1383
+ : values.some(v => eq(val, v));
1384
+ const cmp = (bound, op) => val !== null && val !== undefined && bound !== null && bound !== undefined && op(val, bound);
1385
+ const lower = s => String(s).toLowerCase();
1386
+
1387
+ switch (method) {
1388
+ case 'equal': return anyEqual();
1389
+ case 'notEqual': return !anyEqual();
1390
+ case 'lessThan': return cmp(args[1], (a, b) => a < b);
1391
+ case 'lessThanEqual': return cmp(args[1], (a, b) => a <= b);
1392
+ case 'greaterThan': return cmp(args[1], (a, b) => a > b);
1393
+ case 'greaterThanEqual': return cmp(args[1], (a, b) => a >= b);
1394
+ case 'between': return cmp(args[1], (a, b) => a >= b) && cmp(args[2], (a, b) => a <= b);
1395
+ case 'isNull': return val === null || val === undefined;
1396
+ case 'isNotNull': return val !== null && val !== undefined;
1397
+ case 'startsWith': return typeof val === 'string' && values.some(v => lower(val).startsWith(lower(v)));
1398
+ case 'endsWith': return typeof val === 'string' && values.some(v => lower(val).endsWith(lower(v)));
1399
+ case 'contains':
1400
+ if (Array.isArray(val)) return values.some(v => val.some(el => eq(el, v)));
1401
+ if (typeof val === 'string') return values.some(v => lower(val).includes(lower(v)));
1402
+ return false;
1403
+ default: return true; // search: unevaluable
1404
+ }
1405
+ }
1406
+
1189
1407
  // Convert query array to Appwrite Query object
1190
1408
  function toAppwriteQuery(queryArray) {
1191
1409
  if (!Array.isArray(queryArray) || queryArray.length === 0) {
@@ -1194,24 +1412,9 @@ function toAppwriteQuery(queryArray) {
1194
1412
 
1195
1413
  const [method, ...args] = queryArray;
1196
1414
 
1197
- // Map common query methods to Appwrite Query methods
1198
- const queryMap = {
1199
- 'equal': 'equal',
1200
- 'notEqual': 'notEqual',
1201
- 'lessThan': 'lessThan',
1202
- 'lessThanEqual': 'lessThanEqual',
1203
- 'greaterThan': 'greaterThan',
1204
- 'greaterThanEqual': 'greaterThanEqual',
1205
- 'contains': 'contains',
1206
- 'search': 'search',
1207
- 'or': 'or', // Support for multi-team queries (Query.or([Query.equal(...), ...]))
1208
- 'orderAsc': 'orderAsc',
1209
- 'orderDesc': 'orderDesc',
1210
- 'limit': 'limit',
1211
- 'offset': 'offset',
1212
- 'cursorAfter': 'cursorAfter',
1213
- 'cursorBefore': 'cursorBefore'
1214
- };
1415
+ // Map common query methods to Appwrite Query methods (QUERY_MAP above —
1416
+ // also the realtime matcher's evaluable set)
1417
+ const queryMap = QUERY_MAP;
1215
1418
 
1216
1419
  if (!window.Appwrite || !window.Appwrite.Query) {
1217
1420
  console.error('[Manifest Data] Appwrite Query not available');
@@ -1252,6 +1455,42 @@ function toAppwriteQuery(queryArray) {
1252
1455
  }
1253
1456
  }
1254
1457
 
1458
+ // Realtime-expressible operators: the intersection of what the load serializes
1459
+ // (QUERY_MAP), what the matcher evaluates, and what Appwrite realtime filters
1460
+ // server-side. isNull/isNotNull are realtime-supported but the load drops them,
1461
+ // so filtering on them server-side would drop events the matcher keeps — excluded.
1462
+ const REALTIME_FILTER_OPS = new Set(['equal', 'notEqual', 'lessThan', 'lessThanEqual', 'greaterThan', 'greaterThanEqual']);
1463
+
1464
+ function isRealtimeExpressible(query) {
1465
+ if (!Array.isArray(query) || query.length === 0) return false;
1466
+ const [method, ...args] = query;
1467
+ if (method === 'or') return Array.isArray(args[0]) && args[0].length > 0 && args[0].every(isRealtimeExpressible);
1468
+ return REALTIME_FILTER_OPS.has(method);
1469
+ }
1470
+
1471
+ // Server-side realtime filter set, reduced to the expressible subset. Omitting
1472
+ // a query only WIDENS delivery — the client matcher still filters — so
1473
+ // unexpressible queries are dropped whole, never approximated.
1474
+ // mode 'scope' (default): only scope-column queries ride the subscription —
1475
+ // permission-mirrored, so a row leaving the filter still delivers its last
1476
+ // event and drops live. 'all': user queries too (a held row that stops
1477
+ // matching then corrects on the next load, not instantly).
1478
+ // Returns { queries, key } (key = canonical sorted serialization, for
1479
+ // subscription sharing) or null (not ready / nothing expressible → channel-only
1480
+ // subscribe, the 0.5.248 path).
1481
+ async function buildRealtimeQueries(queriesConfig, scope, scopeColumns, mode = 'scope') {
1482
+ const userConfig = (mode === 'all' && Array.isArray(queriesConfig)) ? queriesConfig : [];
1483
+ const resolved = await buildQueries(userConfig, scope, scopeColumns);
1484
+ if (resolved === null) return null;
1485
+ const queries = resolved
1486
+ .filter(isRealtimeExpressible)
1487
+ .map(query => toAppwriteQuery(query))
1488
+ .filter(query => query !== null);
1489
+ if (queries.length === 0) return null;
1490
+ const key = queries.map(q => typeof q === 'string' ? q : JSON.stringify(q)).sort().join('\u001e');
1491
+ return { queries, key };
1492
+ }
1493
+
1255
1494
  // Build Appwrite queries from configuration
1256
1495
  // scopeColumns: { team, user } column names (default teamId/userId) — see manifest.data.config.js getScopeColumns
1257
1496
  // Returns null ("not ready") straight through — see buildQueries.
@@ -1271,6 +1510,8 @@ window.ManifestDataQueries = {
1271
1510
  getAuthValue,
1272
1511
  buildQueries,
1273
1512
  buildAppwriteQueries,
1513
+ buildRealtimeQueries,
1514
+ rowMatchesQueries,
1274
1515
  toAppwriteQuery,
1275
1516
  ALLOWED_VARIABLES,
1276
1517
  DEFAULT_SCOPE_COLUMNS