洪 民憙 (Hong Minhee) :nonbinary:'s avatar

洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · 973 following · 1319 followers

An intersectionalist, feminist, and socialist living in Seoul (UTC+09:00). @tokolovesme's spouse. Who's behind @fedify, @hollo, and @botkit. Write some free software in , , , & . They/them.

서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. , , , 等으로 自由 소프트웨어 만듦.

()

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Hello, I'm an open source software engineer in my late 30s living in , , and an avid advocate of and the .

I'm the creator of @fedify, an server framework in , @hollo, an ActivityPub-enabled microblogging software for single users, and @botkit, a simple ActivityPub bot framework.

I'm also very interested in East Asian languages (so-called ) and . Feel free to talk to me in , (), or (), or even in Literary Chinese (, )!

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

安寧(안녕)하세요, 저는 서울에 살고 있는 30() 後半(후반) 오픈 소스 소프트웨어 엔지니어이며, 自由(자유)·오픈 소스 소프트웨어와 聯合宇宙(연합우주)(fediverse)의 熱烈(열렬)支持者(지지자)입니다.

저는 TypeScript() ActivityPub 서버 프레임워크인 @fedify 프로젝트와 싱글 유저() ActivityPub 마이크로블로그인 @hollo 프로젝트와 ActivityPub 봇 프레임워크인 @botkit 프로젝트의 製作者(제작자)이기도 합니다.

저는 ()아시아 言語(언어)(이른바 )와 유니코드에도 關心(관심)이 많습니다. 聯合宇宙(연합우주)에서는 國漢文混用體(국한문 혼용체)를 쓰고 있어요! 제게 韓國語(한국어)英語(영어), 日本語(일본어)로 말을 걸어주세요. (아니면, 漢文(한문)으로도!)

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

こんにちは、私はソウルに住んでいる30代後半のオープンソースソフトウェアエンジニアで、自由・オープンソースソフトウェアとフェディバースの熱烈な支持者です。名前は洪 民憙ホン・ミンヒです。

私はTypeScript用のActivityPubサーバーフレームワークである「@fedify」と、ActivityPubをサポートする1人用マイクロブログである 「@hollo」と、ActivityPubのボットを作成する為のシンプルなフレームワークである「@botkit」の作者でもあります。

私は東アジア言語(いわゆるCJK)とUnicodeにも興味が多いです。日本語、英語、韓国語で話しかけてください。(または、漢文でも!)

고남현's avatar
고남현

@gnh1201@hackers.pub

불경한게 많은 세상

세상에는 참 불경(?)한 것들이 많습니다.

개발에 쓰이는 도구가 불경하다며 어떠한 도구도 깔지말고 메모장(Notepad)만으로 코딩을 하라더니, 이제는 데이터베이스가 불경하다며 PC에 이미 설치된 기본 기능만으로 데이터베이스 처럼 사용할 수 있는 방법을 강구하라고 합니다.

아... 먹고 살기 쉽지 않습니다.

하지만 메모장 "Only" 코딩도 성공시킨 제가 데이터베이스라고 성공시키지 못하겠습니까? 해봅시다.

지금까지의 이야기

외부 개발 도구가 매우 제한적으로 공급되는 환경(사실상 쓸 수 있는 개발 도구가 "메모장"밖에 없는 환경)에서도 고급 기능을 구현할 수 있는 방안에 대한 요구는, 윈도우즈 운영체제의 내부 ECMAScript를 활용하는 WelsonJS 프레임워크의 공개를 통해 해결할 수 있었습니다.

WelsonJS 프레임워크는 2025년 6월 26일 기준, 깃허브(GitHub)에서 350개의 긍정적인 평가(Stars)를 받는 성과도 이루었습니다.

이후로, 지금까지의 경험을 데이터베이스 영역으로 확대해보자는 의견이 나오기 시작하였습니다. 상용 데이터베이스의 존재는 그 자체만으로도 매우 거대하기 때문에 외부 개발 도구의 공급이 제한되는 환경에서는 데이터베이스도 업무에 걸림돌이 되기 때문입니다.

결국 운영체제에서 기본적으로 지원되는 데이터베이스로 사용 가능한 시스템이 있는지 사전 조사를 시작합니다.

사전 조사가 끝나다

결국 윈도우즈 운영체제에는 Windows 2000부터 현재 버전(Windows 11)에도 탑재된 오던 ESENT (ESE) 데이터베이스라는 것이 존재한다는걸 확인하게 됩니다.

제 그동안의 경험에 의한 관심법과, LLM(거대 언어 모델)과의 협공이라면 빠른 시간 내에 구현이 가능할거라 생각하지만 상대적으로 정보가 부족하여 생각보다는 오래 걸렸습니다.

데이터베이스 기능 추상화 (칼럼, 스키마, CRUD) 구현

