1. guides
prorm
  • prorm documentation
  • Known test gaps
  • Architecture
  • Core concepts
  • Database types & coverage
  • Getting started
  • Going further
  • compliance
    • Compliance
    • The security toolkit: `prorm audit`
    • Checklists by regulation
    • Declaring personal data
    • Evidence bundles
    • Evidence
    • Frameworks: `@Security`
    • Jurisdictions
    • Platform policies
    • Protecting data
    • The regulation dataset
    • Residency and cross-border transfers
    • Retention & integrity
    • Status
    • Subject rights
  • decorators
    • prorm with decorators
    • Models with decorators
    • Querying a decorated model
    • Relations with decorators
  • diagrams
    • Diagram generators
    • Chen diagram
    • Class diagram
    • Dependency diagram
    • ER diagram
    • Flow diagram
    • From your models to a diagram
    • Gantt diagram
    • Index diagram
    • Migration diagram
    • Model diagram
    • Package diagram
    • Crow’s-foot diagram
    • Schema documentation
    • Sequence diagram
    • State diagram
    • Tree diagram
  • dialects
    • SQL dialects
    • AlloyDB Dialect
    • AlloyDB Omni Dialect
    • Amazon Athena Dialect
    • Aurora DSQL Dialect
    • Amazon Aurora MySQL Dialect
    • Amazon Aurora PostgreSQL Dialect
    • Azure Database for MySQL Dialect
    • Azure Database for PostgreSQL Dialect
    • Azure SQL Database Dialect
    • Azure SQL Managed Instance Dialect
    • Google BigQuery Dialect
    • Citus Dialect
    • ClickHouse dialect
    • Cloud Authentication (`cloud-auth`)
    • Cloud SQL for SQL Server Dialect
    • Cloud SQL for MySQL Dialect
    • Cloud SQL for PostgreSQL Dialect
    • CockroachDB Dialect
    • Azure Cosmos DB for PostgreSQL Dialect
    • CrateDB Dialect
    • Cloudflare D1 Dialect
    • Databricks dialect
    • IBM Db2 Dialect
    • Dolt Dialect
    • Apache Doris Dialect
    • Druid Dialect
    • DuckDB Dialect
    • Exasol dialect
    • Microsoft Fabric Data Warehouse Dialect
    • Firebird Dialect
    • Greenplum Dialect
    • SAP HANA Dialect
    • MySQL HeatWave Dialect
    • Azure Data Explorer (Kusto) Dialect — T-SQL
    • MariaDB Dialect
    • Materialize Dialect
    • MonetDB Dialect
    • MotherDuck Dialect
    • Microsoft SQL Server Dialect
    • MySQL Dialect
    • Neon Dialect
    • OceanBase Dialect
    • openGauss Dialect
    • Oracle Dialect
    • Oracle Autonomous Database Dialect
    • ParadeDB Dialect
    • PolarDB Dialect
    • PostgreSQL Dialect
    • Presto Dialect
    • QuestDB Dialect
    • Aurora RDS Data API Dialect (`rdsdata`)
    • Amazon RDS for Db2 Dialect
    • Amazon RDS for MariaDB Dialect
    • Amazon RDS for SQL Server Dialect
    • Amazon RDS for MySQL Dialect
    • Amazon RDS for Oracle Dialect
    • Amazon RDS for PostgreSQL Dialect
    • Amazon Redshift
    • Amazon Redshift Data API Dialect
    • RisingWave Dialect
    • rqlite Dialect
    • SingleStore Dialect
    • Snowflake Dialect
    • Google Cloud Spanner Dialect
    • Spanner (PostgreSQL interface) Dialect
    • SQLite Dialect
    • StarRocks Dialect
    • Supabase Dialect
    • Azure Synapse Analytics (dedicated SQL pool)
    • Teradata Dialect
    • TiDB Dialect
    • TimescaleDB Dialect
    • Trino Dialect
    • Turso / libSQL Dialect
    • Vertica dialect
    • Vitess Dialect
    • Yellowbrick Dialect
    • YugabyteDB Dialect
  • guides
    • Access rules and behaviour profiles
    • ArangoDB (AQL)
    • Associations
    • Audit levels and escalation
    • Audit logging
    • In the browser
    • Bulk operations
    • Caching
    • Capabilities, tiers and team roles
    • CLI
    • Cloudflare
    • Compliance & data protection
    • Connection Pooling & Performance
    • Security in the console
    • Couchbase: structured N1QL queries
    • Data types
    • Decorators
    • Eager loading
    • Enums and constants
    • Error handling & error types
    • Extensions
    • External fields
    • Foreign data wrappers
    • Graph databases
    • Hooks & lifecycle events
    • Indexes & constraints
    • InfluxDB: structured Flux pipelines
    • klock-errors
    • klock — the `.klock` file
    • Local databases (`prorm db up`)
    • Structured log search — Splunk, Graylog, Papertrail, Sumo Logic
    • Logging
    • Grafana Loki: structured LogQL queries
    • MCP server
    • Migrate — the data transfer subsystem
    • Migrations & Schema Sync
    • Models from an existing database
    • Defining models
    • Multiple databases
    • OrientDB, Neptune and TigerGraph
    • Passwords
    • Importing a Prisma schema
    • Structured PromQL and MetricsQL
    • Query firewall
    • QueryInterface
    • Query operators reference
    • Query optimization
    • Composing find options with `pipe()` and `Q`
    • Query safety: the fail-closed WHERE guard
    • Querying
    • Raw Queries & Query Building
    • Read replicas
    • Schema diffing & migration generation
    • Views, triggers & other schema objects
    • Scopes & default scopes
    • Seeders
    • SPARQL / RDF triplestores
    • SQL constants
    • SQL features
    • SQL function builders
    • SQLite advanced features
    • StoreKey — a prod-orm database as the storage
    • Streaming results
    • Terraform
    • Transactions & Isolation Levels
    • Type mapping: legacy and modern
    • TypeScript types
    • Database users, roles & privileges
    • Validation & constraints
    • Virtual & Computed Fields
    • Writing docs
  • javascript
    • prorm in JavaScript
    • Models in JavaScript
    • Querying in JavaScript
    • Relations in JavaScript
  • runbooks
    • Operational runbooks
    • Connection failures
    • Credential rotation
    • Failover
    • Incident checklist
    • Deploying klock v2
    • Migration rollback
  • running-databases
    • Running databases in Docker
    • Running prorm under Bun in Docker
    • Running Cassandra in Docker with prorm
    • Running ClickHouse in Docker with prorm
    • Running CockroachDB in Docker with prorm
    • Running prorm under Deno in Docker
    • Running Elasticsearch in Docker with prorm
    • Running MariaDB in Docker with prorm
    • Running MongoDB in Docker with prorm
  • prorm control plane
    • Introduction
    • Authentication
    • Errors
    • Getting started
      • Service
        • Is it up
        • This description
    • Account
      • Sign-up and verification
        • Create an account
        • Exchange credentials for a token
        • Verify with the emailed code
        • Verify by clicking the emailed link
        • Name the parent or guardian
        • What a consent link is about
        • Give consent for a child's account
        • Ask the parent or guardian again
        • Send a new code and link
      • Family
        • Everyone this account is responsible for
        • One child, in detail
        • Turn a limit on or off
      • Profile
        • Who am I
        • Change the account
        • Every address on the account
        • Add an address
        • Confirm an address with the code mailed to it
        • Mail a fresh code to an unconfirmed address
        • Make an address primary
        • Remove an address
        • What this caller may do
    • Security
      • Sessions
        • Everywhere this account is signed in
        • Sign out everywhere else
        • Sign one out
        • A token for the CLI on a named machine
        • A token for the customer console, from the staff one
        • Park a sign-in for the CLI to collect
        • The CLI collecting its sign-in
        • The team's members' live sessions
        • Sign a member's session out
        • Sign an account out everywhere
        • Everywhere the account is signed in
        • Sign an account's session out
      • Single sign-on
        • Begin a single sign-on
        • The provider's return
        • Link the signed-in account to a team's provider
        • Which team an address signs in to
        • The team's single sign-on, as a member sees it
        • Set the team's OpenID Connect provider up, or change it
        • Remove the provider
        • The team's domain claims and the DNS record each needs
        • Prove a claimed domain
      • MFA and passwords
        • Start enrolling a second factor
        • Prove the app has it
        • Remove the second factor
        • Ask for a reset link
        • Set a new password with the emailed token
      • Klock
        • Open a device-bound klock session
        • Check in, for the next grant
        • Step up the session asking
        • Rotate the device key
        • Freeze a session
        • Unfreeze a session
        • A guest grant
        • The revocation feed
        • The published keyset
        • What this control plane supports
      • Devices
        • Your devices, or a team's
        • Approve a new device's pairing code
        • One device
        • Rename a device
        • Revoke a device
        • Suspend a device
        • Reinstate a suspended device
        • Wipe a device
        • Ask a device to rotate its key
        • The team's members' devices
        • Revoke a member's device
        • An account's devices
        • Revoke an account's device
        • Wipe an account's device
        • The device fleet
      • Access rules and behaviour
        • The access rules that bear on you
        • Add an access rule
        • This request, as the rules see it
        • One access rule
        • Remove an access rule
        • The behaviour profiles that bear on you
        • Add or change a behaviour profile
        • The profile presets
        • Your behaviour findings
        • Acknowledge a finding
        • One behaviour profile
        • Remove a behaviour profile
        • The team's access rules
        • Add a team access rule
        • Remove a team access rule
        • The team's behaviour profiles
        • Add or change a team behaviour profile
        • Remove a team behaviour profile
        • What the team's profiles caught
        • Acknowledge a team finding
      • Recovery and lockdown
        • Ask for a recovery link
        • Recover access with the emailed code
        • Is the account locked down
        • Lock the account down
        • Release a lockdown
        • Lock a member down
        • Release a member's lockdown
      • MCP tokens
        • Make a delegated token for one MCP client
        • The account's delegated tokens
        • Revoke a delegated token
        • What a delegated token may do now
        • Run one MCP tool with a delegated token
        • Run a device-signed MCP tool call relayed by the MCP Worker
      • Kerberos
        • Give the team its own Kerberos realm
        • The team's realm
        • Remove the team's realm
    • Logging and monitoring
      • Audit log
        • What has been done to the account
        • What has happened to this account
        • The account's audit trail
      • Operations
        • Everything that has been asked of this database
        • One, with its steps
        • Stop it
        • Put a failed one back in the queue
        • Every operation, across all accounts
      • Metrics and events
        • Every measurement, for a graph
        • Who did what to it
    • Billing
      • Plans and pricing
        • What the plans allow
        • The rate card
        • What one database would cost
        • What this account is paying for, itemised
      • Subscriptions and invoices
        • What this month's invoice will say
        • What this account is on
        • What it has been charged
        • Start a Stripe Checkout session
        • Open the customer portal
        • Stripe's events
        • Buy more connection pools
      • Team billing
        • The team's billing account
        • What the team has been charged
        • What this month's invoice will say
        • The team's cards
        • Add a card, through a Stripe-hosted setup session
        • Make a card the team's default
        • Remove a card
        • Start the team paying
        • Open the Stripe portal on the team's customer
        • Change the team's plan
        • Who receives the team's bills
        • How the team pays for transfers
    • Support
      • Support chat
        • My conversations
        • Start a conversation
        • Every support conversation, open first
        • A ticket for the socket
        • One conversation, with who is in it and who is connected
        • Rename a group; open or close a support conversation
        • The lines, oldest first
        • Say something
        • Add somebody
        • Mark read
    • Administration
      • Users
        • Every account
        • One account, with everything hanging off it
        • Grant or revoke admin
        • Verify an account by hand
        • Lock or unlock sign-in
        • Allow or hold provisioning
        • Send the verification mail again
        • Set the plan by hand
      • Platform
        • Every database and store, across all accounts
        • The counts worth putting on a dashboard
      • Staff and permissions
        • What kind of admin the caller is, and which scopes they hold
        • The admin presets
        • Every admin, and the scopes each resolves to
        • Make an account an admin of a kind
        • Change an admin's kind, or a custom admin's scopes
        • Revoke admin
      • Team administration
        • Every team, with its owner and size
        • One team in full — members with their accounts, and projects
        • Hand the team to another member
        • Put an account in the team, active at once
        • Change a member's role
        • Remove a member
      • Assumed identities
        • Assume an identity — act as a customer account
        • The identities the caller holds open
        • End an assumed identity
      • Support inbox
        • The platform's addresses
        • Add a platform address
        • One address, with its counts
        • Change an address's label, routing, or whether it accepts mail
        • Remove an address that holds no mail
        • Make, adopt or repair the address's routing rule
        • Every admin's personal address, made where missing
        • File a raw message, as if it had arrived
        • The inbox
        • One message, whole
        • Mark a message
        • Hand a message to an admin, or take it back
        • Leave an internal note
        • Reply, from the address the message came to
        • What is waiting
    • Databases
      • Databases
        • Data stores
          • Every data store on the account
          • Provision a data store
          • Change a data store that is already running
          • One data store
          • Deprovision it
          • Report what it is using
          • How to reach a data store
        • Connections and credentials
          • The api keys on a database
          • Issue a key
          • Revoke a key
          • The engine users we created
          • Create a user inside the database
          • Give a user a new password
          • Drop a user
          • How the database is reached
          • Set how it is reached
          • Revoke every api key on it
          • How to connect to it
          • The ssh keys that open its bastion
          • Add a key, or have one made
          • The private half of a generated key, once
          • Revoke an ssh key
        • Schema and objects
          • The SQL objects we put in it
          • Create one, and apply it
          • What is in the database, as the engine reports it
          • One table
          • What it would run, without running it
          • Redefine an object in place
          • Drop it
          • The extensions installed in it
        • Migrations
          • A plan compiled to statements, recording nothing
          • The migrations recorded for it
          • Record one
          • Apply everything recorded and not yet run
          • Apply everything pending, oldest version first
        • Lifecycle
          • Grow or shrink it
          • Stop it accepting connections
          • Start it again
          • Move it to another region
        • Backups and replicas
          • Take a backup
          • The backups it has
          • Restore one into it
          • What has been restored into it, and from where
          • Its read copies
          • Add one
        • Policies
          • The catalog
          • One policy
          • What this database is held to
          • Switch a policy on, or change its knobs
          • Switch it off
          • Mark the policies reviewed
          • Evaluate the periodic policies now
          • Who is behind a credential
        • Passwordless
          • The database's passwordless sessions
          • Issue a passwordless session (v1)
          • Revoke a passwordless session
          • Bind a session to a device key
          • Issue a v2 session for a host to bind
          • Redeem an enrolment ticket on the host
          • Check a passwordless credential
          • Run one described query under a passwordless credential
        • Database roles
          • The database's roles
          • Define a role
          • One role
          • Change what a role permits
          • Delete a role, and revoke every key granted it
          • Grant a role, which mints a key
          • Who holds a role
          • The api keys bound to a role
        • Pools
          • A database's connection pools, and how busy each is
          • Open another pool
          • Rename a pool or change its size
          • Close a pool
        • Data plane
          • Run a query against a database
          • Several statements, in order, over one connection
          • Is this database answering
          • What the proxy itself is holding
        • Transfers
          • The strategies this account's tier may use
          • Price a transfer without creating it
          • The account's transfer jobs, newest first
          • Create a transfer job
          • One job, and whether its certificate holds
          • Cancel an unfinished job
          • Start a created job, confirming its price
          • Report a job's status (the runner, or the owner's side of a local run)
        • Every database on the account
        • Provision a database
        • Change a database that is already running
        • One database
        • Deprovision it
        • Read or write it
        • Report what it is using
        • The engines this API will provision
        • Where a managed database can be put
    • Teams
      • Teams
        • Members
          • Who is on it
          • Invite somebody
          • Accept an invitation
          • Change somebody's role
          • Remove somebody
        • Projects
          • The projects a team runs
          • Start one
          • Rename a project, or move its environment
          • Delete a project
          • One project, and what is in it
          • Put a database or store in it
          • What is in a project
          • Take one out
        • The teams this account is on
        • Start one
        • The twelve roles, and what each may do
        • Rename a team
        • Close a team
        • One team
        • Hand the team to somebody else
        • Leave a team
    • Schemas
      • Error
      • ValidationErrors
      • Token
      • PolicyCategory
      • PolicySeverity
      • PolicyAction
      • PolicyTrigger
      • PolicyParam
      • Policy
      • PolicyCatalog
      • PolicyAssignment
      • PolicyOutcome
      • PolicyFiring
      • CredentialTrace
      • Relation
      • Restrictions
      • ChildSummary
      • SignupRequest
      • SigninRequest
      • UpdateMeRequest
      • User
      • Address
      • GeoPoint
      • D1Region
      • Usage
      • UsageReport
      • Resource
      • Database
      • DataStore
      • NewDatabase
      • NewDataStore
      • QueryRequest
      • DatabasePrivilege
      • ApiKey
      • DbUser
      • Access
      • AccessRequest
      • DatabaseObject
      • NewDatabaseObject
      • NewMigration
      • SchemaOperation
      • Migration
      • Operation
      • Session
      • AdminPowers
      • Admin
      • AuditEntry
      • TeamRole
      • Conversation
      • ChatMessage
      • TeamSso
      • Team
      • TeamMember
      • SshKey
      • ConnectionInfo
      • InboxAddress
      • InboxMailbox
      • InboxMessageSummary
      • InboxMessage
      • InboxNote
      • Page
      • Quote
      • Plan
      • ApiError
      • CapabilityRefusal
      • KlockErrorCode
      • PageWindow
      • SessionView
      • KlockStepUpRequest
      • KlockOpenRequest
      • KlockOpenedSession
      • UpdateMeResult
      • CapabilitiesView
      • SessionLimits
      • Device
      • DeviceListed
      • DeviceTeamRef
      • DeviceCommand
      • DeviceAuditLine
      • DeviceActionRequest
      • StaffActionRequest
      • ManagedSession
      • SessionRevoked
      • AccessRule
      • AccessRuleRequest
      • BehaviourProfile
      • BehaviourProfileRequest
      • BehaviourProfileSaved
      • BehaviourFinding
      • BehaviourFindingAck
      • StepUpProof
      • Removed
      • MemberLockdownRequest
      • McpClient
      • McpDelegatedView
      • McpToolCall
      • McpForwardRequest
      • PasswordlessScope
      • RoleScope
      • PasswordlessSession
      • PasswordlessTlsMaterial
      • PasswordlessKerberosMaterial
      • ServiceDeviceKeys
      • ServiceSession
      • DatabaseRole
      • TransferEndpoint
      • TransferJobRequest
      • TransferQuote
      • TransferStrategy
      • TransferJob
      • TransferJobAccess
      • Pool
      • PoolAllowance
      • BillingSubscription
      • Invoice
      • BillingContact
      • PaymentMethod
      • UpcomingCharges
      • TeamBilling
      • TeamKerberosRealm
      • TeamSsoDomain
      • TeamSsoResponse
      • StoreConnection
      • BatchFailure
      • ProxyStatus
  1. guides

