Class SubscriptionStore

java.lang.Object
com.iizix.server.push.engine.store.SubscriptionStore

public final class SubscriptionStore extends Object
The subscription table IZS.SUBSCRIPTIONS: one row per (user, application, Topic, device), carrying the transport and what it needs, plus the device's last-seen address.

The key is (uid, app_id, topic, device_uuid) (GATE 1). The device is in it, so a user holds the same (application, Topic) on as many devices as they subscribe from; "all my devices" is the resolver expanding the key over the device dimension, not a different row. app_id is the validated application name, * for the iiziRun subscriptions of the server, and topic is the empty string for the application-level subscription - both NOT NULL, because a NULL in a unique key is not unique in Derby or PostgreSQL and the previous DDL's NULL conventions made the key unenforceable for exactly the two values the client sends most.

SQL relied on, and its portability. CREATE TABLE with BIGINT, VARCHAR(n), CHAR(36), SMALLINT; a named UNIQUE constraint; a FOREIGN KEY ... ON DELETE CASCADE to IZS.USERS(uid); SELECT, INSERT, UPDATE, DELETE with ? parameters; IN (...) lists; ALTER TABLE ... ADD COLUMN of a nullable column; DROP TABLE; DatabaseMetaData.getColumns. All of it is SQL-92 and runs unchanged on Derby (the shipped default) and PostgreSQL (the likely production target). Nothing here needs MERGE, UPSERT, LIMIT, sequences or a vendor type. Identifiers are unquoted, so each database folds them to its own case, which is why the migration probe looks the table up in both.

Migration. ensureSchema() creates the table when it is absent and replaces it when it has the previous shape (aid BIGINT with a foreign key to IZS.APPS that nothing populates, device_uuid CHAR(20) that cannot hold a 36-character UUID, no device in the key). The previous shape's rows are dropped, not migrated (R12, 2026-09-22): none can be reached by any client, because no 20-character device UUID matches a 36-character one, and ALTER can neither widen a CHAR column nor retype provider on Derby (SQLState 42Z16, 42Z15). The loss is logged WARNING with the table and the row count so an operator can tell their users why the subscriptions vanished; an empty previous-shape table is replaced with an INFO line. A table of the current shape without vapid_key and fcm_project (B-10a) gains them by ALTER TABLE ... ADD COLUMN, nullable, so no row is lost: the rows stored before keep NULL in both.

Several servers on one database (PUSH-CLUSTER-DESIGN 3.1, ruled PC-1): the members of a hub share the pool's database, so two of them may run ensureSchema() at the same moment. A table or column that another server created first is not an error: the statement is rolled back, the columns are read again, and what was found is used (F-2, IIZI-PUSH-STORE-SCHEMA-CREATE-RACE: until B-10a the second CREATE TABLE threw and that server's Push engine did not start). The previous-shape DROP TABLE, which in a hub would delete every member's rows because one member started, is refused while a hub definition is configured.

The credentials a row was made under (PUSH-CLUSTER-DESIGN 3.3, ruled PC-8): vapid_key, the fingerprint of the VAPID public key a Browser row was made under (VAPIDKeys.fingerprint(String)), and fcm_project, the Firebase project of an FCM row, both written by register(long,String,String,String,PushTransport,String,String,String,String,String,String) from the configuration in force when a subscription is stored. Nothing reads them to sign yet: that is B-10b.

Undeliverable rows. ensureSchema() also deletes, at every startup, the rows whose provider is PushTransport.APNS: R1 (2026-09-22) makes delivery FCM-only and no transport implements APNs, so such a row can never be delivered to. The delete is recoverable from the server log: see purgeUndeliverable(Connection).

The push server ID (CC-DESIGN-PUSH-APPID-FILTER SV-1, ruled D-2 (a) and D-3 (a)): a UUID made once, at the first engine start, and kept in the one-row table IZS.PUSH_SERVER beside the subscriptions (ensureServerId()), so that every server sharing this database - the members of a hub - has the same ID and deleting the spool does not change it. The server writes it into every data.iizi it sends, the channel tells the device, and a receipt naming another ID is not this store's (contract 6.2, 7).

Thread-safe: every method takes its own connection and commits or rolls back it.