ESENT 데이터베이스를 사용하기 위한 API 구현은 존재하지만, Column(칼럼), Schema(스키마, 혹은 테이블), CRUD(생성, 읽기, 수정, 삭제) 등 실제 어플리케이션을 구현하는 용도로 사용하기에 적합하도록 데이터베이스의 개념을 추상화해둔 구현은 존재하지 않았습니다.

그런고로, 직접 작성을 해보았습니다! 먼저 칼럼과 스키마라는 개념을 구현해봅시다.

// Column.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class Column
    {
        public string Name { get; set; }
        public JET_coltyp Type { get; set; }
        public int MaxSize { get; set; }
        public JET_CP CodePage { get; set; }
        public bool IsPrimaryKey { get; set; } = false;

        public override string ToString()
        {
            return Name;
        }

        public static explicit operator string(Column c)
        {
            return c.ToString();
        }

        public Column(string name, JET_coltyp type, int maxSize = 0, JET_CP codePage = JET_CP.None)
        {
            Name = name;
            Type = type;
            MaxSize = maxSize;
            CodePage = codePage == JET_CP.None ?
                JET_CP.Unicode : codePage;
        }

        public Column(string name, Type dotNetType, int maxSize = 0, Encoding encoding = null)
        {
            Name = name;
            Type = GetJetColtypFromType(dotNetType);
            MaxSize = maxSize;
            CodePage = GetJetCpFromEncoding(encoding ?? Encoding.Unicode);
        }

        private static JET_coltyp GetJetColtypFromType(Type type)
        {
            if (type == typeof(string)) return JET_coltyp.Text;
            if (type == typeof(int)) return JET_coltyp.Long;
            if (type == typeof(long)) return JET_coltyp.Currency;
            if (type == typeof(bool)) return JET_coltyp.Bit;
            if (type == typeof(float)) return JET_coltyp.IEEESingle;
            if (type == typeof(double)) return JET_coltyp.IEEEDouble;
            if (type == typeof(DateTime)) return JET_coltyp.DateTime;
            if (type == typeof(byte[])) return JET_coltyp.LongBinary;

            throw new NotSupportedException($"Unsupported .NET type: {type.FullName}");
        }

        private static JET_CP GetJetCpFromEncoding(Encoding encoding)
        {
            if (encoding == Encoding.Unicode) return JET_CP.Unicode;
            if (encoding == Encoding.ASCII) return JET_CP.ASCII;
            if (encoding.CodePage == 1252) return (JET_CP)1252; // Windows-1252 / Latin1
            if (encoding.CodePage == 51949) return (JET_CP)51949; // EUC-KR
            if (encoding.CodePage == 949) return (JET_CP)949; // UHC (Windows Korean)
            if (encoding.CodePage == 932) return (JET_CP)932; // Shift-JIS (Japanese)
            if (encoding.CodePage == 936) return (JET_CP)936; // GB2312 (Simplified Chinese)
            if (encoding.CodePage == 65001) return (JET_CP)65001; // UTF-8
            if (encoding.CodePage == 28591) return (JET_CP)28591; // ISO-8859-1

            throw new NotSupportedException($"Unsupported encoding: {encoding.WebName} (code page {encoding.CodePage})");
        }
    }
}

// Schema.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;

namespace WelsonJS.Esent
{
    public class Schema
    {
        public string TableName { get; set; }
        public List<Column> Columns { get; set; }
        public Column PrimaryKey
        {
            get
            {
                return Columns.Find(c => c.IsPrimaryKey) ?? null;
            }
        }

        public Schema(string tableName, List<Column> columns)
        {
            TableName = tableName;
            Columns = columns ?? new List<Column>();
        }

        public void SetPrimaryKey(string columnName)
        {
            Column column = Columns.Find(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase));
            if (column != null)
            {
                column.IsPrimaryKey = true;
            }
            else
            {
                throw new ArgumentException($"Column '{columnName}' does not exist in schema '{TableName}'.");
            }
        }
    }
}

그리고, 대망의 CRUD를 구현해줍니다. (예시의 내용 중 Logger 인터페이스의 활용과 관련된 내용은 생략하였습니다.)