Structured log search — Splunk, Graylog, Papertrail, Sumo Logic

Why this page exists#

The four hosted log stores in this library used to expose exactly one way to search: query(searchString), where the caller wrote the search string by hand. That contradicts the library's stated principle — the public API never requires you to write a query language yourself — and it puts every escaping decision on the caller, in four languages that escape differently.
Each store now has a compiler: a findAll-shaped spec in, the exact search text out, with every caller-supplied value escaped or refused. query() stays where it is, as the deliberate escape hatch for what the compilers will not emit.
StoreLanguageCompiler moduleStore methods
SplunkSPL (piped)src/nosql/splunk/spl.tscompileSearch(), search()
GraylogLucene filter + Views JSONsrc/nosql/graylog/lucene.tscompileSearch(), search(), compileAggregation(), aggregate()
Papertrailword-boolean expressionsrc/nosql/papertrail/search-expression.tscompileSearch(), search()
Sumo LogicSumo search (piped)src/nosql/sumologic/sumo-query.tscompileSearch(), search()
Every compiler is compile-only: it holds no connection and performs no I/O, so compileSearch() works without connecting and is the thing to reach for in a test or a log line. This mirrors N1qlCompiler (src/nosql/couchbase/n1ql.ts), which the stores use the same way.

How much these four share, and why it is so little#

