Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/botnadzor/extension/llms.txt

Use this file to discover all available pages before exploring further.

Static lists are a core data structure in Botnadzor, providing:
  • Bot/spam account lists — VK IDs and domains of known bots
  • Insertion configurations — DOM modification configs for UI injection
  • Other reference data — Various lookup tables and metadata
The StaticListsService manages fetching lists from remote servers, caching them in IndexedDB, and combining remote data with local user additions/overrides.

Architecture

Key Features

  • Streaming ingestion — Lists are fetched as JSONL streams and processed line-by-line
  • Dual instances — Two IndexedDB tables per list (A/B swap for atomic updates)
  • Combining modes — Three modes: remoteOnly, localOnly, remoteWithLocalOverrides
  • Local overrides — Users can add items or override remote items
  • Pollable summaries — Reactive summary statistics (counts, timestamps)

StaticListsService Implementation

The service is registered in the background entrypoint:
src/entrypoints/background.ts
const staticListsService = new StaticListsService({
  aliasManagerForStaticApi,
  rootConfigService,
});

registerService(staticListsServiceKey, staticListsService);

// Populate lists when root config is ready
void populateInitialStaticListsIfNeeded({
  rootConfigService,
  staticListsService,
});

Constructor

The service initializes IndexedDB and creates pollable state for each list:
src/entrypoints/background/@services/static-lists-service.ts
constructor({
  aliasManagerForStaticApi,
  rootConfigService,
}: {
  aliasManagerForStaticApi: AliasManager;
  rootConfigService: RootConfigService;
}) {
  this.aliasManagerForStaticApi = aliasManagerForStaticApi;
  this.rootConfigService = rootConfigService;

  // Initialize IndexedDB with Dexie
  this.db = new Dexie("static-lists");
  this.db.version(2).stores({
    [metadataTableName]: "listId",
    ...Object.fromEntries(
      staticListDefinitionEntries.flatMap(([listId, listDefinition]) => [
        [
          generateRemoteTableName(listId, "a"),
          ["++", ...listDefinition.indexes].join(","),
        ],
        [
          generateRemoteTableName(listId, "b"),
          ["++", ...listDefinition.indexes].join(","),
        ],
        [
          generateLocalTableName(listId),
          ["++", ...listDefinition.indexes].join(","),
        ],
      ]),
    ),
  });

  // Create pollable state for each list
  for (const listId of staticListIds) {
    pollableListMetadataByListId[listId] = new Pollable<
      StaticListMetadata | undefined
    >(undefined);

    pollableListSummaryByListId[listId] = new Pollable<
      StaticListSummary | undefined
    >(undefined);
  }

  // Start syncing metadata with DB
  void this.startSyncingMetadataWithDb();
}

List Metadata

Each list has metadata stored in IndexedDB:
type StaticListMetadata = {
  listId: StaticListId;
  remoteActiveInstance: "a" | "b";
  remoteActive?: {
    startedAt: IsoDateTime;
    summary: unknown;
    updatedAt: IsoDateTime;
    upstreamInfo: {
      generatedAt: IsoDateTime;
      url: string;
    };
  };
  remoteNext?: {
    lockId: string;
    startedAt: IsoDateTime;
    summary: unknown;
    updatedAt: IsoDateTime;
  };
  combiningMode: "remoteOnly" | "localOnly" | "remoteWithLocalOverrides";
  localSummary?: unknown;
  localUpdatedAt?: IsoDateTime;
  combinedSummary?: unknown;
};

Dual Instance System

Each list has two IndexedDB tables: listId_remote_a and listId_remote_b.
  • Active instance — Currently serving reads
  • Next instance — Target for new data during updates
This allows atomic swaps: fetch new data into the inactive table, then switch remoteActiveInstance from a to b (or vice versa).
function generateRemoteTableName(
  listId: StaticListId,
  instance: StaticListRemoteInstance,
): string {
  return `${listId}_remote_${instance}`;
}

function pickAnotherInstance(
  instance: StaticListRemoteInstance,
): StaticListRemoteInstance {
  return instance === "a" ? "b" : "a";
}

Fetching Lists