// DataStore.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class EsentDatabase : IDisposable
    {
        private const string _primaryKeyindexName = "primary";
        private const string _indexNamePrefix = "idx_";
        private const string _databaseName = "metadata.edb";

        private readonly ICompatibleLogger _logger;
        private static readonly object _lock = new object();
        private static bool _initialized = false;
        private static Instance _instance;
        private static string _workingDirectory;
        private static string _filePath;

        private readonly Session _session;
        private readonly JET_DBID _dbid;
        private readonly Schema _schema;
        private readonly Column _primaryKey;
        private readonly Dictionary<string, JET_COLUMNID> _columnIds;

        public EsentDatabase(Schema schema, string workingDirectory, ICompatibleLogger logger = null)
        {
            _logger = logger ?? new TraceLogger();

            if (schema == null)
                throw new ArgumentNullException(nameof(schema));

            _primaryKey = schema.PrimaryKey;

            if (_primaryKey == null)
                throw new ArgumentNullException();

            if (!schema.Columns.Exists(c => c == _primaryKey))
                throw new ArgumentException($"Primary key '{_primaryKey.Name}' is not in schema.");

            _workingDirectory = workingDirectory;
            _schema = schema;
            _columnIds = new Dictionary<string, JET_COLUMNID>(StringComparer.OrdinalIgnoreCase);

            InitializeInstance();

            _session = new Session(_instance);

            if (!File.Exists(_filePath))
            {
                Api.JetCreateDatabase(_session, _filePath, null, out _dbid, CreateDatabaseGrbit.None);
                CreateTable(_schema);
            }
            else
            {
                Api.JetAttachDatabase(_session, _filePath, AttachDatabaseGrbit.None);
                Api.JetOpenDatabase(_session, _filePath, null, out _dbid, OpenDatabaseGrbit.None);
            }

            CacheColumns();
        }

        private static void InitializeInstance()
        {
            if (_initialized) return;

            lock (_lock)
            {
                if (_initialized) return;

                // set the file path
                _filePath = Path.Combine(_workingDirectory, _databaseName);

                // config the instance
                _instance = new Instance(typeof(EsentDatabase).Namespace);
                _instance.Parameters.SystemDirectory = _workingDirectory;
                _instance.Parameters.LogFileDirectory = _workingDirectory;
                _instance.Parameters.TempDirectory = _workingDirectory;

                // initialize the instance
                _instance.Init();
                _initialized = true;
            }
        }

        private void CreateTable(Schema schema)
        {
            Api.JetBeginTransaction(_session);
            JET_TABLEID tableid;
            Api.JetCreateTable(_session, _dbid, schema.TableName, 0, 100, out tableid);

            foreach (var col in schema.Columns)
            {
                var coldef = new JET_COLUMNDEF
                {
                    coltyp = col.Type,
                    cbMax = col.MaxSize,
                    cp = col.CodePage
                };
                Api.JetAddColumn(_session, tableid, col.Name, coldef, null, 0, out _);
            }

            CreateIndex(tableid, new[] { _primaryKey }, CreateIndexGrbit.IndexPrimary | CreateIndexGrbit.IndexUnique);

            Api.JetCloseTable(_session, tableid);
            Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);
        }

        public void CreateIndex(JET_TABLEID tableid, IEnumerable<Column> columns, CreateIndexGrbit grbit)
        {
            if (columns == null)
                throw new ArgumentNullException(nameof(columns));

            var columnList = columns.ToList();
            if (columnList.Count == 0)
                throw new ArgumentException("At least one column is required to create an index.", nameof(columns));

            if (tableid == JET_TABLEID.Nil)
                throw new ArgumentException("Invalid table ID.", nameof(tableid));

            bool isPrimaryKeyIndex = (columnList.Count == 1 && columnList[0].IsPrimaryKey);

            if (isPrimaryKeyIndex && (grbit & CreateIndexGrbit.IndexPrimary) == 0)
                throw new ArgumentException("Primary key index must have the CreateIndexGrbit.IndexPrimary flag set.", nameof(grbit));

            string indexName = isPrimaryKeyIndex
                ? _primaryKeyindexName
                : _indexNamePrefix + string.Join("_", columnList.Select(c => c.Name));

            string key = string.Concat(columnList.Select(c => "+" + c.Name));
            string keyDescription = key + "\0\0"; // double null-terminated
            int keyDescriptionLength = keyDescription.Length;

            Api.JetCreateIndex(
                _session,
                tableid,
                indexName,
                grbit,
                keyDescription,
                keyDescriptionLength,
                100
            );
        }

        private void CacheColumns()
        {
            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                foreach (var col in _schema.Columns)
                {
                    try
                    {
                        JET_COLUMNID colid = Api.GetTableColumnid(_session, table, col.Name);
                        _columnIds[col.Name] = colid;
                    }
                    catch (EsentColumnNotFoundException)
                    {
                        _logger.Warn($"Column '{col.Name}' not found.");
                    }
                }
            }
        }

        public bool Insert(Dictionary<string, object> values, out object key)
        {
            return TrySaveRecord(values, JET_prep.Insert, expectSeek: false, out key);
        }

        public bool Update(Dictionary<string, object> values)
        {
            return TrySaveRecord(values, JET_prep.Replace, expectSeek: true, out _);
        }

        private bool TrySaveRecord(
            Dictionary<string, object> values,
            JET_prep prepType,
            bool expectSeek,
            out object primaryKeyValue)
        {
            primaryKeyValue = null;

            if (!TryGetPrimaryKeyValue(values, out var keyValue))
                return false;

            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                try
                {
                    Api.JetBeginTransaction(_session);

                    Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                    MakeKeyByType(keyValue, keyType, _session, table);
                    bool found = Api.TrySeek(_session, table, SeekGrbit.SeekEQ);

                    if (expectSeek != found)
                    {
                        _logger.Warn($"[ESENT] Operation skipped. Seek result = {found}, expected = {expectSeek}");
                        Api.JetRollback(_session, RollbackTransactionGrbit.None);
                        return false;
                    }

                    Api.JetPrepareUpdate(_session, table, prepType);
                    SetAllColumns(values, table);

                    Api.JetUpdate(_session, table);
                    Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);

                    if (prepType == JET_prep.Insert)
                        primaryKeyValue = keyValue;

                    return true;
                }
                catch (Exception ex)
                {
                    Api.JetRollback(_session, RollbackTransactionGrbit.None);
                    throw new InvalidOperationException($"[ESENT] Operation failed: {ex.Message}");
                }
            }
        }

        public Dictionary<string, object> FindById(object keyValue)
        {
            var result = new Dictionary<string, object>();
            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return null;

                foreach (var col in _schema.Columns)
                {
                    if (!_columnIds.TryGetValue(col.Name, out var colid))
                        continue;

                    var value = RetrieveColumnByType(_session, table, colid, col.Type);
                    result[col.Name] = value;
                }
            }

            return result;
        }

        public List<Dictionary<string, object>> FindAll()
        {
            var results = new List<Dictionary<string, object>>();

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);

                if (!Api.TryMoveFirst(_session, table))
                    return results;

                do
                {
                    var row = new Dictionary<string, object>();
                    foreach (var col in _schema.Columns)
                    {
                        if (!_columnIds.TryGetValue(col.Name, out var colid))
                            continue;

                        var value = RetrieveColumnByType(_session, table, colid, col.Type);
                        row[col.Name] = value;
                    }
                    results.Add(row);
                }
                while (Api.TryMoveNext(_session, table));
            }

            return results;
        }

        public bool DeleteById(object keyValue)
        {
            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return false;

                Api.JetDelete(_session, table);
                return true;
            }
        }

        private object RetrieveColumnByType(Session session, Table table, JET_COLUMNID columnId, JET_coltyp type)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    return Api.RetrieveColumnAsString(session, table, columnId, Encoding.Unicode);
                case JET_coltyp.Long:
                    return Api.RetrieveColumnAsInt32(session, table, columnId);
                case JET_coltyp.IEEEDouble:
                    return Api.RetrieveColumnAsDouble(session, table, columnId);
                case JET_coltyp.DateTime:
                    return Api.RetrieveColumnAsDateTime(session, table, columnId);
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    return Api.RetrieveColumn(session, table, columnId);
                default:
                    _logger.Warn($"[ESENT] Unsupported RetrieveColumn type: {type}");
                    return null;
            }
        }

        private bool TryGetPrimaryKeyValue(Dictionary<string, object> values, out object keyValue)
        {
            keyValue = null;

            if (!values.TryGetValue(_primaryKey.Name, out keyValue))
            {
                _logger.Warn($"[ESENT] Missing primary key '{_primaryKey.Name}'.");
                return false;
            }

            if (keyValue == null)
            {
                _logger.Warn("[ESENT] Primary key value cannot be null.");
                return false;
            }

            return true;
        }

        private JET_coltyp GetColumnType(string columnName)
        {
            var column = _schema.Columns.FirstOrDefault(c => c.Name == columnName);
            if (column == null)
                throw new ArgumentException($"Column '{columnName}' not found in schema.");

            return column.Type;
        }

        private void SetAllColumns(Dictionary<string, object> values, Table table)
        {
            foreach (var kv in values)
            {
                if (!_columnIds.TryGetValue(kv.Key, out var colid))
                {
                    _logger.Warn($"[ESENT] Column '{kv.Key}' not found in cache.");
                    continue;
                }

                var colType = GetColumnType(kv.Key);
                SetColumnByType(_session, table, colid, kv.Value, colType);
            }
        }

        private void SetColumnByType(Session session, Table table, JET_COLUMNID columnId, object value, JET_coltyp type)
        {
            if (value == null)
                return;

            switch (type)
            {
                case JET_coltyp.Text:
                    Api.SetColumn(session, table, columnId, value.ToString(), Encoding.Unicode);
                    break;
                case JET_coltyp.Long:
                    Api.SetColumn(session, table, columnId, Convert.ToInt32(value));
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.SetColumn(session, table, columnId, Convert.ToDouble(value));
                    break;
                case JET_coltyp.DateTime:
                    Api.SetColumn(session, table, columnId, Convert.ToDateTime(value));
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.SetColumn(session, table, columnId, (byte[])value);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported SetColumn type: {type}");
                    break;
            }
        }

        private void MakeKeyByType(object value, JET_coltyp type, Session session, Table table)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    Api.MakeKey(session, table, value.ToString(), Encoding.Unicode, MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Long:
                    Api.MakeKey(session, table, Convert.ToInt32(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.MakeKey(session, table, Convert.ToDouble(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.DateTime:
                    Api.MakeKey(session, table, Convert.ToDateTime(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.MakeKey(session, table, (byte[])value, MakeKeyGrbit.NewKey);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported MakeKey type: {type}");
                    break;
            }
        }

        public void Dispose()
        {
            _session?.Dispose();
        }
    }
}

이렇게하면 ESENT (ESE) 데이터베이스에서 어플리케이션 개발 용도에 적합한 칼럼, 스키마 및 CRUD를 위한 메소드를 구현할 수 있습니다.

활용

이렇게 만들어진 구현은 다음과 같이 사용할 수 있습니다.

using WelsonJS.Esent;

// connect the database to manage instances
Schema schema = new Schema("Instances", new List<Column>
{
    new Column("InstanceId", typeof(string), 255),
    new Column("FirstDeployTime", typeof(DateTime), 1)
});
schema.SetPrimaryKey("InstanceId");
_db = new EsentDatabase(schema, Path.GetTempPath());

// Insert row
try
{
    _db.Insert(new Dictionary<string, object>
    {
        ["InstanceId"] = instanceId,
        ["FirstDeployTime"] = now
    }, out _);
}
catch (Exception ex)
{
    // Handle exception
}

// find all
var instances = _db.FindAll();
foreach (var instance in instances)
{
    try
    {
        string instanceId = instance["InstanceId"].ToString();
        string firstDeployTime = instance.ContainsKey("FirstDeployTime")
            ? ((DateTime)instance["FirstDeployTime"]).ToString("yyyy-MM-dd HH:mm:ss")
            : "Unknown";

        Console.WriteLine($"{firstDeployTime}, {instanceId}");
    }
    catch (Exception ex)
    {
        // Handle exception
    }
}

우리에게 다소 익숙한 어플리케이션 개발에 적합한 메소드를 지원하고 있음을 확인할 수 있습니다.

Park Hyunwoo's avatar
Park Hyunwoo

@lqez@mastodon.cloud

오늘 만난 분에게 유튜브 출연 제의를 하다 알게 되었는데, 생각보다 부모님도 프로그래머인 경우가 – 그리고 아직도 2대에 걸쳐 현업인 경우도 이제는 꽤 많을 것 같다. 그래서 혹시 동반 출연 가능하실지 여쭤봤는데 과연… 관심있는 분들의 DM 기다립니다 ㅋㅋ

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Excited to share that I've joined (Open Source Software Contribution Academy) as a mentor for the @fedify project!

OSSCA is a national program run by South Korea's NIPA (National IT Industry Promotion Agency) through their Open Source Software Support Center, aimed at fostering the next generation of open source contributors.

We're currently in the process of selecting around 20 mentees who will start contributing to once the selection is complete. I've been busy preparing good first issues to help them get started on their open source journey.

Looking forward to working with these new contributors and seeing what amazing things we can build together!

Deno's avatar
Deno

@deno_land@fosstodon.org

Coming soon

월퍄's avatar
월퍄

@wolffia@bakedbean.xyz

이유는 모르겠는데 구글키보드
대만 필기인식에 구결자가 있다

Fedify: ActivityPub server framework's avatar
Fedify: ActivityPub server framework

@fedify@hollo.social

We're pleased to share that Encyclia has joined our success stories.

@encyclia bridges academic research to the by making researcher profiles and publications discoverable through —built with for seamless interoperability across Mastodon and other fediverse platforms.

This demonstrates Fedify's versatility beyond traditional social networking, helping specialized domains connect to the federated web.

We're also grateful for 's sponsorship support, which helps make Fedify's development possible.

Learn more about Encyclia at https://encyclia.pub/. 📚

Fedify: ActivityPub server framework's avatar
Fedify: ActivityPub server framework

@fedify@hollo.social

We are pleased to announce the release of 1.7.0. This release was expedited at the request of the Ghost team, who are actively using Fedify for their implementation. As a result, several features originally planned for this version have been moved to Fedify 1.8.0 to ensure timely delivery of the most critical improvements.

This release focuses on enhancing message queue functionality and improving compatibility with ActivityPub servers through refined HTTP signature handling.

Native retry mechanism support

This release introduces support for native retry mechanisms in message queue backends. The new MessageQueue.nativeRetrial property allows queue implementations to indicate whether they provide built-in retry functionality, enabling Fedify to optimize its retry behavior accordingly.

When nativeRetrial is set to true, Fedify will delegate retry handling to the queue backend rather than implementing its own retry logic. This approach reduces overhead and leverages the proven retry mechanisms of established queue systems.

Current implementations with native retry support include:

  • DenoKvMessageQueue — utilizes Deno KV's automatic retry with exponential backoff
  • WorkersMessageQueue — leverages Cloudflare Queues' automatic retry and dead-letter queue features
  • AmqpMessageQueue — can now be configured to use AMQP broker's native retry mechanisms

The InProcessMessageQueue continues to use Fedify's internal retry mechanism, while ParallelMessageQueue inherits the retry behavior from its wrapped queue.

AMQP message queue improvements

Alongside Fedify 1.7.0, we have also released @fedify/amqp 0.3.0. This release adds the nativeRetrial option to AmqpMessageQueueOptions, enabling you to leverage your AMQP broker's built-in retry mechanisms. When enabled, this option allows the AMQP broker to handle message retries according to its configured policies, rather than relying on Fedify's internal retry logic.

Configurable double-knocking

The new FederationOptions.firstKnock option provides control over the HTTP Signatures specification used for the initial signature attempt when communicating with previously unknown servers.

Previously, the first knock for newly encountered servers always used RFC 9421 (HTTP Message Signatures), falling back to draft-cavage-http-signatures-12 if needed. With this release, you can now configure which specification to use for the first knock when communicating with unknown servers, with RFC 9421 remaining the default.

Summary

This release maintains Fedify's commitment to reliability and compatibility while laying the groundwork for more efficient message processing. The native retry mechanism support will particularly benefit applications using queue backends with sophisticated retry capabilities, while the double-knocking mechanism addresses real-world compatibility challenges in the ActivityPub ecosystem.

For detailed technical information about these changes, please refer to the changelog in the repository.

Emelia 👸🏻's avatar
Emelia 👸🏻

@thisismissem@hachyderm.io

Oh! Yay! This shipped!!

Coming in Mastodon 4.4, mods will be able to leave notes directly on instances just like they can on reports and accounts.

That's my big 4.4 feature. This is way better than using the private_comment field in domain blocks.
vmst.io/@mergebot/114743061796

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

韓國女性民友會(한국여성민우회) 티셔츠 申請(신청) 完了(완료)!

한국여성민우회🎗's avatar
한국여성민우회🎗

@womenlink.or.kr@bsky.brid.gy

[앵콜제작❤️] 민우회 티셔츠를 만날 수 있는 정말(眞) 마지막 기회! 가장 인기 있었던 티셔츠, 가장 요청이 많았던 티셔츠 (新)블랙에디션(NEW) 2종을 추가제작합니다. 사전신청: 6/25(수)~7/7(월)까지 신청폼: forms.gle/vqpe6XJrgfJg...

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub

Hackers' Pub이 커뮤니티 자격으로 올해 파이콘 한국에 후원하게 되어, 8월 16일(土)–17일(日) 후원사 부스를 운영하게 되었는데요. 부스 운영을 도와주실 분을 한 분에서 두 분 정도 찾습니다! 이틀 중 하루만 도와주셔도 좋습니다. (당연하지만 저는 이틀 모두 나갑니다.) 도와주신 분께는 약소하지만 제가 점심과 저녁을 대접하겠습니다.

geeknews_bot's avatar
geeknews_bot

@geeknews_bot@sns.lemondouble.com

JavaScript 라이브러리를 위한 새로운 로깅 접근법: LogTape
------------------------------
### 라이브러리 vs 애플리케이션: 근본적으로 다른 로깅 요구사항
- *애플리케이션 로깅* : 개발자가 직접 제어하는 환경에서 명시적 설정과 관리
- *라이브러리 로깅* : 타인의 프로젝트에 포함되어 사용자 환경과 선택권 존중 필요
- *기존 방식의 한계* : 애플리케이션 중심 로거(winston, Pino)를 라이브러…
------------------------------
https://news.hada.io/topic?id=21610&utm_source=googlechat&utm_medium=bot&utm_campaign=1834

BotKit by Fedify :botkit:'s avatar
BotKit by Fedify :botkit:

@botkit@hollo.social

We're pleased to announce that .js support has been merged and will be available in 0.3.0.

Now you can build your bots with both and Node.js, giving you more flexibility in choosing your preferred runtime environment.

Stay tuned for BotKit 0.3.0!

Deno's avatar
Deno

@deno_land@fosstodon.org

Deno 2.3.7 was released with a bunch of bug fixes and now is using aws-lc

github.com/denoland/deno/relea

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


One of the enduring challenges in software programming is this: “How do we pass the invisible?” Loggers, HTTP request contexts, current locales, I/O handles—these pieces of information are needed throughout our programs, yet threading them explicitly through every function parameter would be unbearably verbose.

Throughout history, various approaches have emerged to tackle this problem. Dynamic scoping, aspect-oriented programming, context variables, and the latest effect systems… Some represent evolutionary steps in a continuous progression, while others arose independently. Yet we can view all these concepts through a unified lens.

Dynamic scoping

Dynamic scoping, which originated in 1960s Lisp, offered the purest form of solution. “A variable's value is determined not by where it's defined, but by where it's called.” Simple and powerful, yet it fell out of favor in mainstream programming languages after Common Lisp and Perl due to its unpredictability. Though we can still trace its lineage in JavaScript's this binding.

;; Common Lisp example - logger bound dynamically
(defvar *logger* nil)

(defun log-message (message)
  (when *logger*
    (funcall *logger* message)))

(defun process-user-data (data)
  (log-message (format nil "Processing user: ~a" data))
  ;; actual processing logic…
  )

(defun main ()
  (let ((*logger* (lambda (msg) (format t "[INFO] ~a~%" msg))))
    (process-user-data "john@example.com"))) ; logger passed implicitly

Aspect-oriented programming

AOP structured the core idea of “modularizing cross-cutting concerns.” The philosophy: “Inject context, but with rules.” By separating cross-cutting concerns like logging and transactions into aspects, it maintained dynamic scoping's flexibility while pursuing more predictable behavior. However, debugging difficulties and performance overhead limited its spread beyond Java and .NET ecosystems.

// Spring AOP example - logging separated as cross-cutting concern
@Aspect
public class LoggingAspect {
    private Logger logger = LoggerFactory.getLogger(LoggingAspect.class);
    
    @Around("@annotation(Loggable)")
    public Object logMethodCall(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        logger.info("Entering method: " + methodName);
        Object result = joinPoint.proceed();
        logger.info("Exiting method: " + methodName);
        return result;
    }
}

@Service
public class UserService {
    @Loggable  // logger implicitly injected through aspect
    public User processUser(String userData) {
        // actual processing logic…
        return new User(userData);
    }
}

Context variables

Context variables represent dynamic scoping redesigned for modern requirements—asynchronous and parallel programming. Python's contextvars and Java's ThreadLocal exemplify this approach. Yet they still suffer from runtime dependency and the fact that API context requirements are only discoverable through documentation.

Another manifestation of context variables appears in React's contexts and similar concepts in other UI frameworks. While their usage varies, they all solve the same problem: prop drilling. Implicit propagation through component trees mirrors propagation through function call stacks.

# Python contextvars example - custom logger propagated through context
from contextvars import ContextVar

# Define custom logger function as context variable
logger_func = ContextVar('logger_func')

def log_info(message):
    log_fn = logger_func.get()
    if log_fn:
        log_fn(f"[INFO] {message}")

def process_user_data(data):
    log_info(f"Processing user: {data}")
    validate_user_data(data)

def validate_user_data(data):
    log_info(f"Validating user: {data}")  # logger implicitly propagated

def main():
    # Set specific logger function in context
    def my_logger(msg):
        print(f"CustomLogger: {msg}")
    
    logger_func.set(my_logger)
    process_user_data("john@example.com")

Monads

Monads approach this from a different starting point. Rather than implicit context passing, monads attempt to encode effects in the type system—addressing a more fundamental problem. The Reader monad specifically corresponds to context variables. However, when combining multiple effects through monad transformers, complexity exploded. Developers had to wrestle with unwieldy types like ReaderT Config (StateT AppState (ExceptT Error IO)). Layer ordering mattered, each layer required explicit lifting, and usability suffered. Consequently, monadic ideas remained largely confined to serious functional programming languages like Haskell, Scala, and F#.

-- Haskell Logger monad example - custom Logger monad definition
newtype Logger a = Logger (IO a)

instance Functor Logger where
    fmap f (Logger io) = Logger (fmap f io)

instance Applicative Logger where
    pure = Logger . pure
    Logger f <*> Logger x = Logger (f <*> x)

instance Monad Logger where
    Logger io >>= f = Logger $ do
        a <- io
        let Logger io' = f a
        io'

-- Logging functions
logInfo :: String -> Logger ()
logInfo msg = Logger $ putStrLn $ "[INFO] " ++ msg

processUserData :: String -> Logger ()
processUserData userData = do
    logInfo $ "Processing user: " ++ userData
    validateUserData userData

validateUserData :: String -> Logger ()
validateUserData userData = do
    logInfo $ "Validating user: " ++ userData  -- logger passed through monad

runLogger :: Logger a -> IO a
runLogger (Logger io) = io

main :: IO ()
main = runLogger $ processUserData "john@example.com"

Effect systems

Effect systems emerged to solve the compositional complexity of monads. Implemented in languages like Koka and Eff, they operate through algebraic effects and handlers. Multiple effect layers compose without ordering constraints. Multiple overlapping layers require no explicit lifting. Effect handlers aren't fixed—they can be dynamically replaced, offering significant flexibility.

However, compiler optimizations remain immature, interoperability with existing ecosystems poses challenges, and the complexity of effect inference and its impact on type systems present ongoing research questions. Effect systems represent the newest approach discussed here, and their limitations will be explored as they gain wider adoption.

// Koka effect system example - logging effects flexibly propagated
effect logger
  fun log-info(message: string): ()
  fun log-error(message: string): ()

fun process-user-data(user-data: string): logger ()
  log-info("Processing user: " ++ user-data)
  validate-user-data(user-data)

fun validate-user-data(user-data: string): logger ()
  log-info("Validating user: " ++ user-data)  // logger effect implicitly propagated
  if user-data == "" then
    log-error("Invalid user data: empty string")

fun main()
  // Different logger implementations can be chosen dynamically
  with handler
    fun log-info(msg) println("[INFO] " ++ msg)
    fun log-error(msg) println("[ERROR] " ++ msg)
  process-user-data("john@example.com")

The art of passing the invisible—this is the essence shared by all the concepts discussed here, and it will continue to evolve in new forms as an eternal theme in software programming.

bgl gwyng's avatar
bgl gwyng

@bgl@hackers.pub

주말에 튜사 모각코하실분 있나요~

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to Brandon Zhang 🇨🇳's post

@heybran Oh, yeah, scallions are what my brother (who cooked that 麻婆豆腐) missed! That would be even more delicious…

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

동생이 만들어 준 麻婆豆腐(마파두부)!

麻婆豆腐
ALT text details麻婆豆腐
:rss: Hacker News

@ycombinator@rss-mstdn.studiofreesia.com

If you're building a JavaScript library and need logging, you would love LogTape
hackers.pub/@hongminhee/2025/l

Curated Hacker News's avatar
Curated Hacker News

@CuratedHackerNews@mastodon.social

If you're building a JavaScript library and need logging, you would love LogTape

hackers.pub/@hongminhee/2025/l

Kagami is they/them 🏳️‍⚧️'s avatar
Kagami is they/them 🏳️‍⚧️

@krosylight@fosstodon.org

Requiring CW for all politics only works for people who have life that is not marked as "political"

Gamers are still convinced that there are only:

Two races: white and "political"
Two genders: Male and "political"
Two hair styles for women: long and "political"
Two sexualities: straight and "political"
Two body types: normative and "political"
ALT text detailsGamers are still convinced that there are only: Two races: white and "political" Two genders: Male and "political" Two hair styles for women: long and "political" Two sexualities: straight and "political" Two body types: normative and "political"
Emelia 👸🏻's avatar
Emelia 👸🏻

@thisismissem@hachyderm.io

Just learned of a self-hostable ticketing software called Pretix: pretix.eu/

It looks extremely full-featured.

Deno's avatar
Deno

@deno_land@fosstodon.org

the story behind the 🥚logo

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to Stefan Bohacek's post

@stefan Oh, great. I'll try translating it into Korean and Japanese when I have time.

Stefan Bohacek's avatar
Stefan Bohacek

@stefan@stefanbohacek.online · Reply to Stefan Bohacek's post

If you'd like to help translate the site, you can learn about our workflow here: github.com/jointhefediverse-ne

Stefan Bohacek's avatar
Stefan Bohacek

@stefan@stefanbohacek.online · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

@hongminhee Oh nice, thank you! I updated the dark/light designs in github.com/jointhefediverse-ne.

On a related note, if you happen to know anyone who'd be interested in helping translate jointhefediverse.net to these languages, definitely feel free to pass this along!

stefanbohacek.online/@stefan/1

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to Stefan Bohacek's post

@stefan Could you add Korean and Japanese words for fediverse?

  • 聯合宇宙
  • 연합우주
  • フェディバース
洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub · Reply to 洪 民憙 (Hong Minhee)'s post

And @vitest would've been perfect, but it doesn't work with @deno_land either… 😞

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub

Trying to build a cross-runtime test suite that works on Node.js, Bun, and Deno, but hitting a roadblock with Bun's incomplete node:test implementation. Missing subtests/test steps support is making this harder than it should be.

AmaseCocoa's avatar
AmaseCocoa

@cocoa@hackers.pub · Reply to AmaseCocoa's post

ここからはひたすらFedifyの実装を読み解きながら動作が違う部分を探して直すだけのお仕事 (苦行)

← Newer
Older →