They share one thing, and it is the right one: the ORM's predicate surface. Every compiler folds WhereOptions through normalizeWhere() from src/graph/types.ts into a GraphPredicate tree, exactly as the N1QL, CQL and graph dialects do, and then renders that tree in its own language. Re-parsing WhereOptions per store would be four drifting copies of the ~80 Op.* operators.
Beyond that, src/nosql/shared/log-search.ts holds about a hundred lines: the LogSearchCapabilityError type, integer validation for the values that are written into a search as bare digits, Date → epoch conversion, and sort-term normalization. There is deliberately no shared escaper and no shared search spec.
A shared spec would be a lie, because the same concept lives in a different place in each language:
SplunkGraylogPapertrailSumo Logic
Filtersearch commandthe whole languagethe whole languagescope + where
Time rangeearliest=/latest= in the languageREST parameter (3 different endpoints)min_time/max_time parametersproperty of the search job
Limit| head commandlimit parameterlimit parameter| limit operator
Ordering| sort commandsingle sort=field:dir parameterdoes not exist (always newest-first)| sort by operator
Aggregation| statsa separate JSON API (Views/pivots)does not exist| count by etc.
So "limit" is a language construct in two of them and an HTTP parameter in the other two; "sort" is absent from one entirely; "aggregate" is a pipeline stage in two, a different API in one, and nothing at all in the fourth. Forcing one spec over that would make three-quarters of its fields conditionally meaningless, and the failure would be silent — a limit that a store quietly ignores is worse than one it refuses.
One tree, four renderers, and three possible results: exact text, a refusal
of a value, or a refusal of a feature. There is no fourth "best effort" path.

