Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

C ABI

infrastore-ffi compiles a cdylib (libinfrastore_ffi.{dylib,so,dll}) exposing a C-compatible API over Store. The header crates/infrastore-ffi/include/infrastore.h is generated by cbindgen — do not hand-edit it. The Julia binding is the primary consumer.

Conventions

  • Return codes. Every function returns int32_t. INFRASTORE_OK (0) is success; any other value is an error. Retrieve the human-readable detail with infrastore_last_error_message.
  • Opaque handles. InfraStore, InfraStoreBulkRead, the readers and the batch are incomplete struct types; you only ever hold pointers. Free them with infrastore_store_free.
  • Out-parameters. Results are written through caller-provided pointers (**out, *out_len, …).
  • Caller-owned buffers. Returned arrays come back through an out-pointer + length and must be freed: f64 percentile buffers (double **) with infrastore_buffer_free_f64, raw element-byte buffers (uint8_t **) with infrastore_buffer_free_u8, timestamp and shape buffers (int64_t **) with infrastore_buffer_free_i64, and the u64 dims buffer from infrastore_bulk_result_get_forecast (uint64_t **) with infrastore_buffer_free_u64.
  • Typed arrays. Add functions take an element_type string (a dtype spelling such as "f64", or a composite kind such as "tuple(3,f64)" / "piecewise_linear" — see Element types), ndims plus a dims_ptr shape array ([length, k1, …]), and the raw little-endian data_ptr of data_byte_len bytes. The physical dtype the bytes are encoded in is derived from the element type. Reads return the dtype code (0=f64, 1=f32, 2=i64, 3=i32, 4=u64, 5=bool, 6=i16, 7=i8, 8=u32, 9=u16, 10=u8) and a raw byte buffer for the caller to decode, plus an optional out_element_type string saying what those bytes mean; infrastore_bulk_result_get_single follows the same dtype-generic convention.
  • owner_category is an int32_t (0 = Component, 1 = SupplementalAttribute) passed to the add functions. The owner identity is the pair (owner_id, owner_category) — a component and a supplemental attribute may share a numeric owner_id and stay distinct — and the category is recorded with the association at add time.
  • application_data is an optional opaque, package-owned payload (typically JSON) passed verbatim to the add functions and stored uninterpreted. Element typing does not go here — that is element_type.
  • Strings are null-terminated UTF-8. Optional string arguments (application_data, features_json, units, quantity_kind, unit_system, time_reference, component_field, name_glob) accept NULL.
  • time_reference records how a series' timestamps were spelled: "utc", "zoneless", a fixed offset ("-07:00"), or an IANA zone name ("America/Denver"). An unparseable value fails with INFRASTORE_ERR_INVALID_PARAMETER rather than degrading to unset. Zone existence is not checked here — the caller's own tz database is the one that should warn. Timestamps stay Unix milliseconds either way, so time_range_zoneless is what tells a wall-clock bound from an instant one, and the store refuses a bound whose spelling the series cannot answer rather than coercing it. The zoneless filter argument on the list/filter exports is a tri-state int32_t: negative means no filter, 0 selects the instant-bearing rows (including those with no reference), and 1 selects the wall-clock rows. See Time references.
  • Features are passed as a JSON object string whose values are int / float / bool / string. An add call whose feature names shadow a time-series or key field (name, resolution, owner_id, …) fails with INFRASTORE_ERR_INVALID_PARAMETER; see reserved feature names.
  • Timestamps are int64_t Unix milliseconds. Resolutions/horizons/intervals are ISO-8601 duration strings (e.g. "PT1H", "P1M", "P1Y"); a NULL (or empty) string means unset. On output they are owned char * strings — free each with infrastore_string_free.

Status Codes