Lists are fetched from the remote API as JSONL (JSON Lines) streams:
src/entrypoints/background/@services/static-lists-service.ts
public async populateListIfOutdated(
  listId: StaticListId,
  toleranceInMinutes: number | undefined,
): Promise<PopulateFromUrlIfOutdatedResult> {
  const listLogger = this.getListLogger(listId);
  listLogger.info("Populating from if outdated");
  const startedAt = Date.now();

  const lockId = nanoid(8);

  const rootConfig = await this.rootConfigService.get();
  const upstreamInfo =
    rootConfig.remoteSystemLookup.staticApi.listLookup[listId];

  try {
    const initialMetadata = await this.getListMetadata(listId);
    if (
      this.isListUpToDate(
        initialMetadata,
        upstreamInfo.generatedAt,
        toleranceInMinutes,
      )
    ) {
      listLogger.info("List is up to date");
      return { success: true, data: "updateNotNeeded" };
    }

    // Acquire lock
    const lockMetadata = await this.waitForAnotherLock(listId, initialMetadata);
    this.setListMetadata({
      ...lockMetadata,
      remoteNext: {
        lockId,
        startedAt: isoDateTimeSchema.parse(startedAt),
        summary: structuredClone(mutableSummary),
        updatedAt: isoDateTimeSchema.parse(startedAt),
        upstreamInfo,
      },
    });

    // Fetch JSONL stream
    const fetchResult = await fetchFromRemoteSystem({
      aliasManager: this.aliasManagerForStaticApi,
      urlSuffix: `/lists/${listId}.jsonl`,
    });

    if (!fetchResult.success) {
      return {
        success: false,
        error: `Failed to fetch list from static API (reason: ${fetchResult.reason})`,
      };
    }

    // Stream and parse JSONL
    const nextInstance = pickAnotherInstance(
      initialMetadata.remoteActiveInstance,
    );
    const nextTable = this.db.table<unknown>(
      generateRemoteTableName(listId, nextInstance)
    );
    await nextTable.clear();

    let storedItemCount = 0;
    const listDefinition = staticListDefinitionLookup[listId];
    const mutableSummary = listDefinition.createEmptySummary();

    for await (const line of streamLines(fetchResult.response.body)) {
      const receivedItemResult = listDefinition.receivedItemSchema.safeParse(
        JSON.parse(line),
      );

      if (!receivedItemResult.success) {
        listLogger.error("Invalid item received: {error}", {
          error: receivedItemResult.error.message,
        });
        continue;
      }

      const itemToStore = listDefinition.mapReceivedToStored(
        receivedItemResult.data,
      );

      itemsToStore.push(itemToStore);
      listDefinition.mutateSummary(mutableSummary, itemToStore);

      if (itemsToStore.length >= itemBatchSize) {
        await nextTable.bulkAdd(itemsToStore);
        itemsToStore = [];
        storedItemCount += itemsToStore.length;
      }
    }

    // Swap to new instance
    this.setListMetadata({
      ...metadataWithoutRemoteNext,
      remoteActiveInstance: nextInstance,
      remoteActive: {
        startedAt: isoDateTimeSchema.parse(startedAt),
        summary: finalSummary,
        updatedAt: isoDateTimeSchema.parse(startedAt),
        upstreamInfo,
      },
    });

    listLogger.info(
      "Populated {storedItemCount} items in {ms}ms",
      { storedItemCount, ms: Date.now() - startedAt },
    );

    return { success: true, data: "updated" };
  } catch (error) {
    listLogger.error("Unexpected error while populating: {error}", { error });
    return { success: false, error: String(error) };
  }
}

Streaming JSONL