One question, four stores#

"Requests that failed with a 5xx on any web* host in the last 15 minutes,
first 100." The filter is the same WhereOptions each time; everything around
it is where the four stores differ. These are the actual compiled outputs:
import { Op } from 'ts-prorm-orm';
import { SplunkStore } from 'ts-prorm-orm/nosql';

const where = { status: { [Op.gte]: 500 }, host: { [Op.startsWith]: 'web' } };

new SplunkStore({ token }).compileSearch({
  index: 'web',
  earliest: '-15m',
  where,
  pipeline: [{ command: 'head', count: 100 }],
});
// { spl: 'search index="web" earliest=-15m (status>=500 AND host="web*") | head 100',
//   earliest: '-15m' }
import { Op } from 'ts-prorm-orm';
import { GraylogStore } from 'ts-prorm-orm/nosql';

const where = { status: { [Op.gte]: 500 }, host: { [Op.startsWith]: 'web' } };

new GraylogStore({ baseURL }).compileSearch({
  where,
  timeRange: { type: 'relative', range: 900 },
  limit: 100,
});
// { endpoint: '/api/search/universal/relative',
//   query: '(status:>=500 AND host:web*)',
//   params: { query: '(status:>=500 AND host:web*)', range: '900', limit: '100' } }
import { Op } from 'ts-prorm-orm';
import { SumoLogicStore } from 'ts-prorm-orm/nosql';