Author:
Christopher Mindus
  • Field Details

  • Constructor Details

    • SubscriptionStore

      public SubscriptionStore(ConnectionSource connections)
      Constructor: a hub definition is configured when the system property or the environment variable HubDefinition reads names a file.
      Parameters:
      connections - The connection source.
    • SubscriptionStore

      public SubscriptionStore(ConnectionSource connections, BooleanSupplier hubConfigured)
      Constructor with the hub seam.
      Parameters:
      connections - The connection source.
      hubConfigured - Answers whether a hub definition is configured; while it does, the previous-shape DROP TABLE of ensureSchema() is refused.
  • Method Details

    • ensureSchema

      public boolean ensureSchema() throws SQLException
      Creates the table when absent, gives a current table the columns of B-10a it lacks, replaces it when it has the previous shape, and when it is current deletes its undeliverable PushTransport.APNS rows.

      Another server on the same database may run this at the same moment (PC-1): a table or column it created first is used as found, see

      invalid reference
      #create(Connection,String,String)
      . The previous-shape replacement is refused while a hub definition is configured, because in a hub it would delete every member's rows: logged SEVERE with the table and its row count, and thrown, so that this server's Push engine does not start and nothing is deleted.
      Returns:
      true if the table was created, replaced or given columns here, false if it was already current or another server made it current.
      Throws:
      SQLException - For SQL errors, and for a previous-shape table while a hub definition is configured.
    • ensureServerId

      public String ensureServerId() throws SQLException
      Gets the push server ID (SV-1, ruled D-2 (a), D-3 (a)): the UUID in the one row of IZS.PUSH_SERVER, made here when the table or its row is absent - at the first engine start on this database - and read back ever after, so it survives a restart, a new spool and a new VAPID key alike, and every server sharing the database gets the same one.

      Another server on the same database may run this at the same moment (PC-1): a table it created first is used as found, see create(Connection,String,String,String), and a row it inserted first wins - this call's insert fails on the key, is rolled back, and the row is read again. Nothing is ever replaced: an ID that is there is the ID.

      Returns:
      The push server ID, a lower-case UUID of 36 characters.
      Throws:
      SQLException - For SQL errors, and when neither this call's row nor another's can be read after the insert.
    • register

      public SubscriptionStore.RegisterResult register(long uid, String appId, String topic, String deviceUuid, PushTransport transport, String subId, String domain, String userAgent, String remoteIp) throws SQLException
      Registers a subscription without the credentials it was made under: vapid_key and fcm_project are NULL, as on a row stored before B-10a, and the row is signed as such a row is. The subscribe paths record them through register(long,String,String,String,PushTransport,String,String,String,String,String,String).
      Parameters:
      uid - The user ID.
      appId - The application ID, * for iiziRun.
      topic - The Topic, empty for the application-level subscription.
      deviceUuid - The device UUID, canonical 36 characters.
      transport - The transport.
      subId - The transport's subscription.
      domain - The host name the client used.
      userAgent - The user agent, null for none.
      remoteIp - The remote address, null when unknown.
      Returns:
      What happened.
      Throws:
      SQLException - For SQL errors.
    • register

      public SubscriptionStore.RegisterResult register(long uid, String appId, String topic, String deviceUuid, PushTransport transport, String subId, String domain, String userAgent, String remoteIp, String vapidPublicKey, String fcmProject) throws SQLException
      Registers a subscription: creates the row, or replaces the subscription of an existing key when it changed. A new row, and a row whose subscription changed, record the credentials the subscription was made under (B-10a, PUSH-CLUSTER-DESIGN 3.3): a PushTransport.VAPID row the fingerprint of vapidPublicKey (VAPIDKeys.fingerprint(String)), an PushTransport.FCM row fcmProject; the other is NULL. An unchanged subscription keeps what it was recorded under - NULL on a row stored before the columns existed - because the server cannot tell which key an existing subscription was made with.

      Every registration is logged at INFO with what happened, the application, the Topic, the transport and the device by its abbreviated UUID (DeviceTokenStore.abbreviate(String)) - never the subscription itself, which is a token or a Browser endpoint with its keys (PA-D-6 of CC-PUSH-ANDROID). Both subscribe paths store here: the HTTP subscribe and the application session's PN channel.

      Parameters:
      uid - The user ID.
      appId - The application ID, * for iiziRun.
      topic - The Topic, empty for the application-level subscription.
      deviceUuid - The device UUID, canonical 36 characters.
      transport - The transport.
      subId - The transport's subscription.
      domain - The host name the client used.
      userAgent - The user agent, null for none.
      remoteIp - The remote address, null when unknown.
      vapidPublicKey - The VAPID public key the client was given to subscribe with, base64url; null for none or unknown.
      fcmProject - The Firebase project of the configuration the FCM token serves, null for none or unknown; one longer than 128 characters is not recorded (WARNING).
      Returns:
      What happened.
      Throws:
      SQLException - For SQL errors.
    • unregister

      public int unregister(long uid, String appId, String topic, String deviceUuid) throws SQLException
      Removes the subscriptions of a device for an application and Topic.
      Parameters:
      uid - The user ID.
      appId - The application ID, null for any.
      topic - The Topic, null for any.
      deviceUuid - The device UUID.
      Returns:
      The number of rows removed.
      Throws:
      SQLException - For SQL errors.
    • unregister

      public int unregister(long uid, String appId, String topic, String deviceUuid, String reason) throws SQLException
      Removes the subscriptions of a device for an application and Topic, reporting each removed row to the listeners with a reason (P4-04). An unsubscribe (IPushNotificationListener.REMOVED_UNSUBSCRIBED, the HTTP unsubscribe and the PN channel's) is logged at INFO: each removed row with its application, Topic, transport and abbreviated device, or that the device held none (PA-D-6 of CC-PUSH-ANDROID).
      Parameters:
      uid - The user ID.
      appId - The application ID, null for any.
      topic - The Topic, null for any.
      deviceUuid - The device UUID.
      reason - An IPushNotificationListener.REMOVED_* constant.
      Returns:
      The number of rows removed.
      Throws:
      SQLException - For SQL errors.
    • unregisterBySubscription

      public int unregisterBySubscription(long uid, String deviceUuid, String subId) throws SQLException
      Removes the subscriptions of a device that carry a given transport subscription: the client's unsubscribe body may carry only the subscription and no application or Topic.
      Parameters:
      uid - The user ID.
      deviceUuid - The device UUID.
      subId - The transport subscription.
      Returns:
      The number of rows removed.
      Throws:
      SQLException - For SQL errors.
    • removeByDevice

      public int removeByDevice(String deviceUuid) throws SQLException
      Removes every subscription of a device, whoever its user: the administrative reset with device of PUSH-WIRE-CONTRACT section 5.4.
      Parameters:
      deviceUuid - The device UUID, canonical 36 characters.
      Returns:
      The number of rows removed.
      Throws:
      SQLException - For SQL errors.
    • removeByUser

      public int removeByUser(long uid) throws SQLException
      Removes every subscription of a user, on every device: the administrative reset with user of PUSH-WIRE-CONTRACT section 5.4.
      Parameters:
      uid - The user ID.
      Returns:
      The number of rows removed.
      Throws:
      SQLException - For SQL errors.
    • delete

      public boolean delete(SubscriptionRecord record) throws SQLException
      Removes one subscription by its identity, e.g. after the push service reported it gone.
      Parameters:
      record - The subscription.
      Returns:
      true if a row was removed.
      Throws:
      SQLException - For SQL errors.
    • addListener

      public boolean addListener(SubscriptionStore.Listener listener)
      Adds a listener. Adding one twice changes nothing.
      Parameters:
      listener - The listener.
      Returns:
      true if added, false if it was already present.
      Throws:
      NullPointerException - If listener is null.
    • removeListener

      public boolean removeListener(SubscriptionStore.Listener listener)
      Removes a listener.
      Parameters:
      listener - The listener.
      Returns:
      true if removed, false if it was not present.
    • find

      public SubscriptionRecord find(long uid, String appId, String topic, String deviceUuid) throws SQLException
      Finds one subscription by its key.
      Parameters:
      uid - The user ID.
      appId - The application ID.
      topic - The Topic.
      deviceUuid - The device UUID.
      Returns:
      The subscription, or null.
      Throws:
      SQLException - For SQL errors.
    • query

      public List<SubscriptionRecord> query(long[] uids, List<String> appIds, List<String> topics, List<String> devices) throws SQLException
      Queries subscriptions. An empty or null list means "any" for that dimension; the result is in (uid, app_id, topic, device_uuid) order. Rows whose stored transport ordinal is outside the constant set are skipped and logged.
      Parameters:
      uids - The user IDs, empty or null for any.
      appIds - The application IDs, empty or null for any.
      topics - The Topics, empty or null for any.
      devices - The device UUIDs, empty or null for any.
      Returns:
      The subscriptions.
      Throws:
      SQLException - For SQL errors.
    • count

      public int count(long[] uids, List<String> appIds, List<String> topics, List<String> devices) throws SQLException
      Counts subscriptions with the same dimensions as query(long[], List, List, List).
      Parameters:
      uids - The user IDs, empty or null for any.
      appIds - The application IDs, empty or null for any.
      topics - The Topics, empty or null for any.
      devices - The device UUIDs, empty or null for any.
      Returns:
      The count.
      Throws:
      SQLException - For SQL errors.
    • touch

      public int touch(long uid, String deviceUuid, String remoteIp) throws SQLException
      Records that a device presented itself: updates last_seen and last_ip of every subscription of the device, and clears the resolved region when the address changed so it is resolved again.
      Parameters:
      uid - The user ID.
      deviceUuid - The device UUID.
      remoteIp - The remote address, null when unknown.
      Returns:
      The number of rows touched.
      Throws:
      SQLException - For SQL errors.
    • setRegion

      public void setRegion(long uid, String deviceUuid, String region) throws SQLException
      Stores the region the device's last address resolved to.
      Parameters:
      uid - The user ID.
      deviceUuid - The device UUID.
      region - The region code, null for unknown.
      Throws:
      SQLException - For SQL errors.