Lists can be large (thousands of entries), so they’re streamed and processed in batches:
src/entrypoints/background/@services/static-lists-service.ts
async function* streamLines(
  readableStream: ReadableStream<Uint8Array>,
): AsyncGenerator<string> {
  const decoder = new TextDecoder();
  const reader = readableStream.getReader();
  let { value: chunk, done } = await reader.read();
  let buffer = "";
  while (!done) {
    buffer += decoder.decode(chunk, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";
    for (const line of lines) {
      yield line;
    }
    ({ value: chunk, done } = await reader.read());
  }
  buffer += decoder.decode();
  if (buffer) {
    for (const line of buffer.split("\n")) {
      if (line.length > 0) {
        yield line;
      }
    }
  }
}

List Summaries

Each list has a summary — aggregated statistics computed during ingestion:
type StaticListSummary = {
  count: number;
  // List-specific fields...
};
Summaries are maintained for:
  • Remote active — Summary of current remote data
  • Local — Summary of user additions
  • Combined — Combined summary (respects combining mode)
Summaries are pollable, so clients can reactively update UI when lists change:
let pollVersion: PollVersion | undefined;
while (true) {
  const result = await staticListsService.pollListSummary(
    pollVersion,
    "insertions",
  );
  pollVersion = result.version;
  
  console.log("Insertions count:", result.value.count);
}

Summary Computation

Summaries are computed incrementally during ingestion:
const mutableSummary = listDefinition.createEmptySummary();

for (const item of items) {
  listDefinition.mutateSummary(mutableSummary, item);
}

const finalSummary = structuredClone(mutableSummary);
Each list definition provides:
  • createEmptySummary() — Creates initial summary object
  • mutateSummary(summary, item) — Updates summary with new item
  • unmutateSummary(summary, item) — Reverses update (for overrides)

Combining Modes

The service supports three combining modes:

remoteOnly

Uses only remote data, ignores local additions/overrides.
if (combiningMode === "remoteOnly") {
  return findInTable(await this.getActiveRemoteTable(listId));
}

localOnly

Uses only local data, ignores remote data.
if (combiningMode === "localOnly") {
  return findInTable(this.getLocalTable(listId));
}

remoteWithLocalOverrides (default)

Combines remote and local data:
  • Start with all remote items
  • Override items matching local items (by first index)
  • Append pure-local items (not in remote)
// Get remote items
const remoteItems = await remoteTable.toArray();
const localItems = await localTable.toArray();

// Build local lookup by first index
const localByKey = new Map();
for (const item of localItems) {
  const key = item[firstIndex];
  localByKey.set(key, item);
}

// Apply local overrides
const result = remoteItems.map((remoteItem) => {
  const key = remoteItem[firstIndex];
  return localByKey.get(key) ?? remoteItem;
});

// Append pure-local items
const remoteKeys = new Set(remoteItems.map((item) => item[firstIndex]));
for (const [key, item] of localByKey) {
  if (!remoteKeys.has(key)) {
    result.push(item);
  }
}

return result;

Local Item Management

Users can add, update, or remove local items:

Add Local Item

await staticListsService.addLocalItem("bots", {
  vkId: 123456,
  reason: "Spam account",
});

Remove Local Item

await staticListsService.removeLocalItem(
  "bots",
  "vkId",  // index
  123456,  // value
);

Put Local Item (add or override)

await staticListsService.putLocalItem("bots", {
  vkId: 123456,
  reason: "Updated reason",
});

Summary Recomputation

After local changes, summaries are recomputed:
src/entrypoints/background/@services/static-lists-service.ts
private async recomputeLocalAndCombinedSummary(
  listId: StaticListId,
): Promise<void> {
  const listDefinition = staticListDefinitionLookup[listId];
  const localItems = await this.getLocalTable(listId).toArray();

  const mutableLocalSummary = listDefinition.createEmptySummary();
  for (const item of localItems) {
    listDefinition.mutateSummary(mutableLocalSummary, item);
  }

  const metadata = await this.getListMetadata(listId);
  const updatedMetadata = await this.recomputeCombinedSummaryForMetadata(
    listId,
    {
      ...metadata,
      localSummary: structuredClone(mutableLocalSummary),
      localUpdatedAt: isoDateTimeSchema.parse(Date.now()),
    },
  );
  this.setListMetadata(updatedMetadata);
}

Item Origin Tracking

The service tracks the origin of each item:
type StaticListItemOrigin = "remote" | "local" | "localOverride";
  • remote — Item only exists in remote list
  • local — Item only exists locally (pure addition)
  • localOverride — Local item overrides a remote item
const origin = await staticListsService.getItemOrigin(
  "bots",
  "vkId",
  123456,
);

if (origin === "localOverride") {
  console.log("This item overrides a remote entry");
}

Paginated Access

For large lists, the service provides paginated access:
const { items, totalCount } = await staticListsService.getItemsPage(
  "bots",
  { offset: 0, limit: 100 },
);

for (const { item, origin, valid } of items) {
  console.log(`Item:`, item, `Origin:`, origin, `Valid:`, valid);
}

console.log(`Total items: ${totalCount}`);

Search by Index

Lists have indexed fields for efficient search:
const { items } = await staticListsService.searchItems(
  "bots",
  { index: "vkId", value: 123456 },
);

if (items.length > 0) {
  console.log("Found bot:", items[0].item);
}

Update Orchestration

The background script orchestrates list updates:
src/entrypoints/background.ts
async function populateInitialStaticListsIfNeeded({
  rootConfigService,
  staticListsService,
}: {
  rootConfigService: RootConfigService;
  staticListsService: StaticListsService;
}) {
  let pollVersion: PollVersion | undefined;
  for (;;) {
    const result = await rootConfigService.poll(pollVersion);
    pollVersion = result.version;
    const rootConfig = result.value;

    if (isEqual(rootConfig, rootConfigSeed)) {
      logger.debug(
        "Root config is the same as the seed, skipping static lists population",
      );
      continue;
    }

    staticListsService.updateIfNeeded();
  }
}
This ensures lists are updated when:
  • Extension starts (if outdated)
  • Root config changes (new upstream list URLs)

Performance Characteristics

Fetch Performance

  • Streaming ingestion — Memory usage is constant regardless of list size
  • Batch writes — Items are written in batches of 1000 for optimal performance
  • IndexedDB — Provides fast indexed lookups

Read Performance

  • Indexed queries — O(log n) lookup by indexed fields
  • Full scans — O(n) for paginated access
  • Pollable state — Clients only re-fetch when data changes

Storage

  • Compression — JSONL format is compact
  • Dual instances — 2x storage for atomic updates
  • Local additions — Separate table, minimal overhead

Next Steps

Proxy Services

Learn how to access StaticListsService from content scripts

Insertion System

See how insertion configs are fetched and applied

Build docs developers (and LLMs) love