MacroValueMeaning
INFRASTORE_OK0Success
INFRASTORE_ERR_NULL_POINTER1A required pointer was NULL
INFRASTORE_ERR_INVALID_UTF82A string argument was not UTF-8
INFRASTORE_ERR_INVALID_PARAMETER3A bad argument value
INFRASTORE_ERR_NOT_FOUND4No matching series / array
INFRASTORE_ERR_DUPLICATE5Key already exists
INFRASTORE_ERR_INTEGRITY6On-disk inconsistency
INFRASTORE_ERR_READ_ONLY7Write on a read-only store
INFRASTORE_ERR_IO8I/O failure
INFRASTORE_ERR_INCOMPATIBLE_FORMAT9The store on disk was written in a different, incompatible on-disk format than this build reads. There is no in-place upgrade.
INFRASTORE_ERR_DUPLICATE_ASSOCIATION10An attachment or parent/child edge with the same identity already exists. Distinct from INFRASTORE_ERR_DUPLICATE, which is about time-series identity.
INFRASTORE_ERR_STORE_EXISTS11A store already exists where one was about to be created. Creating there would discard its arrays while keeping its catalog. Use infrastore_store_create_replacing to discard it on purpose.
INFRASTORE_ERR_MISMATCHED_ARTIFACT12The HDF5 file and its .sqlite catalog do not carry the same generation stamp: they are halves of two different saves.
INFRASTORE_ERR_DUPLICATE_ASSOCIATION_ID13An import supplied an association_id a live row already holds. Distinct from INFRASTORE_ERR_DUPLICATE_ASSOCIATION (the endpoint pair collides) and INFRASTORE_ERR_DUPLICATE (a series' identity collides).
INFRASTORE_ERR_OWNER_MISMATCH14An id-addressed call carrying an expected owner named a row that belongs to a different one. Distinct from INFRASTORE_ERR_NOT_FOUND: the row is there, and it is the caller's belief about who owns it that is stale.
INFRASTORE_ERR_CATALOG_MIGRATION_REQUIRED15The .sqlite catalog is at an older schema revision than this build and the store was opened read-only, so nothing could migrate it. Open it once for writing (or run infrastore upgrade) and the ladder runs.
INFRASTORE_ERR_CATALOG_TOO_NEW16The .sqlite catalog was written by a newer infrastore than this build understands. The mirror of INFRASTORE_ERR_CATALOG_MIGRATION_REQUIRED, with the other remedy: upgrade the software, not the store.
INFRASTORE_ERR_INTERNAL99Unexpected internal error

Lifecycle

/* compression_kind: 0 = none, 1 = DEFLATE (deflate_level 0-9 + shuffle); 1, 3, true is the
   default policy. catalog_mode: 0 = attached (<path>.sqlite), 1 = in memory (written only by
   infrastore_store_persist). in_memory=true admits only catalog_mode=1. */
int32_t infrastore_store_create_with_catalog(const char *path, bool in_memory, uint8_t compression_kind,
                                         uint8_t deflate_level, bool shuffle, uint8_t catalog_mode,
                                         struct InfraStore **out);
int32_t infrastore_store_open_with_catalog(const char *path, bool read_only, uint8_t catalog_mode,
                                         struct InfraStore **out);
/* Open the array half of an artifact whose catalog is ABSENT, minting an empty one stamped to
   match the arrays, and hand back a writable store holding every array and no rows — the way in
   to a store shipped as arrays plus an OpenAPI document. Replay the rows with
   infrastore_store_import_time_series_associations_openapi and its supplemental-attribute
   counterpart. INFRASTORE_ERR_STORE_EXISTS when <path>.sqlite is already there; that store wants
   infrastore_store_open_with_catalog. Never read-only. */
int32_t infrastore_store_open_without_catalog(const char *path, uint8_t catalog_mode,
                                         struct InfraStore **out);
/* The create entry points above fail with INFRASTORE_ERR_STORE_EXISTS when either half of a
   store is already at `path`. This one discards it first — both halves plus the catalog's
   -wal/-shm sidecars — and is destructive and not atomic. */
int32_t infrastore_store_create_replacing(const char *path, uint8_t compression_kind,
                                         uint8_t deflate_level, bool shuffle, uint8_t catalog_mode,
                                         struct InfraStore **out);
/* Copy both halves of the store at `src` to `dest` and open the copy read-write, leaving `src`
   untouched. The safe way to load a store you intend to change: mutating an artifact in place is
   unrecoverable if interrupted, since HDF5 has no journal. INFRASTORE_ERR_STORE_EXISTS if `dest`
   already holds a store. */
int32_t infrastore_store_open_copy(const char *src, const char *dest, uint8_t catalog_mode,
                                         struct InfraStore **out);
int32_t infrastore_store_catalog_mode(const struct InfraStore *handle, uint8_t *out);
void    infrastore_store_free(struct InfraStore *handle);
/* Frees any owned `char *` this library returns (resolutions, horizons, intervals, …). */
void    infrastore_string_free(char *s);
void    infrastore_buffer_free_f64(double *ptr, uint64_t len);
void    infrastore_buffer_free_u8(uint8_t *ptr, uint64_t len);
void    infrastore_buffer_free_i64(int64_t *ptr, uint64_t len);
void    infrastore_buffer_free_u64(uint64_t *ptr, uint64_t len);

SingleTimeSeries

There is no key on this ABI. A series is addressed by its catalog association id, an int64_t that every write reports through infrastore_store_add_batch's out_ids and that infrastore_store_list_metadata carries on every row. Reads take that id and come back through the shared bulk-result handle, so one decoding path serves the single and bulk reads alike; see Bulk Reads.

Writes go through a batch. There is one write path on this ABI: build a batch with infrastore_batch_new, append to it, then commit it with infrastore_store_add_batch, which hands back one id per item in batch order. A single add is a one-item batch, which costs one extra call and keeps a second, near-identical set of per-type entry points off the surface. See Batch Writes.

int32_t infrastore_batch_add_single(struct InfraStoreBatch *batch,
                            int64_t owner_id, const char *owner_type,
                            int32_t owner_category,           /* 0=Component, 1=SupplementalAttribute */
                            const char *name,
                            int64_t initial_ts_unix_ms, const char *resolution,  /* ISO-8601 */
                            const char *element_type, /* e.g. "f64", "piecewise_linear" */
                            uint64_t ndims, const uint64_t *dims_ptr,
                            const uint8_t *data_ptr, uint64_t data_byte_len,
                            const char *application_data,         /* optional */
                            const char *features_json,        /* optional */
                            const char *units,                /* optional */
                            const char *quantity_kind,        /* optional */
                            const char *unit_system,          /* optional: "natural_units" | "component_base" */
                            const char *time_reference,       /* optional: "utc" | "zoneless" | "-07:00" | IANA name */
                            const char *component_field);     /* optional: e.g. "max_active_power" */

/* All-or-nothing removal by catalog association id: an id naming no row returns
   INFRASTORE_ERR_NOT_FOUND and removes nothing. A repeated id is removed, and
   counted, once. `ids` may be null only when `n` is 0.
   Set has_owner to hold every id to (owner_id, owner_category): the owner is read
   and the row deleted by the same transaction, and a row belonging to anyone else
   is INFRASTORE_ERR_OWNER_MISMATCH with the whole batch rolled back. */
int32_t infrastore_store_remove_by_ids(struct InfraStore *handle, const int64_t *ids,
                             uint64_t n,
                             bool has_owner, int64_t owner_id, int32_t owner_category,
                             uint64_t *out_removed);

/* Whether an association is filed under `id` -- a primary-key probe that fetches
   no row, so a consumer can validate every reference in its own model on load
   rather than discovering a dangling one mid-run. Never NOT_FOUND: a stale
   reference is the answer, not an error. */
int32_t infrastore_store_association_exists(const struct InfraStore *handle,
                             int64_t association_id, bool *out_present);

NonSequentialTimeSeries

infrastore_batch_add_non_sequential takes an explicit int64_t Unix-millisecond timestamp array alongside the typed data buffer. It is read like every other type — by id, through infrastore_store_read_by_id and then infrastore_bulk_result_get_irregular, which returns owned timestamp, shape, and raw-byte buffers (free with infrastore_buffer_free_i64, infrastore_buffer_free_i64, and infrastore_buffer_free_u8) plus the dtype code and, in out_element_type, the canonical element-type string. The shape is the full [length, *element_shape] array shape (the first dim is time, so callers can recover an N-dimensional per-step element shape). out_application_data is the optional opaque package-owned payload, returned as an owned C string of its full length (NULL when unset; free with infrastore_string_free). Earlier revisions copied it into a caller-sized buffer, which invited silent truncation.

int32_t infrastore_batch_add_non_sequential(struct InfraStoreBatch *batch,
                                    int64_t owner_id, const char *owner_type,
                                    int32_t owner_category, const char *name,
                                    const int64_t *timestamps_unix_ms, uint64_t timestamps_len,
                                    const char *element_type,
                                    uint64_t ndims, const uint64_t *dims_ptr,
                                    const uint8_t *data_ptr, uint64_t data_byte_len,
                                    const char *application_data, const char *features_json,
                                    const char *units,
                                    const char *quantity_kind, const char *unit_system,
                                    const char *time_reference,
                                    const char *component_field);
/* Reads a NonSequentialTimeSeries or PersistentTimeSeries slot; any other type is
   INFRASTORE_ERR_INVALID_PARAMETER. */
int32_t infrastore_bulk_result_get_irregular(const struct InfraStoreBulkReadHandle *result, uint64_t index,
                                    int64_t **out_timestamps, uint64_t *out_timestamps_len,
                                    int32_t *out_dtype,
                                    int64_t **out_shape, uint64_t *out_shape_len,
                                    uint8_t **out_data, uint64_t *out_data_byte_len,
                                    char **out_application_data, char **out_element_type,
                                    char **out_units, char **out_quantity_kind,
                                    char **out_unit_system, char **out_time_reference,
                                    char **out_component_field);

PersistentTimeSeries

infrastore_batch_add_persistent takes exactly the argument list of infrastore_batch_add_non_sequential above — the same int64_t Unix-millisecond vector, the same owned buffers, the same ownership and free rules. The two types carry the same payload; timestamps is the breakpoint vector, and what differs is what a read between those instants means.

int32_t infrastore_batch_add_persistent(struct InfraStoreBatch *batch, /* ...as add_non_sequential... */);

It is read like every other type — by id, through infrastore_store_read_by_id / infrastore_store_read_by_ids, then infrastore_bulk_result_get_irregular on the slot whose infrastore_bulk_result_item_type is 6.

The value at out_timestamps[i] is in force from that instant until the next breakpoint, and past the last one forever. There is no value before the first breakpoint: a range read whose start precedes it is refused with INFRASTORE_ERR_INVALID_PARAMETER rather than clamped, and a range that starts mid-step begins at the breakpoint in force there, so the returned slice always defines a value at the caller's start.

The ABI discriminant is 6, kept numerically equal to the storage code. See the time-series types.

Attribute-Based Existence

An existence probe stays attribute-addressed: it is answered off the catalog indexes without hydrating a row, so routing it through an id lookup would cost more than the question. There is one of them, taking the filter record every other filter-taking export takes.

Set features_exact to compare features_json as the row's whole feature set — that is a single content-hash comparison. The default subset match adds an indexed probe per requested feature. Neither hydrates a row, but a hot loop testing a complete feature set wants the exact form.

/* True iff any association matches the filter. A NULL filter matches everything. */
int32_t infrastore_store_has_any_by_filter(const struct InfraStore *handle,
                                   const struct InfraStoreFilter *filter,
                                   bool *out_present);

int32_t infrastore_store_get_array_by_hash(const struct InfraStore *handle, const uint8_t *data_hash,
                                   int32_t *out_dtype,
                                   uint8_t **out_data, uint64_t *out_byte_len); /* infrastore_buffer_free_u8 */

/* Count SingleTimeSeries (*out_sts) and DeterministicSingleTimeSeries (*out_dst)
   associations that reference the 32-byte content hash data_hash, across all
   owners — one catalog query to decide whether removing a SingleTimeSeries would
   orphan a DST that shares its backing array. */
int32_t infrastore_store_count_array_references(const struct InfraStore *handle, const uint8_t *data_hash,
                                        uint64_t *out_sts, uint64_t *out_dst);

/* The whole metadata record for one association id, as a JSON object with the
   shape of one infrastore_store_list_metadata element: owner_id, owner_type,
   owner_category, time_series_type, name, data_hash (64-char hex),
   initial_timestamp_ms, resolution, horizon, interval, count, length,
   percentiles, element_type, element_shape, features, units, quantity_kind,
   unit_system, time_reference, component_field, application_data, id -- fields
   that do not apply to the row's type are null. One export covers every time
   series type, static and forecast alike. Probe-then-fetch: call with
   buf = NULL, cap = 0 to learn *out_len, then again with an out_len+1-byte
   buffer. *out_present is false when the id names no row -- a stale reference is
   an answer, not an error. */
int32_t infrastore_store_get_metadata_by_id(const struct InfraStore *handle,
                                     int64_t association_id,
                                     char *buf, uint64_t cap, uint64_t *out_len,
                                     bool *out_present);

/* The same listing addressed by a set of ids, in the order given, as an owned
   JSON array (free with infrastore_string_free). INFRASTORE_ERR_NOT_FOUND if any
   id names no row: a caller naming ids is asserting they exist, and a silently
   short array would let a stale reference pass as an absent match. */
int32_t infrastore_store_list_metadata_by_ids(const struct InfraStore *handle,
                                     const int64_t *ids, uint64_t n,
                                     char **out_json, uint64_t *out_len);

infrastore_store_list_metadata is the identify half: it answers which series exist and carries the id that addresses each. infrastore_store_get_metadata_by_id + infrastore_store_get_array_by_hash is the direct-array read path for a binding that maintains its own object model, and infrastore_store_association_exists answers whether a reference it recorded earlier still resolves without fetching the row. There is deliberately no attribute-to-id resolver: a caller that wants exactly one row poses the filter and checks that it got one.

Forecasts

The forecast types are created and read through the C ABI. ts_type is the TimeSeriesType discriminant — 0 = SingleTimeSeries, 1 = NonSequentialTimeSeries, 2 = Deterministic, 3 = DeterministicSingleTimeSeries, 4 = Probabilistic, 5 = Scenarios, 6 = PersistentTimeSeries. The ABI keeps its own mapping, deliberately identical to the storage codes. As a filter it is read per Type filters below. Forecast values are dtype-generic raw little-endian byte buffers with explicit dimensions — the same element_type, ndims, dims_ptr, data_ptr, data_byte_len convention as the static batch adds (see the data model for the conventional shapes); the store records the windowing parameters in metadata and does not interpret the layout. A DeterministicSingleTimeSeries (3) is read like any other forecast but cannot be written through infrastore_batch_add_forecast — it is derived via infrastore_store_transform_single_time_series.

Forecast writes go through a batch like every other write. infrastore_batch_add_forecast accepts only ts_type 2 = Deterministic or 5 = Scenarios; infrastore_batch_add_probabilistic adds the percentile vector for Probabilistic. DeterministicSingleTimeSeries (3) is not addable through infrastore_batch_add_forecast — it errors and directs you to infrastore_store_transform_single_time_series, which derives a DeterministicSingleTimeSeries from every stored SingleTimeSeries (sharing the backing array) and writes the number transformed to *out_count:

int32_t infrastore_batch_add_forecast(struct InfraStoreBatch *batch,
                              int64_t owner_id, const char *owner_type, int32_t owner_category,
                              const char *name, int32_t ts_type,
                              int64_t initial_ts_unix_ms,
                              const char *resolution, const char *horizon, const char *interval,  /* ISO-8601 */
                              uint64_t count,
                              const char *element_type,
                              uint64_t ndims, const uint64_t *dims_ptr,
                              const uint8_t *data_ptr, uint64_t data_byte_len,
                              const char *application_data,          /* optional */
                              const char *features_json, const char *units,
                              const char *quantity_kind,        /* optional */
                              const char *unit_system,          /* optional: "natural_units" | "component_base" */
                              const char *time_reference,       /* optional: "utc" | "zoneless" | "-07:00" | IANA name */
                              const char *component_field);     /* optional: e.g. "max_active_power" */

int32_t infrastore_batch_add_probabilistic(struct InfraStoreBatch *batch,
                                   int64_t owner_id, const char *owner_type,
                                   int32_t owner_category, const char *name,
                                   int64_t initial_ts_unix_ms,
                                   const char *resolution, const char *horizon, const char *interval,  /* ISO-8601 */
                                   uint64_t count,
                                   const double *percentiles_ptr, uint64_t percentiles_len,
                                   const char *element_type,
                                   uint64_t ndims, const uint64_t *dims_ptr,
                                   const uint8_t *data_ptr, uint64_t data_byte_len,
                                   const char *application_data,     /* optional */
                                   const char *features_json, const char *units,
                                   const char *quantity_kind,        /* optional */
                                   const char *unit_system,          /* optional: "natural_units" | "component_base" */
                                   const char *time_reference,       /* optional: "utc" | "zoneless" | "-07:00" | IANA name */
                                   const char *component_field);     /* optional: e.g. "max_active_power" */

int32_t infrastore_store_transform_single_time_series(struct InfraStore *handle,
                                              const char *horizon, const char *interval,  /* ISO-8601 */
                                              int32_t owner_category,  /* <0 = all categories; else 0=Component, 1=SupplementalAttribute */
                                              const char *resolution,  /* NULL = all resolutions */
                                              bool normalize_single_window,      /* store the zero interval for a single window */
                                              bool require_uniform_forecast_grid, /* every resolution must agree on count + initial_timestamp */
                                              bool dry_run,                      /* check only; legal on a read-only store */
                                              uint64_t *out_count,
                                              uint64_t *out_sources,   /* optional (NULL skips): matched before idempotent skips */
                                              char *out_interval,      /* optional (NULL skips): ISO-8601, INTERVAL_BUF_LEN bytes */
                                              bool *out_interval_normalized,   /* optional (NULL skips) */
                                              int64_t **out_ids);              /* optional (NULL skips): infrastore_buffer_free_i64 */

Every writer takes an optional out_id, which receives the catalog id the row was filed under; pass NULL to skip it. No writer takes an id as input: the catalog always assigns. The one entry point that files rows under ids a caller supplies is infrastore_store_import_time_series_associations_openapi, replaying a document that already recorded them — see Association ids.

infrastore_store_transform_single_time_series reports the rest of its outcome through four optional out-parameters, each skippable with NULL: out_sources is how many SingleTimeSeries matched before idempotent skips, so zero distinguishes "nothing to transform" from "everything was already derived"; out_interval receives the ISO-8601 interval actually stored, NUL-terminated (an ISO period is bounded, so this takes a fixed INTERVAL_BUF_LEN-byte buffer rather than a probe pass); out_interval_normalized is non-zero when the request described a single window; and out_ids receives the id of each view written, *out_count of them in write order, to free with infrastore_buffer_free_i64. When nothing was written — a dry run, or a re-run that finds every view already derived — *out_ids is set to NULL. On a dry run *out_count still reports the count a committing run would produce, so a caller must check the pointer rather than the count before indexing it.

Its two policy flags are the TransformPolicy of the core API. Both false is the permissive default; InfrastructureSystems.jl passes both true. normalize_single_window selects the single-window encoding — non-zero stores the zero interval, which is what InfrastructureSystems.jl looks up by, and zero stores the requested interval verbatim. require_uniform_forecast_grid requires every resolution in scope, plus any forecast already stored at the same (resolution, interval), to agree on the derived window count and initial_timestamp. dry_run runs every check and reports what a committing run would produce without writing, and is legal against a read-only store.

Forecasts are read like everything else: by id, through infrastore_store_read_by_id (or infrastore_store_read_by_ids / ..._range) and then infrastore_bulk_result_get_forecast, which returns the decoded data buffer, its out-dimensions, the window parameters, and — for Probabilistic — the percentile vector. A stored DeterministicSingleTimeSeries is synthesized into Deterministic values (its dense windows are materialized from the backing SingleTimeSeries), but it remains a distinct stored type in the catalog.

Type filters

A read names only an id, so there is no requested type to disagree with what is stored — the family-resolution rules that a ts_type argument used to carry now live entirely in the identify half. infrastore_store_list_metadata's type filter reads one the same way:

  • 2 = Deterministic matches a stored Deterministic or a stored DeterministicSingleTimeSeries. A DST is a synthetic view that reads back as a Deterministic, so a caller selects a deterministic forecast without knowing which form the store holds. Each row still reports its own concrete time_series_type.
  • 3 = DeterministicSingleTimeSeries narrows to the derived form alone — for callers auditing which forecasts are synthetic rather than reading values.
  • 4 = Probabilistic and 5 = Scenarios match only themselves.

The caller owns the returned buffers: free *out_data with infrastore_buffer_free_u8, *out_dims with infrastore_buffer_free_u64, *out_percentiles (non-NULL only for Probabilistic) with infrastore_buffer_free_f64, and each of the *out_resolution / *out_horizon / *out_interval ISO-8601 strings with infrastore_string_free.

Forecast metadata is read with the same infrastore_store_get_metadata_by_id as everything else. The returned row carries the windowing parameters (horizon, interval, count), the content hash, and the percentiles of a Probabilistic, without decoding the array.

infrastore_store_copy_time_series copies one association onto another owner (optionally under a new name). Arrays are content-addressed, so only a new association row is written — no array data is duplicated, and the stored type is preserved (a DeterministicSingleTimeSeries stays one rather than being materialized into a dense Deterministic). The copy keeps the source's owner category. The leading owner_id / owner_category / name / ts_type / resolution / interval / features_json arguments identify the source series; a NULL new_name keeps the source name.

int32_t infrastore_store_copy_time_series(struct InfraStore *handle,
                                  /* source series: */
                                  int64_t owner_id, int32_t owner_category, const char *name,
                                  int32_t ts_type,
                                  const char *resolution, const char *interval,  /* ISO-8601; NULL = unset */
                                  const char *features_json,
                                  /* destination: */
                                  int64_t dst_owner_id, const char *dst_owner_type,
                                  const char *new_name);  /* NULL = keep the source name */

Grid Arithmetic

Stateless entry points into the core's own period arithmetic, for a binding that holds a series as a native struct and would otherwise reimplement the grid.

int32_t infrastore_grid_timestamps(int64_t initial_unix_ms,
                                   const char *resolution_iso,
                                   uint64_t length,
                                   int64_t *buf,
                                   uint64_t cap,
                                   uint64_t *out_len);

int32_t infrastore_infer_period(const int64_t *timestamps_unix_ms,
                                uint64_t len,
                                char **out_iso);

infrastore_grid_timestamps materializes initial + k · resolution for k in [0, length), probe-then-fetch like infrastore_static_reader_timestamps (call with buf null and cap 0 to learn the length). It is calendar-aware for a P1M/P1Y resolution, which steps the UTC calendar — the reference a series records is a spelling, not a grid.

infrastore_infer_period is the inverse: the ISO-8601 period that reproduces a timeline exactly, or INFRASTORE_ERR_INVALID_PARAMETER with a message naming the entry that breaks the pattern and NonSequentialTimeSeries as the remedy. out_iso is an owned string the caller frees with infrastore_string_free. A local-clock daily or monthly grid in a DST zone is refused here, which is what lets a caller hand over the timeline they have instead of asserting a resolution the store cannot check.

There is exactly one implementation of "which instants does this series contain" in the project, and it is the core's Period::add_to. These two functions are how a binding reaches it.

Readers

The per-timestamp read path is exposed as two opaque reader handles — InfraStoreStaticReaderHandle for SingleTimeSeries and InfraStoreForecastReaderHandle for forecasts. A reader is built once over a filter (the same has_owner / owner_id / has_owner_category / owner_category / name / resolution / features_json convention as the attribute-based access, with a forecast reader also taking a ts_type), then driven per timestamp. The lifecycle is: build → read the layout once → *_read in a loop → fetch values per group/entry → free. Each reader pins one resolution and owns reusable buffers that each read overwrites in place.

Ownership rules: the *_grid / *_timeline resolution/interval out-strings (char **) are owned — free each with infrastore_string_free. *_group_id / *_entry_id write a plain int64_t catalog id, so nothing is allocated and nothing needs freeing — resolve one with infrastore_store_get_metadata_by_id to recover the series a column or entry came from. The *_values buffers (const uint8_t **) are borrowed: they point into reader memory, stay valid only until the next read or *_free, and must not be freed. Group/entry shapes follow the probe-then-fetch convention — call *_info with shape_buf = NULL / shape_cap = 0 to learn *out_shape_len, then again with a buffer of that length. *_read errors (never clamps) if at_unix_ms is off the reader's grid/timeline.

StaticReader

Reads every matching static series at one timestamp, partitioned into (dtype, element_shape) groups; each group's values are one dense [num_columns, *element_shape] little-endian buffer whose column j is the key from infrastore_static_reader_group_key(reader, group_idx, j, …).

int32_t infrastore_store_build_static_reader(const struct InfraStore *handle,
                                     int32_t time_series_type,
                                     const struct InfraStoreFilter *filter,
                                     bool has_window_start, int64_t window_start_ms,
                                     bool window_start_zoneless,
                                     bool has_window_length, uint64_t window_length,
                                     struct InfraStoreStaticReaderHandle **out_reader);

int32_t infrastore_static_reader_grid(const struct InfraStoreStaticReaderHandle *reader,
                              int64_t *out_initial_ms, char **out_resolution,  /* null, or free with infrastore_string_free */
                              uint64_t *out_length);
int32_t infrastore_static_reader_time_reference(const struct InfraStoreStaticReaderHandle *reader,
                              char **out_time_reference);  /* null, or free with infrastore_string_free */
int32_t infrastore_static_reader_timestamps(const struct InfraStoreStaticReaderHandle *reader,
                              int64_t *buf, uint64_t cap, uint64_t *out_len);  /* probe with buf=NULL, cap=0 */
int32_t infrastore_static_reader_num_groups(const struct InfraStoreStaticReaderHandle *reader, uint64_t *out_n);
int32_t infrastore_static_reader_group_info(const struct InfraStoreStaticReaderHandle *reader, uint64_t group_idx,
                                    int32_t *out_dtype, uint64_t *out_num_columns,
                                    int64_t *shape_buf, uint64_t shape_cap, uint64_t *out_shape_len);
int32_t infrastore_static_reader_group_id(const struct InfraStoreStaticReaderHandle *reader,
                                  uint64_t group_idx, uint64_t col_idx,
                                  int64_t *out_id);  /* the column's catalog id */
int32_t infrastore_static_reader_read(struct InfraStoreStaticReaderHandle *reader,
                              const struct InfraStore *store, int64_t at_unix_ms);
int32_t infrastore_static_reader_invalidate(struct InfraStoreStaticReaderHandle *reader);
                              /* empty every group; for a caller refusing a read of its own */
int32_t infrastore_static_reader_group_values(const struct InfraStoreStaticReaderHandle *reader, uint64_t group_idx,
                                      const uint8_t **out_ptr,  /* borrowed; valid until next read/free */
                                      uint64_t *out_byte_len);
void infrastore_static_reader_free(struct InfraStoreStaticReaderHandle *reader);

has_window_start lifts the shared-grid requirement for SingleTimeSeries: the reader then sweeps the window window_start_ms (plus window_length steps, or as far as every matched series reaches when has_window_length is false) rather than the grid the series happen to share, and each column reads at an offset of its own. It is checked, never clamped — a matched series that does not cover the window is INFRASTORE_ERR_INVALID_PARAMETER with a message naming it, the anchor must fall on each series' own step boundaries, and a calendar resolution is refused where re-anchoring would move the dates. window_start_zoneless carries how the caller spelled the anchor, the same convention as the ranged reads' bounds: the wire form is Unix milliseconds either way, and the flag is what tells a wall clock from an instant. The window belongs to SingleTimeSeries alone, and has_window_length without has_window_start is an error.

The filter record's has_initial_timestamp / has_length are something else: the grid filter, which every filter-taking export carries. They match only the series already on that grid, so the ones that are not never become columns — where a window sweeps a named span across whatever matched. Use the window when the ragged series should all take part, the filter when they should not; they compose. The filter carries no spelling flag because it selects rather than reads: a grid no row is on is an empty result, not an error.

time_series_type is the reader's own argument rather than the filter's — a reader is built for one type, so a record that also sets has_time_series_type must name the same type or the build is INFRASTORE_ERR_INVALID_PARAMETER — and picks the three shapes a reader can take. The rest of the record is infrastore_store_list_metadata's in full; a static series stores no interval, so a filter that sets one matches nothing on a static reader. For SingleTimeSeries (0), the filter's resolution must be a non-empty ISO-8601 period — one resolution per reader — and all matched series must share one grid (initial_timestamp + length). For NonSequentialTimeSeries (1), resolution must be null (an irregular series has none) and all matched series must instead share one timestamp vector; infrastore_static_reader_grid then reports *out_resolution as null, and infrastore_static_reader_timestamps is how the timeline is read. For PersistentTimeSeries (6), resolution must likewise be null — but this is the one case whose columns need not share a timeline: a step function has a value at every instant from its first breakpoint on, so each column carries its values forward on breakpoints of its own, and the reader's timeline is the union of them all. Reading at an instant before some column's first breakpoint is an error naming that column. Any other discriminant is rejected.

Uniformity — where it is required — is validated at build and errors on divergence, so in every case each column has a value at every valid timestamp (no presence mask).

infrastore_static_reader_time_reference reports the one spelling the axis carries — "utc", "zoneless", a fixed offset such as "-07:00", or an IANA zone name — as an owned string the caller frees with infrastore_string_free. It is null when the cohort records no spelling, which is not the same claim as "zoneless": the second says the timestamps are wall clocks. A cohort whose columns agree reports their reference; one whose columns merely agree on naming instants reports "utc"; one mixing zoneless with the rest never builds at all. Reading the axis without it leaves a C consumer unable to tell a wall-clock timeline from an unspecified or a UTC one. infrastore_forecast_reader_time_reference is the window-timeline counterpart, on the same terms.

ForecastReader

Reads the forecast window at one timestamp for every matching forecast of one type. The build ts_type names the forecast type; a Deterministic reader (2) also includes DeterministicSingleTimeSeries (3), read into identical [H, *E] windows — the same rule infrastore_store_list_metadata's type filter applies. All matched forecasts must share one window timeline (initial_timestamp + interval + count). Each entry's window is a little-endian buffer of its *_entry_info shape.

int32_t infrastore_store_build_forecast_reader(const struct InfraStore *handle,
                                       int32_t time_series_type,
                                       const struct InfraStoreFilter *filter,
                                       struct InfraStoreForecastReaderHandle **out_reader);

int32_t infrastore_forecast_reader_timeline(const struct InfraStoreForecastReaderHandle *reader,
                                    int64_t *out_initial_ms,
                                    char **out_resolution, char **out_interval,  /* free each with infrastore_string_free */
                                    uint64_t *out_count);
int32_t infrastore_forecast_reader_time_reference(const struct InfraStoreForecastReaderHandle *reader,
                                    char **out_time_reference);  /* null, or free with infrastore_string_free */
int32_t infrastore_forecast_reader_num_entries(const struct InfraStoreForecastReaderHandle *reader, uint64_t *out_n);
int32_t infrastore_forecast_reader_num_slots(const struct InfraStoreForecastReaderHandle *reader, uint64_t *out_n);
int32_t infrastore_forecast_reader_entry_slot(const struct InfraStoreForecastReaderHandle *reader,
                                      uint64_t entry_idx, uint64_t *out_slot);
int32_t infrastore_forecast_reader_entry_info(const struct InfraStoreForecastReaderHandle *reader, uint64_t entry_idx,
                                      int32_t *out_dtype,
                                      int64_t *shape_buf, uint64_t shape_cap, uint64_t *out_shape_len);
int32_t infrastore_forecast_reader_entry_id(const struct InfraStoreForecastReaderHandle *reader,
                                    uint64_t entry_idx, int64_t *out_id);  /* the entry's catalog id */
int32_t infrastore_forecast_reader_read(struct InfraStoreForecastReaderHandle *reader,
                                const struct InfraStore *store, int64_t at_unix_ms);
int32_t infrastore_forecast_reader_invalidate(struct InfraStoreForecastReaderHandle *reader);
                                /* empty every entry's window */
int32_t infrastore_forecast_reader_entry_values(const struct InfraStoreForecastReaderHandle *reader, uint64_t entry_idx,
                                        const uint8_t **out_ptr,  /* borrowed; valid until next read/free */
                                        uint64_t *out_byte_len);
void infrastore_forecast_reader_free(struct InfraStoreForecastReaderHandle *reader);

A read is all-or-nothing. A read spans every group (or entry), so a failing infrastore_static_reader_read / infrastore_forecast_reader_read leaves the reader wholly empty — ..._group_values / ..._entry_values hand back a zero-length buffer — rather than some groups holding the new timestamp's values and the rest the previous read's. A binding that refuses a read before calling in (the Julia wrapper checks the bound's spelling against the reader's timeline, which at_unix_ms cannot carry) calls ..._invalidate to leave the same empty reader behind.

Window-read deduplication. Forecasts that reference the same backing array and read plan (deduplicated identical data, or several DeterministicSingleTimeSeries over one SingleTimeSeries) collapse to a single window slot. infrastore_forecast_reader_read performs one backend read per slot, not per entry, so a forecast shared by N owners is read once per timestamp. infrastore_forecast_reader_num_slots is that physical read count, and infrastore_forecast_reader_entry_slot gives the 0-based slot backing each entry (entries that share data report the same slot) — group entries by slot to also decode each unique window only once.

Batch Writes

A batch is the only write path on this ABI. There are no per-type store-level add functions: a single add is a one-item batch, which costs one extra call and keeps a second, near-identical set of entry points off the surface.

A batch accumulates add requests client-side (no store I/O); infrastore_store_add_batch commits them all in one metadata transaction, which is much faster than per-item adds when ingesting many series. It is also the fast HDF5 write path: same-shaped SingleTimeSeries are packed into batch-sized datasets so the timestamp-major chunks are filled whole rather than a column at a time. Data buffers are copied into the batch, so they only need to stay valid for the infrastore_batch_add_* call. The submit is all-or-nothing and drains the batch in either case (on error nothing was committed and the batch is left empty). On success *out_ids holds one id per item, in batch order; the caller owns that array and frees it with infrastore_buffer_free_i64(*out_ids, *out_len). The batch handle itself is reusable after submit and must eventually be released with infrastore_batch_free.

struct InfraStoreBatch *infrastore_batch_new(void);
void            infrastore_batch_free(struct InfraStoreBatch *batch);

int32_t infrastore_batch_add_single(struct InfraStoreBatch *batch, ...);
int32_t infrastore_batch_add_non_sequential(struct InfraStoreBatch *batch, ...);
int32_t infrastore_batch_add_persistent(struct InfraStoreBatch *batch, ...);
int32_t infrastore_batch_add_forecast(struct InfraStoreBatch *batch, ...);       /* 2=Deterministic, 5=Scenarios */
int32_t infrastore_batch_add_probabilistic(struct InfraStoreBatch *batch, ...);

int32_t infrastore_store_add_batch(struct InfraStore *handle, struct InfraStoreBatch *batch,
                           uint64_t *out_len, int64_t **out_ids);  /* infrastore_buffer_free_i64 */

Bulk Reads

infrastore_store_read_by_ids reads many series in one call, reading each packed dataset's column span once instead of re-reading every chunk per series — the efficient way to load many whole series (e.g. for exploration or plotting), where a single full-series read otherwise touches every chunk under the timestamp-major layout. infrastore_store_read_by_id is the single-id form, which also takes a window; infrastore_store_read_by_ids_range is the bounds form, which clips.

Every read returns its results in an InfraStoreBulkRead handle — a single read holds exactly one item — in the order the ids were given, repeats included. Elements are read out with infrastore_bulk_result_get_single / ..._get_irregular / ..._get_forecast, chosen by the type infrastore_bulk_result_item_type reports. The caller owns the returned strings and buffers and frees them with infrastore_string_free, infrastore_buffer_free_i64, and infrastore_buffer_free_u8. The handle is not consumed by a read (elements may be read more than once) and must be released with infrastore_bulk_result_free.

int32_t infrastore_store_read_by_ids(const struct InfraStore *handle,
                             const int64_t *ids, uint64_t n,
                             struct InfraStoreBulkRead **out_result);
int32_t infrastore_store_read_by_ids_range(const struct InfraStore *handle,
                             const int64_t *ids, uint64_t n,
                             bool zoneless, int64_t start_ms, int64_t end_ms,
                             struct InfraStoreBulkRead **out_result);
int32_t infrastore_bulk_result_item_type(const struct InfraStoreBulkRead *result, uint64_t index,
                                 int32_t *out_type);
int32_t infrastore_bulk_result_get_single(const struct InfraStoreBulkRead *result, uint64_t index, ...);
void    infrastore_bulk_result_free(struct InfraStoreBulkRead *result);

An id naming no row fails the whole call with INFRASTORE_ERR_NOT_FOUND rather than being skipped. The handle carries each item's name, and infrastore_bulk_result_item_name hands item index's back as an owned C string, freed with infrastore_string_free — the companion to infrastore_bulk_result_item_type, and how a caller labels what it decodes.

infrastore_store_remove_by_ids is the removal direction of the same reference, listed with the other removals above: one all-or-nothing transaction, the count through out_removed, and INFRASTORE_ERR_NOT_FOUND if any id names no row.

Both id-addressed calls carry an optional owner guardhas_owner beside owner_id and owner_category, ignored when the flag is false — that holds the addressed row to one owner and returns INFRASTORE_ERR_OWNER_MISMATCH when it belongs to another. A consumer that addresses a series by id but means "this owner's series" has to use it rather than confirming the owner in a call of its own: an id survives a reassignment, so a separate check has a window after it in which the row can move, and a removal issued after that window retires the new owner's series. The guard puts the check and the act in one transaction, and costs the read nothing at all — the owner comes off the same row the values are materialized from.

infrastore_store_read_by_id also takes a window. Each optional argument is a *_present flag beside its value; with none present it reads the whole series. start_ms is Unix milliseconds, spelled zoned or zoneless by start_zoneless like every other bound; len counts timesteps (the static types) and count counts windows (the forecasts), and supplying the one that does not apply is INFRASTORE_ERR_INVALID_PARAMETER rather than an argument the store drops. So is a start off the series' own grid or an extent running past its end — a window is checked where infrastore_store_read_by_ids_range clips.

int32_t infrastore_store_read_by_ids(const struct InfraStore *handle, const int64_t *ids,
                             uint64_t n, struct InfraStoreBulkRead **out_result);
int32_t infrastore_store_read_by_id(const struct InfraStore *handle, int64_t id,
                             bool start_present, bool start_zoneless, int64_t start_ms,
                             bool len_present, uint64_t len,
                             bool count_present, uint64_t count,
                             bool has_owner, int64_t owner_id, int32_t owner_category,
                             struct InfraStoreBulkRead **out_result);
int32_t infrastore_bulk_result_item_name(const struct InfraStoreBulkRead *result,
                                 uint64_t index, char **out_name);

Store-Wide Operations

/* True iff the store holds nothing at all — no time series, and no associations
   in any catalog. One short-circuited existence probe per catalog table, so it is
   O(1) in store size and stays correct as the catalog gains tables; prefer it to
   a caller-side conjunction over the counting entry points below, which is
   neither. */
int32_t infrastore_store_is_empty(const struct InfraStore *handle, bool *out);

int32_t infrastore_store_counts(const struct InfraStore *handle, int64_t *out_components_with_time_series,
                        int64_t *out_static_time_series, int64_t *out_forecasts);
/* Association count per type as a JSON array of {time_series_type, count};
   probe-then-fetch. */
int32_t infrastore_store_counts_by_type(const struct InfraStore *handle,
                                char *buf, uint64_t cap, uint64_t *out_len);
/* Distinct stored arrays (content hashes); shared arrays count once. */
int32_t infrastore_store_num_distinct_arrays(const struct InfraStore *handle, int64_t *out_count);
/* Grouped static / forecast summaries as JSON arrays (one object per group with a
   `count` field); probe-then-fetch. */
int32_t infrastore_store_static_summary(const struct InfraStore *handle,
                                char *buf, uint64_t cap, uint64_t *out_len);
int32_t infrastore_store_forecast_summary(const struct InfraStore *handle,
                                  char *buf, uint64_t cap, uint64_t *out_len);
/* Distinct owners per category + distinct arrays per kind (static/forecast). */
int32_t infrastore_store_counts_detailed(const struct InfraStore *handle, int64_t *out_components,
                                 int64_t *out_supplemental_attributes,
                                 int64_t *out_static_time_series, int64_t *out_forecasts);
/* Distinct owner ids of owner_category as a JSON array; optional type/resolution
   filters (NULL resolution = none). Probe-then-fetch. */
int32_t infrastore_store_list_owner_ids(const struct InfraStore *handle, int32_t owner_category,
                                bool has_time_series_type, int32_t time_series_type,
                                const char *resolution, char *buf, uint64_t cap, uint64_t *out_len);
/* out_present = false when no matching forecast. The out_horizon/out_interval/
   out_resolution ISO-8601 strings are NULL when absent (free with infrastore_string_free);
   out_count and out_initial_ms are -1 when absent. filter_* NULL = no filter. */
int32_t infrastore_store_get_forecast_parameters(const struct InfraStore *handle,
                                         const char *filter_resolution, const char *filter_interval,
                                         bool *out_present, char **out_horizon,
                                         char **out_interval, int64_t *out_count,
                                         char **out_resolution, int64_t *out_initial_ms);
/* Per-resolution static grids as a JSON array of {"resolution","initial_timestamp_ms",
   "length"} objects, ordered by resolution (empty array when no SingleTimeSeries);
   error when the series at one resolution disagree. filter_resolution NULL = every
   resolution, else scope to that ISO-8601 grid. Probe-then-fetch (buf=NULL, cap=0 to size). */
int32_t infrastore_store_check_static_consistency(const struct InfraStore *handle,
                                          const char *filter_resolution, char *buf,
                                          uint64_t cap, uint64_t *out_len);
/* Distinct resolutions as a JSON array of ISO-8601 duration strings, ascending;
   optional type filter. Probe-then-fetch (buf=NULL, cap=0 to size). */
int32_t infrastore_store_get_resolutions(const struct InfraStore *handle,
                                 bool has_time_series_type, int32_t time_series_type,
                                 char *buf, uint64_t cap, uint64_t *out_len);
/* out_kind: 0 = none, 1 = DEFLATE (out_level 0-9 + out_shuffle). */
int32_t infrastore_store_get_compression(const struct InfraStore *handle, uint8_t *out_kind,
                                 uint8_t *out_level, bool *out_shuffle);
int32_t infrastore_store_verify(const struct InfraStore *handle, uint64_t *out_error_count);
/* Compacts and returns the report as a JSON object {slots_reclaimed, datasets_dropped,
   feature_sets_reclaimed, timestamp_sets_reclaimed, bytes_reclaimed}. Owned allocation:
   free with infrastore_string_free. On an on-disk store this rewrites the .h5 file and
   replaces it, so the call must run exactly once (no probe-then-fetch). */
int32_t infrastore_store_compact(struct InfraStore *handle, char **out_json, uint64_t *out_len);
int32_t infrastore_store_flush(struct InfraStore *handle);
/* Cross-operation transactions. Adds, removals, and transforms between a begin and
   its matching commit either all take effect or none do; removals are reversible
   only inside one. Calls nest -- only the outermost commit is durable. This is
   store state, not a borrowed guard, so nothing has to survive the ABI boundary.
   Holds the SQLite write lock until the outermost commit/rollback. */
int32_t infrastore_store_begin_transaction(struct InfraStore *handle);
int32_t infrastore_store_commit_transaction(struct InfraStore *handle);
int32_t infrastore_store_rollback_transaction(struct InfraStore *handle);
int32_t infrastore_store_in_transaction(struct InfraStore *handle, bool *out);
/* The byte budget an open transaction's buffered adds are held to, and through it how wide a
   dataset a run of single adds writes. Raised, that run produces the dataset the equivalent
   bulk add produces -- a batch handed over as a list applies no budget, the caller already
   holding it. The figure belongs to the handle, not the artifact: nothing is persisted.
   `bytes` must be non-zero; lowering it under an open transaction writes out whatever the
   buffer already holds beyond the new figure, so that call can do file I/O and fail. */
int32_t infrastore_store_write_buffer_bytes(const struct InfraStore *handle, uint64_t *out);
int32_t infrastore_store_set_write_buffer_bytes(struct InfraStore *handle, uint64_t bytes);
/* Persist the store's data to `path` (HDF5) and `<path>.sqlite` (metadata),
   materializing an in-memory store to disk. Existing target files are overwritten. */
int32_t infrastore_store_persist(struct InfraStore *handle, const char *path);
/* Write an in-memory catalog to this store's own <path>.sqlite, stamped to match the HDF5 file
   already beside it. Unlike infrastore_store_persist, writes no arrays: they are already in
   place. A checkpoint, not a mode switch. For catalog_mode=0 this is infrastore_store_flush. */
int32_t infrastore_store_persist_catalog(struct InfraStore *handle);
/* has_owner=false clears all; when true, the owner is the pair (owner_id, owner_category). */
int32_t infrastore_store_clear(struct InfraStore *handle, bool has_owner, int64_t owner_id,
                       int32_t owner_category); /* owner_category: 0=Component, 1=SupplementalAttribute */
/* Reassign every time series owned by old_owner_id (in owner_category) to
   new_owner_id; *out_updated (when non-NULL) receives the number of associations
   changed. */
int32_t infrastore_store_replace_owner(struct InfraStore *handle,
                               int64_t old_owner_id, int64_t new_owner_id,
                               int32_t owner_category, uint64_t *out_updated);
/* List catalog metadata rows as a JSON array (identity, the per-type descriptive
   snapshot, the physical detail, and the association `id` the write handed back
   — the address every read and removal takes). A row carries no
   timestamp vector: an irregular series' time axis is the one part of a row that
   costs a read per row, so a listing omits it and a caller that needs it reads
   the series. See the filter record below; a NULL filter lists the whole store.
   The JSON comes back as an *owned* allocation through out_json (free with
   infrastore_string_free), with out_len its byte length — NOT probe-then-fetch,
   because a listing's size scales with the catalog and probing would run the
   query and serialize the rows twice. */
int32_t infrastore_store_list_metadata(const struct InfraStore *handle,
                           const struct InfraStoreFilter *filter,
                           char **out_json, uint64_t *out_len);

The filter record

Every filter-taking export — infrastore_store_list_metadata, list_names, list_owner_types, has_any_by_filter, remove_by_filter, export_time_series_associations_openapi, and both reader builders — takes the catalog filter as one const struct InfraStoreFilter * rather than as positional arguments.

A NULL pointer, and equally an all-zero record, is the empty filter: it matches everything. So a caller writes InfraStoreFilter f = {0}; and sets only the fields it cares about. The predicates are independent and ANDed. The record borrows its strings — they must outlive the call, and nothing in it is freed.

The layout is part of the ABI. The record buys one filter parser and no churn across the eight call sites when a predicate is added; it does not make the ABI forward-compatible. A caller compiled against an older header allocates the older sizeof(InfraStoreFilter), so appending a field is a breaking change like any other — rebuild against the regenerated header.

typedef struct InfraStoreFilter {
  bool has_owner_id;         int64_t owner_id;
  bool has_owner_category;   int32_t owner_category;   /* 0=Component, 1=SupplementalAttribute */
  bool has_time_series_type; int32_t time_series_type;
  const char *name;             /* NULL = unset */
  const char *name_glob;        /* SQLite GLOB over the name (`*`/`?`, case-sensitive), ANDed
                                   with `name` rather than replacing it */
  const char *resolution;       /* ISO-8601 */
  const char *interval;         /* ISO-8601; matches forecasts only — static rows carry none */
  const char *features_json;    /* JSON object; int, float, bool or string values */
  bool features_exact;          /* compare `features_json` as the row's whole feature set
                                   (content hash) rather than a subset it must contain;
                                   with `features_json` NULL, only feature-less rows match */
  const char *component_field;  /* exact, case-sensitive; a row declaring none matches no value */
  bool has_zoneless;         bool zoneless;            /* which timestamp-spelling group */
  bool has_initial_timestamp; int64_t initial_timestamp_ms;
  bool has_length;           uint64_t length;
} InfraStoreFilter;

Every row carries the hex data_hash of the array it resolves to, and rows that share a stored array share it — so grouping a listing by data_hash discovers which time series share their underlying data, in one query.

Associations

Two catalogs of relationships between entities the store does not otherwise model. Both are independent of time series: there are no foreign keys and no cascade (both endpoints live in the caller's object graph, so a cascade could never fire), so removing a time series never removes an association and vice versa; a caller that wants both makes both calls.

Each family's query predicate crosses the boundary as one JSON object string rather than a set of positional arguments, because half its fields are string lists. Every field is optional and the set ones are ANDed:

{ "component_id": 1, "component_types": ["Generator", "Load"],
  "attribute_id": 100, "attribute_types": ["GeographicInfo"] }

{ "parent_id": 1, "parent_types": ["Generator"],
  "child_id": 7, "child_types": ["Bus"] }

A NULL (or empty) filter_json is the empty filter and matches every row — which is what makes a bulk export/import round trip one call each way. The *_types lists hold concrete type names, rendered as SQL IN (…); expanding an abstract type into its subtypes stays in the calling language, and an empty list matches nothing. An unknown field or malformed JSON is INFRASTORE_ERR_INVALID_PARAMETER. Every remove function reports its count through out_removed and treats removing nothing as success, not an error.

The list functions use the probe-then-fetch convention: call with buf = NULL, cap = 0 to learn the length via out_len, then again with a len + 1-byte buffer. (infrastore_store_list_metadata, infrastore_store_list_metadata_by_ids, and infrastore_store_list_supplemental_attribute_associations are the exceptions in this header — they return an owned string instead, because a no-filter call exports the whole catalog or table.)

Supplemental-attribute associations

Which supplemental attributes are attached to which components. Identity is the (component_id, attribute_id) pair; the type names are denormalized labels, so re-attaching the same pair under different type names is still a duplicate. One attribute may be attached to many components.

/* Attach supplemental attribute (attribute_id, attribute_type) to component
   (component_id, component_type). INFRASTORE_ERR_DUPLICATE_ASSOCIATION if that component
   already carries that attribute, whatever type names are supplied. */
int32_t infrastore_store_add_supplemental_attribute_association(struct InfraStore *handle,
                                                        int64_t component_id,
                                                        const char *component_type,
                                                        int64_t attribute_id,
                                                        const char *attribute_type);

/* Attach many in one all-or-nothing transaction, from a JSON array of objects with
   component_id, component_type, attribute_id, and attribute_type. The import half
   of the round trip whose export is infrastore_store_list_supplemental_attribute_associations
   with a NULL filter. *out_added (when non-NULL) receives the number inserted. */
int32_t infrastore_store_add_supplemental_attribute_associations(struct InfraStore *handle,
                                                         const char *associations_json,
                                                         uint64_t *out_added,   /* optional */
                                                         int64_t **out_ids);    /* optional; owned,
                                                            infrastore_buffer_free_i64 */

/* Whether any attachment matches filter_json (NULL = any). */
int32_t infrastore_store_has_supplemental_attribute_association(const struct InfraStore *handle,
                                                        const char *filter_json,
                                                        bool *out_found);

/* Matching attachments as a JSON array, in insertion order; each object carries
   component_id, component_type, attribute_id, attribute_type. Returns the JSON
   through out_json as an OWNED allocation, freed with infrastore_string_free
   (a no-filter call exports the whole table, so this follows the owned-string
   convention rather than probe-then-fetch). */
int32_t infrastore_store_list_supplemental_attribute_associations(const struct InfraStore *handle,
                                                          const char *filter_json,
                                                          char **out_json, uint64_t *out_len);

/* Distinct attribute ids of the matching rows, ascending, as a JSON array — the
   attributes attached to a component when component_id is set. Probe-then-fetch. */
int32_t infrastore_store_list_supplemental_attribute_ids(const struct InfraStore *handle,
                                                 const char *filter_json,
                                                 char *buf, uint64_t cap,
                                                 uint64_t *out_len);

/* Distinct component ids of the matching rows, ascending, as a JSON array — the
   components carrying an attribute when attribute_id is set. Probe-then-fetch. */
int32_t infrastore_store_list_components_with_attributes(const struct InfraStore *handle,
                                                 const char *filter_json,
                                                 char *buf, uint64_t cap,
                                                 uint64_t *out_len);

/* Remove every matching attachment; *out_removed (when non-NULL) receives the
   count. Removing nothing is success, not an error. */
int32_t infrastore_store_remove_supplemental_attribute_associations(struct InfraStore *handle,
                                                            const char *filter_json,
                                                            uint64_t *out_removed);

/* Move every attachment from component old_id to new_id; *out_updated (when
   non-NULL) receives the rows changed. INFRASTORE_ERR_DUPLICATE_ASSOCIATION if new_id
   already carries one of the attributes being moved. */
int32_t infrastore_store_replace_supplemental_attribute_component_id(struct InfraStore *handle,
                                                             int64_t old_id, int64_t new_id,
                                                             uint64_t *out_updated);

/* Attachment counts through out_count. kind selects what is counted:
   0 = rows matching the filter, 1 = distinct attributes among them,
   2 = distinct components among them. */
int32_t infrastore_store_count_supplemental_attribute_associations(const struct InfraStore *handle,
                                                           const char *filter_json,
                                                           int32_t kind, int64_t *out_count);

/* Grouped counts as JSON arrays: by attribute type, as {"type": …, "count": …}
   ordered by type; and by both type names, as
   {"component_type": …, "attribute_type": …, "count": …} ordered by attribute type
   then component type. Probe-then-fetch. */
int32_t infrastore_store_supplemental_attribute_counts_by_type(const struct InfraStore *handle,
                                                       char *buf, uint64_t cap,
                                                       uint64_t *out_len);
int32_t infrastore_store_supplemental_attribute_summary(const struct InfraStore *handle,
                                                char *buf, uint64_t cap,
                                                uint64_t *out_len);
/* The attribute ids attached to component 1. */
const char *filter = "{\"component_id\":1}";
uint64_t len = 0;
infrastore_store_list_supplemental_attribute_ids(store, filter, NULL, 0, &len);
char *json = malloc(len + 1);
infrastore_store_list_supplemental_attribute_ids(store, filter, json, len + 1, &len); /* e.g. "[100]" */
free(json);

Parent/child associations

Directed edges between components — a generator (parent) connected to a bus (child), say. Both endpoints are always components. Identity is the ordered (parent_id, child_id) pair, so the reversed pair is a different edge, and with no relationship-kind column one ordered pair may be related at most once.

This family is deliberately narrower than the supplemental one — no counts-by-type and no grouped summary — because there is no consumer for them yet; both are additive if one appears.

/* Record a directed edge from component (parent_id, parent_type) to component
   (child_id, child_type). INFRASTORE_ERR_DUPLICATE_ASSOCIATION if that ordered pair is
   already related; the reversed pair is a different edge. */
int32_t infrastore_store_add_parent_child_association(struct InfraStore *handle,
                                              int64_t parent_id, const char *parent_type,
                                              int64_t child_id, const char *child_type);

/* Record many edges in one all-or-nothing transaction, from a JSON array of objects
   with parent_id, parent_type, child_id, and child_type. *out_added (when non-NULL)
   receives the number inserted. */
int32_t infrastore_store_add_parent_child_associations(struct InfraStore *handle,
                                               const char *associations_json,
                                               uint64_t *out_added,   /* optional */
                                               int64_t **out_ids);    /* optional; owned,
                                                  infrastore_buffer_free_i64 */

/* Whether any edge matches filter_json (NULL = any). */
int32_t infrastore_store_has_parent_child_association(const struct InfraStore *handle,
                                              const char *filter_json,
                                              bool *out_found);

/* Matching edges as a JSON array, in insertion order; each object carries
   parent_id, parent_type, child_id, child_type. Probe-then-fetch. */
int32_t infrastore_store_list_parent_child_associations(const struct InfraStore *handle,
                                                const char *filter_json,
                                                char *buf, uint64_t cap,
                                                uint64_t *out_len);

/* Distinct ids on one end of the matching edges, ascending, as a JSON array.
   endpoint is 0 for parents and 1 for children — so endpoint = 1 with parent_id
   set is "the children of this component". Probe-then-fetch. */
int32_t infrastore_store_list_parent_child_ids(const struct InfraStore *handle,
                                       const char *filter_json, int32_t endpoint,
                                       char *buf, uint64_t cap, uint64_t *out_len);

/* Remove every matching edge; *out_removed (when non-NULL) receives the count.
   Removing nothing is success, not an error. */
int32_t infrastore_store_remove_parent_child_associations(struct InfraStore *handle,
                                                  const char *filter_json,
                                                  uint64_t *out_removed);

/* Rewrite component old_id to new_id on BOTH ends of every edge; *out_updated
   (when non-NULL) receives the rows changed. INFRASTORE_ERR_DUPLICATE_ASSOCIATION if the
   rewrite would duplicate an edge new_id already has. */
int32_t infrastore_store_replace_parent_child_component_id(struct InfraStore *handle,
                                                   int64_t old_id, int64_t new_id,
                                                   uint64_t *out_updated);

/* Number of edges matching filter_json, through out_count. */
int32_t infrastore_store_count_parent_child_associations(const struct InfraStore *handle,
                                                 const char *filter_json,
                                                 int64_t *out_count);
/* The children of component 1. */
const char *filter = "{\"parent_id\":1}";
uint64_t len = 0;
infrastore_store_list_parent_child_ids(store, filter, 1 /* children */, NULL, 0, &len);
char *json = malloc(len + 1);
infrastore_store_list_parent_child_ids(store, filter, 1, json, len + 1, &len);   /* e.g. "[7]" */
free(json);

Neither association catalog is exposed over the gRPC server or the infrastore CLI.

Store Attributes

Key/value provenance about the artifact as a whole. See Store attributes for the model.

int32_t infrastore_store_set_store_attribute(InfraStore *store,
                                             const char *key,
                                             const char *value);
int32_t infrastore_store_get_store_attribute(const InfraStore *store,
                                             const char *key,
                                             char **out_value,
                                             uint64_t *out_len);
int32_t infrastore_store_list_store_attributes(const InfraStore *store,
                                               char **out_json,
                                               uint64_t *out_len);
int32_t infrastore_store_remove_store_attribute(InfraStore *store,
                                                const char *key,
                                                bool *out_removed);
infrastore_store_set_store_attribute(store, "creator", "sienna-build");

char *value = NULL;
uint64_t len = 0;
infrastore_store_get_store_attribute(store, "creator", &value, &len);
if (value != NULL) {          /* NULL means the key is unset */
    puts(value);
    infrastore_string_free(value);
}

char *json = NULL;
infrastore_store_list_store_attributes(store, &json, &len);   /* {"creator":"sienna-build"} */
infrastore_string_free(json);

bool removed = false;
infrastore_store_remove_store_attribute(store, "creator", &removed);

Two conventions to note:

  • get spells "unset" as a null *out_value, not as a zero *out_len. A key set to the empty string is a legitimate value, and the two must stay distinguishable. An unset key is INFRASTORE_OK, not INFRASTORE_ERR_NOT_FOUND.
  • list returns a JSON object, following the owned-string convention, rather than an array-of-pairs buffer — the ABI has no pair type and this needs no new one.

Unlike the JSON payloads every other owned-string export produces, a value is the caller's own text and can contain an interior NUL; get reports one as INFRASTORE_ERR_INTEGRITY rather than returning a value truncated at it. An empty key or one beginning with infrastore. (reserved, on removal as well as on write) is INFRASTORE_ERR_INVALID_PARAMETER; a write to a read-only store is INFRASTORE_ERR_READ_ONLY.

The read half is available over the gRPC server; the writes are not, like every other write.

OpenAPI-row Association Serde

Direct JSON serde of the two association catalogs, in the wire spelling SiennaSchemas defines (TimeSeries/*.json, Core/Associations/SupplementalAttributeAssociation.json). The two exports use the owned-string convention.

/* Export time_series_associations matching the filter as a sorted OpenAPI-row
   JSON array. Each row's uri and data_hash are the hex-encoded content hash
   the store already has for that row -- never a caller-supplied locator.
   Takes the same filter record as infrastore_store_list_metadata. Returns the
   JSON through out_json as an OWNED allocation, freed with infrastore_string_free.
   PersistentTimeSeries rows are omitted -- the wire contract has no schema for
   the type -- and a filter naming it is INFRASTORE_ERR_INVALID_PARAMETER. */
int32_t infrastore_store_export_time_series_associations_openapi(const struct InfraStore *handle,
                                                          const struct InfraStoreFilter *filter,
                                                          char **out_json, uint64_t *out_len);

/* Bulk-ingest a JSON array of time-series association OpenAPI rows in one
   all-or-nothing transaction -- the import half of the round trip whose export
   is infrastore_store_export_time_series_associations_openapi. Rows only: every
   row must name an array this store already holds, an irregular row must name
   its time axis with timestamps_uri, and each keeps the association_id it
   carries. A PersistentTimeSeries row is refused: no export writes one, and the
   wire contract has no schema to check one against. *out_added (when non-NULL)
   receives the number inserted. */
int32_t infrastore_store_import_time_series_associations_openapi(
    struct InfraStore *handle, const char *json, uint64_t *out_added);

/* Export the whole supplemental_attribute_associations table as an OpenAPI-row
   JSON array, sorted by (component_id, attribute_id). Owned-string return. */
int32_t infrastore_store_export_supplemental_attribute_associations_openapi(
    const struct InfraStore *handle, char **out_json, uint64_t *out_len);

/* Bulk-ingest a JSON array of supplemental-attribute association OpenAPI rows in
   one all-or-nothing transaction -- the import half of the round trip whose
   export is infrastore_store_export_supplemental_attribute_associations_openapi.
   *out_added (when non-NULL) receives the number inserted. */
int32_t infrastore_store_import_supplemental_attribute_associations_openapi(
    struct InfraStore *handle, const char *json, uint64_t *out_added);

A catalog row's data_hash is NOT NULL, and infrastore fills a row's uri/data_hash wire fields with that same content hash, hex-encoded, on export. The time-series import writes rows only: infrastore never modifies the data to make an incoming document agree with what it already holds, so a row naming an array this store does not hold is refused (INFRASTORE_ERR_INVALID_PARAMETER) rather than written as a dangling reference. A NonSequentialTimeSeries row locates its time axis the same way, with timestamps_uri: the values cannot imply it, since two irregular series with identical values on different axes share one content-addressed array, so a row missing the locator or naming an axis this store does not hold is refused alike; a PersistentTimeSeries row is refused earlier still, as a type outside the six the wire contract defines. A geometry disagreement between an added series and its own association row is likewise rejected at the add boundary, loudly and without writing anything.

Error Messages

int32_t infrastore_last_error_message(char *buf, uint64_t buf_len, uint64_t *needed);

Copies the thread-local error message (UTF-8, null-terminated) into buf. *needed receives the length excluding the NUL. If buf_len is too small, the message is truncated but INFRASTORE_OK is still returned — call once with buf = NULL, buf_len = 0 to learn the needed size, then again with a buffer of *needed + 1. This is the pattern TimeSeries.jl uses.

Building

cargo build -p infrastore-ffi --release
# Library at: target/release/libinfrastore_ffi.{dylib,so,dll}

Point consumers at it via the INFRASTORE_LIB environment variable (see the Julia guide).

Tracing

int32_t infrastore_store_init_logging(const char *filter);

Initialize the Rust tracing subscriber. filter is a null-terminated UTF-8 EnvFilter directive string (e.g. "debug" or "infrastore_core=debug"). Pass NULL to read the RUST_LOG environment variable; if that variable is also unset, no output is produced.

The subscriber is initialized at most once per process — subsequent calls are no-ops. Returns INFRASTORE_OK on success or INFRASTORE_ERR_INVALID_UTF8 if filter is not valid UTF-8.

The Julia binding calls this automatically from its __init__ hook when RUST_LOG is set, and exposes init_logging for explicit control.