Skip to main content

src/telega_storage_postgres.erl

-module(telega_storage_postgres).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/telega_storage_postgres.gleam").
-export([new_with_table/2, new/1, migrate_table/2, migrate/1]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " PostgreSQL storage adapter for Telega.\n"
    "\n"
    " Implements `telega/storage.KeyValueStorage` on top of a single Postgres\n"
    " table with a `text` value column, suitable for production bots. TTL is\n"
    " stored as an epoch-millisecond `expires_at` column and enforced lazily on\n"
    " access (`get`/`scan`), so no background job is required.\n"
).

-file("src/telega_storage_postgres.gleam", 160).
-spec now_ms() -> integer().
now_ms() ->
    os:system_time(erlang:binary_to_atom(<<"millisecond"/utf8>>)).

-file("src/telega_storage_postgres.gleam", 131).
-spec do_scan(pog:connection(), binary(), binary()) -> {ok, list(binary())} |
    {error, pog:query_error()}.
do_scan(Conn, Table, Prefix) ->
    Sql = <<<<"SELECT key FROM "/utf8, Table/binary>>/binary,
        " WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > $2)"/utf8>>,
    Query = begin
        _pipe = Sql,
        _pipe@1 = pog:'query'(_pipe),
        _pipe@2 = pog:parameter(
            _pipe@1,
            pog_ffi:coerce(<<Prefix/binary, "%"/utf8>>)
        ),
        _pipe@3 = pog:parameter(_pipe@2, pog_ffi:coerce(now_ms())),
        pog:returning(
            _pipe@3,
            gleam@dynamic@decode:at(
                [0],
                {decoder, fun gleam@dynamic@decode:decode_string/1}
            )
        )
    end,
    case pog:execute(Query, Conn) of
        {ok, {returned, _, Rows}} ->
            {ok, Rows};

        {error, Err} ->
            {error, Err}
    end.

-file("src/telega_storage_postgres.gleam", 116).
-spec do_delete(pog:connection(), binary(), binary()) -> {ok, nil} |
    {error, pog:query_error()}.
do_delete(Conn, Table, Key) ->
    Query = begin
        _pipe = (<<<<"DELETE FROM "/utf8, Table/binary>>/binary,
            " WHERE key = $1"/utf8>>),
        _pipe@1 = pog:'query'(_pipe),
        pog:parameter(_pipe@1, pog_ffi:coerce(Key))
    end,
    case pog:execute(Query, Conn) of
        {ok, _} ->
            {ok, nil};

        {error, Err} ->
            {error, Err}
    end.

-file("src/telega_storage_postgres.gleam", 92).
-spec do_set(
    pog:connection(),
    binary(),
    binary(),
    binary(),
    gleam@option:option(integer())
) -> {ok, nil} | {error, pog:query_error()}.
do_set(Conn, Table, Key, Value, Expires_at) ->
    Sql = <<<<<<"INSERT INTO "/utf8, Table/binary>>/binary,
            " (key, value, expires_at) VALUES ($1, $2, $3)"/utf8>>/binary,
        " ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3"/utf8>>,
    Query = begin
        _pipe = Sql,
        _pipe@1 = pog:'query'(_pipe),
        _pipe@2 = pog:parameter(_pipe@1, pog_ffi:coerce(Key)),
        _pipe@3 = pog:parameter(_pipe@2, pog_ffi:coerce(Value)),
        pog:parameter(_pipe@3, pog:nullable(fun pog_ffi:coerce/1, Expires_at))
    end,
    case pog:execute(Query, Conn) of
        {ok, _} ->
            {ok, nil};

        {error, Err} ->
            {error, Err}
    end.

-file("src/telega_storage_postgres.gleam", 153).
?DOC(" An absent (`None`) `expires_at` means \"never expires\".\n").
-spec is_live(gleam@option:option(integer())) -> boolean().
is_live(Expires_at) ->
    case Expires_at of
        none ->
            true;

        {some, At} ->
            now_ms() < At
    end.