const where = { status: { [Op.gte]: 500 }, host: { [Op.startsWith]: 'web' } };

new SumoLogicStore({}).compileSearch({
  sourceCategory: 'prod/web',
  from: '-15m',
  pipeline: [
    { operator: 'json', field: '_raw', paths: ['status', 'host'] },
    { operator: 'where', where },
    { operator: 'limit', count: 100 },
  ],
});
// { query: '_sourceCategory="prod/web" | json field=_raw "status", "host"
//           | where (status >= 500 and host matches /^web/) | limit 100',
//   timeZone: 'UTC', from: '-15m' }
import { Op } from 'ts-prorm-orm';
import { PapertrailStore } from 'ts-prorm-orm/nosql';

// `status` is not something Papertrail indexes, so the same where is refused:
new PapertrailStore({ token }).compileSearch({ where: { status: { [Op.gte]: 500 } } });
// LogSearchCapabilityError: "filter on "status"" is not supported by the papertrail
// search language. Papertrail indexes syslog metadata and message text, not structured
// fields. The only filters it has are program, host, severity, facility. …

// The nearest honest question uses its metadata and severity instead:
new PapertrailStore({ token }).compileSearch({
  where: { host: { [Op.startsWith]: 'web' }, severity: { [Op.in]: ['err', 'crit'] } },
  minTime: new Date(Date.now() - 15 * 60 * 1000),
  limit: 100,
});
// { query: '(host:web* (severity:err OR severity:crit))',
//   params: { q: '…same…', min_time: '1790459719', limit: '100' } }
Note where the 15 minutes went: into the SPL text, into a Graylog URL
parameter, onto the Sumo search job, and into a Papertrail min_time.
Op.startsWith became a quoted wildcard, a bare wildcard, an anchored regex,
and a trailing *.
The honest grouping is two and two: SPL and Sumo are piped languages where a query is a scope plus a chain of stages, so their specs have pipeline arrays and look alike in shape. Graylog and Papertrail are filter expressions where everything else is a request parameter, so their specs are flat and their compiled output is { query, params }. Even within each pair the details do not transfer — SPL's stats is not Sumo's count by, and Graylog's Lucene is nothing like Papertrail's word matching — so the shapes rhyme and the code does not.

Escaping is the whole safety story#

All four searches travel to the server as text. None of them has wire-level parameter binding. There is no $1 to hide behind, so escaping correctness is the only thing between a caller's value and an altered search. This module follows the discipline the SPARQL dialect established (src/graph/sparql/terms.ts): a small, audited set of functions is allowed to turn a JavaScript value into query text, and nothing else concatenates one.
Two rules run through all four compilers:
Field names are validated, never rewritten. safeIdent-style rewriting is right for a Cypher label, where the label is the name. It is wrong here: user-id and user_id are two different fields, and quietly searching the wrong one returns a plausible answer nobody checks.
A value that cannot be escaped is refused, not mangled. Where a language defines no escape for a character — Papertrail's ", Sumo's ", SPL's * in the search command — the compiler raises a ValidationError naming the character, the reason, and the alternative construct that does work.