-file("src/telega_storage_postgres.gleam", 60).
-spec do_get(pog:connection(), binary(), binary()) -> {ok,
        gleam@option:option(binary())} |
    {error, pog:query_error()}.
do_get(Conn, Table, Key) ->
    Decoder = begin
        gleam@dynamic@decode:field(
            0,
            {decoder, fun gleam@dynamic@decode:decode_string/1},
            fun(Value) ->
                gleam@dynamic@decode:field(
                    1,
                    gleam@dynamic@decode:optional(
                        {decoder, fun gleam@dynamic@decode:decode_int/1}
                    ),
                    fun(Expires_at) ->
                        gleam@dynamic@decode:success({Value, Expires_at})
                    end
                )
            end
        )
    end,
    Sql = <<<<"SELECT value, expires_at FROM "/utf8, Table/binary>>/binary,
        " WHERE key = $1 LIMIT 1"/utf8>>,
    Query = begin
        _pipe = Sql,
        _pipe@1 = pog:'query'(_pipe),
        _pipe@2 = pog:parameter(_pipe@1, pog_ffi:coerce(Key)),
        pog:returning(_pipe@2, Decoder)
    end,
    case pog:execute(Query, Conn) of
        {ok, {returned, _, [{Value@1, Expires_at@1} | _]}} ->
            case is_live(Expires_at@1) of
                true ->
                    {ok, {some, Value@1}};

                false ->
                    case do_delete(Conn, Table, Key) of
                        {ok, _} ->
                            {ok, none};

                        {error, Err} ->
                            {error, Err}
                    end
            end;

        {ok, {returned, _, []}} ->
            {ok, none};

        {error, Err@1} ->
            {error, Err@1}
    end.

-file("src/telega_storage_postgres.gleam", 23).
?DOC(" Like `new`, but with a custom table name.\n").
-spec new_with_table(pog:connection(), binary()) -> telega@storage:key_value_storage(pog:query_error()).
new_with_table(Conn, Table) ->
    {key_value_storage,
        fun(Key) -> do_get(Conn, Table, Key) end,
        fun(Key@1, Value) -> do_set(Conn, Table, Key@1, Value, none) end,
        fun(Key@2, Value@1, Ttl_ms) ->
            do_set(Conn, Table, Key@2, Value@1, {some, now_ms() + Ttl_ms})
        end,
        fun(Key@3) -> do_delete(Conn, Table, Key@3) end,
        fun(Prefix) -> do_scan(Conn, Table, Prefix) end}.

-file("src/telega_storage_postgres.gleam", 18).
?DOC(
    " Build a `KeyValueStorage` backed by the given pog connection.\n"
    "\n"
    " Uses the default table name `telega_storage`. Call `migrate` once at startup\n"
    " to create the table.\n"
).
-spec new(pog:connection()) -> telega@storage:key_value_storage(pog:query_error()).
new(Conn) ->
    new_with_table(Conn, <<"telega_storage"/utf8>>).

-file("src/telega_storage_postgres.gleam", 46).
?DOC(" Create a custom-named storage table if it does not exist.\n").
-spec migrate_table(pog:connection(), binary()) -> {ok, nil} |
    {error, pog:query_error()}.
migrate_table(Conn, Table) ->
    Sql = <<<<"CREATE TABLE IF NOT EXISTS "/utf8, Table/binary>>/binary,
        " (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at BIGINT)"/utf8>>,
    case begin
        _pipe = Sql,
        _pipe@1 = pog:'query'(_pipe),
        pog:execute(_pipe@1, Conn)
    end of
        {ok, _} ->
            {ok, nil};

        {error, Err} ->
            {error, Err}
    end.

-file("src/telega_storage_postgres.gleam", 41).
?DOC(" Create the storage table if it does not exist. Run once at startup.\n").
-spec migrate(pog:connection()) -> {ok, nil} | {error, pog:query_error()}.
migrate(Conn) ->
    migrate_table(Conn, <<"telega_storage"/utf8>>).