The wildcard problem, per language#

An unescaped * in a value does not raise an error. It silently widens the search and returns plausible rows. That makes it the most important case in this whole module, and it is handled differently in each language because the languages differ:
LanguageWildcardsLiteral * in a valueWhere a deliberate wildcard comes from
SPL search* only, live inside quotes toorefused — SPL has no escape for itOp.startsWith/endsWith/substring/like append it after escaping
SPL where (eval)none; like() uses SQL %/_passes through, quotedOp.like → like(); literal operators → match() over an escaped regex
Graylog Lucene* and ?escaped (\*, \?) in phrases and in bare termsappended after luceneEscapeTerm()
Papertrailtrailing * onlyrefused — no escape existsOp.startsWith only; endsWith/substring are refused
Sumo scope* and ?, unquoted terms onlyrefused — quoting is not a reliable defenceappended after sumoBareToken() validation
Sumo wherenone in the exact formpasses through, quotedmatches /regex/ over a regex-escaped value
The recurring trick, borrowed from the N1QL compiler, is that literal-valued operators (startsWith, endsWith, substring) do not compile to a pattern with the value pasted into it. Where the language has an exact alternative — SPL's match(), Sumo's matches /…/ — they compile to that instead, over a regex-escaped value, so there is no wildcard in the output at all. Only when the language has nothing better (Graylog, Papertrail, Sumo's scope) is a wildcard emitted, and then only after the value has been escaped or validated, so the wildcard the compiler adds is provably the only live one.

Refusals#

[!WARNING]
Refusals are the feature, not a gap to work around. Catching
LogSearchCapabilityError and retrying through query() with the same
value pasted in reintroduces exactly the escaping problem the compiler refused.
Two real refusals, each naming what to do instead:
import { SplunkStore, SumoLogicStore } from 'ts-prorm-orm/nosql';

new SplunkStore({ token }).compileSearch({ index: 'web', where: { user: 'a*' } });
// ValidationError: Splunk: value for "user" contains "*", which is a wildcard in the
// search command even inside double quotes, and SPL has no escape that makes it literal.
// Refusing rather than silently widening the search: "a*". If you meant a wildcard, use
// Op.startsWith / Op.endsWith / Op.substring / Op.like. If you meant a literal asterisk,
// compare it in a `{ command: "where" }` stage, where "=" is exact.

new SumoLogicStore({}).compileSearch({ sourceCategory: 'prod/web', where: { status: 500 } });
// LogSearchCapabilityError: "scope filter on "status"" is not supported by the sumologic
// search language. Only the built-in metadata fields (_sourceCategory, _source, …) exist
// before the first pipe; a parsed field does not. … Put it in a `{ operator: "where" }`
// stage instead.
When a language cannot express something, the compiler throws LogSearchCapabilityError (src/nosql/shared/log-search.ts), which names the feature and the store — the pattern set by GraphCapabilityError and N1qlCapabilityError. Emitting a best-effort approximation would be worse here than anywhere else in the repo: a search the server rejects is annoying, while a search the server accepts that means something else returns rows and is never questioned.

Splunk — SPL#

import { SplunkStore } from 'ts-prorm-orm/nosql';

const store = new SplunkStore({ token: process.env.SPLUNK_TOKEN });
const compiled = store.compileSearch({
  index: 'main',
  sourcetype: 'access_combined',
  earliest: '-24h',
  where: { status: { [Op.gte]: 500 } },
  pipeline: [
    { command: 'eval', assignments: { ms: { op: '*', left: { field: 'seconds' }, right: 1000 } } },
    { command: 'where', where: { ms: { [Op.gt]: 250 } } },
    { command: 'stats', functions: [{ fn: 'count', as: 'errors' }], by: 'host' },
    { command: 'sort', by: '-errors' },
    { command: 'head', count: 10 },
    { command: 'table', fields: ['host', 'errors'] },
  ],
});

compiled.spl;
// search index="main" sourcetype="access_combined" earliest=-24h status>=500
//   | eval ms=(seconds * 1000) | where ms > 250 | stats count AS errors BY host
//   | sort -errors | head 10 | table host, errors

The two predicate languages#

SPL has two places you can write a comparison and they do not mean the same thing. This is the single most important thing to know about the Splunk compiler:
search command (SplSearchSpec.where)where command ({ command: 'where' })
field=valuevalue is a wildcard patternexact comparison
casefield values compare case-insensitivelycase-sensitive
wildcards* only, no single-character wildcardnone; like() uses % and _
regexnot available (refused, by name)match(field, "re")
absent fieldNOT field=*isnull(field)
Both renderers walk the same normalized tree, so Op.startsWith works in both — it just compiles to path="/api*" in one and match(path, "^/api") in the other.

Covered#

Search command: index/sourcetype/source/host constraints (single or ORed lists), bare quoted terms, the full boolean predicate (AND/OR/NOT/grouping), field comparisons (=, !=, >, >=, <, <=, IN), wildcard patterns, field=* existence tests, and earliest/latest time modifiers validated against SPL's grammar.
Pipeline: stats (with BY, aliases, and percentile functions such as perc95), eval (a typed expression AST: field refs, literals, arithmetic and . concatenation, whitelisted functions, if over a WhereOptions condition), where, sort (with an optional leading count), head (count or condition), tail, table, fields (+/-), dedup (count, sortby, keepevents, keepempty), rename.

Not covered, and why#

timechart, chart, transaction, join, lookup, rex, spath, eventstats, streamstats, tstats, subsearches and macros. Each is a feature in its own right rather than a variation on the ones above — timechart needs a span/bin vocabulary, join and subsearches embed a second search whose escaping is a separate problem, rex is a regex-extraction DSL. They stay behind query().
Field names are restricted to [A-Za-z_][A-Za-z0-9_.]*. Real Splunk data does contain k8s-pod and foo{}.bar, which need single-quoting in eval and a rename in search — different rules on either side — so they are refused rather than handled correctly in one context and silently wrongly in the other. Wildcard renames (foo* AS bar*) are refused by the same rule.
Op.iLike in the search command compiles identically to Op.like, because search-command field values already compare case-insensitively. In eval context it lowers both sides.

Graylog — Lucene, plus the Views API#

import { GraylogStore } from 'ts-prorm-orm/nosql';

const store = new GraylogStore({ baseURL: 'https://graylog.example.com' });
const compiled = store.compileSearch({
  where: { status: { [Op.gte]: 500 }, source: 'web01' },
  timeRange: { type: 'relative', range: 900 },
  limit: 50,
  order: '-timestamp',
  streamId: '000000000000000000000001',
});
// endpoint: '/api/search/universal/relative'
// query:    '(status:>=500 AND source:"web01")'
// params:   { query, range: '900', limit: '50', sort: 'timestamp:desc', filter: 'streams:…' }

await store.aggregate({
  where: { status: { [Op.gte]: 500 } },
  timeRange: { type: 'relative', range: 900 },
  groupBy: ['source', { field: 'status', limit: 5 }],
  series: [{ fn: 'count' }, { fn: 'percentile', field: 'took_ms', percentile: 95 }],
});

Two APIs, because Graylog has two#

Graylog's query language expresses a filter and nothing else — no ordering, no limit, no time range, no aggregation. So the compiler has two halves that do not resemble each other:
1.
compileSearch() → { endpoint, query, params } for the universal-search API. The endpoint is chosen by the time range, because Graylog splits the same search across /relative, /absolute and /keyword URLs that take different parameters.
2.
compileViewsSearch() → the POST /api/views/search request body: pivots, row and column groups, series, sorting and limits, all as JSON. aggregate() runs it (create, then execute — two calls, because that is what the API is).
Values in the Views request are JSON fields, so JSON.stringify handles the quoting and there is nothing to escape; the one exception is the embedded query_string, which goes through the same query compiler as (1). Field names are still validated, because a pivot field is an Elasticsearch field reference and a wrong one aggregates the wrong column rather than failing.

Covered#

field:value and field:"phrase", _exists_ for presence, prefix comparisons (field:>10), ranges (field:[lo TO hi]), field:(a OR b) lists, wildcards (* and ?), regex literals (field:/re/), the full boolean structure, bare terms against the default field; plus limit/offset/sort/stream filter as parameters and three time-range kinds. On the Views side: values and time row/column groupings, and the count, sum, avg, min, max, stddev, variance, card, latest, sumofsquares and percentile series, with an optional messages search type alongside the pivot.

Not covered, and why#

Op.iLike is refused. In Elasticsearch, case folding is a property of the field's analyzer and is fixed at index time; there is no query-side case-insensitive operator. Lowering the pattern would match on an analyzed text field, miss on a keyword field, and never say which happened.
Multi-field ordering on the universal-search API is refused: that endpoint takes a single sort=field:dir. The Views API takes a list.
Fuzzy (~) and proximity searches, saved-search and dashboard management, and the decorator/highlight machinery are out of scope.

Escaping#

luceneQuotePhrase() escapes \, ", * and ? inside "…". The last two are escaped defensively: a phrase is normally not wildcard-expanded, but Graylog versions differ on analyze_wildcard, the parser unescapes \* back to a literal, and the cost of being wrong is a silently wider search. luceneEscapeTerm() escapes every operator character and whitespace for the bare terms that wildcard queries must be. Regex patterns have only their / delimiter escaped — doubling the backslashes would turn \d into "a backslash then a d".

Papertrail — a word-boolean expression#

import { PapertrailStore } from 'ts-prorm-orm/nosql';

const store = new PapertrailStore({ token: process.env.PAPERTRAIL_TOKEN });
const compiled = store.compileSearch({
  where: { program: 'nginx', severity: { [Op.in]: ['error', 'crit'] } },
  terms: 'connection refused',
  minTime: new Date('2024-01-01T00:00:00Z'),
  limit: 250,
});
// query:  '"connection refused" (program:nginx (severity:error OR severity:crit))'
// params: { q, min_time: '1704067200', limit: '250' }

Covered#

Bare terms and quoted phrases, implicit AND, OR, grouping, -term exclusion, a trailing * prefix match, and the four metadata filters Papertrail indexes: program, host, severity, facility. Everything else — the time window, the page size, the pagination cursors, the system/group scope — is a request parameter, because the language has no way to say any of it.

Not covered — and this is most of the ORM's operator surface#

Papertrail is the shallowest of the four and the compiler says so out loud. Each of these throws LogSearchCapabilityError naming the feature and the reason:
RefusedReason
a filter on any other fieldPapertrail indexes syslog metadata and message text, not structured fields. user_id:42 would compile happily and search for those words.
gt/gte/lt/lte/betweenthe search has no comparison operators at all; time is the only ordered dimension, and it is a parameter
endsWith/substringmatching is word-boundary based with a trailing wildcard only — there is no leading-wildcard form
like/iLikeno LIKE-style pattern operator exists; the trailing wildcard is the whole of its pattern matching
regexpthe index is word-based and exposes no regex matching
isNull/isNotNullevery event carries all four metadata fields, so absence has no meaning
NOT over a groupPapertrail negates one term at a time with -; NOT (a OR b) has no faithful spelling. Push it down yourself: -a -b.

Escaping#

Papertrail documents no escape character, so this compiler validates rather than escapes. A " is refused (there is nothing to turn it into), a * is refused in every literal position, metadata values must be bare tokens matching [A-Za-z0-9_.@/:+-]+, and free-text terms are quoted as phrases whenever they are not plain tokens — which also neutralizes a leading - and the words OR/AND/NOT. severity and facility values are checked against the syslog vocabularies, because those are closed sets and a typo there means "match nothing" rather than an error.

Sumo Logic — scope plus pipeline#

import { SumoLogicStore } from 'ts-prorm-orm/nosql';

const store = new SumoLogicStore({ /* accessId, accessKey */ });
const compiled = store.compileSearch({
  sourceCategory: 'prod/app',
  terms: 'error',
  from: '-15m',
  pipeline: [
    { operator: 'json', field: '_raw', paths: 'status', as: 'status' },
    { operator: 'where', where: { status: { [Op.gte]: 500 } } },
    { operator: 'timeslice', interval: '5m' },
    { operator: 'aggregate', functions: { fn: 'count', as: 'errors' }, by: '_timeslice' },
    { operator: 'sort', by: '-errors' },
    { operator: 'limit', count: 10 },
  ],
});
// query: _sourceCategory="prod/app" "error" | json field=_raw "status" as status
//        | where status >= 500 | timeslice 5m | count as errors by _timeslice
//        | sort by errors desc | limit 10
// from:  '-15m'   (a property of the search job, not of the query)

Scope and where are two different dialects#

The scope — everything before the first | — can only match the built-in metadata fields (_sourceCategory, _source, _sourceHost, _sourceName, _collector, _index, _view) and free-text keywords. A parsed field does not exist yet at that point. Writing status=500 there is not an error: Sumo reads it as a keyword search for the literal text status=500 and returns whatever lines contain it. The compiler therefore refuses a non-metadata field in the scope and names the where stage instead.
The two contexts also differ in spelling, and the compiler follows the language rather than picking one:
scopewhere clause
booleansAND / OR / NOT, uppercase, AND implicitand / or / !, lowercase
not-equalno !=; spelled NOT field=value<>
wildcards*/? on unquoted termsnone — matches with a glob or an RE2 regex
comparisonsnone=, <>, <, <=, >, >=, in, not in, isNull

Covered#

Scope metadata constraints (single or ORed lists), keyword terms, and the scope predicate; then the pipeline: where, parse (anchor and parse regex, with field= and nodrop), json (auto or explicit paths, with as and nodrop), timeslice, aggregations (count, count_distinct, sum, avg, min, max, stddev, variance, pct, first, last, each with as and by), sort by, limit, fields (include/exclude) and dedup (consecutive, count, by). The search-job window (from/to/timeZone/byReceiptTime) is returned beside the query rather than inside it, because that is where Sumo puts it.

Not covered, and why#

lookup, join, transpose, outlier, save, transaction and top — each is a feature with its own argument grammar rather than a variation on the above, and several (lookup, save) touch resources outside the search. They stay behind query().
An unscoped search is refused. * would technically work, but an unscoped Sumo search reads every message in the retention window — an expensive accident, and never what a forgotten argument meant.

Escaping#

Sumo documents no escape character either, so sumoQuote() refuses a " rather than emitting a literal the value could break out of. In the scope, * and ? are refused inside literals: quoting is documented as suppressing wildcard expansion, but the failure mode of being wrong is a silently wider search, so a deliberate wildcard has to come from Op.startsWith/Op.like, which validate the value as a bare token first. In a where clause the literal operators compile to matches /^regex$/ over a regex-escaped value, which is exact — that is also how Op.like keeps SQL's _ semantics, which the scope's glob cannot express.

Testing#

tests/nosql/splunk-spl.test.ts, graylog-lucene.test.ts, papertrail-search.test.ts and sumologic-query.test.ts assert the exact compiled search for every construct, with toBe rather than toContain — a "contains the value" assertion passes just as happily against a compiler that widened the search or dropped a negation. Every value-carrying position is then fed hostile input — a double quote, a backslash, an asterisk, a pipe, the language's own boolean operators, a newline, a NUL — and the result is asserted to be either correctly escaped or refused with a named error. Field names, sort fields, output aliases, rename targets, stream ids, time modifiers, intervals and limits are all covered, since each of those is a position where a value reaches the search unquoted.

Related reading#

SplunkStore · GraylogStore · PapertrailStore · SumoLogicStore
CQL queries and Couchbase (N1QL) — the same compile-only pattern for Cassandra and Couchbase
Documentation index — including the Op.* operator surface these compilers render
Modified at 2026-10-04 00:40:16
Previous
Local databases (`prorm db up`)
Next
Logging
Built with